Add multi-repo folder workspaces (v1) (#5172)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-11 02:34:01 -07:00 committed by GitHub
parent 7fbb3003f2
commit 91e354ccb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
99 changed files with 8285 additions and 761 deletions

View File

@ -47,6 +47,8 @@ import {
const REPO_ID = 'repo1'
const WORKTREE_ID = `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/wt`
const FOLDER_WORKSPACE_ID = 'folder-workspace-1'
const FOLDER_WORKTREE_ID = `folder:${FOLDER_WORKSPACE_ID}`
const TAB_ID = 'tab-1'
const PANE_KEY = `${TAB_ID}:leaf-1`
@ -192,6 +194,41 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('renames a pending folder workspace title without touching git', async () => {
const { deps, onRenamed, setDisplayName } = makeDeps({
resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID,
getFolderWorkspacePath: () => '/workspace/platform',
isPendingFirstAgentMessageRename: () => true,
getCurrentDisplayName: () => 'Platform workspace'
})
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(resolveTextGenerationParamsMock).toHaveBeenCalledWith(
expect.anything(),
'local',
'branchName',
null
)
expect(setDisplayName).toHaveBeenCalledWith(FOLDER_WORKTREE_ID, 'Fix auth')
expect(onRenamed).toHaveBeenCalledWith(FOLDER_WORKTREE_ID)
})
it('does not rename folder workspace titles without the pending marker', async () => {
const { deps, setDisplayName } = makeDeps({
resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID,
getFolderWorkspacePath: () => '/workspace/platform',
isPendingFirstAgentMessageRename: () => false
})
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(generateBranchNameMock).not.toHaveBeenCalled()
expect(setDisplayName).not.toHaveBeenCalled()
})
it('ignores replayed events and non-working states', async () => {
const { deps } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent({ isReplay: true }), deps)

View File

@ -5,6 +5,7 @@
// summarize the prompt via the configured agent, and rename.
import type { GlobalSettings, Repo } from '../../shared/types'
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import { parsePaneKey } from '../../shared/stable-pane-id'
import {
humanizeBranchSlug,
@ -50,6 +51,10 @@ export type FirstWorkBranchRenameDeps = {
getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined
/** Current sidebar display name for the worktree, if one is stored. */
getCurrentDisplayName: (worktreeId: string) => string | undefined
/** Current workspace path for non-git folder workspaces. */
getFolderWorkspacePath?: (worktreeId: string) => string | undefined
/** True while a workspace title is waiting for the first agent message. */
isPendingFirstAgentMessageRename?: (worktreeId: string) => boolean
/** True only for Orca-created worktrees whose branch Orca is allowed to rename. */
canRenameOrcaCreatedBranch: (worktreeId: string) => boolean
/** Persist a new sidebar display name for the worktree. */
@ -164,6 +169,18 @@ async function runAutoRename(
return false
}
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
return runFolderWorkspaceTitleAutoRename(
worktreeId,
prompt,
assistantMessage,
deps,
stop,
retry
)
}
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
const parsed = splitWorktreeId(worktreeId)
if (!repo || !parsed) {
@ -276,6 +293,59 @@ async function runAutoRename(
return true
}
async function runFolderWorkspaceTitleAutoRename(
worktreeId: string,
prompt: string,
assistantMessage: string | undefined,
deps: FirstWorkBranchRenameDeps,
stop: (reason: string, clearError?: boolean) => true,
retry: (reason: string) => false
): Promise<boolean> {
if (deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true) {
return stop('folder workspace is not pending title rename', true)
}
const folderPath = deps.getFolderWorkspacePath?.(worktreeId)
if (!folderPath) {
return stop('folder workspace path unavailable')
}
const settings = deps.getSettings()
const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null)
if (!resolvedParams.ok) {
deps.setRenameError(worktreeId, resolvedParams.error)
return stop(`no generation agent: ${resolvedParams.error}`)
}
const target = await resolveGenerationTarget(
folderPath,
resolvedParams.params.agentId,
null,
deps
)
if (!target) {
deps.setRenameError(worktreeId, 'Could not prepare the workspace-name generation environment.')
return retry('could not prepare generation environment')
}
const generated = await generateBranchNameFromContext(
{ firstPrompt: prompt, assistantMessage },
resolvedParams.params,
target
)
if (!generated.success) {
if (!generated.canceled) {
deps.setRenameError(worktreeId, generated.error)
}
return retry(`generation failed: ${generated.error}`)
}
const newDisplayName = humanizeBranchSlug(generated.slug)
deps.setDisplayName(worktreeId, newDisplayName)
deps.setRenameError(worktreeId, null)
deps.onRenamed(worktreeId)
console.info(`[auto-branch-rename] renamed folder workspace title -> "${newDisplayName}"`)
return true
}
async function resolveGenerationTarget(
worktreePath: string,
agentId: string,

View File

@ -93,6 +93,7 @@ import { StarNagService } from './star-nag/service'
import { agentHookServer } from './agent-hooks/server'
import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename'
import { getRepoIdFromWorktreeId } from '../shared/worktree-id'
import { parseWorkspaceKey } from '../shared/workspace-scope'
import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state'
import {
clearProviderPtyState,
@ -207,7 +208,29 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
getSettings: () => currentStore.getSettings(),
getRepo: (repoId) => currentStore.getRepo(repoId),
getAgentEnvResolvers: () => currentRuntime.getCommitMessageAgentEnvironmentResolvers(),
getCurrentDisplayName: (worktreeId) => currentStore.getWorktreeMeta(worktreeId)?.displayName,
getCurrentDisplayName: (worktreeId) => {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
return currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.name
}
return currentStore.getWorktreeMeta(worktreeId)?.displayName
},
getFolderWorkspacePath: (worktreeId) => {
const scope = parseWorkspaceKey(worktreeId)
return scope?.type === 'folder'
? currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath
: undefined
},
isPendingFirstAgentMessageRename: (worktreeId) => {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
return (
currentStore.getFolderWorkspace(scope.folderWorkspaceId)
?.pendingFirstAgentMessageRename === true
)
}
return currentStore.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true
},
canRenameOrcaCreatedBranch: (worktreeId) => {
const meta = currentStore.getWorktreeMeta(worktreeId)
// Why: a user/imported branch can coincidentally be named after a creature.
@ -215,6 +238,16 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true
},
setDisplayName: (worktreeId, displayName) => {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
currentStore.updateFolderWorkspace(scope.folderWorkspaceId, {
name: displayName,
pendingFirstAgentMessageRename: false,
firstAgentMessageRenameError: null
})
currentRuntime.notifyFolderWorkspaceChanged()
return
}
currentStore.setWorktreeMeta(worktreeId, {
displayName,
pendingFirstAgentMessageRename: false,
@ -226,6 +259,20 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
setRenameError: (worktreeId, error) => {
// Skip the write + renderer push when nothing changes — benign skips
// clear the error on every settled worktree, most of which never had one.
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const current = currentStore.getFolderWorkspace(
scope.folderWorkspaceId
)?.firstAgentMessageRenameError
if ((current ?? null) === (error ?? null)) {
return
}
currentStore.updateFolderWorkspace(scope.folderWorkspaceId, {
firstAgentMessageRenameError: error
})
currentRuntime.notifyFolderWorkspaceChanged()
return
}
const current = currentStore.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError
if ((current ?? null) === (error ?? null)) {
return
@ -236,7 +283,13 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
currentRuntime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId))
},
resolveWorktreeIdForTab: (tabId) => currentStore.getWorktreeIdForTab(tabId),
onRenamed: (repoId) => currentRuntime.notifyBranchRenamed(repoId)
onRenamed: (repoIdOrWorktreeId) => {
if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') {
currentRuntime.notifyFolderWorkspaceChanged()
return
}
currentRuntime.notifyBranchRenamed(repoIdOrWorktreeId)
}
}
)
}

View File

@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type * as RepoWorktrees from '../repo-worktrees'
import { listRepoWorktrees } from '../repo-worktrees'
import type { GitWorktreeInfo, Repo } from '../../shared/types'
import type { FolderWorkspace, GitWorktreeInfo, ProjectGroup, Repo } from '../../shared/types'
import {
invalidateAuthorizedRootsCache,
isDescendantOrEqual,
@ -35,9 +35,52 @@ const repo: Repo = {
kind: 'git'
}
function makeStore(repos: Repo[] = [repo]): Store {
function makeProjectGroup(overrides: Partial<ProjectGroup> = {}): ProjectGroup {
return {
id: 'group-1',
name: 'Workspace',
parentPath: '/folders/workspace',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
return {
id: 'folder-workspace-1',
projectGroupId: 'group-1',
name: 'Feature',
folderPath: '/folders/workspace',
comment: '',
linkedTask: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeStore(
repos: Repo[] = [repo],
options: {
projectGroups?: ProjectGroup[]
folderWorkspaces?: FolderWorkspace[]
} = {}
): Store {
return {
getRepos: () => repos,
getProjectGroups: () => options.projectGroups ?? [],
getFolderWorkspaces: () => options.folderWorkspaces ?? [],
getSettings: () => ({})
} as unknown as Store
}
@ -111,6 +154,113 @@ describe('filesystem-auth path containment', () => {
}
})
it('authorizes local folder workspace roots outside child repo roots', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-folder-workspace-'))
try {
const folderPath = join(tempRoot, 'platform')
const repoPath = join(folderPath, 'web')
await mkdir(repoPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath })
const folderWorkspace = makeFolderWorkspace({ folderPath, projectGroupId: projectGroup.id })
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }], {
projectGroups: [projectGroup],
folderWorkspaces: [folderWorkspace]
})
await expect(resolveAuthorizedPath(folderPath, store)).resolves.toBe(
await realpath(folderPath)
)
await expect(resolveAuthorizedPath(join(folderPath, 'notes.md'), store)).resolves.toBe(
join(await realpath(folderPath), 'notes.md')
)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('authorizes local folder-backed project group roots outside child repo roots', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-project-group-'))
try {
const folderPath = join(tempRoot, 'platform')
const repoPath = join(folderPath, 'web')
await mkdir(repoPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath })
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }], {
projectGroups: [projectGroup]
})
await expect(resolveAuthorizedPath(folderPath, store)).resolves.toBe(
await realpath(folderPath)
)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('does not authorize SSH-only folder workspace roots as local paths', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-folder-workspace-'))
try {
const folderPath = join(tempRoot, 'remote-platform')
const repoPath = join(folderPath, 'web')
await mkdir(repoPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath })
const folderWorkspace = makeFolderWorkspace({ folderPath, projectGroupId: projectGroup.id })
const store = makeStore(
[{ ...repo, id: 'repo-temp', path: repoPath, connectionId: 'ssh-1' }],
{
projectGroups: [projectGroup],
folderWorkspaces: [folderWorkspace]
}
)
await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied')
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('does not authorize repo-less SSH-provenance folder roots as local paths', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-folder-provenance-'))
try {
const folderPath = join(tempRoot, 'remote-platform')
await mkdir(folderPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath, connectionId: 'ssh-1' })
const folderWorkspace = makeFolderWorkspace({
folderPath,
projectGroupId: projectGroup.id,
connectionId: 'ssh-1'
})
const store = makeStore([], {
projectGroups: [projectGroup],
folderWorkspaces: [folderWorkspace]
})
await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied')
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('does not authorize SSH-only folder-backed project group roots as local paths', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-project-group-'))
try {
const folderPath = join(tempRoot, 'remote-platform')
const repoPath = join(folderPath, 'web')
await mkdir(repoPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath })
const store = makeStore(
[{ ...repo, id: 'repo-temp', path: repoPath, connectionId: 'ssh-1' }],
{
projectGroups: [projectGroup]
}
)
await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied')
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it.skipIf(process.platform === 'win32')(
'rejects missing descendants under a symlinked ancestor outside the repo',
async () => {

View File

@ -7,6 +7,9 @@ import { realpath } from 'fs/promises'
import type { Store } from '../persistence'
import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees'
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { getProjectGroupSubtreeIds } from '../../shared/project-groups'
import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types'
export const PATH_ACCESS_DENIED_MESSAGE =
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
@ -17,6 +20,8 @@ const registeredWorktreeRootRepoIds = new Set<string>()
let registeredWorktreeRootsDirty = true
let registeredWorktreeRootsRefresh: Promise<void> | null = null
const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8
type FolderScopeStore = Pick<Store, 'getRepos'> &
Partial<Pick<Store, 'getProjectGroups' | 'getFolderWorkspaces'>>
export function authorizeExternalPath(targetPath: string): void {
const resolvedTarget = resolve(targetPath)
@ -43,6 +48,75 @@ function getLocalRepos(store: Store) {
return store.getRepos().filter((repo) => !repo.connectionId)
}
function getFolderScopeCandidateRepos(
folderPath: string,
projectGroupId: string,
projectGroups: readonly ProjectGroup[],
repos: readonly Repo[]
): Repo[] {
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId)
return repos.filter(
(repo) =>
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
isPathInsideOrEqual(folderPath, repo.path)
)
}
function isRemoteOnlyFolderScope(
folderPath: string,
projectGroupId: string,
connectionId: string | null | undefined,
projectGroups: readonly ProjectGroup[],
repos: readonly Repo[]
): boolean {
if (connectionId) {
return true
}
const candidates = getFolderScopeCandidateRepos(folderPath, projectGroupId, projectGroups, repos)
return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId))
}
function getFolderWorkspaceConnectionId(
workspace: FolderWorkspace,
projectGroups: readonly ProjectGroup[]
): string | null {
return (
workspace.connectionId ??
projectGroups.find((group) => group.id === workspace.projectGroupId)?.connectionId ??
null
)
}
function getLocalFolderScopeRoots(store: Store): string[] {
const scopeStore = store as FolderScopeStore
const repos = scopeStore.getRepos()
// Why: many filesystem tests use narrow Store doubles; folder scopes are additive.
const projectGroups = scopeStore.getProjectGroups?.() ?? []
const roots: string[] = []
for (const group of projectGroups) {
if (
group.parentPath &&
!isRemoteOnlyFolderScope(group.parentPath, group.id, group.connectionId, projectGroups, repos)
) {
roots.push(resolve(group.parentPath))
}
}
for (const workspace of scopeStore.getFolderWorkspaces?.() ?? []) {
if (
!isRemoteOnlyFolderScope(
workspace.folderPath,
workspace.projectGroupId,
getFolderWorkspaceConnectionId(workspace, projectGroups),
projectGroups,
repos
)
) {
roots.push(resolve(workspace.folderPath))
}
}
return roots
}
/**
* Check whether resolvedTarget is equal to or a descendant of resolvedBase.
* Uses relative() so it works with both `/` (Unix) and `\` (Windows) separators.
@ -63,7 +137,10 @@ export function isDescendantOrEqual(resolvedTarget: string, resolvedBase: string
export function getAllowedRoots(store: Store): string[] {
const localRepos = getLocalRepos(store)
const settings = store.getSettings()
const roots = localRepos.map((repo) => resolve(repo.path))
const roots = [
...localRepos.map((repo) => resolve(repo.path)),
...getLocalFolderScopeRoots(store)
]
if (settings.workspaceDir) {
if (localRepos.length === 0) {
roots.push(resolve(settings.workspaceDir))

View File

@ -64,6 +64,12 @@ import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import {
assertFolderWorkspacePathUsable,
getFolderWorkspacePathStatus
} from '../project-groups/folder-workspace-path-status'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
// ─── Provider Registry ──────────────────────────────────────────────
// Routes PTY operations by connectionId. null = local provider.
@ -1569,6 +1575,21 @@ export function registerPtyHandlers(
mainWindow.webContents.on('did-finish-load', didFinishLoadHandler)
}
const assertFolderWorkspacePtyPathUsable = async (
worktreeId: string | undefined
): Promise<void> => {
const workspaceScope = typeof worktreeId === 'string' ? parseWorkspaceKey(worktreeId) : null
if (!store || workspaceScope?.type !== 'folder') {
return
}
const status = await getFolderWorkspacePathStatus(
store,
{ scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId },
{ getSshFilesystemProvider }
)
assertFolderWorkspacePathUsable(status)
}
// Why: the runtime controller must route through getProviderForPty() so that
// CLI commands (terminal.send, terminal.stop) work for both local and remote PTYs.
// Hardcoding localProvider.getPtyProcess() would silently fail for remote PTYs.
@ -1578,6 +1599,7 @@ export function registerPtyHandlers(
if (startupPromise) {
await startupPromise
}
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
const provider = getProvider(args.connectionId)
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
@ -2030,6 +2052,7 @@ export function registerPtyHandlers(
if (startupPromise) {
await startupPromise
}
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
const provider = getProvider(args.connectionId)
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {

View File

@ -11,16 +11,19 @@ import type {
BaseRefSearchResult,
Repo,
ProjectGroup,
FolderWorkspace,
ProjectGroupImportResult,
NestedRepoScanResult,
BaseRefDefaultResult,
SparsePreset
} from '../../shared/types'
import type { FolderWorkspacePathStatusRequest } from '../../shared/folder-workspace-path-status'
import { isFolderRepo } from '../../shared/repo-kind'
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { normalizeRepoBadgeColor } from '../../shared/repo-badge-color'
import { sanitizeRepoIcon } from '../../shared/repo-icon'
import { normalizeRepoSourceControlAiOverrides } from '../../shared/source-control-ai'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import type { ChildProcess } from 'child_process'
import { access, mkdir, readdir, rm } from 'fs/promises'
@ -62,6 +65,11 @@ import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import type { RepoMethod } from '../../shared/telemetry-events'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import {
assertFolderWorkspacePathUsable,
getFolderWorkspacePathStatus,
getFolderWorkspacePathStatusForPath
} from '../project-groups/folder-workspace-path-status'
// Why: `method` answers "which entry point did the user take?", not "what did
// they add?" — so the IPC the renderer invoked IS the method. We never send
@ -136,6 +144,7 @@ const GIT_AVAILABILITY_TIMEOUT_MS = 1500
const ProjectGroupCreateArgs = z.object({
name: z.string().min(1),
parentPath: z.string().nullable().optional(),
connectionId: z.string().nullable().optional(),
parentGroupId: z.string().nullable().optional(),
createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional()
})
@ -160,6 +169,64 @@ const ProjectGroupMoveProjectArgs = z.object({
order: z.number().finite().optional()
})
const FolderWorkspaceLinkedTaskArgs = z
.object({
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
type: z.enum(['issue', 'pr', 'mr']),
number: z.number().finite(),
title: z.string().min(1),
url: z.string().min(1),
linearIdentifier: z.string().min(1).optional(),
jiraIdentifier: z.string().min(1).optional(),
repoId: z.string().min(1).optional()
})
.nullable()
const FolderWorkspaceCreateArgs = z.object({
projectGroupId: z.string().min(1),
name: z.string().optional(),
folderPath: z.string().nullable().optional(),
connectionId: z.string().nullable().optional(),
linkedTask: FolderWorkspaceLinkedTaskArgs.optional(),
createdWithAgent: z.string().refine(isTuiAgent).optional(),
pendingFirstAgentMessageRename: z.boolean().optional()
})
const FolderWorkspaceUpdateArgs = z.object({
folderWorkspaceId: z.string().min(1),
updates: z.object({
name: z.string().optional(),
folderPath: z.string().optional(),
linkedTask: FolderWorkspaceLinkedTaskArgs.optional(),
comment: z.string().optional(),
isArchived: z.boolean().optional(),
isUnread: z.boolean().optional(),
isPinned: z.boolean().optional(),
sortOrder: z.number().finite().optional(),
manualOrder: z.number().finite().optional(),
workspaceStatus: z.string().optional(),
createdWithAgent: z.string().refine(isTuiAgent).optional(),
pendingFirstAgentMessageRename: z.boolean().optional(),
firstAgentMessageRenameError: z.string().nullable().optional(),
lastActivityAt: z.number().finite().optional()
})
})
const FolderWorkspaceSelectorArgs = z.object({
folderWorkspaceId: z.string().min(1)
})
const FolderWorkspacePathStatusArgs = z.discriminatedUnion('scope', [
z.object({
scope: z.literal('folder-workspace'),
folderWorkspaceId: z.string().min(1)
}),
z.object({
scope: z.literal('project-group'),
projectGroupId: z.string().min(1)
})
])
const ProjectGroupScanNestedArgs = z.object({
path: z.string().min(1),
connectionId: z.string().min(1).optional(),
@ -470,6 +537,11 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('projectGroups:scanNested')
ipcMain.removeHandler('projectGroups:cancelNestedScan')
ipcMain.removeHandler('projectGroups:importNested')
ipcMain.removeHandler('folderWorkspaces:list')
ipcMain.removeHandler('folderWorkspaces:create')
ipcMain.removeHandler('folderWorkspaces:update')
ipcMain.removeHandler('folderWorkspaces:delete')
ipcMain.removeHandler('folderWorkspaces:getPathStatus')
ipcMain.removeHandler('repos:pickFolder')
ipcMain.removeHandler('repos:pickDirectory')
ipcMain.removeHandler('repos:clone')
@ -495,6 +567,104 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.handle('projectGroups:list', () => store.getProjectGroups())
ipcMain.handle('folderWorkspaces:list', (): FolderWorkspace[] => store.getFolderWorkspaces())
ipcMain.handle('folderWorkspaces:getPathStatus', async (_event, rawArgs: unknown) => {
const args = parseProjectGroupIpcArgs(
FolderWorkspacePathStatusArgs,
rawArgs,
'invalid_folder_workspace_path_status_args'
) as FolderWorkspacePathStatusRequest
return getFolderWorkspacePathStatus(store, args, { getSshFilesystemProvider })
})
ipcMain.handle(
'folderWorkspaces:create',
async (_event, rawArgs: unknown): Promise<FolderWorkspace> => {
const args = parseProjectGroupIpcArgs(
FolderWorkspaceCreateArgs,
rawArgs,
'invalid_folder_workspace_create_args'
)
const projectGroups = store.getProjectGroups()
const group = projectGroups.find((entry) => entry.id === args.projectGroupId)
const folderPath =
typeof args.folderPath === 'string' && args.folderPath.trim().length > 0
? args.folderPath
: group?.parentPath
if (!group || !folderPath) {
throw new Error('folder_workspace_project_group_not_found')
}
const status = await getFolderWorkspacePathStatusForPath(
{
folderPath,
projectGroupId: group.id,
connectionId: args.connectionId ?? group.connectionId ?? null,
projectGroups,
repos: store.getRepos()
},
{ getSshFilesystemProvider }
)
assertFolderWorkspacePathUsable(status)
const workspace = store.createFolderWorkspace(args)
notifyReposChanged(mainWindow)
return workspace
}
)
ipcMain.handle(
'folderWorkspaces:update',
async (_event, rawArgs: unknown): Promise<FolderWorkspace | null> => {
const args = parseProjectGroupIpcArgs(
FolderWorkspaceUpdateArgs,
rawArgs,
'invalid_folder_workspace_update_args'
)
if (
typeof args.updates.folderPath === 'string' &&
args.updates.folderPath.trim().length > 0
) {
const workspace = store.getFolderWorkspace(args.folderWorkspaceId)
if (!workspace) {
return null
}
const projectGroups = store.getProjectGroups()
const status = await getFolderWorkspacePathStatusForPath(
{
folderPath: args.updates.folderPath,
projectGroupId: workspace.projectGroupId,
connectionId:
workspace.connectionId ??
projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ??
null,
projectGroups,
repos: store.getRepos()
},
{ getSshFilesystemProvider }
)
assertFolderWorkspacePathUsable(status)
}
const updated = store.updateFolderWorkspace(args.folderWorkspaceId, args.updates)
if (updated) {
notifyReposChanged(mainWindow)
}
return updated
}
)
ipcMain.handle('folderWorkspaces:delete', (_event, rawArgs: unknown): boolean => {
const args = parseProjectGroupIpcArgs(
FolderWorkspaceSelectorArgs,
rawArgs,
'invalid_folder_workspace_delete_args'
)
const deleted = store.removeFolderWorkspace(args.folderWorkspaceId)
if (deleted) {
notifyReposChanged(mainWindow)
}
return deleted
})
ipcMain.handle('projectGroups:create', (_event, rawArgs: unknown): ProjectGroup => {
const args = parseProjectGroupIpcArgs(
ProjectGroupCreateArgs,
@ -504,6 +674,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
const group = store.createProjectGroup({
name: args.name,
parentPath: args.parentPath ?? null,
connectionId: args.connectionId ?? null,
parentGroupId: args.parentGroupId ?? null,
createdFrom: args.createdFrom ?? 'manual'
})
@ -598,6 +769,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
parentPath: scan.selectedPath,
groupName: args.groupName ?? '',
mode: args.mode,
connectionId: args.connectionId ?? null,
repoPaths: selection.selectedPaths,
createGroup: (input) => store.createProjectGroup(input)
})
const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map(

View File

@ -31,6 +31,7 @@ import {
ONBOARDING_FINAL_STEP,
ONBOARDING_FLOW_VERSION
} from '../shared/constants'
import { folderWorkspaceKey } from '../shared/workspace-scope'
import { SshConnectionStore } from './ssh/ssh-connection-store'
// Shared mutable state so the electron mock can reference a per-test directory
@ -2144,6 +2145,57 @@ describe('Store', () => {
expect(store.getRepo('sibling')?.projectGroupId).toBe(sibling.id)
})
it('adapts flat folder-scan groups into sparse nested folder scopes on load', async () => {
writeDataFile({
schemaVersion: 1,
repos: [
makeRepo({ id: 'api', path: '/workspace/platform/api', projectGroupId: 'root' }),
makeRepo({ id: 'web', path: '/workspace/platform/web', projectGroupId: 'root' }),
makeRepo({
id: 'repo1',
path: '/workspace/platform/packages/shared/repo1',
projectGroupId: 'root'
}),
makeRepo({
id: 'repo2',
path: '/workspace/platform/packages/shared/repo2',
projectGroupId: 'root'
})
],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
projectGroups: [
{
id: 'root',
name: 'Platform',
parentPath: '/workspace/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
]
})
const store = await createStore()
const groups = store.getProjectGroups()
const shared = groups.find((group) => group.name === 'packages/shared')
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
['Platform', null, '/workspace/platform'],
['packages/shared', 'root', '/workspace/platform/packages/shared']
])
expect(store.getRepo('api')?.projectGroupId).toBe('root')
expect(store.getRepo('web')?.projectGroupId).toBe('root')
expect(store.getRepo('repo1')?.projectGroupId).toBe(shared?.id)
expect(store.getRepo('repo2')?.projectGroupId).toBe(shared?.id)
})
it('creates a project group when persisted group history is very large', async () => {
const projectGroups: ProjectGroup[] = Array.from({ length: 130_000 }, (_, index) => ({
id: `group-${index}`,
@ -2525,6 +2577,319 @@ describe('Store', () => {
expect(updated.comment).toBe('updated')
})
it('creates and updates folder workspaces from folder-backed project groups', async () => {
const store = await createStore()
const group = store.createProjectGroup({
name: 'Platform',
parentPath: '/workspace/platform',
createdFrom: 'folder-scan'
})
const linkedTask = {
provider: 'linear' as const,
type: 'issue' as const,
number: 0,
title: 'Refund fix',
url: 'https://linear.app/acme/issue/ENG-123',
linearIdentifier: 'ENG-123'
}
const workspace = store.createFolderWorkspace({
projectGroupId: group.id,
name: 'Refund fix',
linkedTask
})
const updated = store.updateFolderWorkspace(workspace.id, {
comment: 'Coordinate api and web',
isPinned: true,
lastActivityAt: 123
})
expect(workspace.folderPath).toBe('/workspace/platform')
expect(updated).toMatchObject({
id: workspace.id,
projectGroupId: group.id,
name: 'Refund fix',
folderPath: '/workspace/platform',
linkedTask,
comment: 'Coordinate api and web',
isPinned: true,
lastActivityAt: 123
})
expect(store.getFolderWorkspaces()).toHaveLength(1)
})
it('rejects folder workspace creation for non-folder-backed project groups', async () => {
const store = await createStore()
const group = store.createProjectGroup({ name: 'Manual', createdFrom: 'manual' })
expect(() => store.createFolderWorkspace({ projectGroupId: group.id })).toThrow(
'Folder-backed project group not found.'
)
})
it('normalizes persisted folder workspaces and drops orphaned records', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
projectGroups: [
{
id: 'root',
name: 'Platform',
parentPath: '/workspace/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
],
folderWorkspaces: [
{
id: 'fw-1',
projectGroupId: 'root',
name: ' ',
folderPath: '',
comment: 42,
isArchived: true,
isUnread: true,
isPinned: false,
sortOrder: 10,
lastActivityAt: 5,
createdAt: 2,
updatedAt: 3
},
{
id: 'orphan',
projectGroupId: 'missing',
name: 'Orphan',
folderPath: '/missing'
}
]
})
const store = await createStore()
expect(store.getFolderWorkspaces()).toEqual([
expect.objectContaining({
id: 'fw-1',
projectGroupId: 'root',
name: 'Untitled workspace',
folderPath: '/workspace/platform',
comment: '',
isArchived: true,
isUnread: true
})
])
})
it('backfills folder-scope SSH provenance from unambiguous child repos on load', async () => {
writeDataFile({
schemaVersion: 1,
repos: [
makeRepo({
id: 'api',
path: '/workspace/platform/api',
projectGroupId: 'root',
connectionId: 'ssh-1'
})
],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
projectGroups: [
{
id: 'root',
name: 'Platform',
parentPath: '/workspace/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
],
folderWorkspaces: [
{
id: 'fw-1',
projectGroupId: 'root',
name: 'Refund fix',
folderPath: '/workspace/platform',
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
]
})
const store = await createStore()
expect(store.getProjectGroups()[0]).toMatchObject({ id: 'root', connectionId: 'ssh-1' })
expect(store.getFolderWorkspaces()[0]).toMatchObject({ id: 'fw-1', connectionId: 'ssh-1' })
})
it('backfills folder-scope SSH provenance from grouped repos despite unrelated same-path SSH repos', async () => {
writeDataFile({
schemaVersion: 1,
repos: [
makeRepo({
id: 'api-ssh-1',
path: '/workspace/platform/api',
projectGroupId: 'root',
connectionId: 'ssh-1'
}),
makeRepo({
id: 'api-ssh-2',
path: '/workspace/platform/api',
projectGroupId: 'other-root',
connectionId: 'ssh-2'
})
],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
projectGroups: [
{
id: 'root',
name: 'Platform',
parentPath: '/workspace/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
},
{
id: 'other-root',
name: 'Platform other',
parentPath: '/workspace/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 1,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
],
folderWorkspaces: [
{
id: 'fw-1',
projectGroupId: 'root',
name: 'Refund fix',
folderPath: '/workspace/platform',
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
]
})
const store = await createStore()
expect(store.getProjectGroups().find((group) => group.id === 'root')).toMatchObject({
connectionId: 'ssh-1'
})
expect(store.getFolderWorkspaces()[0]).toMatchObject({ id: 'fw-1', connectionId: 'ssh-1' })
})
it('removes folder workspace metadata and its scoped session state only', async () => {
const store = await createStore()
const group = store.createProjectGroup({
name: 'Platform',
parentPath: '/workspace/platform',
createdFrom: 'folder-scan'
})
store.addRepo(
makeRepo({ id: 'api', path: '/workspace/platform/api', projectGroupId: group.id })
)
const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'Refund fix' })
const key = folderWorkspaceKey(workspace.id)
const tab = makeTerminalTab({ id: 'folder-tab', worktreeId: key })
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
activeWorkspaceKey: key,
activeWorktreeId: key,
activeTabId: tab.id,
tabsByWorktree: { [key]: [tab], 'repo::/wt': [makeTerminalTab({ id: 'repo-tab' })] },
terminalLayoutsByTabId: {
[tab.id]: { root: null, activeLeafId: null, expandedLeafId: null },
'repo-tab': { root: null, activeLeafId: null, expandedLeafId: null }
},
browserTabsByWorktree: {
[key]: [
{
id: 'browser-workspace',
worktreeId: key,
url: 'about:blank',
title: 'Blank',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
},
browserPagesByWorkspace: {
'browser-workspace': [
{
id: 'page-1',
workspaceId: 'browser-workspace',
worktreeId: key,
url: 'about:blank',
title: 'Blank',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
},
activeTabIdByWorktree: { [key]: tab.id },
lastVisitedAtByWorktreeId: { [key]: 10 }
})
expect(store.removeFolderWorkspace(workspace.id)).toBe(true)
const session = store.getWorkspaceSession()
expect(store.getFolderWorkspaces()).toEqual([])
expect(store.getProjectGroups()).toHaveLength(1)
expect(store.getRepo('api')?.projectGroupId).toBe(group.id)
expect(session.activeWorkspaceKey).toBeNull()
expect(session.activeWorktreeId).toBeNull()
expect(session.activeTabId).toBeNull()
expect(session.tabsByWorktree[key]).toBeUndefined()
expect(session.tabsByWorktree['repo::/wt']).toHaveLength(1)
expect(session.terminalLayoutsByTabId['folder-tab']).toBeUndefined()
expect(session.terminalLayoutsByTabId['repo-tab']).toBeDefined()
expect(session.browserPagesByWorkspace?.['browser-workspace']).toBeUndefined()
})
// ── 9. Settings: get/update ────────────────────────────────────────
it('updateSettings merges partial updates', async () => {

View File

@ -36,6 +36,7 @@ import type {
PersistedState,
Repo,
ProjectGroup,
FolderWorkspace,
SparsePreset,
WorktreeMeta,
WorktreeLineage,
@ -85,7 +86,10 @@ import { agentHookServer } from './agent-hooks/server'
import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers'
import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history'
import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../shared/worktree-id'
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison
} from '../shared/cross-platform-path'
import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands'
import { normalizeTaskProviderSettings } from '../shared/task-providers'
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-rename-branch-from-work-settings'
@ -121,6 +125,7 @@ import {
normalizeProjectGroupName,
normalizeProjectGroups
} from '../shared/project-groups'
import { createNestedProjectGroupResolver } from './project-groups/nested-repo-import'
import {
mergeLegacyCommitMessageAiIntoSourceControlAi,
normalizeRepoSourceControlAiOverrides,
@ -139,6 +144,11 @@ import {
import { normalizeTerminalCursorStyleDefault } from '../shared/terminal-cursor-style-settings'
import { normalizeUiLanguage } from '../shared/ui-language'
import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom'
import {
normalizeFolderWorkspaceName,
normalizeFolderWorkspaces
} from '../shared/folder-workspaces'
import { folderWorkspaceKey } from '../shared/workspace-scope'
import {
collectTerminalScrollbackSnapshotRefs,
deleteTerminalScrollbackSnapshotSync,
@ -1642,6 +1652,167 @@ function cloneWorkspaceSessionState(session: WorkspaceSessionState): WorkspaceSe
return structuredClone(session)
}
function removeWorkspaceSessionOwner(
session: WorkspaceSessionState | undefined,
ownerKey: string
): WorkspaceSessionState | undefined {
if (!session) {
return session
}
const next = cloneWorkspaceSessionState(session)
const removedTerminalTabs = next.tabsByWorktree?.[ownerKey] ?? []
if (next.tabsByWorktree) {
delete next.tabsByWorktree[ownerKey]
}
for (const tab of removedTerminalTabs) {
delete next.terminalLayoutsByTabId[tab.id]
if (next.activeTabId === tab.id) {
next.activeTabId = null
}
}
if (next.openFilesByWorktree) {
delete next.openFilesByWorktree[ownerKey]
}
if (next.activeFileIdByWorktree) {
delete next.activeFileIdByWorktree[ownerKey]
}
const browserWorkspaces = next.browserTabsByWorktree?.[ownerKey] ?? []
if (next.browserTabsByWorktree) {
delete next.browserTabsByWorktree[ownerKey]
}
if (next.browserPagesByWorkspace) {
for (const workspace of browserWorkspaces) {
delete next.browserPagesByWorkspace[workspace.id]
}
}
if (next.activeBrowserTabIdByWorktree) {
delete next.activeBrowserTabIdByWorktree[ownerKey]
}
if (next.activeTabTypeByWorktree) {
delete next.activeTabTypeByWorktree[ownerKey]
}
if (next.activeTabIdByWorktree) {
delete next.activeTabIdByWorktree[ownerKey]
}
if (next.unifiedTabs) {
delete next.unifiedTabs[ownerKey]
}
if (next.tabGroups) {
delete next.tabGroups[ownerKey]
}
if (next.tabGroupLayouts) {
delete next.tabGroupLayouts[ownerKey]
}
if (next.activeGroupIdByWorktree) {
delete next.activeGroupIdByWorktree[ownerKey]
}
if (next.lastVisitedAtByWorktreeId) {
delete next.lastVisitedAtByWorktreeId[ownerKey]
}
if (next.defaultTerminalTabsAppliedByWorktreeId) {
delete next.defaultTerminalTabsAppliedByWorktreeId[ownerKey]
}
if (next.sleepingAgentSessionsByPaneKey) {
for (const [paneKey, record] of Object.entries(next.sleepingAgentSessionsByPaneKey)) {
if (record.worktreeId === ownerKey) {
delete next.sleepingAgentSessionsByPaneKey[paneKey]
}
}
}
if (next.activeWorkspaceKey === ownerKey) {
next.activeWorkspaceKey = null
}
if (next.activeWorktreeId === ownerKey) {
next.activeWorktreeId = null
}
next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter(
(worktreeId) => worktreeId !== ownerKey
)
return next
}
function inferFolderScopeConnectionIdForMigration(args: {
folderPath: string
projectGroupId: string
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
}): string | null {
const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
const groupRepos = args.repos.filter(
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
)
const candidateRepos =
groupRepos.length > 0
? groupRepos
: args.repos.filter((repo) => isPathInsideOrEqual(args.folderPath, repo.path))
if (candidateRepos.length === 0) {
return null
}
let hasLocalRepo = false
const connectionIds = new Set<string>()
for (const repo of candidateRepos) {
if (repo.connectionId) {
connectionIds.add(repo.connectionId)
} else {
hasLocalRepo = true
}
}
if (hasLocalRepo || connectionIds.size !== 1) {
return null
}
return [...connectionIds][0]
}
function backfillFolderScopeConnectionIds(state: PersistedState): {
state: PersistedState
changed: boolean
} {
const groups = state.projectGroups ?? []
const repos = state.repos ?? []
let changed = false
const projectGroups = groups.map((group) => {
if (group.connectionId || !group.parentPath) {
return group
}
const connectionId = inferFolderScopeConnectionIdForMigration({
folderPath: group.parentPath,
projectGroupId: group.id,
projectGroups: groups,
repos
})
if (!connectionId) {
return group
}
changed = true
return { ...group, connectionId }
})
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
const folderWorkspaces = (state.folderWorkspaces ?? []).map((workspace) => {
if (workspace.connectionId) {
return workspace
}
const groupConnectionId = groupsById.get(workspace.projectGroupId)?.connectionId ?? null
const connectionId =
groupConnectionId ??
inferFolderScopeConnectionIdForMigration({
folderPath: workspace.folderPath,
projectGroupId: workspace.projectGroupId,
projectGroups,
repos
})
if (!connectionId) {
return workspace
}
changed = true
return { ...workspace, connectionId }
})
return {
changed,
state: changed ? { ...state, projectGroups, folderWorkspaces } : state
}
}
function deleteRemovedTerminalScrollbackSnapshots(
prior: WorkspaceSessionState | undefined,
next: WorkspaceSessionState
@ -1676,6 +1847,7 @@ export class Store {
const loaded = this.load()
const normalized = normalizePersistedPaneIdentityState(loaded)
this.state = normalized.state
const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups()
for (const entry of normalized.migrationUnsupportedEntries) {
setMigrationUnsupportedPty(entry)
}
@ -1696,7 +1868,7 @@ export class Store {
this.state.legacyPaneKeyAliasEntries = entries
this.scheduleSave()
})
if (normalized.changed || this.loadNeedsSave) {
if (normalized.changed || this.loadNeedsSave || adaptedProjectGroups) {
// Why: upgraded sessions may contain legacy pane:1 leaves. Rewrite them at
// the main persistence boundary so older renderer writes cannot revive them.
// Other one-shot load migrations also set loadNeedsSave to persist their
@ -1705,6 +1877,86 @@ export class Store {
}
}
private adaptFlatFolderScanProjectGroups(): boolean {
// Why: older folder imports persisted a real parent path but kept all repos
// flat. Upgrade that shape into v1 sparse folder scopes on load.
const groups = this.state.projectGroups ?? []
const repos = this.state.repos
if (groups.length === 0 || repos.length === 0) {
return false
}
let changed = false
let maxOrder = -1
for (const group of groups) {
maxOrder = Math.max(maxOrder, group.tabOrder)
}
const childGroupIds = new Set(
groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : []))
)
const initialGroupCount = groups.length
for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) {
const rootGroup = groups[groupIndex]
if (!rootGroup) {
continue
}
if (
rootGroup.createdFrom !== 'folder-scan' ||
!rootGroup.parentPath ||
rootGroup.parentGroupId ||
childGroupIds.has(rootGroup.id)
) {
continue
}
const rootPath = rootGroup.parentPath
const repoCandidates = repos.filter(
(repo) =>
!isFolderRepo(repo) &&
repo.projectGroupId === rootGroup.id &&
isPathInsideOrEqual(rootPath, repo.path)
)
if (repoCandidates.length < 2) {
continue
}
const resolver = createNestedProjectGroupResolver({
parentPath: rootPath,
groupName: rootGroup.name,
mode: 'group',
repoPaths: repoCandidates.map((repo) => repo.path),
createGroup: (input) => {
if (!input.parentGroupId) {
return rootGroup
}
maxOrder += 1
const group = createProjectGroup({
...input,
tabOrder: maxOrder
})
groups.push(group)
changed = true
return group
}
})
const nextOrderByGroupId = new Map<string, number>()
for (const repo of repoCandidates) {
const group = resolver.getGroupForRepo(repo.path)
if (!group) {
continue
}
const nextOrder = nextOrderByGroupId.get(group.id) ?? 0
nextOrderByGroupId.set(group.id, nextOrder + 1)
if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) {
repo.projectGroupId = group.id
repo.projectGroupOrder = nextOrder
changed = true
}
}
}
return changed
}
// Why (issue #1158): debounced writes fire as often as every 300ms during
// active use. The backup ring should capture meaningfully different moments,
// not five near-identical snapshots from one burst of store updates.
@ -1995,13 +2247,18 @@ export class Store {
if (!parsed.onboarding) {
this.loadNeedsSave = true
}
const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups)
result = {
...defaults,
...parsed,
featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets(
parsed.featureInteractionTelemetryBuckets
),
projectGroups: normalizeProjectGroups(parsed.projectGroups),
projectGroups: normalizedProjectGroups,
folderWorkspaces: normalizeFolderWorkspaces(
parsed.folderWorkspaces,
normalizedProjectGroups
),
worktreeLineageById: parsed.worktreeLineageById ?? {},
settings: {
...defaults.settings,
@ -2313,11 +2570,15 @@ export class Store {
this.loadNeedsSave = true
}
result = {
const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({
...result,
repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []),
workspaceSession: migratedScrollback.session
})
if (folderScopeConnectionMigration.changed) {
this.loadNeedsSave = true
}
result = folderScopeConnectionMigration.state
return this.migrateTelemetry(result, fileExistedOnLoad)
}
@ -2555,6 +2816,7 @@ export class Store {
createProjectGroup(input: {
name: string
parentPath?: string | null
connectionId?: string | null
parentGroupId?: string | null
createdFrom: ProjectGroup['createdFrom']
}): ProjectGroup {
@ -2613,6 +2875,165 @@ export class Store {
? { ...repo, projectGroupId: null }
: repo
)
for (const workspace of this.state.folderWorkspaces ?? []) {
if (deletedGroupIds.has(workspace.projectGroupId)) {
this.state.workspaceSession = removeWorkspaceSessionOwner(
this.state.workspaceSession,
folderWorkspaceKey(workspace.id)
)!
}
}
this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter(
(workspace) => !deletedGroupIds.has(workspace.projectGroupId)
)
this.scheduleSave()
return true
}
getFolderWorkspaces(): FolderWorkspace[] {
return [...(this.state.folderWorkspaces ?? [])].sort(
(left, right) => right.sortOrder - left.sortOrder || left.name.localeCompare(right.name)
)
}
getFolderWorkspace(id: string): FolderWorkspace | undefined {
return (this.state.folderWorkspaces ?? []).find((workspace) => workspace.id === id)
}
createFolderWorkspace(input: {
projectGroupId: string
name?: string
folderPath?: string | null
linkedTask?: FolderWorkspace['linkedTask']
connectionId?: string | null
createdWithAgent?: FolderWorkspace['createdWithAgent']
pendingFirstAgentMessageRename?: boolean
}): FolderWorkspace {
const group = (this.state.projectGroups ?? []).find(
(entry) => entry.id === input.projectGroupId
)
const folderPath =
typeof input.folderPath === 'string' && input.folderPath.trim().length > 0
? input.folderPath
: group?.parentPath
if (!group || !folderPath) {
throw new Error('Folder-backed project group not found.')
}
const now = Date.now()
const workspace: FolderWorkspace = {
id: randomUUID(),
projectGroupId: group.id,
name: normalizeFolderWorkspaceName(input.name, `${group.name} workspace`),
folderPath,
connectionId: input.connectionId ?? group.connectionId ?? null,
linkedTask: input.linkedTask ?? null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: now,
...(input.createdWithAgent ? { createdWithAgent: input.createdWithAgent } : {}),
...(input.pendingFirstAgentMessageRename === true && input.createdWithAgent
? { pendingFirstAgentMessageRename: true }
: {}),
lastActivityAt: 0,
createdAt: now,
updatedAt: now
}
this.state.folderWorkspaces = [workspace, ...(this.state.folderWorkspaces ?? [])]
this.scheduleSave()
return workspace
}
updateFolderWorkspace(
id: string,
updates: Partial<
Pick<
FolderWorkspace,
| 'name'
| 'folderPath'
| 'linkedTask'
| 'comment'
| 'isArchived'
| 'isUnread'
| 'isPinned'
| 'sortOrder'
| 'manualOrder'
| 'workspaceStatus'
| 'createdWithAgent'
| 'pendingFirstAgentMessageRename'
| 'firstAgentMessageRenameError'
| 'lastActivityAt'
>
>
): FolderWorkspace | null {
const workspace = this.getFolderWorkspace(id)
if (!workspace) {
return null
}
if (updates.name !== undefined) {
workspace.name = normalizeFolderWorkspaceName(updates.name, workspace.name)
}
if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) {
workspace.folderPath = updates.folderPath
}
if (updates.linkedTask !== undefined) {
workspace.linkedTask = updates.linkedTask
}
if (updates.comment !== undefined) {
workspace.comment = updates.comment
}
if (updates.isArchived !== undefined) {
workspace.isArchived = updates.isArchived
}
if (updates.isUnread !== undefined) {
workspace.isUnread = updates.isUnread
}
if (updates.isPinned !== undefined) {
workspace.isPinned = updates.isPinned
}
if (updates.sortOrder !== undefined && Number.isFinite(updates.sortOrder)) {
workspace.sortOrder = updates.sortOrder
}
if (updates.manualOrder !== undefined) {
if (Number.isFinite(updates.manualOrder)) {
workspace.manualOrder = updates.manualOrder
} else {
delete workspace.manualOrder
}
}
if (updates.workspaceStatus !== undefined) {
workspace.workspaceStatus = updates.workspaceStatus
}
if (updates.createdWithAgent !== undefined) {
workspace.createdWithAgent = updates.createdWithAgent
}
if (updates.pendingFirstAgentMessageRename !== undefined) {
workspace.pendingFirstAgentMessageRename = updates.pendingFirstAgentMessageRename
}
if (updates.firstAgentMessageRenameError !== undefined) {
workspace.firstAgentMessageRenameError = updates.firstAgentMessageRenameError
}
if (updates.lastActivityAt !== undefined && Number.isFinite(updates.lastActivityAt)) {
workspace.lastActivityAt = updates.lastActivityAt
}
workspace.updatedAt = Date.now()
this.scheduleSave()
return workspace
}
removeFolderWorkspace(id: string): boolean {
const before = this.state.folderWorkspaces?.length ?? 0
this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter(
(workspace) => workspace.id !== id
)
if ((this.state.folderWorkspaces?.length ?? 0) === before) {
return false
}
this.state.workspaceSession = removeWorkspaceSessionOwner(
this.state.workspaceSession,
folderWorkspaceKey(id)
)!
this.scheduleSave()
return true
}

View File

@ -0,0 +1,221 @@
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { randomUUID } from 'crypto'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, expect, it, vi } from 'vitest'
import {
getFolderWorkspacePathStatusForPath,
inferFolderWorkspacePathConnection
} from './folder-workspace-path-status'
import type { IFilesystemProvider } from '../providers/types'
import type { ProjectGroup, Repo } from '../../shared/types'
function makeGroup(overrides: Partial<ProjectGroup> = {}): ProjectGroup {
return {
id: 'group-1',
name: 'Platform',
parentPath: '/workspace/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/workspace/platform/api',
displayName: 'api',
badgeColor: 'gray',
addedAt: 1,
projectGroupId: 'group-1',
...overrides
}
}
describe('folder workspace path status', () => {
it('reports existing local directories and local files', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-folder-status-'))
try {
const filePath = join(root, 'notes.txt')
await writeFile(filePath, 'hello')
await expect(
getFolderWorkspacePathStatusForPath(
{
folderPath: root,
projectGroupId: 'group-1',
projectGroups: [makeGroup({ parentPath: root })],
repos: []
},
{ getSshFilesystemProvider: () => undefined }
)
).resolves.toEqual({ path: root, exists: true })
await expect(
getFolderWorkspacePathStatusForPath(
{
folderPath: filePath,
projectGroupId: 'group-1',
projectGroups: [makeGroup({ parentPath: filePath })],
repos: []
},
{ getSshFilesystemProvider: () => undefined }
)
).resolves.toEqual({ path: filePath, exists: false, reason: 'not-directory' })
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('reports missing local directories', async () => {
const missingPath = join(tmpdir(), `orca-folder-status-missing-${randomUUID()}`)
await expect(
getFolderWorkspacePathStatusForPath(
{
folderPath: missingPath,
projectGroupId: 'group-1',
projectGroups: [makeGroup({ parentPath: missingPath })],
repos: []
},
{ getSshFilesystemProvider: () => undefined }
)
).resolves.toEqual({ path: missingPath, exists: false, reason: 'missing' })
})
it('routes inferred SSH folder scopes through the SSH filesystem provider', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 })
} as unknown as IFilesystemProvider
await expect(
getFolderWorkspacePathStatusForPath(
{
folderPath: '/workspace/platform',
projectGroupId: 'group-1',
projectGroups: [makeGroup()],
repos: [makeRepo({ connectionId: 'ssh-1' })]
},
{ getSshFilesystemProvider: () => provider }
)
).resolves.toEqual({ path: '/workspace/platform', exists: true })
expect(provider.stat).toHaveBeenCalledWith('/workspace/platform')
})
it('routes explicit SSH folder scopes through SSH without child repos', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 })
} as unknown as IFilesystemProvider
await expect(
getFolderWorkspacePathStatusForPath(
{
folderPath: '/workspace/platform',
projectGroupId: 'group-1',
connectionId: 'ssh-1',
projectGroups: [makeGroup({ connectionId: 'ssh-1' })],
repos: []
},
{ getSshFilesystemProvider: () => provider }
)
).resolves.toEqual({ path: '/workspace/platform', exists: true })
expect(provider.stat).toHaveBeenCalledWith('/workspace/platform')
})
it('reports unavailable when an inferred SSH provider is missing', async () => {
await expect(
getFolderWorkspacePathStatusForPath(
{
folderPath: '/workspace/platform',
projectGroupId: 'group-1',
projectGroups: [makeGroup()],
repos: [makeRepo({ connectionId: 'ssh-1' })]
},
{ getSshFilesystemProvider: () => undefined }
)
).resolves.toEqual({ path: '/workspace/platform', exists: false, reason: 'unavailable' })
})
it('reports ambiguous connection for mixed SSH scopes', () => {
expect(
inferFolderWorkspacePathConnection({
folderPath: '/workspace/platform',
projectGroupId: 'group-1',
projectGroups: [makeGroup()],
repos: [
makeRepo({ id: 'repo-1', connectionId: 'ssh-1' }),
makeRepo({ id: 'repo-2', connectionId: 'ssh-2' })
]
})
).toEqual({ kind: 'ambiguous' })
})
it('reports ambiguous connection for mixed local and SSH scopes', () => {
expect(
inferFolderWorkspacePathConnection({
folderPath: '/workspace/platform',
projectGroupId: 'group-1',
projectGroups: [makeGroup()],
repos: [
makeRepo({ id: 'repo-1', connectionId: undefined }),
makeRepo({ id: 'repo-2', connectionId: 'ssh-1' })
]
})
).toEqual({ kind: 'ambiguous' })
})
it('reports ambiguous connection when explicit SSH scope conflicts with repos', () => {
expect(
inferFolderWorkspacePathConnection({
folderPath: '/workspace/platform',
projectGroupId: 'group-1',
connectionId: 'ssh-1',
projectGroups: [makeGroup({ connectionId: 'ssh-1' })],
repos: [
makeRepo({ id: 'repo-1', connectionId: 'ssh-1' }),
makeRepo({ id: 'repo-2', connectionId: 'ssh-2' })
]
})
).toEqual({ kind: 'ambiguous' })
})
it('keeps explicit SSH scopes isolated from unrelated same-path SSH repos', async () => {
const provider = {
stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 })
} as unknown as IFilesystemProvider
await expect(
getFolderWorkspacePathStatusForPath(
{
folderPath: '/workspace/platform',
projectGroupId: 'group-1',
connectionId: 'ssh-1',
projectGroups: [
makeGroup({ id: 'group-1', connectionId: 'ssh-1' }),
makeGroup({ id: 'group-2', connectionId: 'ssh-2' })
],
repos: [
makeRepo({ id: 'repo-1', path: '/workspace/platform/api', connectionId: 'ssh-1' }),
makeRepo({
id: 'repo-2',
path: '/workspace/platform/api',
projectGroupId: 'group-2',
connectionId: 'ssh-2'
})
]
},
{
getSshFilesystemProvider: (connectionId) =>
connectionId === 'ssh-1' ? provider : undefined
}
)
).resolves.toEqual({ path: '/workspace/platform', exists: true })
expect(provider.stat).toHaveBeenCalledWith('/workspace/platform')
})
})

View File

@ -0,0 +1,215 @@
import { stat as statLocalPath } from 'fs/promises'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import type {
FolderWorkspacePathStatus,
FolderWorkspacePathStatusRequest
} from '../../shared/folder-workspace-path-status'
import { getProjectGroupSubtreeIds } from '../../shared/project-groups'
import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types'
import type { IFilesystemProvider } from '../providers/types'
type FolderWorkspacePathStatusStore = {
getRepos: () => Repo[]
getProjectGroups?: () => ProjectGroup[]
getFolderWorkspaces?: () => FolderWorkspace[]
}
export type FolderWorkspacePathConnectionResolution =
| { kind: 'local' }
| { kind: 'ssh'; connectionId: string }
| { kind: 'ambiguous' }
type FolderWorkspacePathStatusDeps = {
getSshFilesystemProvider: (connectionId: string) => IFilesystemProvider | undefined
}
function getFolderScopeCandidateRepos(args: {
folderPath: string
projectGroupId: string
connectionId?: string | null
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
}): Repo[] {
const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
const groupRepos = args.repos.filter(
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
)
const pathRepos = args.repos.filter(
(repo) =>
!(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) &&
isPathInsideOrEqual(args.folderPath, repo.path)
)
if (args.connectionId) {
return [
...groupRepos,
...pathRepos.filter((repo) => (repo.connectionId ?? null) === args.connectionId)
]
}
if (groupRepos.length === 0) {
return pathRepos
}
const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null))
return [
...groupRepos,
...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null))
]
}
export function inferFolderWorkspacePathConnection(args: {
folderPath: string
projectGroupId: string
connectionId?: string | null
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
}): FolderWorkspacePathConnectionResolution {
const candidateRepos = getFolderScopeCandidateRepos(args)
let hasLocalRepo = false
const connectionIds = new Set<string>()
for (const repo of candidateRepos) {
if (repo.connectionId) {
connectionIds.add(repo.connectionId)
} else {
hasLocalRepo = true
}
}
if (args.connectionId) {
const hasDifferentSshConnection = [...connectionIds].some(
(connectionId) => connectionId !== args.connectionId
)
if (hasLocalRepo || hasDifferentSshConnection) {
return { kind: 'ambiguous' }
}
return { kind: 'ssh', connectionId: args.connectionId }
}
if (hasLocalRepo && connectionIds.size > 0) {
return { kind: 'ambiguous' }
}
if (connectionIds.size === 0) {
return { kind: 'local' }
}
if (connectionIds.size === 1) {
return { kind: 'ssh', connectionId: [...connectionIds][0] }
}
return { kind: 'ambiguous' }
}
function pathStatErrorReason(error: unknown): 'missing' | 'unavailable' {
const code = (error as { code?: unknown } | null)?.code
return code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unavailable'
}
async function statFolderPath(
path: string,
connection: FolderWorkspacePathConnectionResolution,
deps: FolderWorkspacePathStatusDeps
): Promise<FolderWorkspacePathStatus> {
if (connection.kind === 'ambiguous') {
return { path, exists: false, reason: 'ambiguous-connection' }
}
if (connection.kind === 'ssh') {
const provider = deps.getSshFilesystemProvider(connection.connectionId)
if (!provider) {
return { path, exists: false, reason: 'unavailable' }
}
try {
const stats = await provider.stat(path)
return stats.type === 'directory'
? { path, exists: true }
: { path, exists: false, reason: 'not-directory' }
} catch (error) {
return { path, exists: false, reason: pathStatErrorReason(error) }
}
}
try {
const stats = await statLocalPath(path)
return stats.isDirectory()
? { path, exists: true }
: { path, exists: false, reason: 'not-directory' }
} catch (error) {
return { path, exists: false, reason: pathStatErrorReason(error) }
}
}
export async function getFolderWorkspacePathStatusForPath(
args: {
folderPath: string
projectGroupId: string
connectionId?: string | null
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
},
deps: FolderWorkspacePathStatusDeps
): Promise<FolderWorkspacePathStatus> {
const connection = inferFolderWorkspacePathConnection(args)
return statFolderPath(args.folderPath, connection, deps)
}
export function resolveFolderWorkspaceStatusPath(args: {
store: FolderWorkspacePathStatusStore
request: FolderWorkspacePathStatusRequest
}): { folderPath: string; projectGroupId: string; connectionId?: string | null } {
const { request } = args
if (request.scope === 'project-group') {
const group = args.store
.getProjectGroups?.()
.find((entry) => entry.id === request.projectGroupId)
if (!group?.parentPath) {
throw new Error('folder_workspace_path_scope_not_found')
}
return {
folderPath: group.parentPath,
projectGroupId: group.id,
connectionId: group.connectionId ?? null
}
}
const workspace = args.store
.getFolderWorkspaces?.()
.find((entry) => entry.id === request.folderWorkspaceId)
if (!workspace) {
throw new Error('folder_workspace_path_scope_not_found')
}
const group = args.store
.getProjectGroups?.()
.find((entry) => entry.id === workspace.projectGroupId)
return {
folderPath: workspace.folderPath,
projectGroupId: workspace.projectGroupId,
connectionId: workspace.connectionId ?? group?.connectionId ?? null
}
}
export async function getFolderWorkspacePathStatus(
store: FolderWorkspacePathStatusStore,
request: FolderWorkspacePathStatusRequest,
deps: FolderWorkspacePathStatusDeps
): Promise<FolderWorkspacePathStatus> {
const scope = resolveFolderWorkspaceStatusPath({ store, request })
return getFolderWorkspacePathStatusForPath(
{
folderPath: scope.folderPath,
projectGroupId: scope.projectGroupId,
connectionId: scope.connectionId,
projectGroups: store.getProjectGroups?.() ?? [],
repos: store.getRepos()
},
deps
)
}
export function assertFolderWorkspacePathUsable(status: FolderWorkspacePathStatus): void {
if (status.exists) {
return
}
if (status.reason === 'missing') {
throw new Error(`folder_workspace_path_missing:${status.path}`)
}
if (status.reason === 'not-directory') {
throw new Error(`folder_workspace_path_not_directory:${status.path}`)
}
if (status.reason === 'ambiguous-connection') {
throw new Error(`folder_workspace_connection_ambiguous:${status.path}`)
}
throw new Error(`folder_workspace_path_unavailable:${status.path}`)
}

View File

@ -6,18 +6,57 @@ import {
} from './nested-repo-import'
import type { ProjectGroup } from '../../shared/types'
function createGroupRecorder(): {
groups: ProjectGroup[]
createGroup: (input: {
name: string
parentPath?: string | null
connectionId?: string | null
parentGroupId?: string | null
createdFrom: ProjectGroup['createdFrom']
}) => ProjectGroup
} {
const groups: ProjectGroup[] = []
return {
groups,
createGroup: (input) => {
const group: ProjectGroup = {
id: `group-${groups.length}`,
name: input.name,
parentPath: input.parentPath ?? null,
connectionId: input.connectionId ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: groups.length,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
groups.push(group)
return group
}
}
}
describe('createNestedProjectGroupResolver', () => {
it('creates one root group for nested repos in grouped imports', () => {
it('creates sparse folder scopes for nested repos in grouped imports', () => {
const groups: ProjectGroup[] = []
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace',
groupName: 'workspace',
mode: 'group',
repoPaths: [
'/workspace/gateway-api',
'/workspace/services/payments/api',
'/workspace/services/payments/worker'
],
createGroup: (input) => {
const group: ProjectGroup = {
id: `group-${groups.length}`,
name: input.name,
parentPath: input.parentPath ?? null,
connectionId: input.connectionId ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: groups.length,
@ -36,17 +75,70 @@ describe('createNestedProjectGroupResolver', () => {
const sibling = resolver.getGroupForRepo('/workspace/services/payments/worker')
expect(direct?.name).toBe('workspace')
expect(nested?.name).toBe('workspace')
expect(nested?.name).toBe('services/payments')
expect(sibling?.id).toBe(nested?.id)
expect(groups.map((group) => [group.name, group.parentGroupId])).toEqual([['workspace', null]])
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
['workspace', null, '/workspace'],
['services/payments', 'group-0', '/workspace/services/payments']
])
expect(resolver.getRootGroup()?.id).toBe('group-0')
})
it('skips intermediate folders that only lead to one meaningful child scope', () => {
const { groups, createGroup } = createGroupRecorder()
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace/platform',
groupName: 'Platform',
mode: 'group',
repoPaths: [
'/workspace/platform/api',
'/workspace/platform/web',
'/workspace/platform/packages/shared/repo1',
'/workspace/platform/packages/shared/repo2'
],
createGroup
})
const api = resolver.getGroupForRepo('/workspace/platform/api')
const repo1 = resolver.getGroupForRepo('/workspace/platform/packages/shared/repo1')
const repo2 = resolver.getGroupForRepo('/workspace/platform/packages/shared/repo2')
expect(api?.name).toBe('Platform')
expect(repo1?.name).toBe('packages/shared')
expect(repo2?.id).toBe(repo1?.id)
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
['Platform', null, '/workspace/platform'],
['packages/shared', 'group-0', '/workspace/platform/packages/shared']
])
})
it('creates a parent folder scope when it has direct repos and nested descendants', () => {
const { groups, createGroup } = createGroupRecorder()
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace/platform',
groupName: 'Platform',
mode: 'group',
repoPaths: ['/workspace/platform/services/api', '/workspace/platform/services/jobs/worker'],
createGroup
})
const direct = resolver.getGroupForRepo('/workspace/platform/services/api')
const nested = resolver.getGroupForRepo('/workspace/platform/services/jobs/worker')
expect(direct?.name).toBe('services')
expect(nested?.id).toBe(direct?.id)
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
['Platform', null, '/workspace/platform'],
['services', 'group-0', '/workspace/platform/services']
])
})
it('does not create groups for separate imports', () => {
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace',
groupName: 'workspace',
mode: 'separate',
repoPaths: ['/workspace/services/api', '/workspace/services/worker'],
createGroup: () => {
throw new Error('should not create a group')
}
@ -62,6 +154,7 @@ describe('createNestedProjectGroupResolver', () => {
parentPath: '/',
groupName: 'root',
mode: 'group',
repoPaths: ['/api', '/services/api'],
createGroup: (input) => {
const group: ProjectGroup = {
id: `group-${groups.length}`,
@ -92,6 +185,7 @@ describe('createNestedProjectGroupResolver', () => {
parentPath: 'C:\\',
groupName: 'C',
mode: 'group',
repoPaths: ['C:\\api', 'C:\\services\\api'],
createGroup: (input) => {
const group: ProjectGroup = {
id: `group-${groups.length}`,
@ -116,6 +210,53 @@ describe('createNestedProjectGroupResolver', () => {
expect(groups.map((group) => group.parentPath)).toEqual(['C:/'])
})
it('creates sparse folder scopes for Windows repo paths', () => {
const { groups, createGroup } = createGroupRecorder()
const resolver = createNestedProjectGroupResolver({
parentPath: 'C:\\workspace\\platform',
groupName: 'Platform',
mode: 'group',
repoPaths: [
'C:\\workspace\\platform\\apps\\web',
'C:\\workspace\\platform\\packages\\shared\\repo1',
'C:\\workspace\\platform\\packages\\shared\\repo2'
],
createGroup
})
const web = resolver.getGroupForRepo('C:\\workspace\\platform\\apps\\web')
const repo1 = resolver.getGroupForRepo('C:\\workspace\\platform\\packages\\shared\\repo1')
expect(web?.name).toBe('Platform')
expect(repo1?.name).toBe('packages/shared')
expect(groups.map((group) => [group.name, group.parentGroupId, group.parentPath])).toEqual([
['Platform', null, 'C:/workspace/platform'],
['packages/shared', 'group-0', 'C:/workspace/platform/packages/shared']
])
})
it('preserves SSH provenance on grouped folder scopes', () => {
const { groups, createGroup } = createGroupRecorder()
const resolver = createNestedProjectGroupResolver({
parentPath: '/workspace/platform',
groupName: 'Platform',
mode: 'group',
connectionId: 'ssh-1',
repoPaths: [
'/workspace/platform/packages/shared/repo1',
'/workspace/platform/packages/shared/repo2'
],
createGroup
})
resolver.getGroupForRepo('/workspace/platform/packages/shared/repo1')
expect(groups.map((group) => [group.name, group.connectionId])).toEqual([
['Platform', 'ssh-1'],
['packages/shared', 'ssh-1']
])
})
it('falls back to the selected parent folder basename for blank group names', () => {
const groups: ProjectGroup[] = []
const resolver = createNestedProjectGroupResolver({

View File

@ -4,12 +4,14 @@ import {
isPathInsideOrEqual,
isRuntimePathAbsolute,
normalizeRuntimePathForComparison,
relativePathInsideRoot,
resolveRuntimePath
} from '../../shared/cross-platform-path'
type CreateGroupInput = {
name: string
parentPath?: string | null
connectionId?: string | null
parentGroupId?: string | null
createdFrom: ProjectGroup['createdFrom']
}
@ -25,6 +27,13 @@ export type ResolvedNestedRepoSelection = {
rejectedPaths: string[]
}
type FolderScope = {
relativePath: string
name: string
folderPath: string
parentRelativePath: string | null
}
function canonicalizeImportPath(path: string): string | null {
if (!isRuntimePathAbsolute(path)) {
return null
@ -39,16 +48,112 @@ function trimPathSeparators(path: string): string {
if (/^\/\/[^/]+\/[^/]+\/?$/.test(path.replace(/\\/g, '/'))) {
return path.replace(/\\/g, '/').replace(/\/$/, '')
}
return path.replace(/[\\/]+$/g, '')
return path.replace(/\\/g, '/').replace(/\/+$/g, '')
}
function normalizeRelativePath(value: string): string {
return value.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
}
function getFolderRelativePathForRepo(parentPath: string, repoPath: string): string | null {
const relativePath = relativePathInsideRoot(parentPath, repoPath)
if (relativePath === null || relativePath === '') {
return null
}
const segments = normalizeRelativePath(relativePath).split('/').filter(Boolean)
segments.pop()
return segments.join('/')
}
function resolveFolderPath(parentPath: string, relativePath: string): string {
return trimPathSeparators(resolveRuntimePath(parentPath, relativePath))
}
function getNearestScopePath(
relativePath: string,
scopePaths: { has: (value: string) => boolean }
): string | null {
const segments = normalizeRelativePath(relativePath).split('/').filter(Boolean)
for (let length = segments.length; length > 0; length -= 1) {
const candidate = segments.slice(0, length).join('/')
if (scopePaths.has(candidate)) {
return candidate
}
}
return null
}
function buildSparseFolderScopes(args: {
parentPath: string
repoPaths: readonly string[]
}): FolderScope[] {
// Why: folder-backed workspaces should expose meaningful launch scopes
// without turning every one-child filesystem segment into sidebar structure.
const folderStats = new Map<string, { directRepoCount: number; totalRepoCount: number }>()
const noteFolder = (relativePath: string, field: 'directRepoCount' | 'totalRepoCount'): void => {
const normalized = normalizeRelativePath(relativePath)
const stats = folderStats.get(normalized) ?? { directRepoCount: 0, totalRepoCount: 0 }
stats[field] += 1
folderStats.set(normalized, stats)
}
for (const repoPath of args.repoPaths) {
const folderRelativePath = getFolderRelativePathForRepo(args.parentPath, repoPath)
if (folderRelativePath === null) {
continue
}
noteFolder(folderRelativePath, 'directRepoCount')
const segments = folderRelativePath.split('/').filter(Boolean)
for (let length = 1; length <= segments.length; length += 1) {
noteFolder(segments.slice(0, length).join('/'), 'totalRepoCount')
}
}
const meaningfulPaths = [...folderStats.entries()]
.filter(([relativePath, stats]) => {
if (!relativePath) {
return false
}
return (
stats.directRepoCount >= 2 ||
(stats.directRepoCount > 0 && stats.totalRepoCount > stats.directRepoCount)
)
})
.map(([relativePath]) => relativePath)
.sort(
(left, right) => left.split('/').length - right.split('/').length || left.localeCompare(right)
)
const meaningfulPathSet = new Set(meaningfulPaths)
return meaningfulPaths.map((relativePath) => {
const parentRelativePath =
getNearestScopePath(relativePath.split('/').slice(0, -1).join('/'), meaningfulPathSet) ?? null
return {
relativePath,
name: relativePath,
folderPath: resolveFolderPath(args.parentPath, relativePath),
parentRelativePath
}
})
}
export function createNestedProjectGroupResolver(args: {
parentPath: string
groupName: string
mode: ProjectGroupImportMode
connectionId?: string | null
repoPaths?: readonly string[]
createGroup: (input: CreateGroupInput) => ProjectGroup
}): NestedProjectGroupResolver {
const createdGroups: ProjectGroup[] = []
const folderScopes = buildSparseFolderScopes({
parentPath: args.parentPath,
repoPaths: args.repoPaths ?? []
})
const folderScopesByRelativePath = new Map(
folderScopes.map((scope) => [scope.relativePath, scope])
)
const folderScopeGroups = new Map<string, ProjectGroup>()
let rootGroup: ProjectGroup | undefined
const ensureRootGroup = (): ProjectGroup | undefined => {
@ -62,6 +167,7 @@ export function createNestedProjectGroupResolver(args: {
rootGroup = args.createGroup({
name: args.groupName.trim() || fallbackName,
parentPath: trimPathSeparators(args.parentPath),
connectionId: args.connectionId ?? null,
parentGroupId: null,
createdFrom: 'folder-scan'
})
@ -69,8 +175,46 @@ export function createNestedProjectGroupResolver(args: {
return rootGroup
}
const ensureFolderScopeGroup = (relativePath: string): ProjectGroup | undefined => {
const root = ensureRootGroup()
if (!root) {
return undefined
}
const existing = folderScopeGroups.get(relativePath)
if (existing) {
return existing
}
const scope = folderScopesByRelativePath.get(relativePath)
if (!scope) {
return root
}
const parentGroup = scope.parentRelativePath
? ensureFolderScopeGroup(scope.parentRelativePath)
: root
const group = args.createGroup({
name: scope.name,
parentPath: scope.folderPath,
connectionId: args.connectionId ?? null,
parentGroupId: parentGroup?.id ?? root.id,
createdFrom: 'folder-scan'
})
folderScopeGroups.set(relativePath, group)
createdGroups.push(group)
return group
}
return {
getGroupForRepo: () => ensureRootGroup(),
getGroupForRepo: (repoPath) => {
const root = ensureRootGroup()
if (!root) {
return undefined
}
const folderRelativePath = getFolderRelativePathForRepo(args.parentPath, repoPath)
const scopePath = folderRelativePath
? getNearestScopePath(folderRelativePath, folderScopesByRelativePath)
: null
return scopePath ? ensureFolderScopeGroup(scopePath) : root
},
getRootGroup: () => rootGroup,
getCreatedGroups: () => [...createdGroups]
}

View File

@ -1,11 +1,14 @@
/* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'events'
import { randomUUID } from 'crypto'
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { ipcMain } from 'electron'
import type {
FolderWorkspace,
ProjectGroup,
TerminalLayoutSnapshot,
WorktreeLineage,
WorktreeMeta,
@ -506,6 +509,10 @@ const TEST_REPO_ID = 'repo-1'
const TEST_REPO_PATH = '/tmp/repo'
const TEST_WORKTREE_PATH = '/tmp/worktree-a'
const TEST_WORKTREE_ID = `${TEST_REPO_ID}::${TEST_WORKTREE_PATH}`
const TEST_FOLDER_PROJECT_GROUP_ID = 'folder-project-group-1'
const TEST_FOLDER_WORKSPACE_ID = 'folder-workspace-1'
const TEST_FOLDER_WORKSPACE_KEY = `folder:${TEST_FOLDER_WORKSPACE_ID}`
const TEST_FOLDER_WORKSPACE_PATH = '/tmp/platform'
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const HEADLESS_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const HEADLESS_SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
@ -623,6 +630,52 @@ function createRuntime(): OrcaRuntimeService {
return new OrcaRuntimeService(store)
}
function makeFolderProjectGroup(overrides: Partial<ProjectGroup> = {}): ProjectGroup {
return {
id: TEST_FOLDER_PROJECT_GROUP_ID,
name: 'Platform',
parentPath: TEST_FOLDER_WORKSPACE_PATH,
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
return {
...overrides,
id: overrides.id ?? TEST_FOLDER_WORKSPACE_ID,
projectGroupId: overrides.projectGroupId ?? TEST_FOLDER_PROJECT_GROUP_ID,
name: overrides.name ?? 'Refund fix',
folderPath: overrides.folderPath ?? TEST_FOLDER_WORKSPACE_PATH,
linkedTask: overrides.linkedTask ?? null,
comment: overrides.comment ?? '',
isArchived: overrides.isArchived ?? false,
isUnread: overrides.isUnread ?? false,
isPinned: overrides.isPinned ?? false,
sortOrder: overrides.sortOrder ?? 0,
lastActivityAt: overrides.lastActivityAt ?? 1,
createdAt: overrides.createdAt ?? 1,
updatedAt: overrides.updatedAt ?? 1
}
}
function createFolderWorkspaceRuntimeStore(
folderWorkspace: FolderWorkspace = makeFolderWorkspace(),
projectGroup: ProjectGroup = makeFolderProjectGroup()
) {
return {
...store,
getProjectGroups: () => [projectGroup],
getFolderWorkspaces: () => [folderWorkspace]
}
}
function makeRpcRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
@ -3952,6 +4005,86 @@ describe('OrcaRuntimeService', () => {
})
})
it.each([
{ label: 'canonical folder workspace selector', selector: TEST_FOLDER_WORKSPACE_KEY },
{ label: 'id-prefixed folder workspace selector', selector: `id:${TEST_FOLDER_WORKSPACE_KEY}` }
])('creates background terminal sessions for a $label', async ({ selector }) => {
const folderPath = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-workspace-'))
const spawn = vi.fn().mockResolvedValue({ id: 'pty-folder' })
const folderWorkspace = makeFolderWorkspace({ folderPath })
const projectGroup = makeFolderProjectGroup({ parentPath: folderPath })
const runtime = new OrcaRuntimeService(
createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never
)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await expect(
runtime.createTerminal(selector, {
command: 'codex',
title: 'multi-repo worker'
})
).resolves.toMatchObject({
worktreeId: TEST_FOLDER_WORKSPACE_KEY,
title: 'multi-repo worker',
surface: 'background'
})
const spawnCall = spawn.mock.calls[0]?.[0] as
| { cwd?: string; env?: Record<string, string>; worktreeId?: string }
| undefined
const spawnedEnv = spawnCall?.env ?? {}
expect(spawnCall).toMatchObject({
cwd: folderPath,
worktreeId: TEST_FOLDER_WORKSPACE_KEY
})
expectStablePaneKeyEnv(spawnedEnv)
expect(spawnedEnv.ORCA_WORKSPACE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY)
expect(spawnedEnv.ORCA_PROJECT_GROUP_ID).toBe(TEST_FOLDER_PROJECT_GROUP_ID)
expect(spawnedEnv.ORCA_WORKSPACE_ROOT).toBe(folderPath)
expect(spawnedEnv.ORCA_WORKTREE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY)
})
it('rejects folder workspace terminal creation when the backing path is missing', async () => {
const missingPath = join(tmpdir(), `orca-missing-folder-workspace-${randomUUID()}`)
const spawn = vi.fn().mockResolvedValue({ id: 'pty-folder' })
const folderWorkspace = makeFolderWorkspace({ folderPath: missingPath })
const projectGroup = makeFolderProjectGroup({ parentPath: missingPath })
const runtime = new OrcaRuntimeService(
createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never
)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await expect(runtime.createTerminal(TEST_FOLDER_WORKSPACE_KEY)).rejects.toThrow(
'folder_workspace_path_missing'
)
expect(spawn).not.toHaveBeenCalled()
})
it('rejects folder workspace folderPath updates when the new path is missing', async () => {
const missingPath = join(tmpdir(), `orca-missing-folder-update-${randomUUID()}`)
const folderWorkspace = makeFolderWorkspace()
const runtimeStore = {
...createFolderWorkspaceRuntimeStore(folderWorkspace),
updateFolderWorkspace: vi.fn()
}
const runtime = new OrcaRuntimeService(runtimeStore as never)
await expect(
runtime.updateFolderWorkspace(TEST_FOLDER_WORKSPACE_ID, { folderPath: missingPath })
).rejects.toThrow('folder_workspace_path_missing')
expect(runtimeStore.updateFolderWorkspace).not.toHaveBeenCalled()
})
it('enables Claude Agent Teams only for direct Claude launches when configured in-process', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtimeStore = {
@ -4166,6 +4299,84 @@ describe('OrcaRuntimeService', () => {
})
})
it('splits folder workspace pty-backed terminal sessions with folder cwd and env', async () => {
const folderPath = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-split-'))
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-folder-source' })
.mockResolvedValueOnce({ id: 'pty-folder-split' })
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-folder' })
const folderWorkspace = makeFolderWorkspace({ folderPath })
const projectGroup = makeFolderProjectGroup({ parentPath: folderPath })
const runtime = new OrcaRuntimeService(
createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never
)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
const { handle } = await runtime.createTerminal(TEST_FOLDER_WORKSPACE_KEY)
const sourceCall = spawn.mock.calls[0]?.[0] as
| { cwd?: string; env?: Record<string, string>; worktreeId?: string }
| undefined
const sourceEnv = sourceCall?.env ?? {}
const sourceLeafId = sourceEnv.ORCA_PANE_KEY.slice(`${sourceEnv.ORCA_TAB_ID}:`.length)
await expect(runtime.splitTerminal(handle, { direction: 'vertical' })).resolves.toMatchObject({
handle: expect.stringMatching(/^term_/),
tabId: sourceEnv.ORCA_TAB_ID,
paneRuntimeId: -1
})
const splitCall = spawn.mock.calls[1]?.[0] as
| { cwd?: string; env?: Record<string, string>; worktreeId?: string }
| undefined
const splitEnv = splitCall?.env ?? {}
const splitLeafId = splitEnv.ORCA_PANE_KEY.slice(`${sourceEnv.ORCA_TAB_ID}:`.length)
expect(sourceCall).toMatchObject({
cwd: folderPath,
worktreeId: TEST_FOLDER_WORKSPACE_KEY
})
expect(splitCall).toMatchObject({
cwd: folderPath,
worktreeId: TEST_FOLDER_WORKSPACE_KEY
})
expectStablePaneKeyEnv(splitEnv)
expect(splitEnv.ORCA_TAB_ID).toBe(sourceEnv.ORCA_TAB_ID)
expect(splitEnv.ORCA_WORKSPACE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY)
expect(splitEnv.ORCA_PROJECT_GROUP_ID).toBe(TEST_FOLDER_PROJECT_GROUP_ID)
expect(splitEnv.ORCA_WORKSPACE_ROOT).toBe(folderPath)
expect(splitEnv.ORCA_WORKTREE_ID).toBe(TEST_FOLDER_WORKSPACE_KEY)
expect(revealTerminalSession).toHaveBeenLastCalledWith(TEST_FOLDER_WORKSPACE_KEY, {
ptyId: 'pty-folder-split',
title: null,
activate: true,
tabId: sourceEnv.ORCA_TAB_ID,
leafId: splitLeafId,
splitFromLeafId: sourceLeafId,
splitDirection: 'vertical'
})
})
it('returns a background handle when inactive tab adoption fails after spawn', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const revealTerminalSession = vi.fn().mockRejectedValue(new Error('Renderer timed out'))

View File

@ -71,6 +71,7 @@ import type {
LinearWorkspaceSelection,
NestedRepoScanResult,
ProjectGroup,
FolderWorkspace,
ProjectGroupImportMode,
ProjectGroupImportResult,
MemorySnapshot,
@ -120,6 +121,11 @@ import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison
} from '../../shared/cross-platform-path'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope'
import type {
FolderWorkspacePathStatus,
FolderWorkspacePathStatusRequest
} from '../../shared/folder-workspace-path-status'
import {
buildKnownOrcaWorkspaceLayouts,
isLegacyRepoForExternalWorktreeVisibility,
@ -487,6 +493,12 @@ import { killAllProcessesForWorktree } from './worktree-teardown'
import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits'
import type { IFilesystemProvider, IPtyProvider } from '../providers/types'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
assertFolderWorkspacePathUsable,
getFolderWorkspacePathStatus,
getFolderWorkspacePathStatusForPath,
inferFolderWorkspacePathConnection
} from '../project-groups/folder-workspace-path-status'
import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { githubAvatarIcon } from '../../shared/repo-icon'
@ -540,6 +552,10 @@ type RuntimeStore = {
updateProjectGroup?: Store['updateProjectGroup']
deleteProjectGroup?: Store['deleteProjectGroup']
moveProjectToGroup?: Store['moveProjectToGroup']
getFolderWorkspaces?: Store['getFolderWorkspaces']
createFolderWorkspace?: Store['createFolderWorkspace']
updateFolderWorkspace?: Store['updateFolderWorkspace']
removeFolderWorkspace?: Store['removeFolderWorkspace']
removeProject?: Store['removeProject']
reorderRepos?: Store['reorderRepos']
getAllWorktreeMeta: Store['getAllWorktreeMeta']
@ -1194,6 +1210,13 @@ type ResolvedWorktree = Worktree & {
git: GitWorktreeInfo
}
type TerminalWorkspaceLaunchScope = {
id: string
path: string
connectionId: string | null
folderWorkspace: FolderWorkspace | null
}
type WorktreeLineageInput = {
parentWorktree?: string
cwdParentWorktree?: string
@ -6487,9 +6510,14 @@ export class OrcaRuntimeService {
return this.store?.getProjectGroups?.() ?? []
}
listFolderWorkspaces(): FolderWorkspace[] {
return this.store?.getFolderWorkspaces?.() ?? []
}
async createProjectGroup(input: {
name: string
parentPath?: string | null
connectionId?: string | null
parentGroupId?: string | null
createdFrom?: ProjectGroup['createdFrom']
}): Promise<ProjectGroup> {
@ -6499,6 +6527,7 @@ export class OrcaRuntimeService {
const group = this.store.createProjectGroup({
name: input.name,
parentPath: input.parentPath ?? null,
connectionId: input.connectionId ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom ?? 'manual'
})
@ -6548,6 +6577,118 @@ export class OrcaRuntimeService {
return moved
}
async createFolderWorkspace(input: {
projectGroupId: string
name?: string
folderPath?: string | null
connectionId?: string | null
linkedTask?: FolderWorkspace['linkedTask']
createdWithAgent?: FolderWorkspace['createdWithAgent']
pendingFirstAgentMessageRename?: boolean
}): Promise<FolderWorkspace> {
if (!this.store?.createFolderWorkspace) {
throw new Error('runtime_unavailable')
}
const projectGroups = this.store.getProjectGroups?.() ?? []
const group = projectGroups.find((entry) => entry.id === input.projectGroupId)
const folderPath =
typeof input.folderPath === 'string' && input.folderPath.trim().length > 0
? input.folderPath
: group?.parentPath
if (!group || !folderPath) {
throw new Error('folder_workspace_project_group_not_found')
}
const status = await getFolderWorkspacePathStatusForPath(
{
folderPath,
projectGroupId: group.id,
connectionId: input.connectionId ?? group.connectionId ?? null,
projectGroups,
repos: this.store.getRepos()
},
{ getSshFilesystemProvider }
)
assertFolderWorkspacePathUsable(status)
const workspace = this.store.createFolderWorkspace(input)
this.notifyReposChanged()
return workspace
}
async getFolderWorkspacePathStatus(
request: FolderWorkspacePathStatusRequest
): Promise<FolderWorkspacePathStatus> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
return getFolderWorkspacePathStatus(this.store, request, { getSshFilesystemProvider })
}
async updateFolderWorkspace(
folderWorkspaceId: string,
updates: Partial<
Pick<
FolderWorkspace,
| 'name'
| 'folderPath'
| 'linkedTask'
| 'comment'
| 'isArchived'
| 'isUnread'
| 'isPinned'
| 'sortOrder'
| 'manualOrder'
| 'workspaceStatus'
| 'createdWithAgent'
| 'pendingFirstAgentMessageRename'
| 'firstAgentMessageRenameError'
| 'lastActivityAt'
>
>
): Promise<FolderWorkspace | null> {
if (!this.store?.updateFolderWorkspace) {
throw new Error('runtime_unavailable')
}
if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) {
const workspace = this.store
.getFolderWorkspaces?.()
.find((entry) => entry.id === folderWorkspaceId)
if (!workspace) {
return null
}
const projectGroups = this.store.getProjectGroups?.() ?? []
const status = await getFolderWorkspacePathStatusForPath(
{
folderPath: updates.folderPath,
projectGroupId: workspace.projectGroupId,
connectionId:
workspace.connectionId ??
projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ??
null,
projectGroups,
repos: this.store.getRepos()
},
{ getSshFilesystemProvider }
)
assertFolderWorkspacePathUsable(status)
}
const updated = this.store.updateFolderWorkspace(folderWorkspaceId, updates)
if (updated) {
this.notifyReposChanged()
}
return updated
}
async deleteFolderWorkspace(folderWorkspaceId: string): Promise<{ deleted: boolean }> {
if (!this.store?.removeFolderWorkspace) {
throw new Error('runtime_unavailable')
}
const deleted = this.store.removeFolderWorkspace(folderWorkspaceId)
if (deleted) {
this.notifyReposChanged()
}
return { deleted }
}
async scanNestedRepos(path: string): Promise<NestedRepoScanResult> {
if (!isAbsolute(path)) {
throw new Error('Project path must be an absolute path')
@ -6605,6 +6746,8 @@ export class OrcaRuntimeService {
parentPath: args.parentPath,
groupName: args.groupName,
mode: args.mode,
connectionId: null,
repoPaths: selection.selectedPaths,
createGroup: (input) => this.store!.createProjectGroup!(input)
})
const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map(
@ -11147,8 +11290,7 @@ export class OrcaRuntimeService {
if (!this.ptyController?.spawn) {
throw new Error('runtime_unavailable')
}
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
const repo = this.store?.getRepo(worktree.repoId)
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
// Why: mint tabId in main before spawn so paneKey is known at PTY env
// build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys
@ -11184,23 +11326,23 @@ export class OrcaRuntimeService {
shimBin
}).env
})
const env = {
...baseEnv,
...agentTeamsPlan?.env,
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktree.id
}
const env = this.buildTerminalWorkspaceEnv(
workspace,
baseEnv,
paneKey,
tabId,
agentTeamsPlan?.env
)
const result = await this.ptyController.spawn({
cols: 120,
rows: 40,
cwd: worktree.path,
cwd: workspace.path,
command: agentTeamsPlan?.command ?? opts.command,
env,
envToDelete: agentTeamsPlan?.envToDelete,
telemetry: opts.telemetry,
connectionId: repo?.connectionId ?? null,
worktreeId: worktree.id,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
preAllocatedHandle,
tabId,
leafId,
@ -11208,7 +11350,7 @@ export class OrcaRuntimeService {
...(opts.persistHostSessionBinding ? { persistHostSessionBinding: true } : {})
})
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
this.registerPty(result.id, worktree.id, repo?.connectionId ?? null)
this.registerPty(result.id, workspace.id, workspace.connectionId)
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
if (pty) {
if (opts.title) {
@ -11225,7 +11367,7 @@ export class OrcaRuntimeService {
}
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
if (pty) {
this.publishPtyBackedMobileSessionTerminal(worktree.id, pty, {
this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, {
tabId,
leafId,
title: opts.title ?? null,
@ -11239,7 +11381,7 @@ export class OrcaRuntimeService {
// failing here must not strand a live process without returning a handle.
// Pass the pre-minted tabId so the renderer adopts under the same id
// already baked into the PTY env — keeps paneKey hook attribution intact.
await this.notifier.revealTerminalSession(worktree.id, {
await this.notifier.revealTerminalSession(workspace.id, {
ptyId: result.id,
title: opts.title ?? null,
activate: opts.activate === true,
@ -11251,7 +11393,7 @@ export class OrcaRuntimeService {
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
}
}
return { handle, worktreeId: worktree.id, title: opts.title ?? null, surface }
return { handle, worktreeId: workspace.id, title: opts.title ?? null, surface }
}
this.assertGraphReady()
@ -11259,7 +11401,7 @@ export class OrcaRuntimeService {
// Why: mirrors browserTabCreate — when no worktree is specified, pass
// undefined so the renderer uses its current active worktree.
const worktreeId = worktreeSelector
? (await this.resolveWorktreeSelector(worktreeSelector)).id
? (await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)).id
: undefined
const requestId = randomUUID()
@ -11814,29 +11956,23 @@ export class OrcaRuntimeService {
throw new Error('terminal_handle_stale')
}
const direction = opts.direction ?? 'horizontal'
const worktree = await this.resolveWorktreeSelector(`id:${pty.worktreeId}`)
const repo = this.store?.getRepo(worktree.repoId)
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${pty.worktreeId}`)
const leafId = randomUUID()
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
const paneKey = makePaneKey(parentTabId, leafId)
const result = await this.ptyController.spawn({
cols: 120,
rows: 40,
cwd: worktree.path,
cwd: workspace.path,
command: opts.command,
env: {
...opts.env,
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: parentTabId,
ORCA_WORKTREE_ID: worktree.id
},
env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId),
envToDelete: opts.envToDelete,
connectionId: repo?.connectionId ?? null,
worktreeId: worktree.id,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
preAllocatedHandle
})
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
this.registerPty(result.id, worktree.id, repo?.connectionId ?? null)
this.registerPty(result.id, workspace.id, workspace.connectionId)
const createdPty = this.getOrCreatePtyWorktreeRecord(result.id)
if (createdPty) {
createdPty.tabId = parentTabId
@ -11844,7 +11980,7 @@ export class OrcaRuntimeService {
}
try {
await this.notifier?.revealTerminalSession?.(worktree.id, {
await this.notifier?.revealTerminalSession?.(workspace.id, {
ptyId: result.id,
title: null,
activate: opts.activate !== false,
@ -11859,7 +11995,7 @@ export class OrcaRuntimeService {
throw error
}
if (createdPty) {
this.publishPtyBackedMobileSessionTerminal(worktree.id, createdPty, {
this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, {
tabId: parentTabId,
leafId,
title: null,
@ -12063,6 +12199,101 @@ export class OrcaRuntimeService {
}
}
private resolveFolderWorkspaceConnectionId(workspace: FolderWorkspace): string | null {
const repos = this.store?.getRepos() ?? []
const projectGroups = this.store?.getProjectGroups?.() ?? []
const connection = inferFolderWorkspacePathConnection({
folderPath: workspace.folderPath,
projectGroupId: workspace.projectGroupId,
connectionId: workspace.connectionId ?? null,
projectGroups,
repos
})
if (connection.kind === 'ambiguous') {
// Why: a single PTY can only be spawned on one runtime target; mixed
// child repo connections need an explicit V2 routing decision.
throw new Error('folder_workspace_connection_ambiguous')
}
return connection.kind === 'ssh' ? connection.connectionId : null
}
private async resolveFolderWorkspaceLaunchScope(
selector: string
): Promise<TerminalWorkspaceLaunchScope | null> {
const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector
const parsed = parseWorkspaceKey(workspaceSelector)
if (parsed?.type !== 'folder') {
return null
}
const workspace = this.store
?.getFolderWorkspaces?.()
.find((entry) => entry.id === parsed.folderWorkspaceId)
if (!workspace) {
throw new Error('selector_not_found')
}
if (!this.store) {
throw new Error('runtime_unavailable')
}
const status = await getFolderWorkspacePathStatus(
this.store,
{ scope: 'folder-workspace', folderWorkspaceId: workspace.id },
{ getSshFilesystemProvider }
)
assertFolderWorkspacePathUsable(status)
return {
id: folderWorkspaceKey(workspace.id),
path: workspace.folderPath,
connectionId: this.resolveFolderWorkspaceConnectionId(workspace),
folderWorkspace: workspace
}
}
private async resolveTerminalWorkspaceLaunchScope(
selector: string
): Promise<TerminalWorkspaceLaunchScope> {
const folderScope = await this.resolveFolderWorkspaceLaunchScope(selector)
if (folderScope) {
return folderScope
}
const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector
const parsed = parseWorkspaceKey(workspaceSelector)
const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
const repo = this.store?.getRepo(worktree.repoId) ?? null
return {
id: worktree.id,
path: worktree.path,
connectionId: repo?.connectionId ?? null,
folderWorkspace: null
}
}
private buildTerminalWorkspaceEnv(
scope: TerminalWorkspaceLaunchScope,
baseEnv: Record<string, string>,
paneKey: string,
tabId: string,
agentTeamsEnv?: Record<string, string>
): Record<string, string> {
const env = {
...baseEnv,
...agentTeamsEnv,
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: scope.id
}
if (!scope.folderWorkspace) {
return env
}
return {
...env,
ORCA_WORKSPACE_ID: scope.id,
ORCA_PROJECT_GROUP_ID: scope.folderWorkspace.projectGroupId,
ORCA_WORKSPACE_ROOT: scope.folderWorkspace.folderPath
}
}
private async resolveWorktreeSelector(selector: string): Promise<ResolvedWorktree> {
const worktrees = await this.listResolvedWorktrees()
let candidates: ResolvedWorktree[]
@ -12687,6 +12918,11 @@ export class OrcaRuntimeService {
this.notifyWorktreesChanged(repoId)
}
notifyFolderWorkspaceChanged(): void {
this.invalidateResolvedWorktreeCache()
this.notifyReposChanged()
}
private recordPtyWorktree(
ptyId: string,
worktreeId: string,

View File

@ -0,0 +1,98 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
const FolderWorkspaceLinkedTask = z
.object({
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
type: z.enum(['issue', 'pr', 'mr']),
number: z.number().finite(),
title: requiredString('Missing linked task title'),
url: requiredString('Missing linked task URL'),
linearIdentifier: OptionalString,
jiraIdentifier: OptionalString,
repoId: OptionalString
})
.nullable()
const FolderWorkspaceCreate = z.object({
projectGroupId: requiredString('Missing project group id'),
name: OptionalString,
folderPath: OptionalString.nullable().optional(),
connectionId: OptionalString.nullable().optional(),
linkedTask: FolderWorkspaceLinkedTask.optional(),
createdWithAgent: z.string().refine(isTuiAgent).optional(),
pendingFirstAgentMessageRename: z.boolean().optional()
})
const FolderWorkspaceUpdate = z.object({
folderWorkspaceId: requiredString('Missing folder workspace id'),
updates: z.object({
name: OptionalString,
folderPath: OptionalString,
linkedTask: FolderWorkspaceLinkedTask.optional(),
comment: z.string().optional(),
isArchived: z.boolean().optional(),
isUnread: z.boolean().optional(),
isPinned: z.boolean().optional(),
sortOrder: OptionalFiniteNumber,
manualOrder: OptionalFiniteNumber,
workspaceStatus: OptionalString,
createdWithAgent: z.string().refine(isTuiAgent).optional(),
pendingFirstAgentMessageRename: z.boolean().optional(),
firstAgentMessageRenameError: z.string().nullable().optional(),
lastActivityAt: OptionalFiniteNumber
})
})
const FolderWorkspaceSelector = z.object({
folderWorkspaceId: requiredString('Missing folder workspace id')
})
const FolderWorkspacePathStatus = z.discriminatedUnion('scope', [
z.object({
scope: z.literal('folder-workspace'),
folderWorkspaceId: requiredString('Missing folder workspace id')
}),
z.object({
scope: z.literal('project-group'),
projectGroupId: requiredString('Missing project group id')
})
])
export const FOLDER_WORKSPACE_METHODS: RpcMethod[] = [
defineMethod({
name: 'folderWorkspace.list',
params: null,
handler: (_params, { runtime }) => ({
folderWorkspaces: runtime.listFolderWorkspaces()
})
}),
defineMethod({
name: 'folderWorkspace.create',
params: FolderWorkspaceCreate,
handler: async (params, { runtime }) => ({
folderWorkspace: await runtime.createFolderWorkspace(params)
})
}),
defineMethod({
name: 'folderWorkspace.update',
params: FolderWorkspaceUpdate,
handler: async (params, { runtime }) => ({
folderWorkspace: await runtime.updateFolderWorkspace(params.folderWorkspaceId, params.updates)
})
}),
defineMethod({
name: 'folderWorkspace.delete',
params: FolderWorkspaceSelector,
handler: async (params, { runtime }) => runtime.deleteFolderWorkspace(params.folderWorkspaceId)
}),
defineMethod({
name: 'folderWorkspace.getPathStatus',
params: FolderWorkspacePathStatus,
handler: async (params, { runtime }) => ({
status: await runtime.getFolderWorkspacePathStatus(params)
})
})
]

View File

@ -287,7 +287,29 @@ describe('repo RPC methods', () => {
createProjectGroup: vi.fn().mockResolvedValue(group),
updateProjectGroup: vi.fn().mockResolvedValue({ ...group, name: 'Core' }),
deleteProjectGroup: vi.fn().mockResolvedValue({ deleted: true }),
moveProjectToGroup: vi.fn().mockResolvedValue({ id: 'repo-1', projectGroupId: group.id })
moveProjectToGroup: vi.fn().mockResolvedValue({ id: 'repo-1', projectGroupId: group.id }),
listFolderWorkspaces: vi.fn().mockReturnValue([
{
id: 'folder-workspace-1',
projectGroupId: group.id,
name: 'Refund fix',
folderPath: '/srv/platform',
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
]),
createFolderWorkspace: vi.fn().mockResolvedValue({ id: 'folder-workspace-2' }),
updateFolderWorkspace: vi.fn().mockResolvedValue({ id: 'folder-workspace-1', comment: 'x' }),
deleteFolderWorkspace: vi.fn().mockResolvedValue({ deleted: true }),
getFolderWorkspacePathStatus: vi
.fn()
.mockResolvedValue({ path: '/srv/platform', exists: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
@ -313,6 +335,28 @@ describe('repo RPC methods', () => {
order: 2
})
)
const folderListResponse = await dispatcher.dispatch(makeRequest('folderWorkspace.list'))
await dispatcher.dispatch(
makeRequest('folderWorkspace.create', {
projectGroupId: group.id,
name: 'Refund fix'
})
)
await dispatcher.dispatch(
makeRequest('folderWorkspace.update', {
folderWorkspaceId: 'folder-workspace-1',
updates: { comment: 'x' }
})
)
await dispatcher.dispatch(
makeRequest('folderWorkspace.delete', { folderWorkspaceId: 'folder-workspace-1' })
)
const statusResponse = await dispatcher.dispatch(
makeRequest('folderWorkspace.getPathStatus', {
scope: 'folder-workspace',
folderWorkspaceId: 'folder-workspace-1'
})
)
expect(runtime.listProjectGroups).toHaveBeenCalled()
expect(runtime.createProjectGroup).toHaveBeenCalledWith({
@ -326,10 +370,33 @@ describe('repo RPC methods', () => {
})
expect(runtime.deleteProjectGroup).toHaveBeenCalledWith(group.id)
expect(runtime.moveProjectToGroup).toHaveBeenCalledWith('repo-1', group.id, 2)
expect(runtime.listFolderWorkspaces).toHaveBeenCalled()
expect(runtime.createFolderWorkspace).toHaveBeenCalledWith({
projectGroupId: group.id,
name: 'Refund fix'
})
expect(runtime.updateFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', {
comment: 'x'
})
expect(runtime.deleteFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1')
expect(runtime.getFolderWorkspacePathStatus).toHaveBeenCalledWith({
scope: 'folder-workspace',
folderWorkspaceId: 'folder-workspace-1'
})
expect(moveResponse).toMatchObject({
ok: true,
result: { repo: { id: 'repo-1', projectGroupId: group.id } }
})
expect(folderListResponse).toMatchObject({
ok: true,
result: {
folderWorkspaces: [expect.objectContaining({ id: 'folder-workspace-1' })]
}
})
expect(statusResponse).toMatchObject({
ok: true,
result: { status: { path: '/srv/platform', exists: true } }
})
})
it('allows separate nested-repo imports without a group name', async () => {

View File

@ -4,6 +4,7 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
import { FOLDER_WORKSPACE_METHODS } from './folder-workspace'
const RepoSelector = z.object({
repo: requiredString('Missing repo selector')
@ -95,6 +96,7 @@ const RepoReorder = z.object({
const ProjectGroupCreate = z.object({
name: requiredString('Missing group name'),
parentPath: OptionalString,
connectionId: OptionalString.nullable().optional(),
parentGroupId: OptionalString.nullable().optional(),
createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional()
})
@ -187,6 +189,7 @@ export const REPO_METHODS: RpcMethod[] = [
repo: await runtime.moveProjectToGroup(params.repo, params.groupId ?? null, params.order)
})
}),
...FOLDER_WORKSPACE_METHODS,
defineMethod({
name: 'projectGroup.scanNested',
params: ProjectGroupScanNested,

View File

@ -10,6 +10,10 @@ import type {
import type { NativeFileDropPayload } from '../shared/native-file-drop'
import type { AppIdentity } from '../shared/app-identity'
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
import type {
FolderWorkspacePathStatus,
FolderWorkspacePathStatusRequest
} from '../shared/folder-workspace-path-status'
import type {
BaseRefDefaultResult,
BaseRefSearchResult,
@ -122,6 +126,7 @@ import type {
PRRefreshOutcome,
Repo,
ProjectGroup,
FolderWorkspace,
ProjectGroupImportResult,
ProjectGroupImportMode,
ShellHydrationFailureReason,
@ -787,6 +792,7 @@ export type PreloadApi = {
create: (args: {
name: string
parentPath?: string | null
connectionId?: string | null
parentGroupId?: string | null
createdFrom?: ProjectGroup['createdFrom']
}) => Promise<ProjectGroup>
@ -819,6 +825,42 @@ export type PreloadApi = {
mode: ProjectGroupImportMode
}) => Promise<ProjectGroupImportResult>
}
folderWorkspaces: {
list: () => Promise<FolderWorkspace[]>
getPathStatus: (args: FolderWorkspacePathStatusRequest) => Promise<FolderWorkspacePathStatus>
create: (args: {
projectGroupId: string
name?: string
folderPath?: string | null
connectionId?: string | null
linkedTask?: FolderWorkspace['linkedTask']
createdWithAgent?: FolderWorkspace['createdWithAgent']
pendingFirstAgentMessageRename?: boolean
}) => Promise<FolderWorkspace>
update: (args: {
folderWorkspaceId: string
updates: Partial<
Pick<
FolderWorkspace,
| 'name'
| 'folderPath'
| 'linkedTask'
| 'comment'
| 'isArchived'
| 'isUnread'
| 'isPinned'
| 'sortOrder'
| 'manualOrder'
| 'workspaceStatus'
| 'createdWithAgent'
| 'pendingFirstAgentMessageRename'
| 'firstAgentMessageRenameError'
| 'lastActivityAt'
>
>
}) => Promise<FolderWorkspace | null>
delete: (args: { folderWorkspaceId: string }) => Promise<boolean>
}
sparsePresets: {
list: (args: { repoId: string }) => Promise<SparsePreset[]>
save: (args: {

View File

@ -532,6 +532,14 @@ const api = {
importNested: (args) => ipcRenderer.invoke('projectGroups:importNested', args)
} satisfies PreloadApi['projectGroups'],
folderWorkspaces: {
list: () => ipcRenderer.invoke('folderWorkspaces:list'),
getPathStatus: (args) => ipcRenderer.invoke('folderWorkspaces:getPathStatus', args),
create: (args) => ipcRenderer.invoke('folderWorkspaces:create', args),
update: (args) => ipcRenderer.invoke('folderWorkspaces:update', args),
delete: (args) => ipcRenderer.invoke('folderWorkspaces:delete', args)
} satisfies PreloadApi['folderWorkspaces'],
sparsePresets: {
list: (args) => ipcRenderer.invoke('sparsePresets:list', args),

View File

@ -329,6 +329,7 @@ function App(): React.JSX.Element {
toggleSidebar: s.toggleSidebar,
fetchRepos: s.fetchRepos,
fetchProjectGroups: s.fetchProjectGroups,
fetchFolderWorkspaces: s.fetchFolderWorkspaces,
fetchAllWorktrees: s.fetchAllWorktrees,
fetchWorktreeLineage: s.fetchWorktreeLineage,
fetchSettings: s.fetchSettings,
@ -732,6 +733,7 @@ function App(): React.JSX.Element {
await actions.fetchSettings()
await actions.fetchRepos()
await actions.fetchProjectGroups()
await actions.fetchFolderWorkspaces()
await actions.fetchAllWorktrees()
await actions.fetchWorktreeLineage()
const persistedUI = await window.api.ui.get()

View File

@ -54,6 +54,10 @@ type NewWorkspaceComposerCardProps = {
selectedRepoIsGit: boolean
onRepoChange: (value: string) => void
primaryActionLabel: string
projectLabel?: string
projectPlaceholder?: string
emptyProjectMessage?: string
showAddProjectButton?: boolean
name: string
onNameValueChange: (value: string) => void
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
@ -86,10 +90,13 @@ type NewWorkspaceComposerCardProps = {
selectedRepoRequiresConnection: boolean
selectedRepoConnectInProgress: boolean
onConnectSelectedRepo: () => Promise<void>
branchesEnabled?: boolean
setupControlsEnabled?: boolean
canUseSparseCheckout: boolean
sparsePresets: SparsePreset[]
sparseSelectedPresetId: string | null
onSparseSelectPreset: (preset: SparsePreset | null) => void
sparseControlsEnabled?: boolean
}
const SSH_STATUS_LABELS: Record<SshConnectionStatus, string> = {
@ -234,6 +241,10 @@ export default function NewWorkspaceComposerCard({
selectedRepoIsGit,
onRepoChange,
primaryActionLabel,
projectLabel,
projectPlaceholder,
emptyProjectMessage,
showAddProjectButton = true,
name,
onNameValueChange,
onSmartGitHubItemSelect,
@ -265,10 +276,13 @@ export default function NewWorkspaceComposerCard({
selectedRepoRequiresConnection,
selectedRepoConnectInProgress,
onConnectSelectedRepo,
branchesEnabled = true,
setupControlsEnabled = true,
canUseSparseCheckout,
sparsePresets,
sparseSelectedPresetId,
onSparseSelectPreset
onSparseSelectPreset,
sparseControlsEnabled = true
}: NewWorkspaceComposerCardProps): React.JSX.Element {
const { isFileDragOver, dragHandlers } = useComposerFileDragOver()
const openModal = useAppStore((s) => s.openModal)
@ -402,38 +416,41 @@ export default function NewWorkspaceComposerCard({
<div className="space-y-1" data-contextual-tour-target="workspace-creation-project">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground">
{translate('auto.components.NewWorkspaceComposerCard.969a8bff66', 'Project')}
{projectLabel ??
translate('auto.components.NewWorkspaceComposerCard.969a8bff66', 'Project')}
</label>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={handleAddRepo}
className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
aria-label={translate(
'auto.components.NewWorkspaceComposerCard.d6b0a96f32',
'Add project'
)}
>
<FolderPlus className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')}
</TooltipContent>
</Tooltip>
{showAddProjectButton ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={handleAddRepo}
className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
aria-label={translate(
'auto.components.NewWorkspaceComposerCard.d6b0a96f32',
'Add project'
)}
>
<FolderPlus className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')}
</TooltipContent>
</Tooltip>
) : null}
</div>
<RepoCombobox
repos={eligibleRepos}
value={repoId}
onValueChange={onRepoChange}
onValueSelected={focusNameInput}
placeholder={translate(
'auto.components.NewWorkspaceComposerCard.dccd26d4e4',
'Choose project'
)}
placeholder={
projectPlaceholder ??
translate('auto.components.NewWorkspaceComposerCard.dccd26d4e4', 'Choose project')
}
// Why: programmatic .focus() from the Dialog's onOpenAutoFocus
// handler does not reliably trigger :focus-visible in Chromium.
// Mirror the Input component's standard ring (border-ring +
@ -451,10 +468,11 @@ export default function NewWorkspaceComposerCard({
</p>
) : eligibleRepos.length === 0 ? (
<p id={projectDescriptionId} className="text-[11px] text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace',
'Add a project before creating a workspace.'
)}
{emptyProjectMessage ??
translate(
'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace',
'Add a project before creating a workspace.'
)}
</p>
) : null}
{selectedRepoRequiresConnection && selectedRepoConnectionId ? (
@ -522,6 +540,7 @@ export default function NewWorkspaceComposerCard({
disabled={selectedRepoRequiresConnection}
disabledPlaceholder="Connect this repo first"
textOnly={!selectedRepoIsGit}
branchesEnabled={branchesEnabled}
onPlainEnter={() => {
// Why: Enter on the workspace name advances focus to the next
// field (Agent combobox) rather than submitting, letting the user
@ -669,7 +688,7 @@ export default function NewWorkspaceComposerCard({
/>
</div>
{setupConfig ? (
{setupControlsEnabled && setupConfig ? (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground">
@ -771,29 +790,31 @@ export default function NewWorkspaceComposerCard({
</div>
) : null}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.d861de981b',
'Sparse checkout'
)}
</label>
<SparseCheckoutPresetSelect
repoId={repoId}
presets={sparsePresets}
selectedPresetId={sparseSelectedPresetId}
onSelectPreset={onSparseSelectPreset}
disabled={!canUseSparseCheckout}
/>
{!canUseSparseCheckout ? (
<p className="text-[11px] text-muted-foreground">
{sparseControlsEnabled ? (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.cbb47ee0dc',
'Only available for local Git projects.'
'auto.components.NewWorkspaceComposerCard.d861de981b',
'Sparse checkout'
)}
</p>
) : null}
</div>
</label>
<SparseCheckoutPresetSelect
repoId={repoId}
presets={sparsePresets}
selectedPresetId={sparseSelectedPresetId}
onSelectPreset={onSparseSelectPreset}
disabled={!canUseSparseCheckout}
/>
{!canUseSparseCheckout ? (
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.cbb47ee0dc',
'Only available for local Git projects.'
)}
</p>
) : null}
</div>
) : null}
</div>
</div>
</div>

View File

@ -10,6 +10,7 @@ import {
type BackgroundMountTerminalWorktreeDetail
} from '@/constants/terminal'
import { useAppStore } from '../store'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
import { useAllWorktrees } from '../store/selectors'
import { getConnectionId } from '../lib/connection-context'
import { basename } from '../lib/path'
@ -188,6 +189,17 @@ function Terminal(): React.JSX.Element | null {
const mountedWorktreeIdsRef = useRef(new Set<string>())
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
const allWorktrees = useAllWorktrees()
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
const workspaceSurfaces = useMemo(
() => [
...allWorktrees.map((worktree) => ({ id: worktree.id, path: worktree.path })),
...folderWorkspaces.map((workspace) => ({
id: folderWorkspaceKey(workspace.id),
path: workspace.folderPath
}))
],
[allWorktrees, folderWorkspaces]
)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const renderedActiveWorktreeId = activeWorktreeId
const activeView = useAppStore((s) => s.activeView)
@ -723,14 +735,14 @@ function Terminal(): React.JSX.Element | null {
mountedWorktreeIdsRef.current.add(renderedActiveWorktreeId)
}
// Prune IDs of worktrees that no longer exist (deleted/removed)
const allWorktreeIds = new Set(allWorktrees.map((wt) => wt.id))
const allWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id))
for (const id of mountedWorktreeIdsRef.current) {
if (!allWorktreeIds.has(id)) {
mountedWorktreeIdsRef.current.delete(id)
}
}
const anyMountedWorktreeHasLayout = computeAnyMountedWorktreeHasLayout(
allWorktrees.map((wt) => wt.id),
workspaceSurfaces.map((workspace) => workspace.id),
mountedWorktreeIdsRef.current,
layoutByWorktree,
groupsByWorktree,
@ -1645,26 +1657,26 @@ function Terminal(): React.JSX.Element | null {
can preserve hidden trees without reflowing the active one. Keep
a relative anchor here so those panes size to the workspace body
rather than some outer ancestor when split groups are enabled. */}
{allWorktrees
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
.map((worktree) => {
const layout = getEffectiveLayoutForWorktree(worktree.id)
{workspaceSurfaces
.filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id))
.map((workspace) => {
const layout = getEffectiveLayoutForWorktree(workspace.id)
if (!layout) {
return null
}
// Why: use strict equality with 'terminal' instead of !== 'settings'
// so the terminal/browser surface hides on the tasks page too.
const isVisible =
activeView === 'terminal' && worktree.id === renderedActiveWorktreeId
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
return (
<WorktreeSplitSurface
key={`tab-groups-${worktree.id}`}
worktreeId={worktree.id}
worktreePath={worktree.path}
key={`tab-groups-${workspace.id}`}
worktreeId={workspace.id}
worktreePath={workspace.path}
layout={layout}
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
focusedGroupId={activeGroupIdByWorktree[workspace.id]}
isVisible={isVisible}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
activityTerminalPortals={activityTerminalPortals}
@ -1709,18 +1721,18 @@ function Terminal(): React.JSX.Element | null {
: ''
}`}
>
{allWorktrees
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
.map((worktree) => {
{workspaceSurfaces
.filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id))
.map((workspace) => {
// Why: use strict equality with 'terminal' instead of !== 'settings'
// so the terminal/browser surface hides on the tasks page too.
const isVisible =
activeView === 'terminal' && worktree.id === renderedActiveWorktreeId
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id)
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
return (
<div
key={worktree.id}
key={workspace.id}
className={
isVisible
? 'absolute inset-0'
@ -1730,11 +1742,11 @@ function Terminal(): React.JSX.Element | null {
}
aria-hidden={!isVisible}
>
<CodexRestartChip worktreeId={worktree.id} />
{(tabsByWorktree[worktree.id] ?? []).map((tab) => {
<CodexRestartChip worktreeId={workspace.id} />
{(tabsByWorktree[workspace.id] ?? []).map((tab) => {
const activityTerminalPortal = findActivityTerminalPortal(
activityTerminalPortals,
{ worktreeId: worktree.id, tabId: tab.id }
{ worktreeId: workspace.id, tabId: tab.id }
)
const isActivityPortalTab = activityTerminalPortal !== null
const isActiveTerminalTab =
@ -1743,8 +1755,8 @@ function Terminal(): React.JSX.Element | null {
<TerminalPane
key={`${tab.id}-${tab.generation ?? 0}`}
tabId={tab.id}
worktreeId={worktree.id}
cwd={worktree.path}
worktreeId={workspace.id}
cwd={workspace.path}
isActive={isActiveTerminalTab || activityTerminalPortal?.active === true}
// Why: the activity page hosts this existing pane via
// portal while the workspace surface remains hidden.
@ -1781,18 +1793,18 @@ function Terminal(): React.JSX.Element | null {
activeTabType !== 'browser' ? 'hidden' : ''
}`}
>
{allWorktrees.map((worktree) => {
const browserTabs = browserTabsByWorktree[worktree.id] ?? []
{workspaceSurfaces.map((workspace) => {
const browserTabs = browserTabsByWorktree[workspace.id] ?? []
// Why: use strict equality with 'terminal' instead of !== 'settings'
// so browser panes also hide on the tasks page.
const isVisibleWorktree =
activeView === 'terminal' && worktree.id === renderedActiveWorktreeId
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
if (browserTabs.length === 0) {
return null
}
return (
<div
key={`browser-${worktree.id}`}
key={`browser-${workspace.id}`}
className={isVisibleWorktree ? 'absolute inset-0' : 'absolute inset-0 hidden'}
aria-hidden={!isVisibleWorktree}
>

View File

@ -30,19 +30,40 @@ export function HeroIntro({ onStart }: { onStart: () => void }): React.JSX.Eleme
return (
<div className="mp-intro-shell">
<div className="mp-eyebrow-row">
<span className="mp-eyebrow">{translate("auto.components.mobile.MobileHero.5410d55d79", "Orca Mobile")}</span>
<span className="mp-eyebrow">
{translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
</span>
</div>
<h1 className="mp-h1">{translate("auto.components.mobile.MobileHero.cd4e5e816f", "Your workspaces, in your pocket.")}</h1>
<h1 className="mp-h1">
{translate(
'auto.components.mobile.MobileHero.cd4e5e816f',
'Your workspaces, in your pocket.'
)}
</h1>
<p className="mp-lead">
{translate("auto.components.mobile.MobileHero.b4ccce5cb7", "Control Orca from your phone. Check on agents, review changes, and kick off tasks while you're away from your desk.")}</p>
<div className="mp-platform-badges" aria-label={translate("auto.components.mobile.MobileHero.ec0607bf66", "Supported mobile platforms")}>
<span className="mp-platform-label">{translate("auto.components.mobile.MobileHero.da1d5e5ed0", "Available on")}</span>
{translate(
'auto.components.mobile.MobileHero.b4ccce5cb7',
"Control Orca from your phone. Check on agents, review changes, and kick off tasks while you're away from your desk."
)}
</p>
<div
className="mp-platform-badges"
aria-label={translate(
'auto.components.mobile.MobileHero.ec0607bf66',
'Supported mobile platforms'
)}
>
<span className="mp-platform-label">
{translate('auto.components.mobile.MobileHero.da1d5e5ed0', 'Available on')}
</span>
<span className="mp-platform-badge">
<IosBrandIcon />
{translate("auto.components.mobile.MobileHero.711e6f4b47", "iOS")}</span>
{translate('auto.components.mobile.MobileHero.711e6f4b47', 'iOS')}
</span>
<span className="mp-platform-badge">
<AndroidLogo />
{translate("auto.components.mobile.MobileHero.ac1eb64952", "Android")}</span>
{translate('auto.components.mobile.MobileHero.ac1eb64952', 'Android')}
</span>
</div>
<div className="mp-cta-row">
<button
@ -50,7 +71,8 @@ export function HeroIntro({ onStart }: { onStart: () => void }): React.JSX.Eleme
className="mp-primary-action mp-flow-primary-action"
onClick={onStart}
>
{translate("auto.components.mobile.MobileHero.10d27b4cba", "Get started")}<ArrowRight className="size-3.5" />
{translate('auto.components.mobile.MobileHero.10d27b4cba', 'Get started')}
<ArrowRight className="size-3.5" />
</button>
</div>
</div>
@ -73,13 +95,21 @@ export function HeroPaired({
return (
<div>
<div className="mp-eyebrow-row">
<span className="mp-eyebrow">{translate("auto.components.mobile.MobileHero.5410d55d79", "Orca Mobile")}</span>
<span className="mp-eyebrow">
{translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
</span>
</div>
<h1 className="mp-h1">
{devices.length === 1 ? translate("auto.components.mobile.MobileHero.051978a785", "Your phone is paired.") : translate("auto.components.mobile.MobileHero.d0b52871ce", "Your phones are paired.")}
{devices.length === 1
? translate('auto.components.mobile.MobileHero.051978a785', 'Your phone is paired.')
: translate('auto.components.mobile.MobileHero.d0b52871ce', 'Your phones are paired.')}
</h1>
<p className="mp-lead-sm">
{translate("auto.components.mobile.MobileHero.266c18c105", "Open Orca Mobile to pick up where you left off, or pair another device.")}</p>
{translate(
'auto.components.mobile.MobileHero.266c18c105',
'Open Orca Mobile to pick up where you left off, or pair another device.'
)}
</p>
<ul className="mp-paired-list">
{devices.map((device) => {
const revoking = revokingDeviceIds.includes(device.deviceId)
@ -91,7 +121,7 @@ export function HeroPaired({
<div className="mp-paired-main">
<div className="mp-paired-name">{device.name}</div>
<div className="mp-paired-meta">
{translate("auto.components.mobile.MobileHero.94829abdb1", "Paired")}{' '}
{translate('auto.components.mobile.MobileHero.94829abdb1', 'Paired')}{' '}
{new Date(device.pairedAt).toLocaleDateString()}
</div>
</div>
@ -100,8 +130,12 @@ export function HeroPaired({
className="mp-paired-revoke"
onClick={() => onRevoke(device.deviceId)}
disabled={revoking}
aria-label={translate("auto.components.mobile.MobileHero.34f878d04f", "Revoke {{value0}}", { value0: device.name })}
title={translate("auto.components.mobile.MobileHero.f9cbf4bb53", "Revoke device")}
aria-label={translate(
'auto.components.mobile.MobileHero.34f878d04f',
'Revoke {{value0}}',
{ value0: device.name }
)}
title={translate('auto.components.mobile.MobileHero.f9cbf4bb53', 'Revoke device')}
>
<Trash2 className="size-3.5" />
</button>
@ -112,7 +146,8 @@ export function HeroPaired({
<div className="mp-flow-actions">
<button type="button" className="mp-secondary-action" onClick={onPairAnother}>
<Smartphone className="size-3.5" />
{translate("auto.components.mobile.MobileHero.ff48d9d520", "Pair another device")}</button>
{translate('auto.components.mobile.MobileHero.ff48d9d520', 'Pair another device')}
</button>
<span />
</div>
</div>
@ -174,11 +209,19 @@ export function HeroFlow({
<div className="mp-step2-copy">
<div className="mp-eyebrow-row">
<div className="mp-step-num">{stepIdx + 1}</div>
<span className="mp-eyebrow">{translate("auto.components.mobile.MobileHero.92ddfdfa1f", "Step 1 of 2")}</span>
<span className="mp-eyebrow">
{translate('auto.components.mobile.MobileHero.92ddfdfa1f', 'Step 1 of 2')}
</span>
</div>
<h2 className="mp-h2">{translate("auto.components.mobile.MobileHero.0d9b33299e", "Get the app.")}</h2>
<h2 className="mp-h2">
{translate('auto.components.mobile.MobileHero.0d9b33299e', 'Get the app.')}
</h2>
<p className="mp-lead-sm">
{translate("auto.components.mobile.MobileHero.e75647ace0", "Scan the QR with your phone or open the install link to grab Orca Mobile.")}</p>
{translate(
'auto.components.mobile.MobileHero.e75647ace0',
'Scan the QR with your phone or open the install link to grab Orca Mobile.'
)}
</p>
<div className="mp-tab-toggle">
<button
type="button"
@ -187,7 +230,8 @@ export function HeroFlow({
onClick={() => onPlatformChange('ios')}
>
<IosBrandIcon />
{translate("auto.components.mobile.MobileHero.711e6f4b47", "iOS")}</button>
{translate('auto.components.mobile.MobileHero.711e6f4b47', 'iOS')}
</button>
<button
type="button"
className={cn(platform === 'android' && 'is-active')}
@ -195,7 +239,8 @@ export function HeroFlow({
onClick={() => onPlatformChange('android')}
>
<AndroidLogo />
{translate("auto.components.mobile.MobileHero.ac1eb64952", "Android")}</button>
{translate('auto.components.mobile.MobileHero.ac1eb64952', 'Android')}
</button>
</div>
<div className="mp-inline-actions">
<button type="button" className="mp-ghost-action" onClick={onOpenInstallUrl}>
@ -203,11 +248,23 @@ export function HeroFlow({
</button>
<button type="button" className="mp-text-link" onClick={onCopyInstallUrl}>
<Copy className="size-3.5" />
{translate("auto.components.mobile.MobileHero.aa97420ba4", "Copy install link")}</button>
{translate('auto.components.mobile.MobileHero.aa97420ba4', 'Copy install link')}
</button>
</div>
</div>
<div className="mp-qr" aria-label={translate("auto.components.mobile.MobileHero.7af266b80d", "Install QR code")}>
{installQrUrl ? <img src={installQrUrl} alt={translate("auto.components.mobile.MobileHero.3241f3c26a", "Install QR")} /> : null}
<div
className="mp-qr"
aria-label={translate(
'auto.components.mobile.MobileHero.7af266b80d',
'Install QR code'
)}
>
{installQrUrl ? (
<img
src={installQrUrl}
alt={translate('auto.components.mobile.MobileHero.3241f3c26a', 'Install QR')}
/>
) : null}
</div>
</div>
</div>
@ -217,19 +274,26 @@ export function HeroFlow({
<div className="mp-step2-copy">
<div className="mp-eyebrow-row">
<div className="mp-step-num">2</div>
<span className="mp-eyebrow">{translate("auto.components.mobile.MobileHero.3960f5c339", "Step 2 of 2")}</span>
<span className="mp-eyebrow">
{translate('auto.components.mobile.MobileHero.3960f5c339', 'Step 2 of 2')}
</span>
</div>
<h2 className="mp-h2">
{translate("auto.components.mobile.MobileHero.901c98bb93", "Pair this")} {getDeviceLabel()}.
{translate('auto.components.mobile.MobileHero.901c98bb93', 'Pair this')}{' '}
{getDeviceLabel()}.
</h2>
<p className="mp-lead-sm">
{translate("auto.components.mobile.MobileHero.d1495e5e64", "Open Orca Mobile, tap")}{' '}
<strong>{translate("auto.components.mobile.MobileHero.3aa7bb2d8b", "Pair Desktop")}</strong>
{translate("auto.components.mobile.MobileHero.2f077ef4eb", ", and scan the code.")}
{translate('auto.components.mobile.MobileHero.d1495e5e64', 'Open Orca Mobile, tap')}{' '}
<strong>
{translate('auto.components.mobile.MobileHero.3aa7bb2d8b', 'Pair Desktop')}
</strong>
{translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')}
</p>
<div className="mp-network-row">
<span className="mp-network-label">{translate("auto.components.mobile.MobileHero.dfd2aa9d5d", "Network")}</span>
<span className="mp-network-label">
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
</span>
<Select
value={selectedAddress ?? ''}
onValueChange={onSelectedAddressChange}
@ -238,9 +302,17 @@ export function HeroFlow({
<SelectTrigger
size="sm"
className="mp-network-select"
aria-label={translate("auto.components.mobile.MobileHero.79d2f480da", "Network interface to advertise")}
aria-label={translate(
'auto.components.mobile.MobileHero.79d2f480da',
'Network interface to advertise'
)}
>
<SelectValue placeholder={translate("auto.components.mobile.MobileHero.ca85e595a7", "No interfaces found")} />
<SelectValue
placeholder={translate(
'auto.components.mobile.MobileHero.ca85e595a7',
'No interfaces found'
)}
/>
</SelectTrigger>
<SelectContent>
{networkInterfaces.map((iface) => (
@ -255,15 +327,23 @@ export function HeroFlow({
className={cn('mp-network-refresh', refreshingNetworkInterfaces && 'is-spinning')}
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate("auto.components.mobile.MobileHero.85067b9e06", "Refresh network interfaces")}
title={translate("auto.components.mobile.MobileHero.85067b9e06", "Refresh network interfaces")}
aria-label={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
title={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
>
<RefreshCw className="size-3.5" />
</button>
</div>
<div className="mp-inline-actions">
<span className="mp-action-divider">{translate("auto.components.mobile.MobileHero.4c1df4eba7", "Can't scan?")}</span>
<span className="mp-action-divider">
{translate('auto.components.mobile.MobileHero.4c1df4eba7', "Can't scan?")}
</span>
<button
type="button"
className="mp-text-link"
@ -271,19 +351,28 @@ export function HeroFlow({
disabled={!pairingUrl || pairLoading}
>
<Copy className="size-3.5" />
{translate("auto.components.mobile.MobileHero.010dddcf27", "Copy pairing code")}</button>
{translate('auto.components.mobile.MobileHero.010dddcf27', 'Copy pairing code')}
</button>
</div>
</div>
<div className="mp-qr-stack">
<div
className="mp-qr"
aria-label={translate("auto.components.mobile.MobileHero.bb0074ce11", "Pairing QR code")}
aria-label={translate(
'auto.components.mobile.MobileHero.bb0074ce11',
'Pairing QR code'
)}
aria-busy={pairLoading && !pairQrDataUrl}
>
{pairQrDataUrl ? (
<img src={pairQrDataUrl} alt={translate("auto.components.mobile.MobileHero.27735e5f4e", "Pairing QR")} />
<img
src={pairQrDataUrl}
alt={translate('auto.components.mobile.MobileHero.27735e5f4e', 'Pairing QR')}
/>
) : pairLoading ? (
<span className="mp-qr-loading">{translate("auto.components.mobile.MobileHero.65b3f2e8bc", "Generating…")}</span>
<span className="mp-qr-loading">
{translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')}
</span>
) : null}
</div>
<button
@ -292,7 +381,11 @@ export function HeroFlow({
onClick={onRegeneratePairing}
disabled={pairLoading}
>
{pairLoading ? translate("auto.components.mobile.MobileHero.65b3f2e8bc", "Generating…") : pairQrDataUrl ? translate("auto.components.mobile.MobileHero.e59a252eca", "Regenerate code") : translate("auto.components.mobile.MobileHero.a6cffbbb0b", "Generate code")}
{pairLoading
? translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')
: pairQrDataUrl
? translate('auto.components.mobile.MobileHero.e59a252eca', 'Regenerate code')
: translate('auto.components.mobile.MobileHero.a6cffbbb0b', 'Generate code')}
</button>
</div>
</div>
@ -302,7 +395,8 @@ export function HeroFlow({
<div className="mp-flow-actions">
<button type="button" className="mp-flow-back" onClick={onBack}>
<ArrowLeft className="size-3" />
{translate("auto.components.mobile.MobileHero.b622eba64d", "Back")}</button>
{translate('auto.components.mobile.MobileHero.b622eba64d', 'Back')}
</button>
{isLast ? (
onDone ? (
<button
@ -310,7 +404,8 @@ export function HeroFlow({
className="mp-primary-action mp-flow-primary-action"
onClick={onDone}
>
{translate("auto.components.mobile.MobileHero.3f90dbd274", "Done")}<ArrowRight className="size-3.5" />
{translate('auto.components.mobile.MobileHero.3f90dbd274', 'Done')}
<ArrowRight className="size-3.5" />
</button>
) : (
<span />
@ -321,7 +416,8 @@ export function HeroFlow({
className="mp-flow-continue mp-flow-primary-action"
onClick={onContinue}
>
{translate("auto.components.mobile.MobileHero.a8fb43cf1c", "Continue")}<ArrowRight className="size-3.5" />
{translate('auto.components.mobile.MobileHero.a8fb43cf1c', 'Continue')}
<ArrowRight className="size-3.5" />
</button>
)}
</div>

View File

@ -108,6 +108,7 @@ type SmartWorkspaceNameFieldProps = {
disabled?: boolean
disabledPlaceholder?: string
textOnly?: boolean
branchesEnabled?: boolean
}
export type SmartWorkspaceNameSelection = {
@ -178,7 +179,8 @@ export default function SmartWorkspaceNameField({
onPlainEnter,
disabled = false,
disabledPlaceholder,
textOnly = false
textOnly = false,
branchesEnabled = true
}: SmartWorkspaceNameFieldProps): React.JSX.Element {
const {
addRepo,
@ -267,9 +269,12 @@ export default function SmartWorkspaceNameField({
if (item.id === 'linear') {
return linearAvailable
}
if (item.id === 'branches') {
return branchesEnabled
}
return true
}),
[gitlabAvailable, linearAvailable, textOnly]
[branchesEnabled, gitlabAvailable, linearAvailable, textOnly]
)
const selectedSourceFocusKey = selectedSource
@ -531,13 +536,14 @@ export default function SmartWorkspaceNameField({
() =>
getBranchSearchRequest({
disabled,
branchesEnabled,
textOnly,
mode,
selectedRepoId: selectedRepo?.id ?? null,
query: debouncedQuery,
limit: RESULT_LIMIT
}),
[debouncedQuery, disabled, mode, selectedRepo?.id, textOnly]
[branchesEnabled, debouncedQuery, disabled, mode, selectedRepo?.id, textOnly]
)
useEffect(() => {
@ -913,8 +919,12 @@ export default function SmartWorkspaceNameField({
? (disabledPlaceholder ?? 'Unavailable')
: mode === 'smart'
? linearAvailable
? 'Type a name, #1234, branch, GitHub or Linear URL'
: 'Type a name, #1234, branch, or GitHub URL'
? branchesEnabled
? 'Type a name, #1234, branch, GitHub or Linear URL'
: 'Type a name, #1234, GitHub or Linear URL'
: branchesEnabled
? 'Type a name, #1234, branch, or GitHub URL'
: 'Type a name, #1234, or GitHub URL'
: mode === 'github'
? 'Search GitHub PRs and issues'
: mode === 'branches'

View File

@ -20,6 +20,31 @@ describe('Branch source results', () => {
).toEqual({ repoId: 'repo-1', query: '', limit: 12 })
})
it('does not request branch results when branches are disabled', () => {
expect(
getBranchSearchRequest({
branchesEnabled: false,
disabled: false,
textOnly: false,
mode: 'branches',
selectedRepoId: 'repo-1',
query: '',
limit: 12
})
).toBeNull()
expect(
getBranchSearchRequest({
branchesEnabled: false,
disabled: false,
textOnly: false,
mode: 'smart',
selectedRepoId: 'repo-1',
query: 'refund',
limit: 12
})
).toBeNull()
})
it('keeps Smart mode in its start-typing state for an empty query', () => {
expect(
getBranchSearchRequest({

View File

@ -32,6 +32,7 @@ export function getSmartWorkspaceEmptyHint(mode: SmartNameMode): string {
}
export function getBranchSearchRequest({
branchesEnabled,
disabled,
textOnly,
mode,
@ -39,6 +40,7 @@ export function getBranchSearchRequest({
query,
limit
}: {
branchesEnabled?: boolean
disabled: boolean
textOnly: boolean
mode: SmartNameMode
@ -48,7 +50,13 @@ export function getBranchSearchRequest({
}): { repoId: string; query: string; limit: number } | null {
const trimmedQuery = query.trim()
const shouldSearchBranches = mode === 'branches' || (mode === 'smart' && trimmedQuery.length > 0)
if (disabled || textOnly || !selectedRepoId || !shouldSearchBranches) {
if (
branchesEnabled === false ||
disabled ||
textOnly ||
!selectedRepoId ||
!shouldSearchBranches
) {
return null
}
return { repoId: selectedRepoId, query: trimmedQuery, limit }

View File

@ -134,7 +134,12 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
const resolved = preview.found ? preview : await window.api.settings.previewGhosttyImport()
if (!resolved.found || Object.keys(resolved.diff).length === 0) {
if (mountedRef.current) {
toast.info(translate("auto.components.onboarding.ThemeStep.16a9f0446a", "No Ghostty settings found to import"))
toast.info(
translate(
'auto.components.onboarding.ThemeStep.16a9f0446a',
'No Ghostty settings found to import'
)
)
}
track('onboarding_ghostty_import_failed', { reason: 'empty_diff' })
return
@ -165,9 +170,15 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
})
} catch (err) {
if (mountedRef.current) {
toast.error(translate("auto.components.onboarding.ThemeStep.699ddf83c2", "Failed to import Ghostty settings"), {
description: err instanceof Error ? err.message : String(err)
})
toast.error(
translate(
'auto.components.onboarding.ThemeStep.699ddf83c2',
'Failed to import Ghostty settings'
),
{
description: err instanceof Error ? err.message : String(err)
}
)
}
track('onboarding_ghostty_import_failed', { reason: 'unknown' })
} finally {
@ -183,9 +194,24 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
hint: string
icon: typeof Monitor
}[] = [
{ id: 'system', label: translate("auto.components.onboarding.ThemeStep.827ea7b4a2", "System"), hint: 'Match OS', icon: Monitor },
{ id: 'dark', label: translate("auto.components.onboarding.ThemeStep.fa7b673ea9", "Dark"), hint: 'Easy on the eyes', icon: Moon },
{ id: 'light', label: translate("auto.components.onboarding.ThemeStep.ad192706e6", "Light"), hint: 'Bright & crisp', icon: Sun }
{
id: 'system',
label: translate('auto.components.onboarding.ThemeStep.827ea7b4a2', 'System'),
hint: 'Match OS',
icon: Monitor
},
{
id: 'dark',
label: translate('auto.components.onboarding.ThemeStep.fa7b673ea9', 'Dark'),
hint: 'Easy on the eyes',
icon: Moon
},
{
id: 'light',
label: translate('auto.components.onboarding.ThemeStep.ad192706e6', 'Light'),
hint: 'Bright & crisp',
icon: Sun
}
]
return (
@ -234,8 +260,13 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
<div className="flex items-center gap-2 px-1 text-[12px] text-muted-foreground">
<Settings2 className="size-3.5" />
<span>
{translate("auto.components.onboarding.ThemeStep.dd5c16ad1b", "More terminal options, including font, cursor, and palette, in")}{' '}
<span className="font-medium text-foreground">{translate("auto.components.onboarding.ThemeStep.94b9dc561d", "Settings → Terminal")}</span>
{translate(
'auto.components.onboarding.ThemeStep.dd5c16ad1b',
'More terminal options, including font, cursor, and palette, in'
)}{' '}
<span className="font-medium text-foreground">
{translate('auto.components.onboarding.ThemeStep.94b9dc561d', 'Settings → Terminal')}
</span>
</span>
</div>
</div>
@ -264,7 +295,11 @@ function GhosttyDiscoveryRow({
return (
<div className="flex items-center gap-2.5 rounded-lg border border-dashed border-border bg-transparent px-3.5 py-2.5 text-[12px] text-muted-foreground">
<span className="size-1.5 animate-pulse rounded-full bg-muted-foreground/60" />
{translate("auto.components.onboarding.ThemeStep.2c3aa538f8", "Looking for a Ghostty config…")}</div>
{translate(
'auto.components.onboarding.ThemeStep.2c3aa538f8',
'Looking for a Ghostty config…'
)}
</div>
)
}
@ -273,7 +308,9 @@ function GhosttyDiscoveryRow({
<div className="flex items-center gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/[0.07] px-3.5 py-2.5 text-[12px] text-foreground">
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" strokeWidth={3} />
<span className="flex-1">
<span className="font-medium">{translate("auto.components.onboarding.ThemeStep.78b6386140", "Imported from Ghostty.")}</span>
<span className="font-medium">
{translate('auto.components.onboarding.ThemeStep.78b6386140', 'Imported from Ghostty.')}
</span>
{discovery.fields.length > 0 && (
<span className="text-muted-foreground"> {discovery.fields.join(' · ')}</span>
)}
@ -288,10 +325,18 @@ function GhosttyDiscoveryRow({
<img src={ghosttyIcon} alt="" className="size-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-[12px] text-foreground">
<span className="font-medium">{translate("auto.components.onboarding.ThemeStep.7ee9234e54", "Ghostty config detected.")}</span>{' '}
<span className="font-medium">
{translate(
'auto.components.onboarding.ThemeStep.7ee9234e54',
'Ghostty config detected.'
)}
</span>{' '}
<span className="text-muted-foreground">
{translate("auto.components.onboarding.ThemeStep.248c812283", "Import")}{' '}
{fields.length > 0 ? fields.map((f) => f.toLowerCase()).join(', ') : translate("auto.components.onboarding.ThemeStep.906c4373fe", "settings")}?
{translate('auto.components.onboarding.ThemeStep.248c812283', 'Import')}{' '}
{fields.length > 0
? fields.map((f) => f.toLowerCase()).join(', ')
: translate('auto.components.onboarding.ThemeStep.906c4373fe', 'settings')}
?
</span>
</div>
{preview.configPath && (
@ -308,7 +353,9 @@ function GhosttyDiscoveryRow({
disabled={importing || disabled}
onClick={() => onImport(preview)}
>
{importing ? translate("auto.components.onboarding.ThemeStep.ad19e5c916", "Importing…") : translate("auto.components.onboarding.ThemeStep.248c812283", "Import")}
{importing
? translate('auto.components.onboarding.ThemeStep.ad19e5c916', 'Importing…')
: translate('auto.components.onboarding.ThemeStep.248c812283', 'Import')}
</button>
</div>
)
@ -392,24 +439,42 @@ function humanFields(diff: Partial<GlobalSettings>): string[] {
// stays tidy. Anything in the diff that doesn't match a label still gets
// imported; it just isn't surfaced as a chip.
const groups: { label: string; keys: (keyof GlobalSettings)[] }[] = [
{ label: translate("auto.components.onboarding.ThemeStep.cc1858e19e", "Font"), keys: ['terminalFontFamily', 'terminalFontSize', 'terminalFontWeight'] },
{
label: translate("auto.components.onboarding.ThemeStep.ab2a583a97", "Cursor"),
label: translate('auto.components.onboarding.ThemeStep.cc1858e19e', 'Font'),
keys: ['terminalFontFamily', 'terminalFontSize', 'terminalFontWeight']
},
{
label: translate('auto.components.onboarding.ThemeStep.ab2a583a97', 'Cursor'),
keys: ['terminalCursorStyle', 'terminalCursorBlink', 'terminalCursorOpacity']
},
{ label: translate("auto.components.onboarding.ThemeStep.c021e9dddd", "Theme palette"), keys: ['terminalThemeDark', 'terminalThemeLight'] },
{ label: translate("auto.components.onboarding.ThemeStep.06a24f4f2d", "Colors"), keys: ['terminalColorOverrides'] },
{ label: translate("auto.components.onboarding.ThemeStep.86c0f1caa2", "Padding"), keys: ['terminalPaddingX', 'terminalPaddingY'] },
{
label: translate("auto.components.onboarding.ThemeStep.b3a99a2d29", "Window"),
label: translate('auto.components.onboarding.ThemeStep.c021e9dddd', 'Theme palette'),
keys: ['terminalThemeDark', 'terminalThemeLight']
},
{
label: translate('auto.components.onboarding.ThemeStep.06a24f4f2d', 'Colors'),
keys: ['terminalColorOverrides']
},
{
label: translate('auto.components.onboarding.ThemeStep.86c0f1caa2', 'Padding'),
keys: ['terminalPaddingX', 'terminalPaddingY']
},
{
label: translate('auto.components.onboarding.ThemeStep.b3a99a2d29', 'Window'),
keys: ['terminalBackgroundOpacity', 'windowBackgroundBlur', 'terminalInactivePaneOpacity']
},
{
label: translate("auto.components.onboarding.ThemeStep.8ca01945f2", "Dividers"),
label: translate('auto.components.onboarding.ThemeStep.8ca01945f2', 'Dividers'),
keys: ['terminalDividerColorDark', 'terminalDividerColorLight']
},
{ label: translate("auto.components.onboarding.ThemeStep.6c51398942", "Mouse"), keys: ['terminalMouseHideWhileTyping', 'terminalFocusFollowsMouse'] },
{ label: translate("auto.components.onboarding.ThemeStep.a4b254779d", "macOS Option key"), keys: ['terminalMacOptionAsAlt'] }
{
label: translate('auto.components.onboarding.ThemeStep.6c51398942', 'Mouse'),
keys: ['terminalMouseHideWhileTyping', 'terminalFocusFollowsMouse']
},
{
label: translate('auto.components.onboarding.ThemeStep.a4b254779d', 'macOS Option key'),
keys: ['terminalMacOptionAsAlt']
}
]
return groups.filter(({ keys }) => keys.some((k) => k in diff)).map(({ label }) => label)
}

View File

@ -177,8 +177,16 @@ export default function HostedReviewActions({
const confirmed = await confirm({
title: `${label} ${shortLabel} ${isGitLab ? '!' : '#'}${review.number}?`,
description: isClosing
? translate("auto.components.right.sidebar.HostedReviewActions.a3d572a4de", "This will close the {{value0}}.", { value0: reviewLabel })
: translate("auto.components.right.sidebar.HostedReviewActions.78f5ff294c", "This will reopen the {{value0}}.", { value0: reviewLabel }),
? translate(
'auto.components.right.sidebar.HostedReviewActions.a3d572a4de',
'This will close the {{value0}}.',
{ value0: reviewLabel }
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.78f5ff294c',
'This will reopen the {{value0}}.',
{ value0: reviewLabel }
),
confirmLabel: label,
confirmVariant: isClosing ? 'destructive' : 'default'
})
@ -202,7 +210,19 @@ export default function HostedReviewActions({
setActionError(result.error)
toast.error(result.error)
} else {
toast.success(isClosing ? translate("auto.components.right.sidebar.HostedReviewActions.fa3ee9a515", "{{value0}} closed", { value0: shortLabel }) : translate("auto.components.right.sidebar.HostedReviewActions.377269db6f", "{{value0}} reopened", { value0: shortLabel }))
toast.success(
isClosing
? translate(
'auto.components.right.sidebar.HostedReviewActions.fa3ee9a515',
'{{value0}} closed',
{ value0: shortLabel }
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.377269db6f',
'{{value0}} reopened',
{ value0: shortLabel }
)
)
await onRefreshReview()
}
} catch (err) {
@ -272,7 +292,10 @@ export default function HostedReviewActions({
<GitMerge className="size-3.5" />
)}
{merging
? translate("auto.components.right.sidebar.HostedReviewActions.d2ca293f3d", "Working...")
? translate(
'auto.components.right.sidebar.HostedReviewActions.d2ca293f3d',
'Working...'
)
: mergePresentation.directMergeAvailable
? mergeMethods.defaultLabel
: (mergePresentation.autoMergeAction?.label ?? mergePresentation.label)}
@ -296,8 +319,15 @@ export default function HostedReviewActions({
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
disabled={menuDisabled}
aria-label={translate("auto.components.right.sidebar.HostedReviewActions.2bfaf4379c", "More {{value0}} actions", { value0: reviewLabel })}
title={translate("auto.components.right.sidebar.HostedReviewActions.9845a71e17", "More actions")}
aria-label={translate(
'auto.components.right.sidebar.HostedReviewActions.2bfaf4379c',
'More {{value0}} actions',
{ value0: reviewLabel }
)}
title={translate(
'auto.components.right.sidebar.HostedReviewActions.9845a71e17',
'More actions'
)}
>
{stateUpdating === 'closed' ? (
<LoaderCircle className="size-3.5 animate-spin" />
@ -336,7 +366,11 @@ export default function HostedReviewActions({
onSelect={() => void handleCloseReview()}
>
<GitPullRequestClosed className="size-3.5" />
{translate("auto.components.right.sidebar.HostedReviewActions.4d5fb5a284", "Close")} {shortLabel}
{translate(
'auto.components.right.sidebar.HostedReviewActions.4d5fb5a284',
'Close'
)}{' '}
{shortLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -363,7 +397,16 @@ export default function HostedReviewActions({
) : (
<CircleDot className="size-3.5" />
)}
{stateUpdating === 'open' ? translate("auto.components.right.sidebar.HostedReviewActions.6645ac7dd1", "Reopening...") : translate("auto.components.right.sidebar.HostedReviewActions.3ce211ece6", "Reopen {{value0}}", { value0: shortLabel })}
{stateUpdating === 'open'
? translate(
'auto.components.right.sidebar.HostedReviewActions.6645ac7dd1',
'Reopening...'
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.3ce211ece6',
'Reopen {{value0}}',
{ value0: shortLabel }
)}
</Button>
{actionError && <div className="text-[10px] text-rose-500 break-words">{actionError}</div>}
</div>
@ -385,7 +428,12 @@ export default function HostedReviewActions({
) : (
<Trash2 className="size-3.5" />
)}
{isDeletingWorktree ? translate("auto.components.right.sidebar.HostedReviewActions.eefd50457e", "Deleting...") : translate("auto.components.right.sidebar.HostedReviewActions.e4aca40024", "Delete Workspace")}
{isDeletingWorktree
? translate('auto.components.right.sidebar.HostedReviewActions.eefd50457e', 'Deleting...')
: translate(
'auto.components.right.sidebar.HostedReviewActions.e4aca40024',
'Delete Workspace'
)}
</Button>
)
}

View File

@ -1,5 +1,4 @@
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useAppStore } from '@/store'
import { useActiveWorktree } from '@/store/selectors'
import { getConnectionId } from '@/lib/connection-context'
@ -8,12 +7,11 @@ import type { SearchFileResult, SearchMatch } from '../../../../shared/types'
import { buildSearchRows } from './search-rows'
import { cancelRevealFrame, openMatchResult } from './search-match-open'
import { SearchHeader } from './SearchHeader'
import { FileResultRow, MatchResultRow } from './SearchResultItems'
import { SearchResultsPane } from './SearchResultsPane'
import { translate } from '@/i18n/i18n'
const SEARCH_DEBOUNCE_MS = 300
const SEARCH_MAX_RESULTS = 2000
const SEARCH_VIRTUAL_OVERSCAN = 12
const EMPTY_COLLAPSED_FILES = new Set<string>()
export default function Search(): React.JSX.Element {
@ -165,38 +163,6 @@ export default function Search(): React.JSX.Element {
[deferredSearchResults, fileSearchCollapsedFiles, fileSearchQuery, worktreePath]
)
const virtualizer = useVirtualizer({
count: searchRows.length,
getScrollElement: () => resultsScrollRef.current,
estimateSize: (index) => {
const row = searchRows[index]
if (!row) {
return 20
}
// Why: file rows include pt-1.5 (6 px) for inter-group spacing, so
// their estimate is taller than match rows.
if (row.type === 'file') {
return 28
}
return 20
},
// Why: paddingEnd adds visible breathing room after the last result row.
// paddingStart is unnecessary because each file row already includes
// pt-1.5 for inter-group spacing (which also covers the first row).
paddingEnd: 8,
overscan: SEARCH_VIRTUAL_OVERSCAN,
getItemKey: (index) => {
const row = searchRows[index]
if (!row) {
return `missing:${index}`
}
if (row.type === 'file') {
return `file:${row.fileResult.filePath}`
}
return `match:${row.fileResult.filePath}:${row.match.line}:${row.match.column}:${row.matchIndex}`
}
})
// Execute search with debounce — reads fresh state inside setTimeout
// to avoid stale closures when options change during debounce
const executeSearch = useCallback(
@ -393,79 +359,16 @@ export default function Search(): React.JSX.Element {
}}
/>
{/* Why: the summary is rendered outside the virtualizer so it stays
pinned at the top while the user scrolls through results. */}
{deferredSearchResults && searchRows.length > 0 && (
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b border-border">
{deferredSearchResults.totalMatches}{' '}
{translate('auto.components.right.sidebar.Search.6aeda362ed', 'result')}
{deferredSearchResults.totalMatches !== 1 ? 's' : ''}{' '}
{translate('auto.components.right.sidebar.Search.4107975b3a', 'in')}{' '}
{deferredSearchResults.files.length}{' '}
{translate('auto.components.right.sidebar.Search.0b8104eaf2', 'file')}
{deferredSearchResults.files.length !== 1 ? 's' : ''}
{deferredSearchResults.truncated &&
translate('auto.components.right.sidebar.Search.dcc294f28d', '(results truncated)')}
</div>
)}
<div ref={resultsScrollRef} className="flex-1 min-h-0 overflow-y-auto scrollbar-sleek">
{searchRows.length > 0 && (
<div
className="relative w-full"
style={{
height: virtualizer.getTotalSize()
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = searchRows[virtualRow.index]
if (!row) {
return null
}
return (
<div
key={virtualRow.key}
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${virtualRow.start}px)`
}}
>
{row.type === 'file' && (
<FileResultRow
fileResult={row.fileResult}
collapsed={row.collapsed}
onToggleCollapse={() => toggleActiveCollapsedFile(row.fileResult.filePath)}
/>
)}
{row.type === 'match' && (
<MatchResultRow
match={row.match}
relativePath={row.fileResult.relativePath}
onClick={() => handleMatchClick(row.fileResult, row.match)}
/>
)}
</div>
)
})}
</div>
)}
{!fileSearchResults && fileSearchQuery && !fileSearchLoading && (
<div className="flex items-center justify-center h-32 text-muted-foreground text-xs">
{translate('auto.components.right.sidebar.Search.d56d140747', 'Press Enter to search')}
</div>
)}
{!fileSearchQuery && (
<div className="flex items-center justify-center h-32 text-muted-foreground text-xs">
{translate(
'auto.components.right.sidebar.Search.1abfb25a66',
'Type to search in files'
)}
</div>
)}
</div>
<SearchResultsPane
results={deferredSearchResults}
hasCommittedResults={fileSearchResults !== null}
query={fileSearchQuery}
loading={fileSearchLoading}
rows={searchRows}
scrollRef={resultsScrollRef}
onToggleCollapsedFile={toggleActiveCollapsedFile}
onMatchClick={handleMatchClick}
/>
</div>
)
}

View File

@ -0,0 +1,133 @@
import React from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { SearchFileResult, SearchMatch, SearchResult } from '../../../../shared/types'
import type { SearchRow } from './search-rows'
import { FileResultRow, MatchResultRow } from './SearchResultItems'
import { translate } from '@/i18n/i18n'
const SEARCH_VIRTUAL_OVERSCAN = 12
type SearchResultsPaneProps = {
results: SearchResult | null
hasCommittedResults: boolean
query: string
loading: boolean
rows: SearchRow[]
scrollRef: React.RefObject<HTMLDivElement | null>
onToggleCollapsedFile: (filePath: string) => void
onMatchClick: (fileResult: SearchFileResult, match: SearchMatch) => void
}
export function SearchResultsPane({
results,
hasCommittedResults,
query,
loading,
rows,
scrollRef,
onToggleCollapsedFile,
onMatchClick
}: SearchResultsPaneProps): React.JSX.Element {
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: (index) => {
const row = rows[index]
if (!row) {
return 20
}
// Why: file rows include pt-1.5 (6 px) for inter-group spacing, so
// their estimate is taller than match rows.
if (row.type === 'file') {
return 28
}
return 20
},
// Why: paddingEnd adds visible breathing room after the last result row.
// paddingStart is unnecessary because each file row already includes
// pt-1.5 for inter-group spacing (which also covers the first row).
paddingEnd: 8,
overscan: SEARCH_VIRTUAL_OVERSCAN,
getItemKey: (index) => {
const row = rows[index]
if (!row) {
return `missing:${index}`
}
if (row.type === 'file') {
return `file:${row.fileResult.filePath}`
}
return `match:${row.fileResult.filePath}:${row.match.line}:${row.match.column}:${row.matchIndex}`
}
})
return (
<>
{/* Why: the summary is rendered outside the virtualizer so it stays
pinned at the top while the user scrolls through results. */}
{results && rows.length > 0 && (
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b border-border">
{results.totalMatches}{' '}
{translate('auto.components.right.sidebar.Search.6aeda362ed', 'result')}
{results.totalMatches !== 1 ? 's' : ''}{' '}
{translate('auto.components.right.sidebar.Search.4107975b3a', 'in')}{' '}
{results.files.length}{' '}
{translate('auto.components.right.sidebar.Search.0b8104eaf2', 'file')}
{results.files.length !== 1 ? 's' : ''}
{results.truncated &&
translate('auto.components.right.sidebar.Search.dcc294f28d', '(results truncated)')}
</div>
)}
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto scrollbar-sleek">
{rows.length > 0 && (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]
if (!row) {
return null
}
return (
<div
key={virtualRow.key}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
{row.type === 'file' && (
<FileResultRow
fileResult={row.fileResult}
collapsed={row.collapsed}
onToggleCollapse={() => onToggleCollapsedFile(row.fileResult.filePath)}
/>
)}
{row.type === 'match' && (
<MatchResultRow
match={row.match}
relativePath={row.fileResult.relativePath}
onClick={() => onMatchClick(row.fileResult, row.match)}
/>
)}
</div>
)
})}
</div>
)}
{!hasCommittedResults && query && !loading && (
<div className="flex items-center justify-center h-32 text-muted-foreground text-xs">
{translate('auto.components.right.sidebar.Search.d56d140747', 'Press Enter to search')}
</div>
)}
{!query && (
<div className="flex items-center justify-center h-32 text-muted-foreground text-xs">
{translate(
'auto.components.right.sidebar.Search.1abfb25a66',
'Type to search in files'
)}
</div>
)}
</div>
</>
)
}

View File

@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'
import { useSidebarResize } from '@/hooks/useSidebarResize'
import type { ActivityBarPosition } from '@/store/slices/editor'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import {
ContextMenu,
@ -37,6 +38,7 @@ import {
} from './right-sidebar-width'
import { translate } from '@/i18n/i18n'
import { RightSidebarPanelContent } from './right-sidebar-panel-content'
import { useMeasuredWidth } from './right-sidebar-measured-width'
import { normalizeRightSidebarRoute } from '@/store/right-sidebar-route'
import { AgentSessionHistoryIcon } from './agent-session-history-icon'
@ -50,11 +52,6 @@ function RightSidebarInner(): React.JSX.Element {
const checksShortcut = useShortcutLabel('sidebar.checks.toggle')
const portsShortcut = useShortcutLabel('sidebar.ports.toggle')
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const activeWorktree = useAppStore((s) =>
rightSidebarOpen && s.activeWorktreeId
? (s.getKnownWorktreeById(s.activeWorktreeId) ?? null)
: null
)
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
const setRightSidebarWidth = useAppStore((s) => s.setRightSidebarWidth)
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
@ -65,10 +62,16 @@ function RightSidebarInner(): React.JSX.Element {
const activityBarPosition = useAppStore((s) => s.activityBarPosition)
const setActivityBarPosition = useAppStore((s) => s.setActivityBarPosition)
const [topActivityStripWidth, setTopActivityStripWidth] = useState<number | null>(null)
const activeWorktreeId = useAppStore((s) => (rightSidebarOpen ? s.activeWorktreeId : null))
// Why: source control and checks are meaningless for non-git folders.
// Hide those tabs so the activity bar only shows relevant actions.
const activeWorktree = useAppStore((s) =>
activeWorktreeId ? (s.getKnownWorktreeById(activeWorktreeId) ?? null) : null
)
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
const isFolder =
parseWorkspaceKey(activeWorktreeId ?? '')?.type === 'folder' ||
(activeRepo ? isFolderRepo(activeRepo) : false)
const isSshRepo = Boolean(activeRepo?.connectionId)
const activityItems = useMemo<ActivityBarItem[]>(
@ -121,6 +124,13 @@ function RightSidebarInner(): React.JSX.Element {
const effectiveTab = visibleItems.some((item) => item.id === normalizedActiveTab)
? normalizedActiveTab
: visibleItems[0].id
useEffect(() => {
if (effectiveTab !== rightSidebarTab) {
// Why: folder workspaces hide git-only panels. Persist the fallback so
// panels and activity-button refs do not churn against a hidden tab.
setRightSidebarTab(effectiveTab)
}
}, [effectiveTab, rightSidebarTab, setRightSidebarTab])
const selectActivityTab = (tab: typeof effectiveTab): void => {
if (tab === 'explorer') {
showRightSidebarFiles()
@ -402,31 +412,6 @@ function getWindowWidth(): number | null {
return window.innerWidth
}
function useMeasuredWidth(onWidth: (width: number | null) => void) {
const observerRef = React.useRef<ResizeObserver | null>(null)
return React.useCallback(
(node: HTMLDivElement | null) => {
observerRef.current?.disconnect()
observerRef.current = null
if (!node || typeof ResizeObserver === 'undefined') {
onWidth(node ? node.getBoundingClientRect().width : null)
return
}
const updateWidth = (): void => {
onWidth(node.getBoundingClientRect().width)
}
updateWidth()
const observer = new ResizeObserver(updateWidth)
observer.observe(node)
observerRef.current = observer
},
[onWidth]
)
}
// ─── Context Menu for Activity Bar Position ───────────
function ActivityBarPositionMenu({
currentPosition,

View File

@ -0,0 +1,35 @@
import { useCallback, useRef } from 'react'
export function useMeasuredWidth(onWidth: (width: number | null) => void) {
const observerRef = useRef<ResizeObserver | null>(null)
const widthRef = useRef<number | null>(null)
return useCallback(
(node: HTMLDivElement | null) => {
observerRef.current?.disconnect()
observerRef.current = null
const commitWidth = (width: number | null): void => {
if (Object.is(widthRef.current, width)) {
return
}
widthRef.current = width
onWidth(width)
}
if (!node || typeof ResizeObserver === 'undefined') {
commitWidth(node ? node.getBoundingClientRect().width : null)
return
}
const updateWidth = (): void => {
commitWidth(node.getBoundingClientRect().width)
}
updateWidth()
const observer = new ResizeObserver(updateWidth)
observer.observe(node)
observerRef.current = observer
},
[onWidth]
)
}

View File

@ -7,7 +7,13 @@ import { RIGHT_SIDEBAR_HEADER_NO_DRAG_CLASS_NAME } from './right-sidebar-titleba
const mockAppState = vi.hoisted(() => ({
rightSidebarOpen: true,
activityBarPosition: 'top' as 'top' | 'side'
activityBarPosition: 'top' as 'top' | 'side',
activeWorktreeId: 'worktree-1',
activeRepo: { id: 'repo-1', kind: 'git', connectionId: null } as {
id: string
kind: 'git' | 'folder'
connectionId: string | null
} | null
}))
vi.mock('@/hooks/useSidebarResize', () => ({
@ -33,6 +39,8 @@ vi.mock('@/store', () => ({
setRightSidebarTab: vi.fn(),
showRightSidebarFiles: vi.fn(),
toggleRightSidebar: vi.fn(),
activeWorktreeId: mockAppState.activeWorktreeId,
getKnownWorktreeById: () => ({ id: mockAppState.activeWorktreeId, repoId: 'repo-1' }),
activityBarPosition: mockAppState.activityBarPosition,
setActivityBarPosition: vi.fn(),
checksByWorktreeId: {},
@ -42,7 +50,8 @@ vi.mock('@/store', () => ({
vi.mock('@/store/selectors', () => ({
useActiveWorktree: () => ({ id: 'worktree-1', repoId: 'repo-1' }),
useRepoById: () => ({ id: 'repo-1', kind: 'git', connectionId: null })
useRepoById: () => mockAppState.activeRepo,
getWorktreeMapFromState: () => new Map()
}))
vi.mock('@/components/ui/tooltip', () => ({
@ -131,6 +140,8 @@ describe('rendered right sidebar titlebar drag regions', () => {
beforeEach(() => {
mockAppState.rightSidebarOpen = true
mockAppState.activityBarPosition = 'top'
mockAppState.activeWorktreeId = 'worktree-1'
mockAppState.activeRepo = { id: 'repo-1', kind: 'git', connectionId: null }
})
it('keeps the rendered top activity strip draggable, context-menuable, and only controls no-drag', () => {
@ -187,6 +198,19 @@ describe('rendered right sidebar titlebar drag regions', () => {
expect(buttonOpeningTag(markup, 'Toggle right sidebar')).toContain('sidebar-toggle')
})
it('hides git-only activity buttons for folder workspace ids without a backing repo', () => {
mockAppState.activeWorktreeId = 'folder:folder-1'
mockAppState.activeRepo = null
const markup = renderToStaticMarkup(<RightSidebar />)
expect(markup).toContain('aria-label="Explorer')
expect(markup).toContain('aria-label="Agents')
expect(markup).not.toContain('aria-label="Search')
expect(markup).not.toContain('aria-label="Source Control')
expect(markup).not.toContain('aria-label="Checks')
})
it('does not render hidden panel content while the sidebar is closed', () => {
mockAppState.rightSidebarOpen = false

View File

@ -294,7 +294,15 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
{ targetGroupId }
)
setActiveView('terminal')
toast.success(translate("auto.components.settings.McpConfigSection.1f3665e35a", "MCP config created"), { description: translate("auto.components.settings.McpConfigSection.9ee215caf6", ".mcp.json") })
toast.success(
translate('auto.components.settings.McpConfigSection.1f3665e35a', 'MCP config created'),
{
description: translate(
'auto.components.settings.McpConfigSection.9ee215caf6',
'.mcp.json'
)
}
)
} catch (error) {
toast.error(extractIpcErrorMessage(error, 'Failed to create MCP config.'))
}
@ -304,12 +312,22 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
<section className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">{translate("auto.components.settings.McpConfigSection.55eea3ef47", "MCP Configs")}</h3>
<h3 className="text-sm font-semibold">
{translate('auto.components.settings.McpConfigSection.55eea3ef47', 'MCP Configs')}
</h3>
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.McpConfigSection.96f5609b04", "Inspect MCP server definitions that agents can use while working in this repo.")}</p>
{translate(
'auto.components.settings.McpConfigSection.96f5609b04',
'Inspect MCP server definitions that agents can use while working in this repo.'
)}
</p>
{repo.connectionId ? (
<p className="text-xs text-muted-foreground">
{translate("auto.components.settings.McpConfigSection.6bac9ddfc6", "SSH repos are read through the remote filesystem. Starter creation is limited to the workspace root config.")}</p>
{translate(
'auto.components.settings.McpConfigSection.6bac9ddfc6',
'SSH repos are read through the remote filesystem. Starter creation is limited to the workspace root config.'
)}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
@ -317,7 +335,10 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
variant="ghost"
size="icon-sm"
onClick={() => void loadConfigs()}
aria-label={translate("auto.components.settings.McpConfigSection.f34c152dc0", "Refresh MCP configs")}
aria-label={translate(
'auto.components.settings.McpConfigSection.f34c152dc0',
'Refresh MCP configs'
)}
>
{loading ? (
<LoaderCircle className="size-3.5 animate-spin" />
@ -333,7 +354,15 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
onClick={() => void handleCreateStarter()}
>
<Plus className="size-3.5" />
{createConfirm ? translate("auto.components.settings.McpConfigSection.0a5c1ead54", "Create empty config") : translate("auto.components.settings.McpConfigSection.82436439eb", "Add MCP config")}
{createConfirm
? translate(
'auto.components.settings.McpConfigSection.0a5c1ead54',
'Create empty config'
)
: translate(
'auto.components.settings.McpConfigSection.82436439eb',
'Add MCP config'
)}
</Button>
) : null}
</div>
@ -342,8 +371,10 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
<div className="rounded-md border border-border/50 bg-muted/20">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
<span>
{detectedCount} {translate("auto.components.settings.McpConfigSection.251b96564a", "detected ·")} {serverCount}{' '}
{translate("auto.components.settings.McpConfigSection.3b224167ff", "server")}
{detectedCount}{' '}
{translate('auto.components.settings.McpConfigSection.251b96564a', 'detected ·')}{' '}
{serverCount}{' '}
{translate('auto.components.settings.McpConfigSection.3b224167ff', 'server')}
{serverCount === 1 ? '' : 's'}
</span>
{loading ? <LoaderCircle className="size-3.5 animate-spin" /> : null}
@ -360,7 +391,11 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
<span>{inspectionUnavailableMessage}</span>
) : (
<span>
{translate("auto.components.settings.McpConfigSection.b900cd6282", "No MCP config found. Add an empty workspace config when you want this repo to define its own MCP servers.")}</span>
{translate(
'auto.components.settings.McpConfigSection.b900cd6282',
'No MCP config found. Add an empty workspace config when you want this repo to define its own MCP servers.'
)}
</span>
)}
</div>
) : (
@ -377,7 +412,9 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
{missingConfigs.length > 0 && !inspectionUnavailable ? (
<div className="space-y-1.5 border-t border-border/50 px-3 py-2">
<p className="text-[11px] text-muted-foreground">{translate("auto.components.settings.McpConfigSection.4d16a0d9ac", "Checked")}</p>
<p className="text-[11px] text-muted-foreground">
{translate('auto.components.settings.McpConfigSection.4d16a0d9ac', 'Checked')}
</p>
<div className="flex flex-wrap gap-1.5">
{missingConfigs.map((config) => (
<span

View File

@ -102,21 +102,23 @@ function renderNestedStep(repoCount: number): string {
}
describe('AddRepoDialogStepContent nested imports', () => {
it('uses the first-import nested repo action when no repos exist yet', () => {
it('asks the monorepo question when no repos exist yet', () => {
const html = renderNestedStep(0)
expect(html).toContain('>Import</button>')
expect(html).not.toContain('Import as group')
expect(html).not.toContain('Import separately')
expect(html).not.toContain('aria-label="Group name"')
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('No, import separately')
expect(html).not.toContain('>Import</button>')
})
it('shows group import controls after a repo already exists', () => {
it('shows the same monorepo import controls after a repo already exists', () => {
const html = renderNestedStep(1)
expect(html).toContain('aria-label="Group name"')
expect(html).toContain('Import separately')
expect(html).toContain('Import as group')
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('No, import separately')
expect(html).not.toContain('>Import</button>')
})

View File

@ -209,7 +209,6 @@ export function AddRepoDialogStepContent({
scan={nestedScan}
groupName={nestedGroupName}
selectedPaths={nestedSelectedPaths}
isFirstRepoImport={repoCount === 0}
isAdding={isAdding}
scanInProgress={nestedScanInProgress}
onGroupNameChange={onNestedGroupNameChange}

View File

@ -1,5 +1,9 @@
// @vitest-environment happy-dom
import { act, type ComponentProps } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AddRepoNestedImportStep } from './AddRepoNestedImportStep'
import { TooltipProvider } from '@/components/ui/tooltip'
import { Dialog } from '@/components/ui/dialog'
@ -22,34 +26,65 @@ const scan: NestedRepoScanResult = {
timeoutMs: null
}
function renderStepMarkup(
overrides: Partial<ComponentProps<typeof AddRepoNestedImportStep>> = {}
): string {
return renderToStaticMarkup(
<TooltipProvider>
<Dialog open>
<AddRepoNestedImportStep
scan={scan}
groupName=""
selectedPaths={new Set(scan.repos.map((repo) => repo.path))}
isAdding={false}
scanInProgress={false}
onGroupNameChange={vi.fn()}
onSelectedPathsChange={vi.fn()}
onImport={vi.fn()}
onStopScan={vi.fn()}
{...overrides}
/>
</Dialog>
</TooltipProvider>
)
}
function findButton(container: HTMLElement, label: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll('button')).find((entry) =>
entry.textContent?.includes(label)
)
if (!button) {
throw new Error(`Button not found: ${label}`)
}
return button
}
describe('AddRepoNestedImportStep', () => {
it('allows grouped import with a blank group name and flat collision labels', () => {
const html = renderToStaticMarkup(
<TooltipProvider>
<Dialog open>
<AddRepoNestedImportStep
scan={scan}
groupName=""
selectedPaths={new Set(scan.repos.map((repo) => repo.path))}
isFirstRepoImport={false}
isAdding={false}
scanInProgress={false}
onGroupNameChange={vi.fn()}
onSelectedPathsChange={vi.fn()}
onImport={vi.fn()}
onStopScan={vi.fn()}
/>
</Dialog>
</TooltipProvider>
)
let root: Root | null = null
let container: HTMLDivElement | null = null
afterEach(() => {
if (root) {
act(() => root?.unmount())
root = null
}
container?.remove()
container = null
})
it('asks whether the selected folder is a monorepo', () => {
const html = renderStepMarkup()
expect(html).toContain('Import repositories from folder')
expect(html).toContain('Found 3 repositories in')
expect(html).toContain('/workspace/platform')
expect(html).toContain('aria-label="Group name"')
expect(html).toContain('aria-label="What is a group name?"')
expect(html).toContain('Import separately')
expect(html).toContain('Import as group')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('aria-label="What is a monorepo name?"')
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('Choose this if these projects belong together')
expect(html).toContain('Orca will group them and let you work from the parent folder')
expect(html).toContain('No, import separately')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('payments/api')
expect(html).toContain('billing/api')
expect(html).not.toContain('disabled=""')
@ -57,33 +92,49 @@ describe('AddRepoNestedImportStep', () => {
expect(html).not.toContain('Project group')
})
it('shows a single primary import action for a first repo import', () => {
const html = renderToStaticMarkup(
<TooltipProvider>
<Dialog open>
<AddRepoNestedImportStep
scan={scan}
groupName=""
selectedPaths={new Set(scan.repos.map((repo) => repo.path))}
isFirstRepoImport={true}
isAdding={false}
scanInProgress={false}
onGroupNameChange={vi.fn()}
onSelectedPathsChange={vi.fn()}
onImport={vi.fn()}
onStopScan={vi.fn()}
/>
</Dialog>
</TooltipProvider>
)
it('disables both import actions while scanning', () => {
const html = renderStepMarkup({ scanInProgress: true })
expect(html).toContain('Found 3 repositories in')
expect(html).toContain('data-variant="default"')
expect(html).toContain('>Import</button>')
expect(html).not.toContain('aria-label="Group name"')
expect(html).not.toContain('What is a group name?')
expect(html).not.toContain('Import as group')
expect(html).not.toContain('Import separately')
expect(html).not.toContain('>Back</button>')
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('No, import separately')
expect(html).toContain('Yes, import as monorepo')
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>No, import separately<\/button>/)
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as monorepo<\/button>/)
})
it('maps the monorepo choice to grouped import and the non-monorepo choice to separate import', () => {
const onImport = vi.fn()
const host = document.createElement('div')
container = host
document.body.appendChild(host)
root = createRoot(host)
act(() => {
root?.render(
<TooltipProvider>
<Dialog open>
<AddRepoNestedImportStep
scan={scan}
groupName=""
selectedPaths={new Set(scan.repos.map((repo) => repo.path))}
isAdding={false}
scanInProgress={false}
onGroupNameChange={vi.fn()}
onSelectedPathsChange={vi.fn()}
onImport={onImport}
onStopScan={vi.fn()}
/>
</Dialog>
</TooltipProvider>
)
})
act(() => {
findButton(host, 'Yes, import as monorepo').click()
findButton(host, 'No, import separately').click()
})
expect(onImport).toHaveBeenNthCalledWith(1, 'group')
expect(onImport).toHaveBeenNthCalledWith(2, 'separate')
})
})

View File

@ -15,7 +15,6 @@ type AddRepoNestedImportStepProps = {
scan: NestedRepoScanResult
groupName: string
selectedPaths: Set<string>
isFirstRepoImport: boolean
isAdding: boolean
scanInProgress: boolean
onGroupNameChange: (value: string) => void
@ -28,7 +27,6 @@ export function AddRepoNestedImportStep({
scan,
groupName,
selectedPaths,
isFirstRepoImport,
isAdding,
scanInProgress,
onGroupNameChange,
@ -38,9 +36,22 @@ export function AddRepoNestedImportStep({
}: AddRepoNestedImportStepProps): React.JSX.Element {
const folderName = getRuntimePathBasename(scan.selectedPath) || scan.selectedPath
const groupNameInputId = useId()
const repoCountLabel = `${scan.repos.length} ${
scan.repos.length === 1 ? 'repository' : 'repositories'
}`
const repoCountLabel =
scan.repos.length === 1
? translate('auto.components.sidebar.AddRepoNestedImportStep.8401a7a0d0', '1 repository')
: translate(
'auto.components.sidebar.AddRepoNestedImportStep.d4f1df62ef',
'{{value0}} repositories',
{ value0: scan.repos.length }
)
const foundSentence = translate(
'auto.components.sidebar.AddRepoNestedImportStep.b4263a2ac4',
'Found {{value0}} in {{value1}}.',
{
value0: repoCountLabel,
value1: scan.selectedPath
}
)
return (
<>
@ -56,17 +67,11 @@ export function AddRepoNestedImportStep({
<DialogDescription className="min-w-0 truncate">
{scanInProgress
? translate(
'auto.components.sidebar.AddRepoNestedImportStep.220dd32d83',
'Scanning...'
'auto.components.sidebar.AddRepoNestedImportStep.24eda6c8b2',
'Scanning... {{value0}}',
{ value0: foundSentence }
)
: null}
{translate('auto.components.sidebar.AddRepoNestedImportStep.4df0d08cc5', 'Found')}{' '}
{repoCountLabel}{' '}
{translate('auto.components.sidebar.AddRepoNestedImportStep.5f857ba8e6', 'in')}{' '}
<span className="font-mono text-[11px] text-foreground" title={scan.selectedPath}>
{scan.selectedPath}
</span>
.
: foundSentence}
</DialogDescription>
</div>
</DialogHeader>
@ -82,77 +87,84 @@ export function AddRepoNestedImportStep({
{scanInProgress || scan.truncated || scan.timedOut || scan.stopped ? (
<NestedRepoScanLimitNotice scan={scan} />
) : null}
{/* Why: first-time import uses one flat action because it is easier for new users to understand. */}
{!isFirstRepoImport ? (
<div className="min-w-0 shrink-0 space-y-1">
<div className="flex shrink-0 items-center gap-1">
<Label htmlFor={groupNameInputId} className="text-[11px] text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.40199ef7b3',
'Group name'
)}
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.sidebar.AddRepoNestedImportStep.787412361a',
'What is a group name?'
)}
className="size-5 text-muted-foreground hover:text-foreground"
>
<CircleHelp className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="max-w-64">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.b20bb7c24f',
'Keeps these repos together in one group. Best for related repos like microservices.'
)}
</TooltipContent>
</Tooltip>
</div>
<Input
id={groupNameInputId}
aria-label={translate(
'auto.components.sidebar.AddRepoNestedImportStep.40199ef7b3',
'Group name'
<div className="min-w-0 shrink-0 space-y-1">
<p className="text-sm font-medium text-foreground">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.fb33359f69',
'Is this a monorepo?'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.d75170194e',
'Choose this if these projects belong together. Orca will group them and let you work from the parent folder.'
)}
</p>
</div>
<div className="min-w-0 shrink-0 space-y-1">
<div className="flex shrink-0 items-center gap-1">
<Label htmlFor={groupNameInputId} className="text-[11px] text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.39d51212cc',
'Monorepo name'
)}
value={groupName}
onChange={(event) => onGroupNameChange(event.target.value)}
disabled={isAdding || scanInProgress}
className="h-9 min-w-0"
placeholder={folderName}
/>
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.sidebar.AddRepoNestedImportStep.e907ec8935',
'What is a monorepo name?'
)}
className="size-5 text-muted-foreground hover:text-foreground"
>
<CircleHelp className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="max-w-64">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.b20bb7c24f',
'Keeps these repos together in one group. Best for related repos like microservices.'
)}
</TooltipContent>
</Tooltip>
</div>
) : null}
<Input
id={groupNameInputId}
aria-label={translate(
'auto.components.sidebar.AddRepoNestedImportStep.39d51212cc',
'Monorepo name'
)}
value={groupName}
onChange={(event) => onGroupNameChange(event.target.value)}
disabled={isAdding || scanInProgress}
className="h-9 min-w-0"
placeholder={folderName}
/>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-2">
<Button
onClick={() => onImport('separate')}
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
variant={isFirstRepoImport ? 'default' : 'outline'}
variant="outline"
>
{isFirstRepoImport
? translate('auto.components.sidebar.AddRepoNestedImportStep.cf9d382ca1', 'Import')
: translate(
'auto.components.sidebar.AddRepoNestedImportStep.5b2e6fe3c8',
'Import separately'
)}
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.aa0247680d',
'No, import separately'
)}
</Button>
<Button
onClick={() => onImport('group')}
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
>
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.a0bc4d1f8e',
'Yes, import as monorepo'
)}
</Button>
{!isFirstRepoImport ? (
<Button
onClick={() => onImport('group')}
disabled={isAdding || scanInProgress || selectedPaths.size === 0}
>
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.c157f31a95',
'Import as group'
)}
</Button>
) : null}
</div>
</div>
</>

View File

@ -0,0 +1,408 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
import AgentSettingsDialog from '@/components/agent/AgentSettingsDialog'
import NewWorkspaceComposerCard from '@/components/NewWorkspaceComposerCard'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { useDetectedAgents } from '@/hooks/useDetectedAgents'
import { useAppStore } from '@/store'
import { getLinkedWorkItemProvider, type LinkedWorkItemSummary } from '@/lib/new-workspace'
import { shouldAllowComposerEnterSubmitTarget } from '@/lib/new-workspace-enter-guard'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import {
pickQuickWorkspaceAgent,
resolveQuickWorkspaceAgentSelection
} from '@/lib/quick-workspace-agent-selection'
import { getSelectedRepoSshGate, isSshConnectInProgress } from '@/lib/new-workspace-ssh-gate'
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
import type {
GitHubWorkItem,
GitLabWorkItem,
LinearIssue,
ProjectGroup,
TuiAgent
} from '../../../../shared/types'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
import {
getFolderSourceRepos,
getLinkedItemDisplayName,
getSmartNameSelection,
toGitHubLinkedWorkItem,
toGitLabLinkedWorkItem,
toLinearLinkedWorkItem
} from './folder-workspace-composer-helpers'
import { useFolderWorkspaceComposerPathStatus } from './folder-workspace-composer-path-status'
import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit'
type FolderWorkspaceComposerDialogProps = {
projectGroup: ProjectGroup | null
open: boolean
onOpenChange: (open: boolean) => void
}
export function FolderWorkspaceComposerDialog({
projectGroup,
open,
onOpenChange
}: FolderWorkspaceComposerDialogProps): React.JSX.Element {
const { createFolderWorkspace, projectGroups, repos, settings, sshConnectionStates } =
useAppStore(
useShallow((s) => ({
createFolderWorkspace: s.createFolderWorkspace,
projectGroups: s.projectGroups,
repos: s.repos,
settings: s.settings,
sshConnectionStates: s.sshConnectionStates
}))
)
const { pathStatusBlocksCreate, pathStatusProjectError } = useFolderWorkspaceComposerPathStatus(
projectGroup,
open
)
const sourceRepos = useMemo(
() => getFolderSourceRepos(repos, projectGroups, projectGroup),
[projectGroup, projectGroups, repos]
)
const [repoId, setRepoId] = useState('')
const selectedRepo = sourceRepos.find((repo) => repo.id === repoId) ?? null
const selectedRepoConnectionId =
selectedRepo?.connectionId ??
(sourceRepos.length === 0 ? (projectGroup?.connectionId ?? null) : null)
const selectedRepoSshState = selectedRepoConnectionId
? (sshConnectionStates.get(selectedRepoConnectionId) ?? null)
: null
const { selectedRepoSshStatus, selectedRepoRequiresConnection, selectedRepoConnectInProgress } =
getSelectedRepoSshGate({
connectionId: selectedRepoConnectionId,
status: selectedRepoSshState?.status ?? null
})
const { detectedIds } = useDetectedAgents(null)
const detectedAgentIds = useMemo(() => (detectedIds ? new Set(detectedIds) : null), [detectedIds])
const [name, setName] = useState('')
const [note, setNote] = useState('')
const [linkedWorkItem, setLinkedWorkItem] = useState<LinkedWorkItemSummary | null>(null)
const [quickAgentOverride, setQuickAgentOverride] = useState<TuiAgent | null | undefined>(
undefined
)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [agentSettingsOpen, setAgentSettingsOpen] = useState(false)
const lastAutoNameRef = useRef('')
const composerRef = useRef<HTMLDivElement | null>(null)
const nameInputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (!open) {
return
}
setRepoId(sourceRepos[0]?.id ?? '')
setName('')
setNote('')
setLinkedWorkItem(null)
setQuickAgentOverride(undefined)
setAdvancedOpen(false)
setSubmitting(false)
lastAutoNameRef.current = ''
}, [open, projectGroup?.id, sourceRepos])
const preferredQuickAgent = useMemo<TuiAgent | null>(
() =>
pickQuickWorkspaceAgent(
settings?.defaultTuiAgent,
detectedAgentIds,
settings?.disabledTuiAgents
),
[detectedAgentIds, settings?.defaultTuiAgent, settings?.disabledTuiAgents]
)
const resolvedQuickAgentSelection = resolveQuickWorkspaceAgentSelection({
quickAgentOverride,
preferredQuickAgent,
detectedAgentIds,
disabledTuiAgents: settings?.disabledTuiAgents
})
if (resolvedQuickAgentSelection.quickAgentOverride !== quickAgentOverride) {
setQuickAgentOverride(resolvedQuickAgentSelection.quickAgentOverride)
}
const quickAgent = resolvedQuickAgentSelection.quickAgent
const applyLinkedWorkItem = useCallback(
(item: LinkedWorkItemSummary): void => {
setLinkedWorkItem(item)
const nextName = getLinkedItemDisplayName(item)
if (
nextName &&
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
) {
setName(nextName)
lastAutoNameRef.current = nextName
}
},
[name]
)
const handleRepoChange = useCallback((nextRepoId: string): void => {
setRepoId(nextRepoId)
setLinkedWorkItem((current) => {
const provider = current ? getLinkedWorkItemProvider(current) : null
return provider === 'github' || provider === 'gitlab' ? null : current
})
}, [])
const handleSmartGitHubItemSelect = useCallback(
(item: GitHubWorkItem): void => {
applyLinkedWorkItem(toGitHubLinkedWorkItem(item))
},
[applyLinkedWorkItem]
)
const handleSmartGitLabItemSelect = useCallback(
(item: GitLabWorkItem): void => {
applyLinkedWorkItem(toGitLabLinkedWorkItem(item))
},
[applyLinkedWorkItem]
)
const handleSmartLinearIssueSelect = useCallback(
(issue: LinearIssue): void => {
applyLinkedWorkItem(toLinearLinkedWorkItem(issue))
},
[applyLinkedWorkItem]
)
const handleClearSmartNameSelection = useCallback((): void => {
setLinkedWorkItem(null)
if (name === lastAutoNameRef.current) {
setName('')
lastAutoNameRef.current = ''
}
}, [name])
const handleQuickAgentChange = useCallback((agent: TuiAgent | null): void => {
setQuickAgentOverride(agent)
}, [])
const onConnectSelectedRepo = useCallback(async (): Promise<void> => {
if (!selectedRepoConnectionId) {
return
}
const liveStatus = useAppStore
.getState()
.sshConnectionStates.get(selectedRepoConnectionId)?.status
if (liveStatus === 'connected' || isSshConnectInProgress(liveStatus ?? null)) {
return
}
try {
await window.api.ssh.connect({ targetId: selectedRepoConnectionId })
} catch (error) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.sidebar.FolderWorkspaceComposerDialog.connectFailed',
'Failed to connect to project.'
)
)
}
}, [selectedRepoConnectionId])
const handleCreate = useCallback(async (): Promise<void> => {
if (
!projectGroup?.parentPath ||
submitting ||
pathStatusBlocksCreate ||
selectedRepoRequiresConnection
) {
return
}
setSubmitting(true)
try {
await submitFolderWorkspaceCreate({
projectGroup,
name,
lastAutoName: lastAutoNameRef.current,
linkedWorkItem,
note,
quickAgent,
autoRenameBranchFromWork: settings?.autoRenameBranchFromWork,
agentCmdOverrides: settings?.agentCmdOverrides,
createFolderWorkspace,
onOpenChange
})
} finally {
setSubmitting(false)
}
}, [
createFolderWorkspace,
linkedWorkItem,
name,
note,
onOpenChange,
projectGroup,
quickAgent,
settings?.agentCmdOverrides,
settings?.autoRenameBranchFromWork,
submitting,
pathStatusBlocksCreate,
selectedRepoRequiresConnection
])
useEffect(() => {
if (!open) {
return
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Enter' && event.key !== 'Escape') {
return
}
const target = event.target
if (!(target instanceof HTMLElement)) {
return
}
if (event.key === 'Escape') {
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
event.preventDefault()
target.blur()
return
}
event.preventDefault()
onOpenChange(false)
return
}
if (!isScreenSubmitShortcut(event)) {
return
}
if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current) || submitting) {
return
}
event.preventDefault()
void handleCreate()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [handleCreate, onOpenChange, open, submitting])
const smartNameSelection = useMemo(() => getSmartNameSelection(linkedWorkItem), [linkedWorkItem])
const emptySourceProjectMessage =
sourceRepos.length === 0
? translate(
'auto.components.sidebar.FolderWorkspaceComposerDialog.noRepos',
'Add a Git project under this folder to attach GitHub or GitLab tasks.'
)
: null
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[calc(100vh-2rem)] flex-col overflow-hidden sm:max-w-lg"
onOpenAutoFocus={(event) => {
event.preventDefault()
const content = event.currentTarget as HTMLElement
const trigger = content.querySelector<HTMLElement>(
'[data-repo-combobox-root="true"][role="combobox"]'
)
trigger?.focus({ preventScroll: true })
}}
>
<DialogHeader className="gap-1">
<DialogTitle className="text-base font-semibold">
{translate(
'auto.components.sidebar.FolderWorkspaceComposerDialog.title',
'Create Folder Workspace'
)}
</DialogTitle>
<DialogDescription>{projectGroup?.parentPath ?? ''}</DialogDescription>
</DialogHeader>
<NewWorkspaceComposerCard
containerClassName="min-h-0 flex-1 overflow-y-auto px-1 scrollbar-sleek"
composerRef={composerRef}
nameInputRef={nameInputRef}
quickAgent={quickAgent}
onQuickAgentChange={handleQuickAgentChange}
eligibleRepos={sourceRepos}
repoId={repoId}
selectedRepoIsGit={true}
onRepoChange={handleRepoChange}
primaryActionLabel={
quickAgent
? translate(
'auto.components.sidebar.FolderWorkspaceComposerDialog.createStart',
'Create & Start Agent'
)
: translate(
'auto.components.sidebar.FolderWorkspaceComposerDialog.create',
'Create Workspace'
)
}
projectLabel={translate(
'auto.components.sidebar.FolderWorkspaceComposerDialog.sourceProject',
'Task Source'
)}
projectPlaceholder={translate(
'auto.components.sidebar.FolderWorkspaceComposerDialog.chooseSourceProject',
'Choose task source'
)}
emptyProjectMessage={emptySourceProjectMessage ?? undefined}
showAddProjectButton={false}
name={name}
onNameValueChange={setName}
onSmartGitHubItemSelect={handleSmartGitHubItemSelect}
onSmartGitLabItemSelect={handleSmartGitLabItemSelect}
onSmartBranchSelect={() => {}}
onSmartLinearIssueSelect={handleSmartLinearIssueSelect}
smartNameSelection={smartNameSelection}
onClearSmartNameSelection={handleClearSmartNameSelection}
forkPushWarning={null}
detectedAgentIds={detectedAgentIds}
onOpenAgentSettings={() => setAgentSettingsOpen(true)}
advancedOpen={advancedOpen}
onToggleAdvanced={() => setAdvancedOpen((value) => !value)}
createDisabled={
submitting ||
!projectGroup?.parentPath ||
pathStatusBlocksCreate ||
selectedRepoRequiresConnection
}
projectError={pathStatusProjectError}
creating={submitting}
onCreate={() => void handleCreate()}
note={note}
onNoteChange={setNote}
setupConfig={null}
requiresExplicitSetupChoice={false}
setupDecision={null}
onSetupDecisionChange={() => {}}
shouldWaitForSetupCheck={false}
resolvedSetupDecision={null}
createError={null}
selectedRepoConnectionId={selectedRepoConnectionId}
selectedRepoSshStatus={selectedRepoSshStatus as SshConnectionStatus | null}
selectedRepoRequiresConnection={selectedRepoRequiresConnection}
selectedRepoConnectInProgress={selectedRepoConnectInProgress}
onConnectSelectedRepo={onConnectSelectedRepo}
branchesEnabled={false}
setupControlsEnabled={false}
canUseSparseCheckout={false}
sparsePresets={[]}
sparseSelectedPresetId={null}
onSparseSelectPreset={() => {}}
sparseControlsEnabled={false}
/>
</DialogContent>
</Dialog>
<AgentSettingsDialog open={agentSettingsOpen} onOpenChange={setAgentSettingsOpen} />
</>
)
}

View File

@ -157,7 +157,7 @@ describe('WorktreeCard quick actions', () => {
expect(markup).toContain('data-worktree-card-meta-row=""')
})
it('renders folder kind in the detailed metadata row', () => {
it('renders folder kind and directory in the detailed metadata row', () => {
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: 'Docs folder', branch: '' })}
@ -168,6 +168,27 @@ describe('WorktreeCard quick actions', () => {
expect(markup).toContain('Docs folder')
expect(markup).toContain('>Folder</span>')
expect(markup).toContain('>quick-action</span>')
expect(markup).toContain('data-worktree-card-meta-row=""')
})
it('renders synthetic folder workspace directory in the detailed metadata row', () => {
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
id: 'folder:folder-1',
displayName: 'Docs folder',
branch: '',
path: '/repo/worktrees/quick-action'
})}
repo={undefined}
isActive={false}
/>
)
expect(markup).toContain('Docs folder')
expect(markup).toContain('>Folder</span>')
expect(markup).toContain('>quick-action</span>')
expect(markup).toContain('data-worktree-card-meta-row=""')
})

View File

@ -61,6 +61,7 @@ import {
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import { translate } from '@/i18n/i18n'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
type WorktreeCardProps = {
worktree: Worktree
@ -107,6 +108,12 @@ function isWebClient(): boolean {
return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__)
}
function getDirectoryName(folderPath: string): string {
const normalized = folderPath.replace(/[\\/]+$/, '')
const parts = normalized.split(/[\\/]+/)
return parts.at(-1) || normalized || folderPath
}
// Why: the pinned repo icon and the compact inline badge share one chip shell;
// keep the box + tooltip identical so both repo cues read as the same affordance.
function RepoIdentityChip({
@ -165,6 +172,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
const openModal = useAppStore((s) => s.openModal)
const openTaskPage = useAppStore((s) => s.openTaskPage)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const renamingWorktreeId = useAppStore((s) => s.renamingWorktreeId)
const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId)
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
@ -247,7 +256,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
const gitIdentityDisplay = getWorktreeGitIdentityDisplay(worktree)
const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null
const branch = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
const isFolder = repo ? isFolderRepo(repo) : false
const workspaceScope = parseWorkspaceKey(worktree.id)
const folderWorkspaceId =
workspaceScope?.type === 'folder' ? workspaceScope.folderWorkspaceId : null
const isFolder = repo ? isFolderRepo(repo) : folderWorkspaceId !== null
const hostedReviewCacheKey =
repo && branch
? getHostedReviewCacheKey(repo.path, branch, settings, repo.id, repo.connectionId)
@ -555,10 +567,27 @@ const WorktreeCard = React.memo(function WorktreeCard({
event.preventDefault()
event.stopPropagation()
if (showDeleteQuickAction) {
if (folderWorkspaceId) {
void deleteFolderWorkspace(folderWorkspaceId).then((deleted) => {
if (
deleted &&
useAppStore.getState().activeWorktreeId === folderWorkspaceKey(folderWorkspaceId)
) {
setActiveWorktree(null)
}
})
return
}
runWorktreeDelete(worktree.id)
}
},
[showDeleteQuickAction, worktree.id]
[
deleteFolderWorkspace,
folderWorkspaceId,
setActiveWorktree,
showDeleteQuickAction,
worktree.id
]
)
const handlePendingFirstAgentMessageRenameInfo = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
@ -965,6 +994,17 @@ const WorktreeCard = React.memo(function WorktreeCard({
onBeginEditingConsumed={() => setRenamingWorktreeId(null)}
/>
{isFolder && (
<Badge
variant="secondary"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 text-muted-foreground bg-accent border border-border dark:bg-accent/80 dark:border-border/50 leading-none"
>
{repo
? getRepoKindLabel(repo)
: translate('auto.components.sidebar.WorktreeCard.93aebe4529', 'Folder')}
</Badge>
)}
{typeof worktree.firstAgentMessageRenameError === 'string' &&
worktree.firstAgentMessageRenameError.length > 0 &&
!titleRenaming ? (
@ -1155,14 +1195,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
{isFolder ? (
<Badge
variant="secondary"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 text-muted-foreground bg-accent border border-border dark:bg-accent/80 dark:border-border/50 leading-none"
<span
className="min-w-0 truncate font-mono text-[11px] leading-none text-muted-foreground"
title={worktree.path}
>
{repo
? getRepoKindLabel(repo)
: translate('auto.components.sidebar.WorktreeCard.93aebe4529', 'Folder')}
</Badge>
{getDirectoryName(worktree.path)}
</span>
) : showBranch ? (
<span className="min-w-0 text-[11px] text-muted-foreground truncate leading-none">
{branch}

View File

@ -43,6 +43,7 @@ import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-st
import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu'
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
import { translate } from '@/i18n/i18n'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
type Props = {
worktree: Worktree
@ -217,6 +218,8 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const projectGroups = useAppStore((s) => s.projectGroups)
const createProjectGroup = useAppStore((s) => s.createProjectGroup)
const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup)
const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const repo = useRepoById(worktree.repoId)
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
const [menuOpen, setMenuOpen] = useState(false)
@ -238,6 +241,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const contextMenuOpenedAtRef = useRef<number | null>(null)
const activeContextWorktrees = menuOpen ? contextWorktrees : effectiveSelectedWorktrees
const isMultiContext = activeContextWorktrees.length > 1
const workspaceScope = parseWorkspaceKey(worktree.id)
const folderWorkspaceId =
workspaceScope?.type === 'folder' ? workspaceScope.folderWorkspaceId : null
const sleepableWorktrees = useMemo(
() =>
activeContextWorktrees.filter((item) =>
@ -408,13 +414,33 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
restoreSidebarPosition()
return
}
if (folderWorkspaceId) {
void deleteFolderWorkspace(folderWorkspaceId).then((deleted) => {
if (
deleted &&
useAppStore.getState().activeWorktreeId === folderWorkspaceKey(folderWorkspaceId)
) {
setActiveWorktree(null)
}
})
restoreSidebarPosition()
return
}
// Why delegate to runWorktreeDelete: keeps the delete-vs-project-removal
// decision tree (and its rationale) in one place shared with command
// surfaces and the memory popover's inline Delete action.
runWorktreeDelete(worktree.id)
restoreSidebarPosition()
}, 50)
}, [batchDeleteWorktrees, isMultiContext, setMenuOpenState, worktree.id])
}, [
batchDeleteWorktrees,
deleteFolderWorkspace,
folderWorkspaceId,
isMultiContext,
setActiveWorktree,
setMenuOpenState,
worktree.id
])
const handleOpenParent = useCallback(() => {
if (validParentWorktreeId) {
@ -703,12 +729,17 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
? translate('auto.components.sidebar.WorktreeContextMenu.b42391d8bf', 'Deleting…')
: isMultiContext
? deleteLabel
: removesProject
: folderWorkspaceId
? translate(
'auto.components.sidebar.WorktreeContextMenu.f5ac91531d',
'Remove Project from Orca'
'auto.components.sidebar.WorktreeContextMenu.250de158fd',
'Remove Workspace'
)
: translate('auto.components.sidebar.WorktreeContextMenu.f4475537d8', 'Delete')}
: removesProject
? translate(
'auto.components.sidebar.WorktreeContextMenu.f5ac91531d',
'Remove Project from Orca'
)
: translate('auto.components.sidebar.WorktreeContextMenu.f4475537d8', 'Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -12,6 +12,7 @@ import {
Eye,
FolderInput,
FolderPlus,
FolderX,
Plus,
Shapes,
SlidersHorizontal,
@ -27,6 +28,8 @@ import {
useWorktreeMap
} from '@/store/selectors'
import WorktreeCard from './WorktreeCard'
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
import { FolderWorkspaceComposerDialog } from './FolderWorkspaceComposerDialog'
import { PendingWorktreeRow } from './PendingWorktreeRow'
import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from './WorktreeCardAgents'
import { Button } from '@/components/ui/button'
@ -45,6 +48,7 @@ import { cn } from '@/lib/utils'
import type {
Worktree,
Repo,
FolderWorkspace,
ProjectGroup,
ProjectOrderBy,
WorktreeLineage,
@ -110,6 +114,11 @@ import {
type VirtualizedScrollAnchor
} from '@/hooks/useVirtualizedScrollAnchor'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry'
import {
getFolderWorkspacePathStatusDescription,
getFolderWorkspacePathStatusTitle
} from '@/lib/folder-workspace-path-status'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import {
SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT,
@ -192,10 +201,21 @@ import { buildImportedWorktreesCardCandidates } from './imported-worktrees-card-
import {
WORKTREE_SECTION_HEADER_PADDING_LEFT,
getProjectGroupHeaderPaddingLeft,
getWorktreeCardContentIndent
getWorktreeCardContentIndent,
getWorktreeCardSurfaceInset
} from './worktree-list-indentation'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import {
isConfirmedStaleFolderPathStatus,
type FolderWorkspacePathStatus
} from '../../../../shared/folder-workspace-path-status'
import {
getFolderWorkspaceRevealGroupKeys,
getKnownSidebarWorktreeById,
sidebarWorkspaceStillExists
} from './worktree-list-folder-reveal'
export {
getScrollTopToRevealBounds,
@ -355,10 +375,6 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string {
}
const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
// Why: nested child worktree cards sit inside the parent card body. Preserve
// the legacy lineage surface offset instead of using the full sidebar tree step.
const NESTED_LINEAGE_CARD_INDENT = 14
type VirtualizedWorktreeViewportProps = {
rows: Row[]
activeWorktreeId: string | null
@ -379,11 +395,13 @@ type VirtualizedWorktreeViewportProps = {
handleRemoveProjectFromGroup: (repo: Repo) => void
handleRenameProjectGroup: (groupId: string, currentName: string) => void
handleDeleteProjectGroup: (groupId: string, groupName: string) => void
handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void
activeModal: string
pendingRevealWorktree: PendingSidebarWorktreeReveal | null
clearPendingRevealWorktreeId: () => void
agentSendTargetWorktreeId: string | null
worktrees: Worktree[]
folderWorkspaces: readonly FolderWorkspace[]
selectedWorktreeIds: ReadonlySet<string>
selectedWorktrees: readonly Worktree[]
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
@ -438,6 +456,7 @@ type VirtualizedWorktreeViewportProps = {
}
type WorktreeItemRow = Extract<Row, { type: 'item' }>
type FolderWorkspaceItemRow = Extract<Row, { type: 'folder-workspace' }>
function formatSectionActivityLabel(count: number, label: string): string {
return `${count} ${label}${count === 1 ? '' : 's'}`
@ -465,6 +484,41 @@ function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element {
)
}
function FolderPathStatusIndicator({
status
}: {
status: FolderWorkspacePathStatus | null | undefined
}): React.JSX.Element | null {
const title = getFolderWorkspacePathStatusTitle(status)
if (!status || status.exists || !title) {
return null
}
const destructive = isConfirmedStaleFolderPathStatus(status)
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex size-4 shrink-0 items-center justify-center rounded-[4px]',
destructive ? 'text-destructive' : 'text-muted-foreground'
)}
aria-label={title}
>
<FolderX className="size-3.5" />
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="max-w-72">
<div className="space-y-1">
<div className="font-medium">{title}</div>
<div className="text-muted-foreground">
{getFolderWorkspacePathStatusDescription(status)}
</div>
</div>
</TooltipContent>
</Tooltip>
)
}
type WorktreeRowDragState = {
draggingWorktreeId: string | null
sourceGroupKey: string | null
@ -594,6 +648,9 @@ export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | n
if (worktreeId === null) {
return false
}
if (row.type === 'folder-workspace') {
return folderWorkspaceKey(row.folderWorkspace.id) === worktreeId
}
if (row.type === 'lineage-group') {
return row.rows.some((item) => item.worktree.id === worktreeId)
}
@ -648,6 +705,9 @@ export function getRenderRowKey(row: RenderRow): string {
if (row.type === 'pending-creation') {
return `pending:${row.creationId}`
}
if (row.type === 'folder-workspace') {
return `folder-workspace:${row.folderWorkspace.id}`
}
return `wt:${row.worktree.id}`
}
@ -661,7 +721,11 @@ export function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] {
groups.push({ key: current.key, worktreeIds: current.ids })
continue
}
if (row.type === 'imported-worktrees-card' || row.type === 'pending-creation') {
if (
row.type === 'imported-worktrees-card' ||
row.type === 'pending-creation' ||
row.type === 'folder-workspace'
) {
continue
}
if (!current) {
@ -725,11 +789,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
handleRemoveProjectFromGroup,
handleRenameProjectGroup,
handleDeleteProjectGroup,
handleCreateFolderWorkspace,
activeModal,
pendingRevealWorktree,
clearPendingRevealWorktreeId,
agentSendTargetWorktreeId,
worktrees,
folderWorkspaces,
selectedWorktreeIds,
selectedWorktrees,
onSelectionGesture,
@ -1000,6 +1066,82 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
[renderRows, activeWorktreeId]
)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const {
folderWorkspacePathStatuses,
fetchFolderWorkspacePathStatus,
getFolderWorkspacePathStatusCacheKey,
getFreshFolderWorkspacePathStatus,
activeRuntimeEnvironmentId
} = useAppStore(
useShallow((s) => ({
folderWorkspacePathStatuses: s.folderWorkspacePathStatuses,
fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus,
getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey,
getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus,
activeRuntimeEnvironmentId: s.settings?.activeRuntimeEnvironmentId ?? null
}))
)
const folderPathStatusRepoMembershipKey = useMemo(
() =>
allRepoIds
.map((repoId) => {
const repo = repoMap.get(repoId)
return `${repoId}:${repo?.path ?? ''}:${repo?.projectGroupId ?? ''}:${repo?.connectionId ?? ''}`
})
.join('\0'),
[allRepoIds, repoMap]
)
const folderPathStatusSshConnectionKey = useMemo(
() =>
[...sshConnectionStates.entries()]
.map(([connectionId, state]) => `${connectionId}:${state.status}`)
.sort()
.join('\0'),
[sshConnectionStates]
)
const folderPathStatusCacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick(
folderWorkspacePathStatuses
)
useEffect(() => {
const requests = new Map<string, Parameters<typeof fetchFolderWorkspacePathStatus>[0]>()
for (const group of projectGroups) {
if (group.parentPath) {
const request = { scope: 'project-group' as const, projectGroupId: group.id }
requests.set(getFolderWorkspacePathStatusCacheKey(request), request)
}
}
for (const workspace of folderWorkspaces) {
const request = { scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id }
requests.set(getFolderWorkspacePathStatusCacheKey(request), request)
}
for (const request of requests.values()) {
void fetchFolderWorkspacePathStatus(request, { force: true })
}
}, [
activeRuntimeEnvironmentId,
fetchFolderWorkspacePathStatus,
folderPathStatusRepoMembershipKey,
folderPathStatusSshConnectionKey,
folderWorkspaces,
getFolderWorkspacePathStatusCacheKey,
projectGroups
])
const getCachedFolderWorkspacePathStatus = useCallback(
(request: Parameters<typeof fetchFolderWorkspacePathStatus>[0]) => {
const cacheKey = getFolderWorkspacePathStatusCacheKey(request)
// Why: expired negative statuses should not keep disabling folder
// workspaces while a fresh status request is in flight.
void folderWorkspacePathStatuses[cacheKey]
void folderPathStatusCacheExpiryTick
return getFreshFolderWorkspacePathStatus(request)
},
[
folderWorkspacePathStatuses,
folderPathStatusCacheExpiryTick,
getFolderWorkspacePathStatusCacheKey,
getFreshFolderWorkspacePathStatus
]
)
const renderRowsRef = useRef(renderRows)
renderRowsRef.current = renderRows
const getVirtualItemKey = useCallback(
@ -1153,53 +1295,66 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
if (agentSendTargetWorktreeId !== pendingRevealWorktree.worktreeId) {
const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId)
if (targetWorktree && !targetWorktree.isPinned) {
const seen = new Set<string>()
let current: Worktree | undefined = targetWorktree
while (current && !seen.has(current.id)) {
seen.add(current.id)
const lineage = worktreeLineageById[current.id]
const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined
if (
!lineage ||
!parent ||
current.instanceId !== lineage.worktreeInstanceId ||
parent.instanceId !== lineage.parentWorktreeInstanceId
) {
break
}
const lineageGroupKey = getLineageGroupKey(parent.id)
if (collapsedGroups.has(lineageGroupKey)) {
toggleGroup(lineageGroupKey)
}
current = parent
}
}
if (targetWorktree?.isPinned) {
// Why: pinned worktrees live in the dedicated "Pinned" section regardless
// of their PR-status / project group. Only uncollapse the Pinned header
// itself — expanding the underlying status group would be surprising since
// the user intentionally collapsed it.
if (collapsedGroups.has(PINNED_GROUP_KEY)) {
toggleGroup(PINNED_GROUP_KEY)
}
} else if (targetWorktree) {
const groupKeys = getGroupKeysForWorktree(
groupBy,
targetWorktree,
repoMap,
prCache,
workspaceStatuses,
settings,
projectGroups
)
for (const groupKey of groupKeys) {
const folderGroupKeys = getFolderWorkspaceRevealGroupKeys(
pendingRevealWorktree.worktreeId,
folderWorkspaces,
projectGroups
)
if (folderGroupKeys.length > 0) {
for (const groupKey of folderGroupKeys) {
if (collapsedGroups.has(groupKey)) {
toggleGroup(groupKey)
}
}
} else {
const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId)
if (targetWorktree && !targetWorktree.isPinned) {
const seen = new Set<string>()
let current: Worktree | undefined = targetWorktree
while (current && !seen.has(current.id)) {
seen.add(current.id)
const lineage = worktreeLineageById[current.id]
const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined
if (
!lineage ||
!parent ||
current.instanceId !== lineage.worktreeInstanceId ||
parent.instanceId !== lineage.parentWorktreeInstanceId
) {
break
}
const lineageGroupKey = getLineageGroupKey(parent.id)
if (collapsedGroups.has(lineageGroupKey)) {
toggleGroup(lineageGroupKey)
}
current = parent
}
}
if (targetWorktree?.isPinned) {
// Why: pinned worktrees live in the dedicated "Pinned" section regardless
// of their PR-status / project group. Only uncollapse the Pinned header
// itself — expanding the underlying status group would be surprising since
// the user intentionally collapsed it.
if (collapsedGroups.has(PINNED_GROUP_KEY)) {
toggleGroup(PINNED_GROUP_KEY)
}
} else if (targetWorktree) {
const groupKeys = getGroupKeysForWorktree(
groupBy,
targetWorktree,
repoMap,
prCache,
workspaceStatuses,
settings,
projectGroups
)
for (const groupKey of groupKeys) {
if (collapsedGroups.has(groupKey)) {
toggleGroup(groupKey)
}
}
}
}
}
@ -1208,8 +1363,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
if (cancelled) {
return
}
const targetWorktreeStillExists = worktrees.some(
(worktree) => worktree.id === pendingRevealWorktree.worktreeId
const targetWorktreeStillExists = sidebarWorkspaceStillExists(
pendingRevealWorktree.worktreeId,
worktrees,
folderWorkspaces
)
const targetIndex = renderRows.findIndex((row) =>
renderRowContainsWorktree(row, pendingRevealWorktree.worktreeId)
@ -1294,6 +1451,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
agentSendTargetWorktreeId,
groupBy,
worktrees,
folderWorkspaces,
repoMap,
prCache,
worktreeLineageById,
@ -2778,6 +2936,20 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
: null
})
: null
const projectGroupPathStatus =
isProjectGroupHeader &&
row.projectGroup &&
'parentPath' in row.projectGroup &&
row.projectGroup.parentPath
? getCachedFolderWorkspacePathStatus({
scope: 'project-group',
projectGroupId: row.projectGroup.id
})
: null
const folderWorkspaceCreateDisabled =
projectGroupPathStatus?.exists === false &&
(isConfirmedStaleFolderPathStatus(projectGroupPathStatus) ||
projectGroupPathStatus.reason === 'ambiguous-connection')
const projectGroupDepth = row.projectGroupDepth ?? 0
// Why: non-project section headers like "All" are labels for the
// flat list, so they should not reserve project hierarchy indent.
@ -2906,6 +3078,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
{row.label}
</div>
<RepoForkIndicator upstream={row.repo?.upstream} />
<FolderPathStatusIndicator status={projectGroupPathStatus} />
<SectionMetricsBadge count={row.count} />
</div>
</div>
@ -2983,6 +3156,61 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
</DropdownMenu>
) : null}
{isProjectGroupHeader &&
!row.repo &&
row.projectGroup &&
'parentPath' in row.projectGroup &&
row.projectGroup.parentPath ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
data-repo-header-action=""
className={cn(
'size-5 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/70 hover:text-foreground focus:opacity-100 group-hover:opacity-100',
folderWorkspaceCreateDisabled &&
'cursor-not-allowed text-muted-foreground/60 hover:bg-transparent hover:text-muted-foreground/60'
)}
aria-label={translate(
'auto.components.sidebar.WorktreeList.bd37a57ac8',
'Create workspace for {{value0}}',
{ value0: row.label }
)}
aria-disabled={folderWorkspaceCreateDisabled}
onKeyDown={stopRepoHeaderKeyboardToggle}
onPointerDown={handleRepoHeaderActionPointerDown}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (folderWorkspaceCreateDisabled) {
return
}
if (
row.projectGroup &&
'parentPath' in row.projectGroup &&
row.projectGroup.parentPath
) {
handleCreateFolderWorkspace(row.projectGroup)
}
}}
>
<Plus className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{projectGroupPathStatus?.exists === false
? getFolderWorkspacePathStatusDescription(projectGroupPathStatus)
: translate(
'auto.components.sidebar.WorktreeList.bd37a57ac8',
'Create workspace for {{value0}}',
{ value0: row.label }
)}
</TooltipContent>
</Tooltip>
) : null}
{row.repo && groupBy === 'repo' ? (
<DropdownMenu modal={false}>
<Tooltip>
@ -3220,7 +3448,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
// still owns the project/group inset inside each card surface.
const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth
const nestedCardPaddingLeft = nested
? Math.max(0, itemRow.depth) * NESTED_LINEAGE_CARD_INDENT
? getWorktreeCardSurfaceInset({
isGrouped: true,
groupDepth: itemRow.depth
})
: 0
const inheritedCardContentIndent = getWorktreeCardContentIndent({
isGrouped: groupBy !== 'none',
@ -3234,7 +3465,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
groupDepth: itemRow.groupDepth,
lineageDepth: paddingDepth
})
const cardContentIndent = nested ? inheritedCardContentIndent : paddingLeft
const surfaceInset = nested
? nestedCardPaddingLeft
: getWorktreeCardSurfaceInset({
isGrouped: groupBy !== 'none',
groupDepth: itemRow.groupDepth
})
const cardContentIndent = Math.max(
0,
(nested ? inheritedCardContentIndent : paddingLeft) - surfaceInset
)
const worktreeDragGroupKey = groupKeyByWorktreeId.get(itemRow.worktree.id)
const worktreeDragGroupIndex = groupIndexByWorktreeId.get(itemRow.worktree.id)
const revealHighlightTone =
@ -3271,8 +3511,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
nested ? undefined : handleWorktreeRowPointerDown(event, itemRow.worktree.id)
}
style={{
paddingLeft:
nested && nestedCardPaddingLeft > 0 ? `${nestedCardPaddingLeft}px` : undefined
paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined
}}
>
<WorktreeCard
@ -3435,6 +3674,73 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
}
if (row.type === 'folder-workspace') {
const folderWorkspaceRow = row as FolderWorkspaceItemRow
const folderWorktree = folderWorkspaceToWorktree(folderWorkspaceRow.folderWorkspace)
const folderWorkspacePathStatus = getCachedFolderWorkspacePathStatus({
scope: 'folder-workspace',
folderWorkspaceId: folderWorkspaceRow.folderWorkspace.id
})
const folderWorkspaceActivationDisabled =
folderWorkspacePathStatus?.exists === false &&
(isConfirmedStaleFolderPathStatus(folderWorkspacePathStatus) ||
folderWorkspacePathStatus.reason === 'ambiguous-connection')
const contentIndent = getWorktreeCardContentIndent({
isGrouped: groupBy !== 'none',
groupDepth: folderWorkspaceRow.groupDepth,
lineageDepth: folderWorkspaceRow.depth
})
// Why: folder workspace surfaces should step inward with their
// project-group nesting, matching lineage child card surfaces
// instead of spanning from the sidebar edge at every depth.
const surfaceInset = getWorktreeCardSurfaceInset({
isGrouped: groupBy !== 'none',
groupDepth: folderWorkspaceRow.groupDepth
})
const insetContentIndent = Math.max(0, contentIndent - surfaceInset)
return (
<div
key={vItem.key}
id={getWorktreeOptionId(folderWorktree.id)}
role="option"
aria-selected={selectedWorktreeIds.has(folderWorktree.id)}
aria-current={activeWorktreeId === folderWorktree.id ? 'page' : undefined}
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-virtual-row-start={vItem.start}
data-index={vItem.index}
ref={measureVirtualRowElement}
className="absolute left-0 right-0 top-0"
style={{ transform: getVirtualRowTransform(vItem.start) }}
onClickCapture={handleWorktreeRowClickCapture}
onPointerDown={(event) => handleWorktreeRowPointerDown(event, folderWorktree.id)}
>
<div
className="relative"
style={surfaceInset > 0 ? { paddingLeft: surfaceInset } : undefined}
>
<WorktreeCard
worktree={folderWorktree}
repo={undefined}
isActive={activeWorktreeId === folderWorktree.id}
isCurrentWorktree={currentWorktreeId === folderWorktree.id}
contentIndent={insetContentIndent}
flushSurface
nativeDragEnabled={false}
onImmediateActivate={
folderWorkspaceActivationDisabled ? undefined : onImmediateWorktreeActivate
}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={onContextMenuSelect}
/>
<div className="pointer-events-auto absolute right-3 top-1.5">
<FolderPathStatusIndicator status={folderWorkspacePathStatus} />
</div>
</div>
</div>
)
}
const itemWorkspaceStatus =
groupBy === 'workspace-status'
? getWorkspaceStatus(row.worktree, workspaceStatuses)
@ -3873,6 +4179,7 @@ const WorktreeList = React.memo(function WorktreeList({
// header order from the sorted visible worktree stream instead.
const repos = useAppStore((s) => s.repos)
const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS)
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
const effectiveCollapsedGroups = useMemo(() => {
if (!agentSendTargetWorktreeId) {
return collapsedGroups
@ -3994,7 +4301,8 @@ const WorktreeList = React.memo(function WorktreeList({
projectGroups,
placeholderRepoIds,
importedWorktreesByRepo,
pendingCreations
pendingCreations,
folderWorkspaces
),
[
groupBy,
@ -4011,7 +4319,8 @@ const WorktreeList = React.memo(function WorktreeList({
projectGroups,
placeholderRepoIds,
importedWorktreesByRepo,
pendingCreations
pendingCreations,
folderWorkspaces
]
)
// Why: status headers change during wake (inactive -> active). Key only on
@ -4025,9 +4334,15 @@ const WorktreeList = React.memo(function WorktreeList({
// positions when grouping is active.
const renderedWorktrees = useMemo(
() =>
rows
.filter((r): r is Extract<Row, { type: 'item' }> => r.type === 'item')
.map((r) => r.worktree),
rows.flatMap((row) => {
if (row.type === 'item') {
return [row.worktree]
}
if (row.type === 'folder-workspace') {
return [folderWorkspaceToWorktree(row.folderWorkspace)]
}
return []
}),
[rows]
)
const renderedWorktreeIds = useMemo(
@ -4214,6 +4529,9 @@ const WorktreeList = React.memo(function WorktreeList({
useState<ProjectGroupNameDialogState | null>(null)
const [projectGroupDeleteDialog, setProjectGroupDeleteDialog] =
useState<ProjectGroupDeleteDialogState | null>(null)
const [folderWorkspaceCreateGroup, setFolderWorkspaceCreateGroup] = useState<ProjectGroup | null>(
null
)
const handleCreateGroupFromRepo = useCallback((repo: Repo) => {
setProjectGroupNameDialog({ type: 'create-from-repo', repo })
@ -4338,6 +4656,13 @@ const WorktreeList = React.memo(function WorktreeList({
projectGroupDeleteDialog
])
const handleCreateFolderWorkspace = useCallback((projectGroup: ProjectGroup) => {
if (!projectGroup.parentPath) {
return
}
setFolderWorkspaceCreateGroup(projectGroup)
}, [])
const moveWorktreeToStatus = useCallback(
(worktreeId: string, status: WorkspaceStatus) => {
const current = worktreeMap.get(worktreeId)
@ -4550,11 +4875,15 @@ const WorktreeList = React.memo(function WorktreeList({
if (!activeWorktreeId) {
return
}
const activeWorktree = worktreeMap.get(activeWorktreeId)
const activeWorktree = getKnownSidebarWorktreeById(
activeWorktreeId,
worktreeMap,
folderWorkspaces
)
if (!activeWorktree || activeWorktree.isArchived) {
return
}
if (!worktrees.some((worktree) => worktree.id === activeWorktreeId)) {
if (!renderedWorktreeIds.includes(activeWorktreeId)) {
// Why: the toolbar action promises to reveal the current workspace; when
// sidebar filters hide it, relax those filters before queuing the reveal.
clearFilters()
@ -4565,7 +4894,14 @@ const WorktreeList = React.memo(function WorktreeList({
beginRename: detail?.beginRename === true
})
},
[activeWorktreeId, clearFilters, revealWorktreeInSidebar, worktreeMap, worktrees]
[
activeWorktreeId,
clearFilters,
folderWorkspaces,
renderedWorktreeIds,
revealWorktreeInSidebar,
worktreeMap
]
)
useEffect(() => {
@ -4668,6 +5004,15 @@ const WorktreeList = React.memo(function WorktreeList({
}}
onConfirm={handleConfirmDeleteProjectGroup}
/>
<FolderWorkspaceComposerDialog
open={folderWorkspaceCreateGroup !== null}
projectGroup={folderWorkspaceCreateGroup}
onOpenChange={(open) => {
if (!open) {
setFolderWorkspaceCreateGroup(null)
}
}}
/>
<VirtualizedWorktreeViewport
key={viewportResetKey}
rows={rows}
@ -4689,11 +5034,13 @@ const WorktreeList = React.memo(function WorktreeList({
handleRemoveProjectFromGroup={handleRemoveProjectFromGroup}
handleRenameProjectGroup={handleRenameProjectGroup}
handleDeleteProjectGroup={handleDeleteProjectGroup}
handleCreateFolderWorkspace={handleCreateFolderWorkspace}
activeModal={activeModal}
pendingRevealWorktree={pendingRevealWorktree}
clearPendingRevealWorktreeId={clearPendingRevealWorktreeId}
agentSendTargetWorktreeId={agentSendTargetWorktreeId}
worktrees={worktrees}
folderWorkspaces={folderWorkspaces}
selectedWorktreeIds={selectedWorktreeIds}
selectedWorktrees={selectedWorktrees}
onSelectionGesture={updateSelectionForGesture}

View File

@ -0,0 +1,116 @@
import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item'
import {
getLinkedWorkItemProvider,
getLinkedWorkItemWorkspaceName,
type LinkedWorkItemSummary
} from '@/lib/new-workspace'
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type {
FolderWorkspace,
GitHubWorkItem,
GitLabWorkItem,
LinearIssue,
ProjectGroup,
Repo
} from '../../../../shared/types'
import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField'
const EMPTY_REPOS: Repo[] = []
export function getFolderSourceRepos(
repos: readonly Repo[],
projectGroups: readonly ProjectGroup[],
projectGroup: ProjectGroup | null
): Repo[] {
if (!projectGroup?.parentPath) {
return EMPTY_REPOS
}
const folderPath = projectGroup.parentPath
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroup.id)
return repos.filter(
(repo) =>
isGitRepoKind(repo) &&
((typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
isPathInsideOrEqual(folderPath, repo.path))
)
}
export function toFolderWorkspaceLinkedTask(
item: LinkedWorkItemSummary | null
): FolderWorkspace['linkedTask'] {
if (!item) {
return null
}
const provider = getLinkedWorkItemProvider(item)
return {
provider,
type: item.type,
number: item.number,
title: item.title,
url: item.url,
...(item.linearIdentifier ? { linearIdentifier: item.linearIdentifier } : {}),
...(item.jiraIdentifier ? { jiraIdentifier: item.jiraIdentifier } : {}),
...(item.repoId ? { repoId: item.repoId } : {})
}
}
export function getSmartNameSelection(
linkedWorkItem: LinkedWorkItemSummary | null
): SmartWorkspaceNameSelection | null {
if (!linkedWorkItem) {
return null
}
const provider = getLinkedWorkItemProvider(linkedWorkItem)
const kind: SmartWorkspaceNameSelection['kind'] =
provider === 'linear'
? 'linear'
: provider === 'jira'
? 'jira'
: provider === 'gitlab'
? linkedWorkItem.type === 'mr'
? 'gitlab-mr'
: 'gitlab-issue'
: linkedWorkItem.type === 'pr'
? 'github-pr'
: 'github-issue'
return {
kind,
label:
provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0
? linkedWorkItem.title
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
url: linkedWorkItem.url
}
}
export function getLinkedItemDisplayName(item: LinkedWorkItemSummary): string | null {
return getLinkedWorkItemWorkspaceName(item)?.displayName ?? (item.title.trim() || null)
}
export function toGitHubLinkedWorkItem(item: GitHubWorkItem): LinkedWorkItemSummary {
return {
type: item.type,
provider: 'github',
number: item.number,
title: item.title,
url: item.url,
repoId: item.repoId
}
}
export function toGitLabLinkedWorkItem(item: GitLabWorkItem): LinkedWorkItemSummary {
return {
type: item.type,
provider: 'gitlab',
number: item.number,
title: item.title,
url: item.url,
repoId: item.repoId
}
}
export function toLinearLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary {
return buildLinearIssueLinkedWorkItem(issue)
}

View File

@ -0,0 +1,182 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ProjectGroup } from '../../../../shared/types'
import { useAppStore } from '@/store'
import { useFolderWorkspaceComposerPathStatus } from './folder-workspace-composer-path-status'
const initialState = useAppStore.getInitialState()
const projectGroup: ProjectGroup = {
id: 'group-1',
name: 'Platform',
parentPath: '/workspace/platform',
connectionId: null,
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const projectGroupRequestSnapshot = '/workspace/platform\0group-1\0\0\0'
let root: Root | null = null
let container: HTMLDivElement | null = null
function HookProbe(): null {
const result = useFolderWorkspaceComposerPathStatus(projectGroup, true)
;(
globalThis as { __folderWorkspaceComposerPathStatusResult?: typeof result }
).__folderWorkspaceComposerPathStatusResult = result
return null
}
describe('useFolderWorkspaceComposerPathStatus', () => {
afterEach(() => {
vi.useRealTimers()
act(() => {
root?.unmount()
})
root = null
container?.remove()
container = null
delete (globalThis as { __folderWorkspaceComposerPathStatusResult?: unknown })
.__folderWorkspaceComposerPathStatusResult
useAppStore.setState(initialState, true)
})
it('does not block creation with an expired negative path status', () => {
vi.useFakeTimers()
vi.setSystemTime(20_000)
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request)
const fetchFolderWorkspacePathStatus = vi.fn()
useAppStore.setState({
projectGroups: [projectGroup],
fetchFolderWorkspacePathStatus,
folderWorkspacePathStatuses: {
[cacheKey]: {
status: {
path: '/workspace/platform',
exists: false,
reason: 'missing'
},
checkedAt: 0,
requestSnapshot: projectGroupRequestSnapshot
}
}
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(<HookProbe />)
})
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
).toBe(false)
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusProjectError: string | null }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusProjectError
).toBeNull()
expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, { force: true })
})
it('does not block creation for an unavailable path status', () => {
vi.useFakeTimers()
vi.setSystemTime(20_000)
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request)
useAppStore.setState({
projectGroups: [projectGroup],
fetchFolderWorkspacePathStatus: vi.fn(),
folderWorkspacePathStatuses: {
[cacheKey]: {
status: {
path: '/workspace/platform',
exists: false,
reason: 'unavailable'
},
checkedAt: 20_000,
requestSnapshot: projectGroupRequestSnapshot
}
}
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(<HookProbe />)
})
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
).toBe(false)
})
it('rerenders when a cached blocking path status expires', () => {
vi.useFakeTimers()
vi.setSystemTime(20_000)
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request)
useAppStore.setState({
projectGroups: [projectGroup],
fetchFolderWorkspacePathStatus: vi.fn(),
folderWorkspacePathStatuses: {
[cacheKey]: {
status: {
path: '/workspace/platform',
exists: false,
reason: 'missing'
},
checkedAt: 20_000,
requestSnapshot: projectGroupRequestSnapshot
}
}
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(<HookProbe />)
})
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
).toBe(true)
act(() => {
vi.advanceTimersByTime(10_001)
})
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
).toBe(false)
})
})

View File

@ -0,0 +1,71 @@
import { useEffect, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry'
import {
getFolderWorkspacePathStatusDescription,
getFolderWorkspacePathStatusTitle
} from '@/lib/folder-workspace-path-status'
import { isConfirmedStaleFolderPathStatus } from '../../../../shared/folder-workspace-path-status'
import type { ProjectGroup } from '../../../../shared/types'
export function useFolderWorkspaceComposerPathStatus(
projectGroup: ProjectGroup | null,
open: boolean
): {
pathStatusBlocksCreate: boolean
pathStatusProjectError: string | null
} {
const {
folderWorkspacePathStatuses,
fetchFolderWorkspacePathStatus,
getFolderWorkspacePathStatusCacheKey,
getFreshFolderWorkspacePathStatus
} = useAppStore(
useShallow((s) => ({
folderWorkspacePathStatuses: s.folderWorkspacePathStatuses,
fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus,
getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey,
getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus
}))
)
const pathStatusRequest = useMemo(
() =>
projectGroup ? { scope: 'project-group' as const, projectGroupId: projectGroup.id } : null,
[projectGroup]
)
const cacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick(folderWorkspacePathStatuses)
const pathStatus = useMemo(() => {
if (!pathStatusRequest) {
return null
}
const cacheKey = getFolderWorkspacePathStatusCacheKey(pathStatusRequest)
// Why: subscribe to cache writes, but only let the TTL-aware accessor decide
// whether a cached negative status is still authoritative.
void folderWorkspacePathStatuses[cacheKey]
void cacheExpiryTick
return getFreshFolderWorkspacePathStatus(pathStatusRequest)
}, [
folderWorkspacePathStatuses,
cacheExpiryTick,
getFolderWorkspacePathStatusCacheKey,
getFreshFolderWorkspacePathStatus,
pathStatusRequest
])
useEffect(() => {
if (!open || !pathStatusRequest) {
return
}
void fetchFolderWorkspacePathStatus(pathStatusRequest, { force: true })
}, [fetchFolderWorkspacePathStatus, open, pathStatusRequest])
const pathStatusBlocksCreate =
pathStatus?.exists === false &&
(isConfirmedStaleFolderPathStatus(pathStatus) || pathStatus.reason === 'ambiguous-connection')
const title = pathStatus?.exists === false ? getFolderWorkspacePathStatusTitle(pathStatus) : null
const pathStatusProjectError =
title && pathStatus ? `${title}. ${getFolderWorkspacePathStatusDescription(pathStatus)}` : null
return { pathStatusBlocksCreate, pathStatusProjectError }
}

View File

@ -0,0 +1,100 @@
import {
CLIENT_PLATFORM,
buildAgentPromptWithContext,
type LinkedWorkItemSummary
} from '@/lib/new-workspace'
import { getLinkedWorkItemPromptContext } from '@/lib/linked-work-item-context'
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { activateAndRevealFolderWorkspace } from '@/lib/worktree-activation'
import { isWorkItemLookupText } from '@/lib/work-item-lookup-text'
import type { FolderWorkspace, ProjectGroup, TuiAgent } from '../../../../shared/types'
import {
getLinkedItemDisplayName,
toFolderWorkspaceLinkedTask
} from './folder-workspace-composer-helpers'
type FolderWorkspaceCreateInput = {
projectGroupId: string
name: string
linkedTask: FolderWorkspace['linkedTask']
createdWithAgent?: TuiAgent
pendingFirstAgentMessageRename?: boolean
}
type SubmitFolderWorkspaceCreateParams = {
projectGroup: ProjectGroup
name: string
lastAutoName: string
linkedWorkItem: LinkedWorkItemSummary | null
note: string
quickAgent: TuiAgent | null
autoRenameBranchFromWork: boolean | undefined
agentCmdOverrides: Record<string, string> | undefined
createFolderWorkspace: (input: FolderWorkspaceCreateInput) => Promise<FolderWorkspace | null>
onOpenChange: (open: boolean) => void
}
export async function submitFolderWorkspaceCreate({
projectGroup,
name,
lastAutoName,
linkedWorkItem,
note,
quickAgent,
autoRenameBranchFromWork,
agentCmdOverrides,
createFolderWorkspace,
onOpenChange
}: SubmitFolderWorkspaceCreateParams): Promise<void> {
const linkedName = linkedWorkItem ? getLinkedItemDisplayName(linkedWorkItem) : null
const nameIsAutoManaged = !name.trim() || name === lastAutoName || isWorkItemLookupText(name)
const workspaceName =
nameIsAutoManaged && linkedName
? linkedName
: name.trim() || linkedName || `${projectGroup.name} workspace`
const pendingFirstAgentMessageRename =
autoRenameBranchFromWork === true && !name.trim() && !linkedWorkItem && Boolean(quickAgent)
const workspace = await createFolderWorkspace({
projectGroupId: projectGroup.id,
name: workspaceName,
linkedTask: toFolderWorkspaceLinkedTask(linkedWorkItem),
...(quickAgent ? { createdWithAgent: quickAgent } : {}),
...(pendingFirstAgentMessageRename ? { pendingFirstAgentMessageRename: true } : {})
})
if (!workspace) {
return
}
const linkedPromptContext = getLinkedWorkItemPromptContext(linkedWorkItem)
const startupPrompt = buildAgentPromptWithContext(
note,
[],
linkedPromptContext.linkedUrls,
linkedPromptContext.linkedContextBlocks
)
const startupPlan = quickAgent
? buildAgentStartupPlan({
agent: quickAgent,
prompt: startupPrompt,
cmdOverrides: agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true
})
: null
const startup =
quickAgent && startupPlan
? {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
telemetry: {
agent_kind: tuiAgentToAgentKind(quickAgent),
launch_source: 'sidebar' as const,
request_kind: 'new' as const
}
}
: undefined
activateAndRevealFolderWorkspace(workspace.id, startup ? { startup } : undefined)
onOpenChange(false)
}

View File

@ -10,6 +10,7 @@ type WorktreeDragUnitRow =
| { type: 'item'; worktree: { id: string }; depth: number }
| { type: 'imported-worktrees-card' }
| { type: 'pending-creation' }
| { type: 'folder-workspace' }
export function getWorktreeDragUnitGroups(
rows: readonly WorktreeDragUnitRow[]
@ -27,7 +28,11 @@ export function getWorktreeDragUnitGroups(
})
continue
}
if (row.type === 'imported-worktrees-card' || row.type === 'pending-creation') {
if (
row.type === 'imported-worktrees-card' ||
row.type === 'pending-creation' ||
row.type === 'folder-workspace'
) {
continue
}
if (!current) {

View File

@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import type { FolderWorkspace, ProjectGroup, Worktree } from '../../../../shared/types'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import {
getFolderWorkspaceRevealGroupKeys,
getKnownSidebarWorktreeById,
sidebarWorkspaceStillExists
} from './worktree-list-folder-reveal'
import { getProjectGroupHeaderKey } from './worktree-list-groups'
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
return {
id: 'folder-workspace-1',
projectGroupId: 'group-child',
name: 'Refund workflow',
folderPath: '/workspace/platform',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeProjectGroup(overrides: Partial<ProjectGroup>): ProjectGroup {
return {
id: 'group-1',
name: 'Platform',
parentPath: '/workspace/platform',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 1,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeWorktree(id: string): Worktree {
return {
id,
repoId: 'repo-1',
path: `/workspace/repo/${id}`,
displayName: id,
branch: id,
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1
}
}
describe('worktree list folder reveal', () => {
it('resolves synthetic folder workspace ids as known sidebar worktrees', () => {
const folderWorkspace = makeFolderWorkspace()
const folderWorktree = getKnownSidebarWorktreeById(
folderWorkspaceKey(folderWorkspace.id),
new Map(),
[folderWorkspace]
)
expect(folderWorktree).toMatchObject({
id: folderWorkspaceKey(folderWorkspace.id),
displayName: folderWorkspace.name,
path: folderWorkspace.folderPath
})
})
it('keeps pending reveals alive for folder workspaces missing from raw git worktrees', () => {
const folderWorkspace = makeFolderWorkspace()
const gitWorktree = makeWorktree('git-worktree-1')
expect(
sidebarWorkspaceStillExists(
folderWorkspaceKey(folderWorkspace.id),
[gitWorktree],
[folderWorkspace]
)
).toBe(true)
expect(sidebarWorkspaceStillExists('missing-worktree', [gitWorktree], [folderWorkspace])).toBe(
false
)
})
it('returns project group keys from root to nested folder workspace owner', () => {
const root = makeProjectGroup({ id: 'group-root', name: 'Company' })
const child = makeProjectGroup({
id: 'group-child',
name: 'Platform',
parentGroupId: root.id
})
const folderWorkspace = makeFolderWorkspace({ projectGroupId: child.id })
expect(
getFolderWorkspaceRevealGroupKeys(
folderWorkspaceKey(folderWorkspace.id),
[folderWorkspace],
[child, root]
)
).toEqual([getProjectGroupHeaderKey(root.id), getProjectGroupHeaderKey(child.id)])
})
})

View File

@ -0,0 +1,65 @@
import type { FolderWorkspace, ProjectGroup, Worktree } from '../../../../shared/types'
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { getProjectGroupHeaderKey } from './worktree-list-groups'
function findFolderWorkspaceByKey(
worktreeId: string,
folderWorkspaces: readonly FolderWorkspace[]
): FolderWorkspace | null {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type !== 'folder') {
return null
}
return folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId) ?? null
}
export function getKnownSidebarWorktreeById(
worktreeId: string,
worktreeMap: ReadonlyMap<string, Worktree>,
folderWorkspaces: readonly FolderWorkspace[]
): Worktree | null {
const worktree = worktreeMap.get(worktreeId)
if (worktree) {
return worktree
}
const folderWorkspace = findFolderWorkspaceByKey(worktreeId, folderWorkspaces)
return folderWorkspace ? folderWorkspaceToWorktree(folderWorkspace) : null
}
export function sidebarWorkspaceStillExists(
worktreeId: string,
worktrees: readonly Worktree[],
folderWorkspaces: readonly FolderWorkspace[]
): boolean {
if (worktrees.some((worktree) => worktree.id === worktreeId)) {
return true
}
return findFolderWorkspaceByKey(worktreeId, folderWorkspaces) !== null
}
export function getFolderWorkspaceRevealGroupKeys(
worktreeId: string,
folderWorkspaces: readonly FolderWorkspace[],
projectGroups: readonly ProjectGroup[]
): string[] {
const folderWorkspace = findFolderWorkspaceByKey(worktreeId, folderWorkspaces)
if (!folderWorkspace) {
return []
}
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
const keys: string[] = []
const seen = new Set<string>()
let groupId: string | null = folderWorkspace.projectGroupId
while (groupId && !seen.has(groupId)) {
seen.add(groupId)
const group = groupsById.get(groupId)
if (!group) {
break
}
keys.unshift(getProjectGroupHeaderKey(group.id))
groupId = group.parentGroupId
}
return keys
}

View File

@ -14,6 +14,7 @@ import {
} from './worktree-list-groups'
import type {
DetectedWorktree,
FolderWorkspace,
Repo,
ProjectGroup,
Worktree,
@ -1314,6 +1315,209 @@ describe('project groups', () => {
expect(rows[0]).toMatchObject({ count: 1 })
})
it('renders folder workspaces under their owning folder-backed Project Group', () => {
const group: ProjectGroup = {
id: 'group-root',
name: 'Platform',
parentPath: '/monorepo',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const folderWorkspace: FolderWorkspace = {
id: 'folder-workspace-1',
projectGroupId: group.id,
name: 'Refund fix',
folderPath: '/monorepo',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 10,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
const rows = buildRows(
'repo',
[],
new Map(),
null,
new Set(),
undefined,
undefined,
undefined,
undefined,
undefined,
false,
undefined,
[group],
new Set(),
new Map(),
[],
[folderWorkspace]
)
expect(rows).toMatchObject([
{
type: 'header',
key: 'project-group:group-root',
count: 1
},
{
type: 'folder-workspace',
folderWorkspace: { id: 'folder-workspace-1' },
projectGroup: { id: 'group-root' },
groupDepth: 1
}
])
})
it('preserves nested Project Group depth for folder workspace rows', () => {
const rootGroup: ProjectGroup = {
id: 'group-root',
name: 'Platform',
parentPath: '/monorepo',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const childGroup: ProjectGroup = {
id: 'group-shared',
name: 'packages/shared',
parentPath: '/monorepo/packages/shared',
parentGroupId: rootGroup.id,
createdFrom: 'folder-scan',
tabOrder: 1,
isCollapsed: false,
color: null,
createdAt: 2,
updatedAt: 2
}
const folderWorkspace: FolderWorkspace = {
id: 'folder-workspace-nested',
projectGroupId: childGroup.id,
name: 'Shared package work',
folderPath: '/monorepo/packages/shared',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 10,
lastActivityAt: 0,
createdAt: 3,
updatedAt: 3
}
const rows = buildRows(
'repo',
[],
new Map(),
null,
new Set(),
undefined,
undefined,
undefined,
undefined,
undefined,
false,
undefined,
[rootGroup, childGroup],
new Set(),
new Map(),
[],
[folderWorkspace]
)
expect(rows).toMatchObject([
{
type: 'header',
key: 'project-group:group-root',
projectGroupDepth: 0
},
{
type: 'header',
key: 'project-group:group-shared',
projectGroupDepth: 1
},
{
type: 'folder-workspace',
folderWorkspace: { id: 'folder-workspace-nested' },
groupDepth: 2
}
])
})
it('does not render folder workspaces under non-folder Project Groups', () => {
const group: ProjectGroup = {
id: 'group-manual',
name: 'Manual',
parentPath: null,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const folderWorkspace: FolderWorkspace = {
id: 'folder-workspace-1',
projectGroupId: group.id,
name: 'Hidden',
folderPath: '/monorepo',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 10,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
const rows = buildRows(
'repo',
[],
new Map(),
null,
new Set(),
undefined,
undefined,
undefined,
undefined,
undefined,
false,
undefined,
[group],
new Set(),
new Map(),
[],
[folderWorkspace]
)
expect(rows).toMatchObject([
{
type: 'header',
key: 'project-group:group-manual',
count: 0
}
])
expect(rows.some((row) => row.type === 'folder-workspace')).toBe(false)
})
it('renders imported repos under nested Project Groups before worktree rows load', () => {
const rootGroup: ProjectGroup = {
id: 'group-root',

View File

@ -3,6 +3,7 @@ import { CircleX, FolderTree, List, Pin } from 'lucide-react'
import type React from 'react'
import type {
DetectedWorktree,
FolderWorkspace,
Repo,
ProjectGroup,
ProjectOrderBy,
@ -10,7 +11,7 @@ import type {
WorktreeLineage,
WorkspaceStatusDefinition
} from '../../../../shared/types'
import { branchName } from '@/lib/git-utils'
import { branchName } from '../../lib/git-utils'
import {
getWorkspaceStatus,
getWorkspaceStatusFromGroupKey,
@ -23,11 +24,11 @@ import {
ConductorReviewIcon
} from './workspace-status-icons'
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
import type { AppState } from '@/store/types'
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import type { AppState } from '../../store/types'
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key'
import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../shared/project-groups'
import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels'
import { translate } from '@/i18n/i18n'
import { getRepoDisplayLabelsByPath } from '../../lib/repo-display-labels'
import { translate } from '../../i18n/i18n'
export { branchName }
@ -78,13 +79,27 @@ export type PendingCreationRow = {
repo: Repo | undefined
}
export type FolderWorkspaceRow = {
type: 'folder-workspace'
key: string
folderWorkspace: FolderWorkspace
projectGroup: ProjectGroup
depth: number
groupDepth: number
}
/** Minimal shape buildRows needs for an in-flight create. Deliberately not the
* full PendingWorktreeCreation: row identity depends only on which creates
* exist and their repo, so callers can subscribe on this stable shape and keep
* progress-field churn (phase/loaderVisible) from rebuilding the whole list. */
export type PendingCreationRef = { creationId: string; repoId: string }
export type Row = GroupHeaderRow | WorktreeRow | ImportedWorktreesCardRow | PendingCreationRow
export type Row =
| GroupHeaderRow
| WorktreeRow
| ImportedWorktreesCardRow
| PendingCreationRow
| FolderWorkspaceRow
function buildPendingCreationRow(
creation: PendingCreationRef,
@ -533,7 +548,8 @@ export function buildRows(
projectGroups: readonly ProjectGroup[] = [],
placeholderRepoIds: ReadonlySet<string> = new Set(),
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map(),
pendingCreations: readonly PendingCreationRef[] = []
pendingCreations: readonly PendingCreationRef[] = [],
folderWorkspaces: readonly FolderWorkspace[] = []
): Row[] {
const result: Row[] = []
@ -798,6 +814,23 @@ export function buildRows(
}
const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group]))
const folderWorkspacesByProjectGroupId = new Map<string, FolderWorkspace[]>()
for (const workspace of folderWorkspaces) {
const group = projectGroupsById.get(workspace.projectGroupId)
if (!group?.parentPath) {
continue
}
const list = folderWorkspacesByProjectGroupId.get(workspace.projectGroupId) ?? []
list.push(workspace)
folderWorkspacesByProjectGroupId.set(workspace.projectGroupId, list)
}
for (const list of folderWorkspacesByProjectGroupId.values()) {
list.sort((left, right) => {
const leftOrder = left.manualOrder ?? left.sortOrder
const rightOrder = right.manualOrder ?? right.sortOrder
return rightOrder - leftOrder || left.name.localeCompare(right.name)
})
}
const childGroupsByParentId = new Map<string | null, ProjectGroup[]>()
for (const group of projectGroups) {
const parentId =
@ -814,10 +847,11 @@ export function buildRows(
const getProjectGroupSubtreeCount = (groupId: string): number => {
const directCount = groupByProjectGroupId.get(groupId)?.length ?? 0
const folderWorkspaceCount = folderWorkspacesByProjectGroupId.get(groupId)?.length ?? 0
const children = childGroupsByParentId.get(groupId) ?? []
return children.reduce(
(count, child) => count + getProjectGroupSubtreeCount(child.id),
directCount
directCount + folderWorkspaceCount
)
}
@ -836,6 +870,16 @@ export function buildRows(
projectGroupDepth: depth
})
if (!collapsedGroups.has(key)) {
for (const folderWorkspace of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) {
result.push({
type: 'folder-workspace',
key: `folder-workspace:${folderWorkspace.id}`,
folderWorkspace,
projectGroup,
depth: 0,
groupDepth: depth + 1
})
}
appendOrderedGroups(withRepoSectionDisplayLabels(repoEntries), depth + 1)
for (const childGroup of childGroups) {
appendProjectGroup(childGroup, depth + 1)

View File

@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
WORKTREE_SECTION_HEADER_PADDING_LEFT,
getProjectGroupHeaderPaddingLeft,
getWorktreeCardContentIndent
getWorktreeCardContentIndent,
getWorktreeCardSurfaceInset
} from './worktree-list-indentation'
describe('worktree list indentation', () => {
@ -40,4 +41,13 @@ describe('worktree list indentation', () => {
it('aligns flat section headers with top-level project headers', () => {
expect(WORKTREE_SECTION_HEADER_PADDING_LEFT).toBe(getProjectGroupHeaderPaddingLeft(0))
})
it('keeps root repo cards flush but insets cards inside project groups', () => {
expect(getWorktreeCardSurfaceInset({ isGrouped: true, groupDepth: 0 })).toBe(0)
expect(getWorktreeCardSurfaceInset({ isGrouped: true, groupDepth: 1 })).toBe(14)
})
it('does not inset card surfaces outside grouped views', () => {
expect(getWorktreeCardSurfaceInset({ isGrouped: false, groupDepth: 4 })).toBe(0)
})
})

View File

@ -2,6 +2,9 @@ export const SIDEBAR_TREE_INDENT = 18
// Why: project-grouped cards need to read as children even after the card
// surface inset is subtracted, while lineage rows keep the base tree step.
const PROJECT_WORKTREE_CARD_EXTRA_INDENT = 2
// Why: grouped workspace cards should move their surface inward without using
// the full tree step, preserving the existing compact child-card rhythm.
const GROUPED_WORKTREE_CARD_SURFACE_INDENT = 14
export const PROJECT_GROUP_HEADER_BASE_PADDING = 10
// Why: workspace/status headers and project headers occupy the same sidebar
// row role, so their titles should not shift when switching grouping modes.
@ -29,3 +32,10 @@ export function getWorktreeCardContentIndent(args: {
const projectCardIndent = args.isGrouped ? PROJECT_WORKTREE_CARD_EXTRA_INDENT : 0
return (groupSteps + clampDepth(args.lineageDepth)) * SIDEBAR_TREE_INDENT + projectCardIndent
}
export function getWorktreeCardSurfaceInset(args: {
isGrouped: boolean
groupDepth: number
}): number {
return args.isGrouped ? clampDepth(args.groupDepth) * GROUPED_WORKTREE_CARD_SURFACE_INDENT : 0
}

View File

@ -7,6 +7,7 @@ export const GROUP_HEADER_ROW_HEIGHT = 28
const SECONDARY_GROUP_HEADER_TOP_MARGIN = 4
const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36
const PENDING_CREATION_ROW_HEIGHT = 56
const FOLDER_WORKSPACE_ROW_HEIGHT = 64
type WorktreeItemRow = Extract<Row, { type: 'item' }>
export type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] }
@ -50,6 +51,9 @@ export function estimateRenderRowSize(
if (row?.type === 'pending-creation') {
return PENDING_CREATION_ROW_HEIGHT
}
if (row?.type === 'folder-workspace') {
return FOLDER_WORKSPACE_ROW_HEIGHT
}
return 116
}

View File

@ -31,8 +31,14 @@ import { translate } from '@/i18n/i18n'
const RANGE_OPTIONS: ClaudeUsageRange[] = ['7d', '30d', '90d', 'all']
const SCOPE_OPTIONS: { value: ClaudeUsageScope; label: string }[] = [
{ value: 'orca', label: translate("auto.components.stats.ClaudeUsagePane.4f8368c272", "Orca worktrees only") },
{ value: 'all', label: translate("auto.components.stats.ClaudeUsagePane.5ce4842c2c", "All local Claude usage") }
{
value: 'orca',
label: translate('auto.components.stats.ClaudeUsagePane.4f8368c272', 'Orca worktrees only')
},
{
value: 'all',
label: translate('auto.components.stats.ClaudeUsagePane.5ce4842c2c', 'All local Claude usage')
}
]
const RANGE_LABELS: Record<ClaudeUsageRange, string> = {
'7d': 'Last 7 days',
@ -108,15 +114,27 @@ export function ClaudeUsagePane(): React.JSX.Element {
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.ClaudeUsagePane.6afacbee37", "Claude Usage Tracking")}</h3>
<h3 className="text-sm font-semibold text-foreground">
{translate(
'auto.components.stats.ClaudeUsagePane.6afacbee37',
'Claude Usage Tracking'
)}
</h3>
<p className="text-sm text-muted-foreground">
{translate("auto.components.stats.ClaudeUsagePane.0cb1a36d7d", "Reads local Claude usage logs to show token, model, and session stats.")}</p>
{translate(
'auto.components.stats.ClaudeUsagePane.0cb1a36d7d',
'Reads local Claude usage logs to show token, model, and session stats.'
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={false}
aria-label={translate("auto.components.stats.ClaudeUsagePane.424cd50412", "Enable Claude usage analytics")}
aria-label={translate(
'auto.components.stats.ClaudeUsagePane.424cd50412',
'Enable Claude usage analytics'
)}
onClick={() => handleSetEnabled(true)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
>
@ -137,10 +155,18 @@ export function ClaudeUsagePane(): React.JSX.Element {
<div className="space-y-4 rounded-lg border border-border/60 bg-card/30 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.ClaudeUsagePane.6afacbee37", "Claude Usage Tracking")}</h3>
<h3 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.ClaudeUsagePane.6afacbee37', 'Claude Usage Tracking')}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
{formatUpdatedAt(scanState.lastScanCompletedAt)}
{scanState.lastScanError ? translate("auto.components.stats.ClaudeUsagePane.2d41fd45c6", " • Last scan error: {{value0}}", { value0: scanState.lastScanError }) : ''}
{scanState.lastScanError
? translate(
'auto.components.stats.ClaudeUsagePane.2d41fd45c6',
' • Last scan error: {{value0}}',
{ value0: scanState.lastScanError }
)
: ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 self-start">
@ -152,17 +178,27 @@ export function ClaudeUsagePane(): React.JSX.Element {
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-xs" aria-label={translate("auto.components.stats.ClaudeUsagePane.e9bf9fce0e", "Claude usage options")}>
<Button
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.stats.ClaudeUsagePane.e9bf9fce0e',
'Claude usage options'
)}
>
<SlidersHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate("auto.components.stats.ClaudeUsagePane.dd29209b21", "Filters")}</TooltipContent>
{translate('auto.components.stats.ClaudeUsagePane.dd29209b21', 'Filters')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel>{translate("auto.components.stats.ClaudeUsagePane.f61cffb9c8", "Scope")}</DropdownMenuLabel>
<DropdownMenuLabel>
{translate('auto.components.stats.ClaudeUsagePane.f61cffb9c8', 'Scope')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={scope}
onValueChange={(value) => void setClaudeUsageScope(value as ClaudeUsageScope)}
@ -174,7 +210,9 @@ export function ClaudeUsagePane(): React.JSX.Element {
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>{translate("auto.components.stats.ClaudeUsagePane.505be9aac4", "Range")}</DropdownMenuLabel>
<DropdownMenuLabel>
{translate('auto.components.stats.ClaudeUsagePane.505be9aac4', 'Range')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={range}
onValueChange={(value) => void setClaudeUsageRange(value as ClaudeUsageRange)}
@ -195,20 +233,27 @@ export function ClaudeUsagePane(): React.JSX.Element {
size="icon-xs"
onClick={() => void refreshClaudeUsage()}
disabled={scanState.isScanning}
aria-label={translate("auto.components.stats.ClaudeUsagePane.c5b9b344d0", "Refresh Claude usage")}
aria-label={translate(
'auto.components.stats.ClaudeUsagePane.c5b9b344d0',
'Refresh Claude usage'
)}
>
<RefreshCw className={`size-3.5 ${scanState.isScanning ? 'animate-spin' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate("auto.components.stats.ClaudeUsagePane.8d18bbb771", "Refresh")}</TooltipContent>
{translate('auto.components.stats.ClaudeUsagePane.8d18bbb771', 'Refresh')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
type="button"
role="switch"
aria-checked={true}
aria-label={translate("auto.components.stats.ClaudeUsagePane.424cd50412", "Enable Claude usage analytics")}
aria-label={translate(
'auto.components.stats.ClaudeUsagePane.424cd50412',
'Enable Claude usage analytics'
)}
onClick={() => handleSetEnabled(false)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
>
@ -225,32 +270,39 @@ export function ClaudeUsagePane(): React.JSX.Element {
{!hasAnyData ? (
<div className="rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-6 text-sm text-muted-foreground">
{translate("auto.components.stats.ClaudeUsagePane.7dde9331fd", "No local Claude usage found yet for this scope.")}</div>
{translate(
'auto.components.stats.ClaudeUsagePane.7dde9331fd',
'No local Claude usage found yet for this scope.'
)}
</div>
) : (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<StatCard
label={translate("auto.components.stats.ClaudeUsagePane.ea71fae8fc", "Input tokens")}
label={translate('auto.components.stats.ClaudeUsagePane.ea71fae8fc', 'Input tokens')}
value={formatTokens(summary?.inputTokens ?? 0)}
icon={<Sparkles className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.ClaudeUsagePane.2b8a2f14aa", "Output tokens")}
label={translate('auto.components.stats.ClaudeUsagePane.2b8a2f14aa', 'Output tokens')}
value={formatTokens(summary?.outputTokens ?? 0)}
icon={<Activity className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.ClaudeUsagePane.268cf0af51", "Cache read")}
label={translate('auto.components.stats.ClaudeUsagePane.268cf0af51', 'Cache read')}
value={formatTokens(summary?.cacheReadTokens ?? 0)}
icon={<DatabaseZap className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.ClaudeUsagePane.b786fb4a70", "Cache write")}
label={translate('auto.components.stats.ClaudeUsagePane.b786fb4a70', 'Cache write')}
value={formatTokens(summary?.cacheWriteTokens ?? 0)}
icon={<Waypoints className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.ClaudeUsagePane.1634c4f404", "Cache reuse rate")}
label={translate(
'auto.components.stats.ClaudeUsagePane.1634c4f404',
'Cache reuse rate'
)}
value={
summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined
? `${Math.round(summary.cacheReuseRate * 100)}%`
@ -259,7 +311,10 @@ export function ClaudeUsagePane(): React.JSX.Element {
icon={<Gauge className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.ClaudeUsagePane.8cc23be4a3", "Zero-cache-read turns")}
label={translate(
'auto.components.stats.ClaudeUsagePane.8cc23be4a3',
'Zero-cache-read turns'
)}
value={
summary && summary.turns > 0
? `${Math.round((summary.zeroCacheReadTurns / summary.turns) * 100)}%`
@ -268,28 +323,41 @@ export function ClaudeUsagePane(): React.JSX.Element {
icon={<DatabaseZap className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.ClaudeUsagePane.0f3e696ca9", "Sessions / Turns")}
label={translate(
'auto.components.stats.ClaudeUsagePane.0f3e696ca9',
'Sessions / Turns'
)}
value={`${(summary?.sessions ?? 0).toLocaleString()} / ${(summary?.turns ?? 0).toLocaleString()}`}
icon={<FolderKanban className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.ClaudeUsagePane.b26d4ddb58", "Est. API-equivalent cost")}
label={translate(
'auto.components.stats.ClaudeUsagePane.b26d4ddb58',
'Est. API-equivalent cost'
)}
value={formatCost(summary?.estimatedCostUsd ?? null)}
icon={<Coins className="size-4" />}
/>
</div>
<p className="px-1 text-xs text-muted-foreground">
{translate("auto.components.stats.ClaudeUsagePane.51ae85fa00", "Cache reuse rate is calculated as cache read tokens / (input tokens + cache read tokens).")}</p>
{translate(
'auto.components.stats.ClaudeUsagePane.51ae85fa00',
'Cache reuse rate is calculated as cache read tokens / (input tokens + cache read tokens).'
)}
</p>
<ClaudeUsageDailyChart daily={daily} />
<div className="grid gap-4 xl:grid-cols-2">
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.ClaudeUsagePane.0f394c24e3", "By model")}</h4>
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.ClaudeUsagePane.0f394c24e3', 'By model')}
</h4>
<p className="text-xs text-muted-foreground">
{translate("auto.components.stats.ClaudeUsagePane.c3fdbc5474", "Top model:")}{' '}
{summary?.topModel ?? translate("auto.components.stats.ClaudeUsagePane.7765a4c3e1", "n/a")}
{translate('auto.components.stats.ClaudeUsagePane.c3fdbc5474', 'Top model:')}{' '}
{summary?.topModel ??
translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')}
</p>
</div>
<div className="space-y-3">
@ -302,8 +370,10 @@ export function ClaudeUsagePane(): React.JSX.Element {
</span>
</div>
<div className="text-xs text-muted-foreground">
{row.sessions} {translate("auto.components.stats.ClaudeUsagePane.02a046792e", "sessions •")} {row.turns}{' '}
{translate("auto.components.stats.ClaudeUsagePane.32176e1d44", "turns")}
{row.sessions}{' '}
{translate('auto.components.stats.ClaudeUsagePane.02a046792e', 'sessions •')}{' '}
{row.turns}{' '}
{translate('auto.components.stats.ClaudeUsagePane.32176e1d44', 'turns')}
</div>
</div>
))}
@ -312,10 +382,13 @@ export function ClaudeUsagePane(): React.JSX.Element {
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.ClaudeUsagePane.7dc9e5613b", "By project")}</h4>
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.ClaudeUsagePane.7dc9e5613b', 'By project')}
</h4>
<p className="text-xs text-muted-foreground">
{translate("auto.components.stats.ClaudeUsagePane.f97435845c", "Top project:")}{' '}
{summary?.topProject ?? translate("auto.components.stats.ClaudeUsagePane.7765a4c3e1", "n/a")}
{translate('auto.components.stats.ClaudeUsagePane.f97435845c', 'Top project:')}{' '}
{summary?.topProject ??
translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')}
</p>
</div>
<div className="space-y-3">
@ -328,8 +401,10 @@ export function ClaudeUsagePane(): React.JSX.Element {
</span>
</div>
<div className="text-xs text-muted-foreground">
{row.sessions} {translate("auto.components.stats.ClaudeUsagePane.02a046792e", "sessions •")} {row.turns}{' '}
{translate("auto.components.stats.ClaudeUsagePane.32176e1d44", "turns")}
{row.sessions}{' '}
{translate('auto.components.stats.ClaudeUsagePane.02a046792e', 'sessions •')}{' '}
{row.turns}{' '}
{translate('auto.components.stats.ClaudeUsagePane.32176e1d44', 'turns')}
</div>
</div>
))}
@ -339,25 +414,41 @@ export function ClaudeUsagePane(): React.JSX.Element {
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.ClaudeUsagePane.7e76c84153", "Recent sessions")}</h4>
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.ClaudeUsagePane.7e76c84153', 'Recent sessions')}
</h4>
<p className="text-xs text-muted-foreground">
{translate("auto.components.stats.ClaudeUsagePane.abfc4a4943", "Cache reuse rate:")}{' '}
{translate('auto.components.stats.ClaudeUsagePane.abfc4a4943', 'Cache reuse rate:')}{' '}
{summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined
? `${Math.round(summary.cacheReuseRate * 100)}%`
: translate("auto.components.stats.ClaudeUsagePane.7765a4c3e1", "n/a")}
: translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')}
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.01476891c7", "Last active")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.c17bed0416", "Project")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.1afc25eb06", "Model")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.0f03975d59", "Turns")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.faf3444859", "Input")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.a8b7487ff7", "Output")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.ClaudeUsagePane.21ea00bfa8", "Cache")}</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.01476891c7', 'Last active')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.c17bed0416', 'Project')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.1afc25eb06', 'Model')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.0f03975d59', 'Turns')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.faf3444859', 'Input')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.a8b7487ff7', 'Output')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.ClaudeUsagePane.21ea00bfa8', 'Cache')}
</th>
</tr>
</thead>
<tbody>
@ -367,7 +458,10 @@ export function ClaudeUsagePane(): React.JSX.Element {
{formatSessionTime(row.lastActiveAt)}
</td>
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
<td className="px-2 py-2 text-muted-foreground">{row.model ?? translate("auto.components.stats.ClaudeUsagePane.cfe2282ffa", "Unknown")}</td>
<td className="px-2 py-2 text-muted-foreground">
{row.model ??
translate('auto.components.stats.ClaudeUsagePane.cfe2282ffa', 'Unknown')}
</td>
<td className="px-2 py-2 text-muted-foreground">{row.turns}</td>
<td className="px-2 py-2 text-muted-foreground">
{formatTokens(row.inputTokens)}

View File

@ -30,8 +30,14 @@ import { translate } from '@/i18n/i18n'
const RANGE_OPTIONS: CodexUsageRange[] = ['7d', '30d', '90d', 'all']
const SCOPE_OPTIONS: { value: CodexUsageScope; label: string }[] = [
{ value: 'orca', label: translate("auto.components.stats.CodexUsagePane.201766b754", "Orca worktrees only") },
{ value: 'all', label: translate("auto.components.stats.CodexUsagePane.4fe8820098", "All local Codex usage") }
{
value: 'orca',
label: translate('auto.components.stats.CodexUsagePane.201766b754', 'Orca worktrees only')
},
{
value: 'all',
label: translate('auto.components.stats.CodexUsagePane.4fe8820098', 'All local Codex usage')
}
]
const RANGE_LABELS: Record<CodexUsageRange, string> = {
'7d': 'Last 7 days',
@ -107,15 +113,24 @@ export function CodexUsagePane(): React.JSX.Element {
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.CodexUsagePane.408210470c", "Codex Usage Tracking")}</h3>
<h3 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.CodexUsagePane.408210470c', 'Codex Usage Tracking')}
</h3>
<p className="text-sm text-muted-foreground">
{translate("auto.components.stats.CodexUsagePane.13badcd8f2", "Reads local Codex usage logs to show token, model, and session stats.")}</p>
{translate(
'auto.components.stats.CodexUsagePane.13badcd8f2',
'Reads local Codex usage logs to show token, model, and session stats.'
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={false}
aria-label={translate("auto.components.stats.CodexUsagePane.f7c1affbd5", "Enable Codex usage analytics")}
aria-label={translate(
'auto.components.stats.CodexUsagePane.f7c1affbd5',
'Enable Codex usage analytics'
)}
onClick={() => handleSetEnabled(true)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
>
@ -129,7 +144,7 @@ export function CodexUsagePane(): React.JSX.Element {
if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) {
return (
<ClaudeUsageLoadingState
title={translate("auto.components.stats.CodexUsagePane.408210470c", "Codex Usage Tracking")}
title={translate('auto.components.stats.CodexUsagePane.408210470c', 'Codex Usage Tracking')}
summaryCardCount={6}
summaryGridClassName="md:grid-cols-3"
/>
@ -142,10 +157,18 @@ export function CodexUsagePane(): React.JSX.Element {
<div className="space-y-4 rounded-lg border border-border/60 bg-card/30 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.CodexUsagePane.408210470c", "Codex Usage Tracking")}</h3>
<h3 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.CodexUsagePane.408210470c', 'Codex Usage Tracking')}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
{formatUpdatedAt(scanState.lastScanCompletedAt)}
{scanState.lastScanError ? translate("auto.components.stats.CodexUsagePane.8a6655f7a2", " • Last scan error: {{value0}}", { value0: scanState.lastScanError }) : ''}
{scanState.lastScanError
? translate(
'auto.components.stats.CodexUsagePane.8a6655f7a2',
' • Last scan error: {{value0}}',
{ value0: scanState.lastScanError }
)
: ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 self-start">
@ -157,17 +180,27 @@ export function CodexUsagePane(): React.JSX.Element {
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-xs" aria-label={translate("auto.components.stats.CodexUsagePane.70b5b8581f", "Codex usage options")}>
<Button
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.stats.CodexUsagePane.70b5b8581f',
'Codex usage options'
)}
>
<SlidersHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate("auto.components.stats.CodexUsagePane.1af1a39b2f", "Filters")}</TooltipContent>
{translate('auto.components.stats.CodexUsagePane.1af1a39b2f', 'Filters')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel>{translate("auto.components.stats.CodexUsagePane.6d68e8399a", "Scope")}</DropdownMenuLabel>
<DropdownMenuLabel>
{translate('auto.components.stats.CodexUsagePane.6d68e8399a', 'Scope')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={scope}
onValueChange={(value) => void setCodexUsageScope(value as CodexUsageScope)}
@ -179,7 +212,9 @@ export function CodexUsagePane(): React.JSX.Element {
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>{translate("auto.components.stats.CodexUsagePane.89162e019b", "Range")}</DropdownMenuLabel>
<DropdownMenuLabel>
{translate('auto.components.stats.CodexUsagePane.89162e019b', 'Range')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={range}
onValueChange={(value) => void setCodexUsageRange(value as CodexUsageRange)}
@ -200,20 +235,27 @@ export function CodexUsagePane(): React.JSX.Element {
size="icon-xs"
onClick={() => void refreshCodexUsage()}
disabled={scanState.isScanning}
aria-label={translate("auto.components.stats.CodexUsagePane.ec4d270e2c", "Refresh Codex usage")}
aria-label={translate(
'auto.components.stats.CodexUsagePane.ec4d270e2c',
'Refresh Codex usage'
)}
>
<RefreshCw className={`size-3.5 ${scanState.isScanning ? 'animate-spin' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate("auto.components.stats.CodexUsagePane.3022cda443", "Refresh")}</TooltipContent>
{translate('auto.components.stats.CodexUsagePane.3022cda443', 'Refresh')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
type="button"
role="switch"
aria-checked={true}
aria-label={translate("auto.components.stats.CodexUsagePane.f7c1affbd5", "Enable Codex usage analytics")}
aria-label={translate(
'auto.components.stats.CodexUsagePane.f7c1affbd5',
'Enable Codex usage analytics'
)}
onClick={() => handleSetEnabled(false)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
>
@ -230,53 +272,73 @@ export function CodexUsagePane(): React.JSX.Element {
{!hasAnyData ? (
<div className="rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-6 text-sm text-muted-foreground">
{translate("auto.components.stats.CodexUsagePane.4c865393b4", "No local Codex usage found yet for this scope.")}</div>
{translate(
'auto.components.stats.CodexUsagePane.4c865393b4',
'No local Codex usage found yet for this scope.'
)}
</div>
) : (
<>
<div className="grid gap-3 md:grid-cols-3">
<StatCard
label={translate("auto.components.stats.CodexUsagePane.e365eaa6fd", "Input tokens")}
label={translate('auto.components.stats.CodexUsagePane.e365eaa6fd', 'Input tokens')}
value={formatTokens(summary?.inputTokens ?? 0)}
icon={<Sparkles className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.CodexUsagePane.5d8eba87bd", "Output tokens")}
label={translate('auto.components.stats.CodexUsagePane.5d8eba87bd', 'Output tokens')}
value={formatTokens(summary?.outputTokens ?? 0)}
icon={<Activity className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.CodexUsagePane.a9ac0f423a", "Cached input")}
label={translate('auto.components.stats.CodexUsagePane.a9ac0f423a', 'Cached input')}
value={formatTokens(summary?.cachedInputTokens ?? 0)}
icon={<DatabaseZap className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.CodexUsagePane.6e18146e9b", "Reasoning output")}
label={translate(
'auto.components.stats.CodexUsagePane.6e18146e9b',
'Reasoning output'
)}
value={formatTokens(summary?.reasoningOutputTokens ?? 0)}
icon={<Brain className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.CodexUsagePane.907b31865f", "Sessions / Events")}
label={translate(
'auto.components.stats.CodexUsagePane.907b31865f',
'Sessions / Events'
)}
value={`${(summary?.sessions ?? 0).toLocaleString()} / ${(summary?.events ?? 0).toLocaleString()}`}
icon={<FolderKanban className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.CodexUsagePane.1a18fbd56b", "Est. API-equivalent cost")}
label={translate(
'auto.components.stats.CodexUsagePane.1a18fbd56b',
'Est. API-equivalent cost'
)}
value={formatCost(summary?.estimatedCostUsd ?? null)}
icon={<Coins className="size-4" />}
/>
</div>
<p className="px-1 text-xs text-muted-foreground">
{translate("auto.components.stats.CodexUsagePane.94ac1f1ee7", "Reasoning tokens are shown for visibility, but cost is calculated from uncached input, cached input, and output only.")}</p>
{translate(
'auto.components.stats.CodexUsagePane.94ac1f1ee7',
'Reasoning tokens are shown for visibility, but cost is calculated from uncached input, cached input, and output only.'
)}
</p>
<CodexUsageDailyChart daily={daily} />
<div className="grid gap-4 xl:grid-cols-2">
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.CodexUsagePane.5a0d1d69cd", "By model")}</h4>
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.CodexUsagePane.5a0d1d69cd', 'By model')}
</h4>
<p className="text-xs text-muted-foreground">
{translate("auto.components.stats.CodexUsagePane.95d2d89285", "Top model:")}{' '}
{summary?.topModel ?? translate("auto.components.stats.CodexUsagePane.ae255c3dba", "n/a")}
{translate('auto.components.stats.CodexUsagePane.95d2d89285', 'Top model:')}{' '}
{summary?.topModel ??
translate('auto.components.stats.CodexUsagePane.ae255c3dba', 'n/a')}
</p>
</div>
<div className="space-y-3">
@ -289,10 +351,12 @@ export function CodexUsagePane(): React.JSX.Element {
</span>
</div>
<div className="text-xs text-muted-foreground">
{row.sessions} {translate("auto.components.stats.CodexUsagePane.bf1bf2f674", "sessions •")} {row.events}{' '}
{translate("auto.components.stats.CodexUsagePane.79a69522a5", "events")}
{row.sessions}{' '}
{translate('auto.components.stats.CodexUsagePane.bf1bf2f674', 'sessions •')}{' '}
{row.events}{' '}
{translate('auto.components.stats.CodexUsagePane.79a69522a5', 'events')}
{row.hasInferredPricing
? ` ${translate("auto.components.stats.CodexUsagePane.247c93ca92", "• inferred pricing")}`
? ` ${translate('auto.components.stats.CodexUsagePane.247c93ca92', '• inferred pricing')}`
: ''}
</div>
</div>
@ -302,10 +366,13 @@ export function CodexUsagePane(): React.JSX.Element {
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.CodexUsagePane.b98718aaab", "By project")}</h4>
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.CodexUsagePane.b98718aaab', 'By project')}
</h4>
<p className="text-xs text-muted-foreground">
{translate("auto.components.stats.CodexUsagePane.829ee743f2", "Top project:")}{' '}
{summary?.topProject ?? translate("auto.components.stats.CodexUsagePane.ae255c3dba", "n/a")}
{translate('auto.components.stats.CodexUsagePane.829ee743f2', 'Top project:')}{' '}
{summary?.topProject ??
translate('auto.components.stats.CodexUsagePane.ae255c3dba', 'n/a')}
</p>
</div>
<div className="space-y-3">
@ -318,8 +385,10 @@ export function CodexUsagePane(): React.JSX.Element {
</span>
</div>
<div className="text-xs text-muted-foreground">
{row.sessions} {translate("auto.components.stats.CodexUsagePane.bf1bf2f674", "sessions •")} {row.events}{' '}
{translate("auto.components.stats.CodexUsagePane.79a69522a5", "events")}
{row.sessions}{' '}
{translate('auto.components.stats.CodexUsagePane.bf1bf2f674', 'sessions •')}{' '}
{row.events}{' '}
{translate('auto.components.stats.CodexUsagePane.79a69522a5', 'events')}
</div>
</div>
))}
@ -329,21 +398,41 @@ export function CodexUsagePane(): React.JSX.Element {
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.CodexUsagePane.0cb0983c07", "Recent sessions")}</h4>
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.CodexUsagePane.0cb0983c07', 'Recent sessions')}
</h4>
<p className="text-xs text-muted-foreground">
{translate("auto.components.stats.CodexUsagePane.0bd8655475", "Most recent local Codex sessions in this scope.")}</p>
{translate(
'auto.components.stats.CodexUsagePane.0bd8655475',
'Most recent local Codex sessions in this scope.'
)}
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.0c36b100be", "Last active")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.1a65900aea", "Project")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.c2478bcc3c", "Model")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.bd0822ca47", "Events")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.3acc582214", "Input")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.bbd20344b8", "Output")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.CodexUsagePane.e0b988599d", "Total")}</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.0c36b100be', 'Last active')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.1a65900aea', 'Project')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.c2478bcc3c', 'Model')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.bd0822ca47', 'Events')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.3acc582214', 'Input')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.bbd20344b8', 'Output')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.CodexUsagePane.e0b988599d', 'Total')}
</th>
</tr>
</thead>
<tbody>
@ -354,7 +443,8 @@ export function CodexUsagePane(): React.JSX.Element {
</td>
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
<td className="px-2 py-2 text-muted-foreground">
{row.model ?? translate("auto.components.stats.CodexUsagePane.bf6cf2d4dd", "Unknown")}
{row.model ??
translate('auto.components.stats.CodexUsagePane.bf6cf2d4dd', 'Unknown')}
{row.hasInferredPricing ? ' *' : ''}
</td>
<td className="px-2 py-2 text-muted-foreground">{row.events}</td>

View File

@ -32,8 +32,17 @@ import { translate } from '@/i18n/i18n'
const RANGE_OPTIONS: OpenCodeUsageRange[] = ['7d', '30d', '90d', 'all']
const SCOPE_OPTIONS: { value: OpenCodeUsageScope; label: string }[] = [
{ value: 'orca', label: translate("auto.components.stats.OpenCodeUsagePane.e04c58327c", "Orca worktrees only") },
{ value: 'all', label: translate("auto.components.stats.OpenCodeUsagePane.144a6050e9", "All local OpenCode usage") }
{
value: 'orca',
label: translate('auto.components.stats.OpenCodeUsagePane.e04c58327c', 'Orca worktrees only')
},
{
value: 'all',
label: translate(
'auto.components.stats.OpenCodeUsagePane.144a6050e9',
'All local OpenCode usage'
)
}
]
const RANGE_LABELS: Record<OpenCodeUsageRange, string> = {
'7d': 'Last 7 days',
@ -109,15 +118,27 @@ export function OpenCodeUsagePane(): React.JSX.Element {
<div className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.OpenCodeUsagePane.bea80ceae0", "OpenCode Usage Tracking")}</h3>
<h3 className="text-sm font-semibold text-foreground">
{translate(
'auto.components.stats.OpenCodeUsagePane.bea80ceae0',
'OpenCode Usage Tracking'
)}
</h3>
<p className="text-sm text-muted-foreground">
{translate("auto.components.stats.OpenCodeUsagePane.b8b3522436", "Reads local OpenCode usage logs to show token, model, and session stats.")}</p>
{translate(
'auto.components.stats.OpenCodeUsagePane.b8b3522436',
'Reads local OpenCode usage logs to show token, model, and session stats.'
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={false}
aria-label={translate("auto.components.stats.OpenCodeUsagePane.f04131b3be", "Enable OpenCode usage analytics")}
aria-label={translate(
'auto.components.stats.OpenCodeUsagePane.f04131b3be',
'Enable OpenCode usage analytics'
)}
onClick={() => handleSetEnabled(true)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-muted-foreground/30 transition-colors"
>
@ -131,7 +152,10 @@ export function OpenCodeUsagePane(): React.JSX.Element {
if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) {
return (
<ClaudeUsageLoadingState
title={translate("auto.components.stats.OpenCodeUsagePane.bea80ceae0", "OpenCode Usage Tracking")}
title={translate(
'auto.components.stats.OpenCodeUsagePane.bea80ceae0',
'OpenCode Usage Tracking'
)}
summaryCardCount={6}
summaryGridClassName="md:grid-cols-3"
/>
@ -144,10 +168,21 @@ export function OpenCodeUsagePane(): React.JSX.Element {
<div className="space-y-4 rounded-lg border border-border/60 bg-card/30 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.OpenCodeUsagePane.bea80ceae0", "OpenCode Usage Tracking")}</h3>
<h3 className="text-sm font-semibold text-foreground">
{translate(
'auto.components.stats.OpenCodeUsagePane.bea80ceae0',
'OpenCode Usage Tracking'
)}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
{formatUpdatedAt(scanState.lastScanCompletedAt)}
{scanState.lastScanError ? translate("auto.components.stats.OpenCodeUsagePane.6cc7782458", " • Last scan error: {{value0}}", { value0: scanState.lastScanError }) : ''}
{scanState.lastScanError
? translate(
'auto.components.stats.OpenCodeUsagePane.6cc7782458',
' • Last scan error: {{value0}}',
{ value0: scanState.lastScanError }
)
: ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 self-start">
@ -156,17 +191,27 @@ export function OpenCodeUsagePane(): React.JSX.Element {
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-xs" aria-label={translate("auto.components.stats.OpenCodeUsagePane.230d6de108", "OpenCode usage options")}>
<Button
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.stats.OpenCodeUsagePane.230d6de108',
'OpenCode usage options'
)}
>
<SlidersHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate("auto.components.stats.OpenCodeUsagePane.01583b30aa", "Filters")}</TooltipContent>
{translate('auto.components.stats.OpenCodeUsagePane.01583b30aa', 'Filters')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel>{translate("auto.components.stats.OpenCodeUsagePane.40d283c837", "Scope")}</DropdownMenuLabel>
<DropdownMenuLabel>
{translate('auto.components.stats.OpenCodeUsagePane.40d283c837', 'Scope')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={scope}
onValueChange={(value) => void setOpenCodeUsageScope(value as OpenCodeUsageScope)}
@ -178,7 +223,9 @@ export function OpenCodeUsagePane(): React.JSX.Element {
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>{translate("auto.components.stats.OpenCodeUsagePane.b5ed5c9fd0", "Range")}</DropdownMenuLabel>
<DropdownMenuLabel>
{translate('auto.components.stats.OpenCodeUsagePane.b5ed5c9fd0', 'Range')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={range}
onValueChange={(value) => void setOpenCodeUsageRange(value as OpenCodeUsageRange)}
@ -199,20 +246,27 @@ export function OpenCodeUsagePane(): React.JSX.Element {
size="icon-xs"
onClick={() => void refreshOpenCodeUsage()}
disabled={scanState.isScanning}
aria-label={translate("auto.components.stats.OpenCodeUsagePane.bed558df0b", "Refresh OpenCode usage")}
aria-label={translate(
'auto.components.stats.OpenCodeUsagePane.bed558df0b',
'Refresh OpenCode usage'
)}
>
<RefreshCw className={`size-3.5 ${scanState.isScanning ? 'animate-spin' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate("auto.components.stats.OpenCodeUsagePane.603cd138dc", "Refresh")}</TooltipContent>
{translate('auto.components.stats.OpenCodeUsagePane.603cd138dc', 'Refresh')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
type="button"
role="switch"
aria-checked={true}
aria-label={translate("auto.components.stats.OpenCodeUsagePane.f04131b3be", "Enable OpenCode usage analytics")}
aria-label={translate(
'auto.components.stats.OpenCodeUsagePane.f04131b3be',
'Enable OpenCode usage analytics'
)}
onClick={() => handleSetEnabled(false)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-foreground transition-colors"
>
@ -229,53 +283,82 @@ export function OpenCodeUsagePane(): React.JSX.Element {
{!hasAnyData ? (
<div className="rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-6 text-sm text-muted-foreground">
{translate("auto.components.stats.OpenCodeUsagePane.bb6363e08c", "No local OpenCode usage found yet for this scope.")}</div>
{translate(
'auto.components.stats.OpenCodeUsagePane.bb6363e08c',
'No local OpenCode usage found yet for this scope.'
)}
</div>
) : (
<>
<div className="grid gap-3 md:grid-cols-3">
<StatCard
label={translate("auto.components.stats.OpenCodeUsagePane.d637a892ed", "Input tokens")}
label={translate(
'auto.components.stats.OpenCodeUsagePane.d637a892ed',
'Input tokens'
)}
value={formatTokens(summary?.inputTokens ?? 0)}
icon={<Sparkles className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.OpenCodeUsagePane.7aa4d8ce35", "Output tokens")}
label={translate(
'auto.components.stats.OpenCodeUsagePane.7aa4d8ce35',
'Output tokens'
)}
value={formatTokens(summary?.outputTokens ?? 0)}
icon={<Activity className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.OpenCodeUsagePane.603504ee3b", "Cached input")}
label={translate(
'auto.components.stats.OpenCodeUsagePane.603504ee3b',
'Cached input'
)}
value={formatTokens(summary?.cachedInputTokens ?? 0)}
icon={<DatabaseZap className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.OpenCodeUsagePane.5a65d68b77", "Reasoning output")}
label={translate(
'auto.components.stats.OpenCodeUsagePane.5a65d68b77',
'Reasoning output'
)}
value={formatTokens(summary?.reasoningOutputTokens ?? 0)}
icon={<Brain className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.OpenCodeUsagePane.7e9433469a", "Sessions / Events")}
label={translate(
'auto.components.stats.OpenCodeUsagePane.7e9433469a',
'Sessions / Events'
)}
value={`${(summary?.sessions ?? 0).toLocaleString()} / ${(summary?.events ?? 0).toLocaleString()}`}
icon={<FolderKanban className="size-4" />}
/>
<StatCard
label={translate("auto.components.stats.OpenCodeUsagePane.15c34d4b08", "Recorded cost")}
label={translate(
'auto.components.stats.OpenCodeUsagePane.15c34d4b08',
'Recorded cost'
)}
value={formatCost(summary?.estimatedCostUsd ?? null)}
icon={<Coins className="size-4" />}
/>
</div>
<p className="px-1 text-xs text-muted-foreground">
{translate("auto.components.stats.OpenCodeUsagePane.e5bb23d85e", "Cost comes from the local OpenCode database when the assistant message recorded one.")}</p>
{translate(
'auto.components.stats.OpenCodeUsagePane.e5bb23d85e',
'Cost comes from the local OpenCode database when the assistant message recorded one.'
)}
</p>
<CodexUsageDailyChart daily={daily} />
<div className="grid gap-4 xl:grid-cols-2">
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.OpenCodeUsagePane.040c044d39", "By model")}</h4>
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.OpenCodeUsagePane.040c044d39', 'By model')}
</h4>
<p className="text-xs text-muted-foreground">
{translate("auto.components.stats.OpenCodeUsagePane.a15206a63a", "Top model:")}{' '}
{summary?.topModel ?? translate("auto.components.stats.OpenCodeUsagePane.8095a63426", "n/a")}
{translate('auto.components.stats.OpenCodeUsagePane.a15206a63a', 'Top model:')}{' '}
{summary?.topModel ??
translate('auto.components.stats.OpenCodeUsagePane.8095a63426', 'n/a')}
</p>
</div>
<div className="space-y-3">
@ -288,9 +371,16 @@ export function OpenCodeUsagePane(): React.JSX.Element {
</span>
</div>
<div className="text-xs text-muted-foreground">
{row.sessions} {translate("auto.components.stats.OpenCodeUsagePane.bc0cb89901", "sessions •")} {row.events}{' '}
{translate("auto.components.stats.OpenCodeUsagePane.1e5d410df0", "events")}
{row.estimatedCostUsd !== null ? `${formatCost(row.estimatedCostUsd)}` : ''}
{row.sessions}{' '}
{translate(
'auto.components.stats.OpenCodeUsagePane.bc0cb89901',
'sessions •'
)}{' '}
{row.events}{' '}
{translate('auto.components.stats.OpenCodeUsagePane.1e5d410df0', 'events')}
{row.estimatedCostUsd !== null
? `${formatCost(row.estimatedCostUsd)}`
: ''}
</div>
</div>
))}
@ -299,10 +389,13 @@ export function OpenCodeUsagePane(): React.JSX.Element {
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.OpenCodeUsagePane.0f0a1684bb", "By project")}</h4>
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.OpenCodeUsagePane.0f0a1684bb', 'By project')}
</h4>
<p className="text-xs text-muted-foreground">
{translate("auto.components.stats.OpenCodeUsagePane.048ffe4d65", "Top project:")}{' '}
{summary?.topProject ?? translate("auto.components.stats.OpenCodeUsagePane.8095a63426", "n/a")}
{translate('auto.components.stats.OpenCodeUsagePane.048ffe4d65', 'Top project:')}{' '}
{summary?.topProject ??
translate('auto.components.stats.OpenCodeUsagePane.8095a63426', 'n/a')}
</p>
</div>
<div className="space-y-3">
@ -315,8 +408,13 @@ export function OpenCodeUsagePane(): React.JSX.Element {
</span>
</div>
<div className="text-xs text-muted-foreground">
{row.sessions} {translate("auto.components.stats.OpenCodeUsagePane.bc0cb89901", "sessions •")} {row.events}{' '}
{translate("auto.components.stats.OpenCodeUsagePane.1e5d410df0", "events")}
{row.sessions}{' '}
{translate(
'auto.components.stats.OpenCodeUsagePane.bc0cb89901',
'sessions •'
)}{' '}
{row.events}{' '}
{translate('auto.components.stats.OpenCodeUsagePane.1e5d410df0', 'events')}
</div>
</div>
))}
@ -326,21 +424,44 @@ export function OpenCodeUsagePane(): React.JSX.Element {
<section className="rounded-lg border border-border/60 bg-card/40 p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">{translate("auto.components.stats.OpenCodeUsagePane.4799177b1c", "Recent sessions")}</h4>
<h4 className="text-sm font-semibold text-foreground">
{translate('auto.components.stats.OpenCodeUsagePane.4799177b1c', 'Recent sessions')}
</h4>
<p className="text-xs text-muted-foreground">
{translate("auto.components.stats.OpenCodeUsagePane.81817a641a", "Most recent local OpenCode sessions in this scope.")}</p>
{translate(
'auto.components.stats.OpenCodeUsagePane.81817a641a',
'Most recent local OpenCode sessions in this scope.'
)}
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.d97bdf6e27", "Last active")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.a4738de041", "Project")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.08c78441b7", "Model")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.d416f5cf92", "Events")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.0f2f266c9d", "Input")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.dfc4513657", "Output")}</th>
<th className="px-2 py-2 font-medium">{translate("auto.components.stats.OpenCodeUsagePane.349f7c3f5c", "Total")}</th>
<th className="px-2 py-2 font-medium">
{translate(
'auto.components.stats.OpenCodeUsagePane.d97bdf6e27',
'Last active'
)}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.OpenCodeUsagePane.a4738de041', 'Project')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.OpenCodeUsagePane.08c78441b7', 'Model')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.OpenCodeUsagePane.d416f5cf92', 'Events')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.OpenCodeUsagePane.0f2f266c9d', 'Input')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.OpenCodeUsagePane.dfc4513657', 'Output')}
</th>
<th className="px-2 py-2 font-medium">
{translate('auto.components.stats.OpenCodeUsagePane.349f7c3f5c', 'Total')}
</th>
</tr>
</thead>
<tbody>
@ -350,7 +471,13 @@ export function OpenCodeUsagePane(): React.JSX.Element {
{formatSessionTime(row.lastActiveAt)}
</td>
<td className="px-2 py-2 text-foreground">{row.projectLabel}</td>
<td className="px-2 py-2 text-muted-foreground">{row.model ?? translate("auto.components.stats.OpenCodeUsagePane.362231082f", "Unknown")}</td>
<td className="px-2 py-2 text-muted-foreground">
{row.model ??
translate(
'auto.components.stats.OpenCodeUsagePane.362231082f',
'Unknown'
)}
</td>
<td className="px-2 py-2 text-muted-foreground">{row.events}</td>
<td className="px-2 py-2 text-muted-foreground">
{formatTokens(row.inputTokens)}

View File

@ -8,10 +8,12 @@ import {
} from '@/lib/agent-status'
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { useAppStore } from '@/store'
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
import { getWorktreeMapFromState } from '@/store/selectors'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport'
import { createIpcPtyTransport } from './pty-transport'
import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport'
import { getConnectionId } from '@/lib/connection-context'
import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding'
import type { PtyConnectionDeps } from './pty-connection-types'
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
@ -1414,19 +1416,31 @@ export function connectPanePty(
// callbacks to the correct Orca pane without resolving worktrees from cwd.
// The key matches the `${tabId}:${leafId}` composite used for cacheTimerByKey
// and agentStatusByPaneKey. Treat it as opaque outside Orca.
const state = useAppStore.getState()
const parsedWorkspaceKey = parseWorkspaceKey(deps.worktreeId)
const folderWorkspace =
parsedWorkspaceKey?.type === 'folder'
? state.folderWorkspaces.find(
(workspace) => workspace.id === parsedWorkspaceKey.folderWorkspaceId
)
: null
const workspaceEnv: Record<string, string> = { ORCA_WORKSPACE_ID: deps.worktreeId }
if (folderWorkspace) {
workspaceEnv.ORCA_PROJECT_GROUP_ID = folderWorkspace.projectGroupId
workspaceEnv.ORCA_WORKSPACE_ROOT = folderWorkspace.folderPath
}
const paneEnv = {
...paneStartup?.env,
...workspaceEnv,
ORCA_PANE_KEY: cacheKey,
ORCA_TAB_ID: deps.tabId,
ORCA_WORKTREE_ID: deps.worktreeId
}
// Why: remote repos route PTY spawn through the SSH provider. Resolve the
// repo's connectionId from the store so the transport passes it to pty:spawn.
const state = useAppStore.getState()
// Why: folder workspaces can inherit their SSH target from child repos, so
// use the shared resolver instead of only looking up repo-backed worktrees.
const worktree = getWorktreeMapFromState(state).get(deps.worktreeId)
const repo = worktree ? getRepoMapFromState(state).get(worktree.repoId) : null
const connectionId = repo?.connectionId ?? null
const connectionId = getConnectionId(deps.worktreeId) ?? null
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find((t) => t.id === deps.tabId)
const shellOverride = tab?.shellOverride
const isNativeWindowsConpty = isLocalNativeWindowsPty({

View File

@ -776,6 +776,7 @@ export function useIpcEvents(): void {
if (event.type === 'reposChanged') {
const state = useAppStore.getState()
void state.fetchProjectGroups()
void state.fetchFolderWorkspaces()
void state.fetchRepos()
return
}
@ -839,6 +840,7 @@ export function useIpcEvents(): void {
}
const state = useAppStore.getState()
void state.fetchProjectGroups()
void state.fetchFolderWorkspaces()
void state.fetchRepos()
})
)

View File

@ -3074,7 +3074,17 @@
"8db50afe1a": "Import repositories from folder",
"5b2e6fe3c8": "Import separately",
"cf9d382ca1": "Import",
"220dd32d83": "Scanning..."
"220dd32d83": "Scanning...",
"fb33359f69": "Is this a monorepo?",
"d75170194e": "Choose this if these projects belong together. Orca will group them and let you work from the parent folder.",
"39d51212cc": "Monorepo name",
"e907ec8935": "What is a monorepo name?",
"aa0247680d": "No, import separately",
"a0bc4d1f8e": "Yes, import as monorepo",
"8401a7a0d0": "1 repository",
"d4f1df62ef": "{{value0}} repositories",
"b4263a2ac4": "Found {{value0}} in {{value1}}.",
"24eda6c8b2": "Scanning... {{value0}}"
},
"AddRepoRemoteStep": {
"5b205b5281": "Stop scan",
@ -3554,7 +3564,8 @@
"f50603c6b2": "Mark Unread",
"8dacff1fe0": "Mark Read",
"3baa7d6507": "Pin",
"697d0f6e1b": "Unpin"
"697d0f6e1b": "Unpin",
"250de158fd": "Remove Workspace"
},
"WorktreeList": {
"d880ea0744": "Create a group and move this project into it.",
@ -3588,6 +3599,7 @@
"c1f4a31623": "Show {{value0}} child workspaces",
"e97297cb75": "Hide {{value0}} child workspace",
"0cd15956d4": "Hide {{value0}} child workspaces",
"bd37a57ac8": "Create workspace for {{value0}}",
"b667b59632": "Some projects could not be removed from Orca",
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
"groupDeleteFailed": "Failed to delete group",
@ -3795,6 +3807,15 @@
},
"index": {
"b826a98b6f": "busy"
},
"FolderWorkspaceComposerDialog": {
"connectFailed": "Failed to connect to project.",
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
"title": "Create Folder Workspace",
"createStart": "Create & Start Agent",
"create": "Create Workspace",
"sourceProject": "Task Source",
"chooseSourceProject": "Choose task source"
}
},
"shared": {

View File

@ -3074,7 +3074,17 @@
"8db50afe1a": "Importar repositorios desde la carpeta",
"5b2e6fe3c8": "Importar por separado",
"cf9d382ca1": "Importar",
"220dd32d83": "Exploración..."
"220dd32d83": "Exploración...",
"fb33359f69": "¿Es esto un monorepo?",
"d75170194e": "Elige esto si estos proyectos pertenecen juntos. Orca los agrupará y te permitirá trabajar desde la carpeta principal.",
"39d51212cc": "Nombre del monorepo",
"e907ec8935": "¿Qué es un nombre de monorepo?",
"aa0247680d": "No, importar por separado",
"a0bc4d1f8e": "Sí, importar como monorepo",
"8401a7a0d0": "1 repositorio",
"d4f1df62ef": "{{value0}} repositorios",
"b4263a2ac4": "Se encontraron {{value0}} en {{value1}}.",
"24eda6c8b2": "Explorando... {{value0}}"
},
"AddRepoRemoteStep": {
"5b205b5281": "Detener escaneo",
@ -3554,7 +3564,8 @@
"f50603c6b2": "Marcar como no leído",
"8dacff1fe0": "Marcar como leído",
"3baa7d6507": "Alfiler",
"697d0f6e1b": "Desprender"
"697d0f6e1b": "Desprender",
"250de158fd": "Remove Workspace"
},
"WorktreeList": {
"d880ea0744": "Crea un grupo y mueve este proyecto a él.",
@ -3588,6 +3599,7 @@
"c1f4a31623": "Mostrar {{value0}} espacios de trabajo secundarios",
"e97297cb75": "Ocultar {{value0}} espacio de trabajo secundario",
"0cd15956d4": "Ocultar {{value0}} espacios de trabajo secundarios",
"bd37a57ac8": "Create workspace for {{value0}}",
"b667b59632": "Some projects could not be removed from Orca",
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
"groupDeleteFailed": "Failed to delete group",
@ -3795,6 +3807,15 @@
},
"index": {
"b826a98b6f": "ocupado"
},
"FolderWorkspaceComposerDialog": {
"connectFailed": "Failed to connect to project.",
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
"title": "Create Folder Workspace",
"createStart": "Create & Start Agent",
"create": "Create Workspace",
"sourceProject": "Task Source",
"chooseSourceProject": "Choose task source"
}
},
"shared": {

View File

@ -3055,7 +3055,17 @@
"8db50afe1a": "フォルダーからリポジトリをインポートする",
"5b2e6fe3c8": "個別にインポート",
"cf9d382ca1": "輸入",
"220dd32d83": "走査..."
"220dd32d83": "走査...",
"fb33359f69": "これはモノレポですか?",
"d75170194e": "これらのプロジェクトがまとまっている場合は、これを選択してください。Orca がグループ化し、親フォルダーから作業できるようにします。",
"39d51212cc": "モノレポ名",
"e907ec8935": "モノレポ名とは何ですか?",
"aa0247680d": "いいえ、個別にインポート",
"a0bc4d1f8e": "はい、モノレポとしてインポート",
"8401a7a0d0": "1 個のリポジトリ",
"d4f1df62ef": "{{value0}} 個のリポジトリ",
"b4263a2ac4": "{{value1}} で {{value0}} が見つかりました。",
"24eda6c8b2": "スキャン中... {{value0}}"
},
"AddRepoRemoteStep": {
"5b205b5281": "スキャンの停止",
@ -3535,7 +3545,8 @@
"f50603c6b2": "未読としてマークする",
"8dacff1fe0": "既読マークを付ける",
"3baa7d6507": "ピン",
"697d0f6e1b": "固定を解除する"
"697d0f6e1b": "固定を解除する",
"250de158fd": "Remove Workspace"
},
"WorktreeList": {
"d880ea0744": "グループを作成し、このプロジェクトをそのグループに移動します。",
@ -3569,6 +3580,7 @@
"c1f4a31623": "{{value0}} 個の子ワークスペースを表示",
"e97297cb75": "{{value0}} 個の子ワークスペースを非表示",
"0cd15956d4": "{{value0}} 個の子ワークスペースを非表示",
"bd37a57ac8": "Create workspace for {{value0}}",
"b667b59632": "Some projects could not be removed from Orca",
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
"groupDeleteFailed": "Failed to delete group",
@ -3795,6 +3807,15 @@
}
}
}
},
"FolderWorkspaceComposerDialog": {
"connectFailed": "Failed to connect to project.",
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
"title": "Create Folder Workspace",
"createStart": "Create & Start Agent",
"create": "Create Workspace",
"sourceProject": "Task Source",
"chooseSourceProject": "Choose task source"
}
},
"shared": {

View File

@ -3055,7 +3055,17 @@
"8db50afe1a": "폴더에서 저장소 가져오기",
"5b2e6fe3c8": "별도로 가져오기",
"cf9d382ca1": "수입",
"220dd32d83": "스캐닝..."
"220dd32d83": "스캐닝...",
"fb33359f69": "이 폴더가 모노레포인가요?",
"d75170194e": "이 프로젝트들이 함께 속한다면 이것을 선택하세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.",
"39d51212cc": "모노레포 이름",
"e907ec8935": "모노레포 이름이란 무엇인가요?",
"aa0247680d": "아니요, 별도로 가져오기",
"a0bc4d1f8e": "예, 모노레포로 가져오기",
"8401a7a0d0": "저장소 1개",
"d4f1df62ef": "저장소 {{value0}}개",
"b4263a2ac4": "{{value1}}에서 {{value0}}을(를) 찾았습니다.",
"24eda6c8b2": "스캔 중... {{value0}}"
},
"AddRepoRemoteStep": {
"5b205b5281": "스캔 중지",
@ -3535,7 +3545,8 @@
"f50603c6b2": "읽지 않은 것으로 표시",
"8dacff1fe0": "마크 리드",
"3baa7d6507": "핀",
"697d0f6e1b": "고정 해제"
"697d0f6e1b": "고정 해제",
"250de158fd": "Remove Workspace"
},
"WorktreeList": {
"d880ea0744": "그룹을 만들고 이 프로젝트를 그룹으로 이동하세요.",
@ -3569,6 +3580,7 @@
"c1f4a31623": "{{value0}}개 하위 워크스페이스 표시",
"e97297cb75": "{{value0}}개 하위 워크스페이스 숨기기",
"0cd15956d4": "{{value0}}개 하위 워크스페이스 숨기기",
"bd37a57ac8": "Create workspace for {{value0}}",
"b667b59632": "Some projects could not be removed from Orca",
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
"groupDeleteFailed": "Failed to delete group",
@ -3795,6 +3807,15 @@
}
}
}
},
"FolderWorkspaceComposerDialog": {
"connectFailed": "Failed to connect to project.",
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
"title": "Create Folder Workspace",
"createStart": "Create & Start Agent",
"create": "Create Workspace",
"sourceProject": "Task Source",
"chooseSourceProject": "Choose task source"
}
},
"shared": {

View File

@ -3055,7 +3055,17 @@
"8db50afe1a": "从文件夹导入存储库",
"5b2e6fe3c8": "单独导入",
"cf9d382ca1": "导入",
"220dd32d83": "扫描..."
"220dd32d83": "扫描...",
"fb33359f69": "这是 Monorepo 吗?",
"d75170194e": "如果这些项目属于同一组请选择此项。Orca 会将它们分组,并让你从父文件夹开始工作。",
"39d51212cc": "Monorepo 名称",
"e907ec8935": "什么是 Monorepo 名称?",
"aa0247680d": "否,单独导入",
"a0bc4d1f8e": "是,作为 Monorepo 导入",
"8401a7a0d0": "1 个仓库",
"d4f1df62ef": "{{value0}} 个仓库",
"b4263a2ac4": "在 {{value1}} 中找到 {{value0}}。",
"24eda6c8b2": "正在扫描... {{value0}}"
},
"AddRepoRemoteStep": {
"5b205b5281": "停止扫描",
@ -3535,7 +3545,8 @@
"f50603c6b2": "标记为未读",
"8dacff1fe0": "马克·里德",
"3baa7d6507": "别针",
"697d0f6e1b": "取消固定"
"697d0f6e1b": "取消固定",
"250de158fd": "Remove Workspace"
},
"WorktreeList": {
"d880ea0744": "创建一个组并将该项目移入其中。",
@ -3569,6 +3580,7 @@
"c1f4a31623": "显示 {{value0}} 个子工作区",
"e97297cb75": "隐藏 {{value0}} 个子工作区",
"0cd15956d4": "隐藏 {{value0}} 个子工作区",
"bd37a57ac8": "Create workspace for {{value0}}",
"b667b59632": "Some projects could not be removed from Orca",
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
"groupDeleteFailed": "Failed to delete group",
@ -3795,6 +3807,15 @@
}
}
}
},
"FolderWorkspaceComposerDialog": {
"connectFailed": "Failed to connect to project.",
"noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
"title": "Create Folder Workspace",
"createStart": "Create & Start Agent",
"create": "Create Workspace",
"sourceProject": "Task Source",
"chooseSourceProject": "Choose task source"
}
},
"shared": {

View File

@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import type { Repo } from '../../../shared/types'
import { useAppStore } from '@/store'
import { getConnectionId } from './connection-context'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
const initialState = useAppStore.getInitialState()
@ -51,4 +52,262 @@ describe('getConnectionId', () => {
expect(getConnectionId('repo-missing::/tmp/repo-feature')).toBeUndefined()
})
it('resolves SSH targets for folder workspaces from repos in the folder scope', () => {
useAppStore.setState({
folderWorkspaces: [
{
id: 'folder-workspace-1',
projectGroupId: 'group-1',
name: 'Platform workspace',
folderPath: '/home/neil/platform',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
],
projectGroups: [
{
id: 'group-1',
name: 'Platform',
parentPath: '/home/neil/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
],
repos: [
makeRepo({
id: 'repo-ssh',
path: '/home/neil/platform/api',
projectGroupId: 'group-1',
connectionId: 'ssh-1'
})
],
worktreesByRepo: {}
})
expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBe('ssh-1')
})
it('resolves SSH targets for repo-less folder workspaces from persisted scope provenance', () => {
useAppStore.setState({
folderWorkspaces: [
{
id: 'folder-workspace-1',
projectGroupId: 'group-1',
name: 'Platform workspace',
folderPath: '/home/neil/platform',
connectionId: 'ssh-1',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
],
projectGroups: [
{
id: 'group-1',
name: 'Platform',
parentPath: '/home/neil/platform',
connectionId: 'ssh-1',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
],
repos: [],
worktreesByRepo: {}
})
expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBe('ssh-1')
})
it('returns undefined when persisted folder workspace provenance conflicts with child repos', () => {
useAppStore.setState({
folderWorkspaces: [
{
id: 'folder-workspace-1',
projectGroupId: 'group-1',
name: 'Platform workspace',
folderPath: '/home/neil/platform',
connectionId: 'ssh-1',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
],
projectGroups: [
{
id: 'group-1',
name: 'Platform',
parentPath: '/home/neil/platform',
connectionId: 'ssh-1',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
],
repos: [
makeRepo({
id: 'repo-ssh',
path: '/home/neil/platform/api',
projectGroupId: 'group-1',
connectionId: 'ssh-2'
})
],
worktreesByRepo: {}
})
expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBeUndefined()
})
it('returns undefined for folder workspaces with mixed local and SSH repos', () => {
useAppStore.setState({
folderWorkspaces: [
{
id: 'folder-workspace-1',
projectGroupId: 'group-1',
name: 'Platform workspace',
folderPath: '/home/neil/platform',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
],
projectGroups: [
{
id: 'group-1',
name: 'Platform',
parentPath: '/home/neil/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
],
repos: [
makeRepo({
id: 'repo-local',
path: '/home/neil/platform/web',
projectGroupId: 'group-1'
}),
makeRepo({
id: 'repo-ssh',
path: '/home/neil/platform/api',
projectGroupId: 'group-1',
connectionId: 'ssh-1'
})
],
worktreesByRepo: {}
})
expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBeUndefined()
})
it('keeps explicit folder workspace provenance isolated from unrelated same-path SSH repos', () => {
useAppStore.setState({
folderWorkspaces: [
{
id: 'folder-workspace-1',
projectGroupId: 'group-1',
name: 'Platform workspace',
folderPath: '/home/neil/platform',
connectionId: 'ssh-1',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
],
projectGroups: [
{
id: 'group-1',
name: 'Platform',
parentPath: '/home/neil/platform',
connectionId: 'ssh-1',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
},
{
id: 'group-2',
name: 'Platform copy',
parentPath: '/home/neil/platform',
connectionId: 'ssh-2',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 1,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
],
repos: [
makeRepo({
id: 'repo-ssh-1',
path: '/home/neil/platform/api',
projectGroupId: 'group-1',
connectionId: 'ssh-1'
}),
makeRepo({
id: 'repo-ssh-2',
path: '/home/neil/platform/api',
projectGroupId: 'group-2',
connectionId: 'ssh-2'
})
],
worktreesByRepo: {}
})
expect(getConnectionId(folderWorkspaceKey('folder-workspace-1'))).toBe('ssh-1')
})
})

View File

@ -1,5 +1,7 @@
import { useAppStore } from '@/store'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { getFolderWorkspaceConnectionId } from './folder-workspace-connection'
/**
* Resolve the SSH connectionId for a worktree. Returns null for local repos,
@ -10,6 +12,13 @@ export function getConnectionId(worktreeId: string | null): string | null | unde
if (!worktreeId) {
return null
}
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
if (parsedWorkspaceKey?.type === 'folder') {
return getFolderWorkspaceConnectionId(
useAppStore.getState(),
parsedWorkspaceKey.folderWorkspaceId
)
}
const state = useAppStore.getState()
const allWorktrees = Object.values(state.worktreesByRepo ?? {}).flat()
const worktree = allWorktrees.find((w) => w.id === worktreeId)

View File

@ -0,0 +1,92 @@
import type { FolderWorkspace, ProjectGroup, Repo } from '../../../shared/types'
import { isPathInsideOrEqual } from '../../../shared/cross-platform-path'
import { getProjectGroupSubtreeIds } from '../../../shared/project-groups'
export type FolderWorkspaceConnectionState = {
folderWorkspaces: FolderWorkspace[]
projectGroups: ProjectGroup[]
repos: Repo[]
}
function getFolderScopeCandidateRepos(args: {
folderPath: string
projectGroupId: string
connectionId?: string | null
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
}): Repo[] {
const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
const groupRepos = args.repos.filter(
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
)
const pathRepos = args.repos.filter(
(repo) =>
!(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) &&
isPathInsideOrEqual(args.folderPath, repo.path)
)
if (args.connectionId) {
return [
...groupRepos,
...pathRepos.filter((repo) => (repo.connectionId ?? null) === args.connectionId)
]
}
if (groupRepos.length === 0) {
return pathRepos
}
const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null))
return [
...groupRepos,
...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null))
]
}
export function getFolderWorkspaceConnectionId(
state: FolderWorkspaceConnectionState,
folderWorkspaceId: string
): string | null | undefined {
const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId)
if (!workspace) {
return undefined
}
const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId)
const scopeConnectionId = workspace.connectionId ?? group?.connectionId ?? null
const candidateRepos = getFolderScopeCandidateRepos({
folderPath: workspace.folderPath,
projectGroupId: workspace.projectGroupId,
connectionId: scopeConnectionId,
projectGroups: state.projectGroups,
repos: state.repos
})
let hasLocalRepo = false
const connectionIds = new Set<string>()
for (const repo of candidateRepos) {
if (repo.connectionId) {
connectionIds.add(repo.connectionId)
} else {
hasLocalRepo = true
}
}
if (scopeConnectionId) {
const hasDifferentSshConnection = [...connectionIds].some(
(connectionId) => connectionId !== scopeConnectionId
)
if (hasLocalRepo || hasDifferentSshConnection) {
return undefined
}
return scopeConnectionId
}
if (candidateRepos.length === 0) {
return null
}
if (hasLocalRepo && connectionIds.size > 0) {
return undefined
}
if (connectionIds.size === 0) {
return null
}
if (connectionIds.size === 1) {
return [...connectionIds][0]
}
return undefined
}

View File

@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
import { FOLDER_WORKSPACE_PATH_STATUS_TTL_MS } from '../../../shared/folder-workspace-path-status'
type FolderWorkspacePathStatusCacheClockEntry = {
checkedAt: number
}
export function useFolderWorkspacePathStatusCacheExpiryTick(
entries: Record<string, FolderWorkspacePathStatusCacheClockEntry>
): number {
const [tick, setTick] = useState(0)
useEffect(() => {
const now = Date.now()
let nextDelayMs = Number.POSITIVE_INFINITY
for (const entry of Object.values(entries)) {
const delayMs = entry.checkedAt + FOLDER_WORKSPACE_PATH_STATUS_TTL_MS - now
if (delayMs > 0) {
nextDelayMs = Math.min(nextDelayMs, delayMs)
}
}
if (!Number.isFinite(nextDelayMs)) {
return
}
// Why: TTL freshness is derived from Date.now(), so subscribers need one
// clock tick when the oldest cached status stops being authoritative.
const timeout = window.setTimeout(() => setTick((value) => value + 1), nextDelayMs + 1)
return () => window.clearTimeout(timeout)
}, [entries, tick])
return tick
}

View File

@ -0,0 +1,80 @@
import type { FolderWorkspacePathStatus } from '../../../shared/folder-workspace-path-status'
import { blocksFolderWorkspaceActivation } from '../../../shared/folder-workspace-path-status'
export function getFolderWorkspacePathStatusTitle(
status: FolderWorkspacePathStatus | null | undefined
): string | null {
if (!status || status.exists) {
return null
}
switch (status.reason) {
case 'missing':
return 'Folder not found'
case 'not-directory':
return 'Path is not a folder'
case 'ambiguous-connection':
return 'Cannot determine connection'
case 'unavailable':
default:
return 'Cannot check folder'
}
}
export function getFolderWorkspacePathStatusDescription(
status: FolderWorkspacePathStatus | null | undefined
): string | null {
if (!status || status.exists) {
return null
}
switch (status.reason) {
case 'missing':
return `Orca cannot find ${status.path}. Remove and re-import this folder workspace.`
case 'not-directory':
return `${status.path} exists, but it is not a folder.`
case 'ambiguous-connection':
return 'Orca cannot tell which SSH connection owns this folder scope.'
case 'unavailable':
default:
return 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.'
}
}
export function formatFolderWorkspaceCreateError(error: unknown): {
title: string
description: string
} {
const message = error instanceof Error ? error.message : String(error)
const path = message.includes(':') ? message.slice(message.indexOf(':') + 1) : ''
if (message.startsWith('folder_workspace_path_missing:')) {
return {
title: 'Folder not found',
description: `Orca cannot find ${path}. Remove and re-import the folder.`
}
}
if (message.startsWith('folder_workspace_path_not_directory:')) {
return {
title: 'Path is not a folder',
description: `${path} exists, but it is not a folder.`
}
}
if (message.startsWith('folder_workspace_connection_ambiguous:')) {
return {
title: 'Cannot determine connection',
description: 'Orca cannot tell which SSH connection owns this folder scope.'
}
}
if (message.startsWith('folder_workspace_path_unavailable:')) {
return {
title: 'Cannot check folder',
description:
'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.'
}
}
return { title: 'Failed to create folder workspace', description: message }
}
export function folderWorkspaceActivationBlocked(
status: FolderWorkspacePathStatus | null | undefined
): boolean {
return blocksFolderWorkspaceActivation(status)
}

View File

@ -6,7 +6,8 @@ import {
} from '@/runtime/runtime-terminal-inspection'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import { isShellProcess } from '@/lib/tui-agent-startup'
import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types'
import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context'
import type { FolderWorkspaceLinkedTask, OrcaHooks, TaskViewPresetId } from '../../../shared/types'
import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy'
import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition'
import { slugifyForWorkspaceName } from '../../../shared/workspace-name'
@ -48,16 +49,11 @@ export const CLIENT_PLATFORM: NodeJS.Platform = navigator.userAgent.includes('Wi
export { getLinkedWorkItemProvider, isGitLabIssueUrl } from './linked-work-item-provider'
export type LinkedWorkItemSummary = {
type: 'issue' | 'pr' | 'mr'
provider?: 'github' | 'gitlab' | 'linear' | 'jira'
number: number
title: string
url: string
linearIdentifier?: string
export type LinkedWorkItemSummary = Omit<FolderWorkspaceLinkedTask, 'provider'> & {
provider?: FolderWorkspaceLinkedTask['provider']
linearWorkspaceId?: string
linearOrganizationUrlKey?: string
jiraIdentifier?: string
linkedContext?: LinkedWorkItemContext
}
// Why: when a repo has no `orca.yaml` issueCommand and no per-user override,

View File

@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => {
openFiles: [] as { worktreeId: string }[]
}
const activateAndRevealWorktree = vi.fn()
const activateAndRevealFolderWorkspace = vi.fn()
const markInputQuietSchedulerInput = vi.fn()
const pendingCallbacks: (() => void)[] = []
const pendingCancels: ReturnType<typeof vi.fn>[] = []
@ -26,6 +27,7 @@ const mocks = vi.hoisted(() => {
})
return {
activateAndRevealWorktree,
activateAndRevealFolderWorkspace,
markInputQuietSchedulerInput,
pendingCallbacks,
pendingCancels,
@ -41,6 +43,7 @@ vi.mock('@/store', () => ({
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace,
activateAndRevealWorktree: mocks.activateAndRevealWorktree
}))
@ -59,6 +62,7 @@ describe('sidebar worktree activation', () => {
delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__
cancelPendingSidebarWorktreeActivation()
mocks.activateAndRevealWorktree.mockClear()
mocks.activateAndRevealFolderWorkspace.mockClear()
mocks.markInputQuietSchedulerInput.mockClear()
mocks.scheduleAfterInputQuiet.mockClear()
mocks.pendingCallbacks.length = 0
@ -91,6 +95,14 @@ describe('sidebar worktree activation', () => {
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-live')
})
it('routes folder workspace activation through the guarded folder path', () => {
activateWorktreeFromSidebar('folder:folder-workspace-1')
expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1')
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
expect(mocks.scheduleAfterInputQuiet).not.toHaveBeenCalled()
})
it('does not defer slept workspace activation in the web client', () => {
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
mocks.state.tabsByWorktree = { 'wt-web-slept': [{ id: 'tab-1' }] }

View File

@ -1,7 +1,11 @@
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import {
activateAndRevealFolderWorkspace,
activateAndRevealWorktree
} from '@/lib/worktree-activation'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
const SLEPT_WORKTREE_ACTIVATION_INPUT_QUIET_MS = 450
const SLEPT_WORKTREE_ACTIVATION_IDLE_TIMEOUT_MS = 120
@ -37,6 +41,11 @@ function shouldDeferSidebarWorktreeActivation(worktreeId: string): boolean {
export function activateWorktreeFromSidebar(worktreeId: string): void {
cancelPendingSidebarWorktreeActivation()
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId)
return
}
const activate = (): void => {
if (pendingSidebarWorktreeActivation?.worktreeId === worktreeId) {

View File

@ -6,6 +6,7 @@ import { buildWorkspaceSessionPayload, type WorkspaceSessionSnapshot } from './w
function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSessionSnapshot {
return {
activeRepoId: null,
activeWorkspaceKey: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},

View File

@ -7,6 +7,7 @@ function createSnapshot(
): WorkspaceSessionSnapshot {
return {
activeRepoId: 'repo-1',
activeWorkspaceKey: 'worktree:wt-1',
activeWorktreeId: 'wt-1',
activeTabId: 'tab-1',
tabsByWorktree: {},

View File

@ -6,6 +6,7 @@ function createSnapshot(
): WorkspaceSessionSnapshot {
return {
activeRepoId: 'repo-1',
activeWorkspaceKey: 'worktree:wt-1',
activeWorktreeId: 'wt-1',
activeTabId: 'tab-1',
tabsByWorktree: {},

View File

@ -6,6 +6,7 @@ describe('SESSION_RELEVANT_FIELDS', () => {
// A snapshot field omitted here would persist stale data after that field changes.
const fixture: Record<keyof WorkspaceSessionSnapshot, true> = {
activeRepoId: true,
activeWorkspaceKey: true,
activeWorktreeId: true,
activeTabId: true,
tabsByWorktree: true,

View File

@ -33,6 +33,7 @@ export function shouldPersistWorkspaceSession(
export type WorkspaceSessionSnapshot = Pick<
AppState,
| 'activeRepoId'
| 'activeWorkspaceKey'
| 'activeWorktreeId'
| 'activeTabId'
| 'tabsByWorktree'
@ -70,6 +71,7 @@ export type WorkspaceSessionSnapshot = Pick<
// time, preventing the gate from silently going stale.
export const SESSION_RELEVANT_FIELDS = [
'activeRepoId',
'activeWorkspaceKey',
'activeWorktreeId',
'activeTabId',
'tabsByWorktree',
@ -333,6 +335,7 @@ export function buildWorkspaceSessionPayload(
const payload = {
activeRepoId: snapshot.activeRepoId,
activeWorkspaceKey: snapshot.activeWorkspaceKey,
activeWorktreeId: snapshot.activeWorktreeId,
activeTabId: snapshot.activeTabId,
tabsByWorktree: buildSanitizedTabsByWorktree(snapshot.tabsByWorktree),

View File

@ -1,5 +1,6 @@
/* eslint-disable max-lines -- Why: worktree activation is a single ordered flow spanning startup, setup, issue commands, and default tabs; splitting it would obscure sequencing guarantees. */
import type {
FolderWorkspace,
SetupSplitDirection,
TuiAgent,
Worktree,
@ -37,6 +38,13 @@ import {
} from '../../../shared/tui-agent-launch-defaults'
import { isTuiAgent } from '../../../shared/tui-agent-config'
import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
import {
folderWorkspaceActivationBlocked,
getFolderWorkspacePathStatusDescription,
getFolderWorkspacePathStatusTitle
} from './folder-workspace-path-status'
import { toast } from 'sonner'
/** Telemetry payload threaded from the launch site to `pty:spawn`. Main
* fires `agent_started` only after the spawn succeeds see
@ -121,6 +129,71 @@ export type ActivateAndRevealResult = {
primaryTabId: string | null
}
function ensureFolderWorkspaceInitialTerminal(
folderWorkspace: FolderWorkspace,
startup?: WorktreeStartupPayload
): string | null {
const state = useAppStore.getState()
const workspaceKey = folderWorkspaceKey(folderWorkspace.id)
const primaryTabId = ensureWorktreeHasInitialTerminal(
state,
workspaceKey,
startup,
undefined,
undefined,
undefined
)
return primaryTabId
}
export function activateAndRevealFolderWorkspace(
folderWorkspaceId: string,
opts?: {
sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior']
startup?: WorktreeStartupPayload
}
): ActivateAndRevealResult | false {
const state = useAppStore.getState()
const folderWorkspace = state.folderWorkspaces.find(
(workspace) => workspace.id === folderWorkspaceId
)
if (!folderWorkspace) {
return false
}
const pathStatus = state.getFreshFolderWorkspacePathStatus({
scope: 'folder-workspace',
folderWorkspaceId
})
if (folderWorkspaceActivationBlocked(pathStatus)) {
toast.error(getFolderWorkspacePathStatusTitle(pathStatus) ?? 'Cannot open folder workspace', {
description: getFolderWorkspacePathStatusDescription(pathStatus) ?? folderWorkspace.folderPath
})
return false
}
if (state.activeView !== 'terminal') {
state.setActiveView('terminal')
}
state.setActiveFolderWorkspace(folderWorkspaceId)
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
state.markWorktreeVisited(workspaceKey)
if (!state.isNavigatingHistory) {
state.recordWorktreeVisit(workspaceKey)
}
resumeSleepingAgentSessionsForWorktree(workspaceKey)
const primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup)
if (opts?.sidebarRevealBehavior) {
state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior })
} else {
state.revealWorktreeInSidebar(workspaceKey)
}
return { primaryTabId }
}
function buildCreatedAgentReopenStartup(worktree: Worktree): WorktreeStartupPayload | undefined {
const agent = worktree.createdWithAgent
if (!isTuiAgent(agent)) {

View File

@ -14,6 +14,7 @@ import type {
} from '../../../../shared/types'
import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types'
import { FLOATING_TERMINAL_WORKTREE_ID, ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { redactKagiSessionToken } from '../../../../shared/browser-url'
import {
MAX_BROWSER_HISTORY_ENTRIES,
@ -1412,6 +1413,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
.map((worktree) => worktree.id)
)
validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of currentState.folderWorkspaces) {
validWorktreeIdsForCleanup.add(folderWorkspaceKey(workspace.id))
}
// Why: mirror closeBrowserTab's contract — reducers are pure, imperative
// side effects bracket them. Compute dropped workspaces first, destroy
@ -1441,6 +1445,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
.map((worktree) => worktree.id)
)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of s.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
}
const browserTabsByWorktree: Record<string, BrowserWorkspace[]> = {}
const browserPagesByWorkspace: Record<string, BrowserPage[]> = {}

View File

@ -31,6 +31,7 @@ import type {
} from '../../../../shared/types'
import { stripCredentialsFromMessage } from '../../../../shared/git-remote-error'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action'
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
import {
@ -3873,6 +3874,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
.map((w) => w.id)
)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of s.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
}
const openFiles: OpenFile[] = []
const editorDrafts: Record<string, string> = {}

View File

@ -1,11 +1,18 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestStore } from './store-test-helpers'
import type { NestedRepoScanResult, Repo, ProjectGroup } from '../../../../shared/types'
import type {
NestedRepoScanResult,
Repo,
ProjectGroup,
FolderWorkspace
} from '../../../../shared/types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import type { SshConnectionState } from '../../../../shared/ssh-types'
const remoteRepo: Repo = {
id: 'remote-repo',
@ -39,9 +46,23 @@ const projectGroupsImportNested = vi.fn()
const projectGroupsScanNested = vi.fn()
const projectGroupsCancelNestedScan = vi.fn()
const projectGroupsOnNestedScanProgress = vi.fn()
const folderWorkspacesList = vi.fn()
const folderWorkspacesGetPathStatus = vi.fn()
const folderWorkspacesCreate = vi.fn()
const folderWorkspacesUpdate = vi.fn()
const folderWorkspacesDelete = vi.fn()
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
function makeSshConnectionState(status: SshConnectionState['status']): SshConnectionState {
return {
targetId: 'ssh-1',
status,
error: null,
reconnectAttempt: 0
}
}
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
reposList.mockReset()
@ -57,6 +78,12 @@ beforeEach(() => {
projectGroupsCancelNestedScan.mockReset()
projectGroupsOnNestedScanProgress.mockReset()
projectGroupsOnNestedScanProgress.mockReturnValue(vi.fn())
folderWorkspacesList.mockReset()
folderWorkspacesGetPathStatus.mockReset()
folderWorkspacesGetPathStatus.mockResolvedValue({ path: '/workspace/platform', exists: true })
folderWorkspacesCreate.mockReset()
folderWorkspacesUpdate.mockReset()
folderWorkspacesDelete.mockReset()
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
@ -79,6 +106,13 @@ beforeEach(() => {
onNestedScanProgress: projectGroupsOnNestedScanProgress,
importNested: projectGroupsImportNested
},
folderWorkspaces: {
list: folderWorkspacesList,
getPathStatus: folderWorkspacesGetPathStatus,
create: folderWorkspacesCreate,
update: folderWorkspacesUpdate,
delete: folderWorkspacesDelete
},
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
}
})
@ -99,6 +133,358 @@ describe('project group store routing', () => {
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('creates, updates, and deletes local folder workspaces', async () => {
const linkedTask: FolderWorkspace['linkedTask'] = {
provider: 'linear',
type: 'issue',
number: 0,
title: 'Refund fix',
url: 'https://linear.app/acme/issue/ENG-123',
linearIdentifier: 'ENG-123'
}
const folderWorkspace: FolderWorkspace = {
id: 'folder-workspace-1',
projectGroupId: projectGroup.id,
name: 'Refund fix',
folderPath: '/workspace/platform',
linkedTask,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
folderWorkspacesCreate.mockResolvedValue(folderWorkspace)
folderWorkspacesUpdate.mockResolvedValue({ ...folderWorkspace, comment: 'Ready' })
folderWorkspacesDelete.mockResolvedValue(true)
const store = createTestStore()
await expect(
store.getState().createFolderWorkspace({
projectGroupId: projectGroup.id,
name: 'Refund fix',
linkedTask
})
).resolves.toEqual(folderWorkspace)
await expect(
store.getState().updateFolderWorkspace(folderWorkspace.id, { comment: 'Ready' })
).resolves.toBe(true)
await expect(store.getState().deleteFolderWorkspace(folderWorkspace.id)).resolves.toBe(true)
expect(folderWorkspacesCreate).toHaveBeenCalledWith({
projectGroupId: projectGroup.id,
name: 'Refund fix',
linkedTask
})
expect(folderWorkspacesUpdate).toHaveBeenCalledWith({
folderWorkspaceId: folderWorkspace.id,
updates: { comment: 'Ready' }
})
expect(folderWorkspacesDelete).toHaveBeenCalledWith({
folderWorkspaceId: folderWorkspace.id
})
expect(store.getState().folderWorkspaces).toEqual([])
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('caches local folder workspace path status by scope', async () => {
const folderGroup = { ...projectGroup, parentPath: '/workspace/platform' }
folderWorkspacesGetPathStatus.mockResolvedValue({
path: '/workspace/platform',
exists: false,
reason: 'missing'
})
const store = createTestStore()
store.setState({ projectGroups: [folderGroup] })
await expect(
store.getState().fetchFolderWorkspacePathStatus({
scope: 'project-group',
projectGroupId: folderGroup.id
})
).resolves.toEqual({
path: '/workspace/platform',
exists: false,
reason: 'missing'
})
const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey({
scope: 'project-group',
projectGroupId: folderGroup.id
})
expect(store.getState().folderWorkspacePathStatuses[cacheKey]?.status).toEqual({
path: '/workspace/platform',
exists: false,
reason: 'missing'
})
expect(folderWorkspacesGetPathStatus).toHaveBeenCalledTimes(1)
})
it('ignores stale folder path status responses after a group path changes', async () => {
let resolveStatus: (status: { path: string; exists: boolean }) => void = () => {}
folderWorkspacesGetPathStatus.mockImplementation(
() =>
new Promise((resolve) => {
resolveStatus = resolve
})
)
const store = createTestStore()
store.setState({
projectGroups: [{ ...projectGroup, parentPath: '/workspace/old-platform' }]
})
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request)
store.setState({
projectGroups: [{ ...projectGroup, parentPath: '/workspace/new-platform' }]
})
resolveStatus({ path: '/workspace/old-platform', exists: true })
await statusPromise
const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request)
expect(store.getState().folderWorkspacePathStatuses[cacheKey]).toBeUndefined()
})
it('ignores stale folder path status responses after repo ownership changes', async () => {
let resolveStatus: (status: { path: string; exists: boolean }) => void = () => {}
folderWorkspacesGetPathStatus.mockImplementation(
() =>
new Promise((resolve) => {
resolveStatus = resolve
})
)
const store = createTestStore()
store.setState({
projectGroups: [{ ...projectGroup, parentPath: '/workspace/platform' }],
repos: [{ ...remoteRepo, id: 'local-repo', path: '/workspace/platform/api' }]
})
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request)
store.setState({
repos: [
{
...remoteRepo,
id: 'ssh-repo',
path: '/workspace/platform/api',
connectionId: 'ssh-1'
}
]
})
resolveStatus({ path: '/workspace/platform', exists: true })
await statusPromise
const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request)
expect(store.getState().folderWorkspacePathStatuses[cacheKey]).toBeUndefined()
})
it('treats expired folder path status cache entries as unknown', async () => {
vi.useFakeTimers()
try {
const store = createTestStore()
store.setState({
projectGroups: [{ ...projectGroup, parentPath: '/workspace/platform' }]
})
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
await store.getState().fetchFolderWorkspacePathStatus(request)
expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toEqual({
path: '/workspace/platform',
exists: true
})
vi.setSystemTime(Date.now() + 10_001)
expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('treats current-state mismatched folder path cache entries as unknown', async () => {
const store = createTestStore()
store.setState({
projectGroups: [
{ ...projectGroup, parentPath: '/workspace/platform', connectionId: 'ssh-1' }
],
sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('connected')]])
})
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
await store.getState().fetchFolderWorkspacePathStatus(request)
expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toEqual({
path: '/workspace/platform',
exists: true
})
store.setState({
sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('disconnected')]])
})
expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toBeNull()
})
it('ignores stale folder path status responses after SSH connection state changes', async () => {
const resolvers: ((status: { path: string; exists: boolean; reason?: string }) => void)[] = []
folderWorkspacesGetPathStatus.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(resolve)
})
)
const store = createTestStore()
store.setState({
projectGroups: [
{ ...projectGroup, parentPath: '/workspace/platform', connectionId: 'ssh-1' }
],
sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('connected')]])
})
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
const connectedStatusPromise = store.getState().fetchFolderWorkspacePathStatus(request)
store.setState({
sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('disconnected')]])
})
const disconnectedStatusPromise = store
.getState()
.fetchFolderWorkspacePathStatus(request, { force: true })
resolvers[1]?.({
path: '/workspace/platform',
exists: false,
reason: 'unavailable'
})
await disconnectedStatusPromise
resolvers[0]?.({ path: '/workspace/platform', exists: true })
await connectedStatusPromise
const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request)
expect(store.getState().folderWorkspacePathStatuses[cacheKey]?.status).toEqual({
path: '/workspace/platform',
exists: false,
reason: 'unavailable'
})
})
it('purges renderer session state when deleting a local folder workspace', async () => {
const folderWorkspace: FolderWorkspace = {
id: 'folder-workspace-1',
projectGroupId: projectGroup.id,
name: 'Refund fix',
folderPath: '/workspace/platform',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
const workspaceKey = folderWorkspaceKey(folderWorkspace.id)
folderWorkspacesDelete.mockResolvedValue(true)
const store = createTestStore()
store.setState({
folderWorkspaces: [folderWorkspace],
activeWorktreeId: workspaceKey,
activeWorkspaceKey: workspaceKey,
activeTabId: 'terminal-tab-1',
activeBrowserTabId: 'browser-tab-1',
activeTabType: 'browser',
tabsByWorktree: {
[workspaceKey]: [
{
id: 'terminal-tab-1',
worktreeId: workspaceKey,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: 'pty-1'
}
]
},
terminalLayoutsByTabId: {
'terminal-tab-1': {
root: { type: 'leaf', leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null
}
},
browserTabsByWorktree: {
[workspaceKey]: [
{
id: 'browser-tab-1',
worktreeId: workspaceKey,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
},
browserPagesByWorkspace: {
'browser-tab-1': [
{
id: 'page-1',
workspaceId: 'browser-tab-1',
worktreeId: workspaceKey,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
},
openFiles: [
{
id: 'file-1',
worktreeId: workspaceKey,
filePath: '/workspace/platform/notes.md',
relativePath: 'notes.md',
language: 'markdown',
isDirty: true,
isPreview: false,
mode: 'edit'
}
],
editorDrafts: { 'file-1': 'draft' },
activeFileIdByWorktree: { [workspaceKey]: 'file-1' },
activeTabTypeByWorktree: { [workspaceKey]: 'browser' },
activeBrowserTabIdByWorktree: { [workspaceKey]: 'browser-tab-1' },
lastVisitedAtByWorktreeId: { [workspaceKey]: 10 }
})
await expect(store.getState().deleteFolderWorkspace(folderWorkspace.id)).resolves.toBe(true)
const state = store.getState()
expect(state.folderWorkspaces).toEqual([])
expect(state.activeWorktreeId).toBeNull()
expect(state.activeWorkspaceKey).toBeNull()
expect(state.tabsByWorktree[workspaceKey]).toBeUndefined()
expect(state.terminalLayoutsByTabId['terminal-tab-1']).toBeUndefined()
expect(state.browserTabsByWorktree[workspaceKey]).toBeUndefined()
expect(state.browserPagesByWorkspace['browser-tab-1']).toBeUndefined()
expect(state.openFiles).toEqual([])
expect(state.editorDrafts).toEqual({})
expect(state.activeFileIdByWorktree[workspaceKey]).toBeUndefined()
expect(state.activeBrowserTabIdByWorktree[workspaceKey]).toBeUndefined()
expect(state.lastVisitedAtByWorktreeId[workspaceKey]).toBeUndefined()
})
it('refreshes local repos and groups after importing nested repos', async () => {
const importedRepo: Repo = {
...remoteRepo,
@ -116,6 +502,7 @@ describe('project group store routing', () => {
}
projectGroupsImportNested.mockResolvedValue(result)
projectGroupsList.mockResolvedValue([projectGroup])
folderWorkspacesList.mockResolvedValue([])
reposList.mockResolvedValue([importedRepo])
const store = createTestStore()
@ -135,6 +522,7 @@ describe('project group store routing', () => {
mode: 'group'
})
expect(projectGroupsList).toHaveBeenCalled()
expect(folderWorkspacesList).toHaveBeenCalled()
expect(reposList).toHaveBeenCalled()
expect(store.getState().projectGroups).toEqual([projectGroup])
expect(store.getState().repos).toEqual([importedRepo])
@ -286,10 +674,26 @@ describe('project group store routing', () => {
name: 'Tools',
tabOrder: 1
}
const childWorkspace: FolderWorkspace = {
id: 'folder-workspace-1',
projectGroupId: childGroup.id,
name: 'Shared cleanup',
folderPath: '/workspace/platform/shared',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
projectGroupsDelete.mockResolvedValue(true)
const store = createTestStore()
store.setState({
projectGroups: [projectGroup, childGroup, siblingGroup],
folderWorkspaces: [childWorkspace],
repos: [
{ ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id },
{ ...remoteRepo, id: 'nested', projectGroupId: childGroup.id },
@ -300,6 +704,7 @@ describe('project group store routing', () => {
await expect(store.getState().deleteProjectGroup(projectGroup.id)).resolves.toBe(true)
expect(store.getState().projectGroups.map((group) => group.id)).toEqual([siblingGroup.id])
expect(store.getState().folderWorkspaces).toEqual([])
expect(store.getState().repos).toMatchObject([
{ id: 'direct', projectGroupId: null },
{ id: 'nested', projectGroupId: null },

View File

@ -8,22 +8,31 @@ import type { AppState } from '../types'
import type {
Repo,
ProjectGroup,
FolderWorkspace,
ProjectGroupImportResult,
NestedRepoScanResult
} from '../../../../shared/types'
import {
FOLDER_WORKSPACE_PATH_STATUS_TTL_MS,
type FolderWorkspacePathStatus,
type FolderWorkspacePathStatusRequest
} from '../../../../shared/folder-workspace-path-status'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
import { selectProjectGroupRemovalTargets } from './project-group-removal-targets'
import { getRepoIdFromWorktreeId } from './worktree-helpers'
import { reconcileFetchedRepos } from './repo-identity-reconcile'
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup'
import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist'
import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt'
import { translate } from '@/i18n/i18n'
import { buildDismissedOnboardingFolderAgentStartup } from '../../lib/onboarding-folder-agent-startup'
import { markOnboardingProjectAdded } from '../../lib/onboarding-project-checklist'
import { filterSetupScriptPromptDismissalsToValidRepos } from '../../lib/setup-script-prompt'
import { translate } from '../../i18n/i18n'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status'
const ERROR_TOAST_DURATION = 60_000
@ -52,6 +61,12 @@ type NestedRepoScanControls = {
onProgress?: (scan: NestedRepoScanResult) => void
}
export type FolderWorkspacePathStatusCacheEntry = {
status: FolderWorkspacePathStatus
checkedAt: number
requestSnapshot: string
}
export type DeleteProjectGroupWithContainedProjectsOptions = {
removeContainedProjects: boolean
}
@ -133,12 +148,122 @@ function getKnownRepoWorktreeIds(state: AppState, projectId: string): string[] {
return [...ids]
}
function getFolderWorkspacePathStatusScopeKey(request: FolderWorkspacePathStatusRequest): string {
return request.scope === 'project-group'
? `project-group:${request.projectGroupId}`
: `folder-workspace:${request.folderWorkspaceId}`
}
function getRuntimeTargetCachePrefix(state: AppState): string {
const target = getActiveRuntimeTarget(state.settings)
return target.kind === 'local' ? 'local' : `environment:${target.environmentId}`
}
function getFolderWorkspaceStatusRequestSnapshot(
state: Pick<AppState, 'projectGroups' | 'folderWorkspaces' | 'repos' | 'sshConnectionStates'>,
request: FolderWorkspacePathStatusRequest
): string | null {
const scope =
request.scope === 'project-group'
? state.projectGroups.find((group) => group.id === request.projectGroupId)
: state.folderWorkspaces.find((workspace) => workspace.id === request.folderWorkspaceId)
const projectGroup =
request.scope === 'project-group'
? scope && 'parentPath' in scope
? scope
: null
: scope && 'projectGroupId' in scope
? state.projectGroups.find((group) => group.id === scope.projectGroupId)
: null
const folderPath =
request.scope === 'project-group'
? scope && 'parentPath' in scope
? scope.parentPath
: null
: scope && 'folderPath' in scope
? scope.folderPath
: null
const projectGroupId =
request.scope === 'project-group'
? request.projectGroupId
: scope && 'projectGroupId' in scope
? scope.projectGroupId
: null
const scopeConnectionId =
request.scope === 'project-group'
? scope && 'parentPath' in scope
? scope.connectionId
: null
: scope && 'folderPath' in scope
? (scope.connectionId ?? projectGroup?.connectionId)
: null
if (!folderPath || !projectGroupId) {
return null
}
const groupIds = getProjectGroupSubtreeIds(state.projectGroups, projectGroupId)
const candidateRepos = state.repos.filter(
(repo) =>
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
isPathInsideOrEqual(folderPath, repo.path)
)
const relevantConnectionIds = new Set<string>()
if (scopeConnectionId) {
relevantConnectionIds.add(scopeConnectionId)
}
for (const repo of candidateRepos) {
if (repo.connectionId) {
relevantConnectionIds.add(repo.connectionId)
}
}
const sshFingerprint = [...relevantConnectionIds]
.map(
(connectionId) =>
`${connectionId}:${state.sshConnectionStates.get(connectionId)?.status ?? 'missing'}`
)
.sort()
.join('|')
const repoFingerprint = candidateRepos
.map(
(repo) => `${repo.id}:${repo.path}:${repo.projectGroupId ?? ''}:${repo.connectionId ?? ''}`
)
.sort()
.join('|')
return [
folderPath,
projectGroupId,
scopeConnectionId ?? '',
sshFingerprint,
repoFingerprint
].join('\0')
}
function getFreshFolderWorkspacePathStatusFromCache(args: {
entry: FolderWorkspacePathStatusCacheEntry | undefined
requestSnapshot: string | null
}): FolderWorkspacePathStatus | null {
const { entry, requestSnapshot } = args
if (!entry || requestSnapshot === null || entry.requestSnapshot !== requestSnapshot) {
return null
}
return Date.now() - entry.checkedAt < FOLDER_WORKSPACE_PATH_STATUS_TTL_MS ? entry.status : null
}
function getFolderWorkspacePathStatusRequestSnapshotForRead(
state: AppState,
request: FolderWorkspacePathStatusRequest
): string | null {
return getFolderWorkspaceStatusRequestSnapshot(state, request)
}
export type RepoSlice = {
repos: Repo[]
projectGroups: ProjectGroup[]
folderWorkspaces: FolderWorkspace[]
folderWorkspacePathStatuses: Record<string, FolderWorkspacePathStatusCacheEntry>
activeRepoId: string | null
fetchRepos: () => Promise<void>
fetchProjectGroups: () => Promise<void>
fetchFolderWorkspaces: () => Promise<void>
addRepo: () => Promise<Repo | null>
addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise<Repo | null>
addNonGitFolder: (path: string) => Promise<Repo | null>
@ -157,6 +282,46 @@ export type RepoSlice = {
mode: 'group' | 'separate'
}) => Promise<ProjectGroupImportResult | null>
createProjectGroup: (name: string) => Promise<ProjectGroup | null>
createFolderWorkspace: (args: {
projectGroupId: string
name?: string
folderPath?: string | null
connectionId?: string | null
linkedTask?: FolderWorkspace['linkedTask']
createdWithAgent?: FolderWorkspace['createdWithAgent']
pendingFirstAgentMessageRename?: boolean
}) => Promise<FolderWorkspace | null>
getFolderWorkspacePathStatusCacheKey: (request: FolderWorkspacePathStatusRequest) => string
getFreshFolderWorkspacePathStatus: (
request: FolderWorkspacePathStatusRequest
) => FolderWorkspacePathStatus | null
fetchFolderWorkspacePathStatus: (
request: FolderWorkspacePathStatusRequest,
options?: { force?: boolean }
) => Promise<FolderWorkspacePathStatus | null>
updateFolderWorkspace: (
folderWorkspaceId: string,
updates: Partial<
Pick<
FolderWorkspace,
| 'name'
| 'folderPath'
| 'linkedTask'
| 'comment'
| 'isArchived'
| 'isUnread'
| 'isPinned'
| 'sortOrder'
| 'manualOrder'
| 'workspaceStatus'
| 'createdWithAgent'
| 'pendingFirstAgentMessageRename'
| 'firstAgentMessageRenameError'
| 'lastActivityAt'
>
>
) => Promise<boolean>
deleteFolderWorkspace: (folderWorkspaceId: string) => Promise<boolean>
updateProjectGroup: (
groupId: string,
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
@ -180,6 +345,8 @@ export type RepoSlice = {
export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, get) => ({
repos: [],
projectGroups: [],
folderWorkspaces: [],
folderWorkspacePathStatuses: {},
activeRepoId: null,
fetchRepos: async () => {
@ -204,6 +371,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
const reconciledRepos = reconcileFetchedRepos(s.repos, repos)
return {
repos: reconciledRepos,
folderWorkspacePathStatuses: {},
activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null,
filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)),
setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos(
@ -233,12 +401,84 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
)
).groups
set({ projectGroups })
set({ projectGroups, folderWorkspacePathStatuses: {} })
} catch (err) {
console.error('Failed to fetch project groups:', err)
}
},
fetchFolderWorkspaces: async () => {
try {
const target = getActiveRuntimeTarget(get().settings)
const folderWorkspaces =
target.kind === 'local'
? await window.api.folderWorkspaces.list()
: (
await callRuntimeRpc<{ folderWorkspaces: FolderWorkspace[] }>(
target,
'folderWorkspace.list',
undefined,
{ timeoutMs: 15_000 }
)
).folderWorkspaces
set({ folderWorkspaces, folderWorkspacePathStatuses: {} })
} catch (err) {
console.error('Failed to fetch folder workspaces:', err)
}
},
getFolderWorkspacePathStatusCacheKey: (request) =>
`${getRuntimeTargetCachePrefix(get())}:${getFolderWorkspacePathStatusScopeKey(request)}`,
getFreshFolderWorkspacePathStatus: (request) => {
const state = get()
const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request)
const cached = state.folderWorkspacePathStatuses[cacheKey]
const requestSnapshot = getFolderWorkspacePathStatusRequestSnapshotForRead(state, request)
return getFreshFolderWorkspacePathStatusFromCache({ entry: cached, requestSnapshot })
},
fetchFolderWorkspacePathStatus: async (request, options) => {
const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request)
const requestSnapshot = getFolderWorkspaceStatusRequestSnapshot(get(), request)
const cached = get().folderWorkspacePathStatuses[cacheKey]
const freshCachedStatus = getFreshFolderWorkspacePathStatusFromCache({
entry: cached,
requestSnapshot
})
if (!options?.force && freshCachedStatus) {
return freshCachedStatus
}
try {
const target = getActiveRuntimeTarget(get().settings)
const status =
target.kind === 'local'
? await window.api.folderWorkspaces.getPathStatus(request)
: (
await callRuntimeRpc<{ status: FolderWorkspacePathStatus }>(
target,
'folderWorkspace.getPathStatus',
request,
{ timeoutMs: 15_000 }
)
).status
set((state) => ({
folderWorkspacePathStatuses:
requestSnapshot !== null &&
getFolderWorkspaceStatusRequestSnapshot(state, request) === requestSnapshot
? {
...state.folderWorkspacePathStatuses,
[cacheKey]: { status, checkedAt: Date.now(), requestSnapshot }
}
: state.folderWorkspacePathStatuses
}))
return status
} catch (err) {
console.error('Failed to fetch folder workspace path status:', err)
return null
}
},
scanNestedRepos: async (path, connectionId, controls) => {
try {
const target = getActiveRuntimeTarget(get().settings)
@ -311,7 +551,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
{ timeoutMs: 60_000 }
)
await get().fetchProjectGroups()
await get().fetchFolderWorkspaces()
await get().fetchRepos()
set({ folderWorkspacePathStatuses: {} })
return result
} catch (err) {
console.error('Failed to import nested repos:', err)
@ -342,7 +584,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
{ timeoutMs: 15_000 }
)
).group
set((s) => ({ projectGroups: [...s.projectGroups, group] }))
set((s) => ({ projectGroups: [...s.projectGroups, group], folderWorkspacePathStatuses: {} }))
return group
} catch (err) {
console.error('Failed to create project group:', err)
@ -350,6 +592,95 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
},
createFolderWorkspace: async (args) => {
try {
const target = getActiveRuntimeTarget(get().settings)
const workspace =
target.kind === 'local'
? await window.api.folderWorkspaces.create(args)
: (
await callRuntimeRpc<{ folderWorkspace: FolderWorkspace }>(
target,
'folderWorkspace.create',
args,
{ timeoutMs: 15_000 }
)
).folderWorkspace
set((s) => ({
folderWorkspaces: [workspace, ...s.folderWorkspaces],
folderWorkspacePathStatuses: {}
}))
return workspace
} catch (err) {
console.error('Failed to create folder workspace:', err)
const { title, description } = formatFolderWorkspaceCreateError(err)
toast.error(title, { description, duration: ERROR_TOAST_DURATION })
return null
}
},
updateFolderWorkspace: async (folderWorkspaceId, updates) => {
try {
const target = getActiveRuntimeTarget(get().settings)
const updated =
target.kind === 'local'
? await window.api.folderWorkspaces.update({ folderWorkspaceId, updates })
: (
await callRuntimeRpc<{ folderWorkspace: FolderWorkspace | null }>(
target,
'folderWorkspace.update',
{ folderWorkspaceId, updates },
{ timeoutMs: 15_000 }
)
).folderWorkspace
if (!updated) {
return false
}
set((s) => ({
folderWorkspaces: s.folderWorkspaces.map((workspace) =>
workspace.id === folderWorkspaceId ? updated : workspace
),
folderWorkspacePathStatuses: {}
}))
return true
} catch (err) {
console.error('Failed to update folder workspace:', err)
return false
}
},
deleteFolderWorkspace: async (folderWorkspaceId) => {
try {
const target = getActiveRuntimeTarget(get().settings)
const deleted =
target.kind === 'local'
? await window.api.folderWorkspaces.delete({ folderWorkspaceId })
: (
await callRuntimeRpc<{ deleted: boolean }>(
target,
'folderWorkspace.delete',
{ folderWorkspaceId },
{ timeoutMs: 15_000 }
)
).deleted
if (!deleted) {
return false
}
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
set((s) => ({
folderWorkspaces: s.folderWorkspaces.filter(
(workspace) => workspace.id !== folderWorkspaceId
),
folderWorkspacePathStatuses: {}
}))
get().purgeWorktreeTerminalState([workspaceKey])
return true
} catch (err) {
console.error('Failed to delete folder workspace:', err)
return false
}
},
updateProjectGroup: async (groupId, updates) => {
try {
const target = getActiveRuntimeTarget(get().settings)
@ -368,7 +699,8 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
return false
}
set((s) => ({
projectGroups: s.projectGroups.map((group) => (group.id === groupId ? updated : group))
projectGroups: s.projectGroups.map((group) => (group.id === groupId ? updated : group)),
folderWorkspacePathStatuses: {}
}))
return true
} catch (err) {
@ -398,11 +730,15 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
const deletedGroupIds = getProjectGroupSubtreeIds(s.projectGroups, groupId)
return {
projectGroups: s.projectGroups.filter((group) => !deletedGroupIds.has(group.id)),
folderWorkspaces: s.folderWorkspaces.filter(
(workspace) => !deletedGroupIds.has(workspace.projectGroupId)
),
repos: s.repos.map((repo) =>
repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)
? { ...repo, projectGroupId: null }
: repo
)
),
folderWorkspacePathStatuses: {}
}
})
return true
@ -498,7 +834,10 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
if (!moved) {
return false
}
set((s) => ({ repos: s.repos.map((repo) => (repo.id === projectId ? moved : repo)) }))
set((s) => ({
repos: s.repos.map((repo) => (repo.id === projectId ? moved : repo)),
folderWorkspacePathStatuses: {}
}))
return true
} catch (err) {
console.error('Failed to move repo to group:', err)
@ -548,7 +887,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
if (s.repos.some((r) => r.id === repo.id)) {
return s
}
return { repos: [...s.repos, repo] }
return { repos: [...s.repos, repo], folderWorkspacePathStatuses: {} }
})
if (alreadyAdded) {
toast.info(translate('auto.store.slices.repos.a8e4b3af5b', 'Project already added'), {
@ -751,6 +1090,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
activeFileId: activeFileCleared ? null : s.activeFileId,
activeTabType: activeFileCleared ? 'terminal' : s.activeTabType,
lastVisitedAtByWorktreeId: nextLastVisitedAtByWorktreeId,
folderWorkspacePathStatuses: {},
sortEpoch: s.sortEpoch + 1,
// Why: removing the last repo while in settings leaves activeView as
// 'settings', which renders an empty settings pane instead of Landing.
@ -760,6 +1100,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
? {
activeView: 'terminal' as const,
activeWorktreeId: null,
activeWorkspaceKey: null,
activeRepoId: null
}
: {})
@ -807,7 +1148,8 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
...updatesWithoutSourceControlAi,
...(sourceControlAi !== undefined ? { sourceControlAi } : {})
}
})
}),
folderWorkspacePathStatuses: {}
}))
return true
} catch (err) {
@ -849,7 +1191,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
// Caller passed a non-permutation — refuse to apply locally.
return
}
set({ repos: next })
set({ repos: next, folderWorkspacePathStatuses: {} })
try {
const target = getActiveRuntimeTarget(get().settings)
const result =

View File

@ -53,6 +53,7 @@ function runtimeScopedStateReset(): Partial<AppState> {
return {
repos: [],
projectGroups: [],
folderWorkspaces: [],
activeRepoId: null,
sparsePresetsByRepo: {},
sparsePresetsLoadingByRepo: {},
@ -62,6 +63,7 @@ function runtimeScopedStateReset(): Partial<AppState> {
detectedWorktreesByRepo: {},
worktreeLineageById: {},
activeWorktreeId: null,
activeWorkspaceKey: null,
deleteStateByWorktreeId: {},
baseStatusByWorktreeId: {},
remoteBranchConflictByWorktreeId: {},

View File

@ -29,6 +29,7 @@ import { buildHydratedTabState, pruneTabGroupLayoutForGroups } from './tabs-hydr
import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
export type TabSplitDirection = 'left' | 'right' | 'up' | 'down'
@ -855,6 +856,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
...(shouldDeactivateWorktree
? {
activeWorktreeId: null,
activeWorkspaceKey: null,
activeTabId: null,
activeBrowserTabId: null,
activeFileId: null,
@ -1818,6 +1820,9 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
.map((w) => w.id)
)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of state.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
}
set(buildHydratedTabState(session, validWorktreeIds))
}
})

View File

@ -8,9 +8,15 @@ import type {
TerminalTab,
TuiAgent,
Worktree,
WorkspaceKey,
WorkspaceSessionState
} from '../../../../shared/types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import {
folderWorkspaceKey,
parseWorkspaceKey,
worktreeWorkspaceKey
} from '../../../../shared/workspace-scope'
import { deriveGeneratedTabTitle } from '../../../../shared/agent-tab-title'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
@ -39,6 +45,7 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection'
import { hasWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
@ -171,9 +178,16 @@ function resolveCreatedTabShellOverride(
}
function worktreeUsesWslPath(
state: Pick<AppState, 'worktreesByRepo'>,
state: Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>,
worktreeId: string
): boolean {
const parsed = parseWorkspaceKey(worktreeId)
if (parsed?.type === 'folder') {
const folderWorkspace = state.folderWorkspaces.find(
(workspace) => workspace.id === parsed.folderWorkspaceId
)
return folderWorkspace ? isWslUncPath(folderWorkspace.folderPath) : false
}
const worktree = Object.values(state.worktreesByRepo)
.flat()
.find((entry) => entry.id === worktreeId)
@ -181,9 +195,13 @@ function worktreeUsesWslPath(
}
export function worktreeUsesRemoteConnection(
state: Pick<AppState, 'repos' | 'worktreesByRepo'>,
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
worktreeId: string
): boolean {
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
if (parsedWorkspaceKey?.type === 'folder') {
return Boolean(getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId))
}
const directRepoId = getRepoIdFromWorktreeId(worktreeId)
const directRepo = state.repos.find((repo) => repo.id === directRepoId)
if (directRepo) {
@ -1968,7 +1986,14 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// its tabs still use the normal terminal session pipeline so daemon PTYs
// can survive app restart just like workspace terminals.
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of s.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
}
for (const worktreeId of Object.keys(session.tabsByWorktree)) {
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
if (parsedWorkspaceKey?.type === 'folder') {
continue
}
if (!validWorktreeIds.has(worktreeId)) {
const repoId = getRepoIdFromWorktreeId(worktreeId)
// Why (#1158): an empty/missing list can mean degraded hydration; a
@ -2041,6 +2066,14 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)
? session.activeWorktreeId
: null
const activeWorkspaceKey: WorkspaceKey | null =
session.activeWorkspaceKey && validWorktreeIds.has(session.activeWorkspaceKey)
? session.activeWorkspaceKey
: activeWorktreeId
? parseWorkspaceKey(activeWorktreeId)
? (activeWorktreeId as WorkspaceKey)
: worktreeWorkspaceKey(activeWorktreeId)
: null
const activeTabId =
session.activeTabId && validTabIds.has(session.activeTabId) ? session.activeTabId : null
const activeRepoId =
@ -2193,6 +2226,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
return {
activeRepoId,
activeWorktreeId,
activeWorkspaceKey,
activeTabId,
activeTabIdByWorktree,
tabsByWorktree,

View File

@ -15,7 +15,8 @@ import type {
WorktreeBaseStatusEvent,
WorktreeLineage,
WorktreeRemoteBranchConflictEvent,
WorktreeMeta
WorktreeMeta,
WorkspaceKey
} from '../../../../shared/types'
import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector'
import type {
@ -41,6 +42,7 @@ export type WorktreeSlice = {
detectedWorktreesByRepo: Record<string, DetectedWorktreeListResult>
worktreeLineageById: Record<string, WorktreeLineage>
activeWorktreeId: string | null
activeWorkspaceKey: WorkspaceKey | null
/**
* In-flight / failed background worktree creations, keyed by a renderer
* `creationId`. Kept separate from `worktreesByRepo` on purpose a real
@ -209,6 +211,7 @@ export type WorktreeSlice = {
*/
seedActiveWorktreeLastVisitedIfMissing: () => void
setActiveWorktree: (worktreeId: string | null) => void
setActiveFolderWorkspace: (folderWorkspaceId: string) => void
setRenamingWorktreeId: (worktreeId: string | null) => void
allWorktrees: () => Worktree[]
getKnownWorktreeById: (worktreeId: string) => Worktree | DetectedWorktree | undefined

View File

@ -7,6 +7,7 @@ import { create } from 'zustand'
import type { AppState } from '../types'
import type {
DetectedWorktreeListResult,
FolderWorkspace,
LocalBaseRefRefreshResult,
Worktree,
WorktreeLineage
@ -89,6 +90,7 @@ import {
unregisterPersistentWebview
} from '../../components/browser-pane/webview-registry'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
function resetRemoteRuntimeMocks() {
clearRuntimeCompatibilityCacheForTests()
@ -200,6 +202,45 @@ function makeLineage(overrides: Partial<WorktreeLineage> = {}): WorktreeLineage
}
}
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
return {
...overrides,
id: overrides.id ?? 'folder-1',
projectGroupId: overrides.projectGroupId ?? 'group-1',
name: overrides.name ?? 'platform workspace',
folderPath: overrides.folderPath ?? '/work/platform',
linkedTask: overrides.linkedTask ?? null,
comment: overrides.comment ?? '',
isArchived: overrides.isArchived ?? false,
isUnread: overrides.isUnread ?? false,
isPinned: overrides.isPinned ?? false,
sortOrder: overrides.sortOrder ?? 0,
manualOrder: overrides.manualOrder ?? 0,
lastActivityAt: overrides.lastActivityAt ?? 0,
createdAt: overrides.createdAt ?? 0,
updatedAt: overrides.updatedAt ?? 0,
workspaceStatus: overrides.workspaceStatus ?? 'active'
}
}
describe('folder workspace lookups', () => {
it('returns a stable synthetic worktree for repeated folder workspace lookups', () => {
const store = createTestStore()
const folderWorkspace = makeFolderWorkspace()
store.setState({ folderWorkspaces: [folderWorkspace] } as Partial<AppState>)
const first = store.getState().getKnownWorktreeById(folderWorkspaceKey(folderWorkspace.id))
const second = store.getState().getKnownWorktreeById(folderWorkspaceKey(folderWorkspace.id))
expect(second).toBe(first)
expect(first).toMatchObject({
id: folderWorkspaceKey(folderWorkspace.id),
displayName: folderWorkspace.name,
path: folderWorkspace.folderPath
})
})
})
describe('setActiveWorktree focus handling', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@ -7,6 +7,7 @@ import type {
TerminalPaneLayoutNode,
LocalBaseRefRefreshResult,
ForceDeleteWorktreeBranchResult,
FolderWorkspace,
GitHubPrStartPoint,
Worktree,
WorkspaceVisibleTabType,
@ -40,6 +41,12 @@ import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/inp
import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast'
import { translate } from '@/i18n/i18n'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import {
folderWorkspaceKey,
parseWorkspaceKey,
worktreeWorkspaceKey
} from '../../../../shared/workspace-scope'
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers'
// Why: old runtime servers only have `worktree.list`; preserve the large-list
@ -50,6 +57,7 @@ const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450
const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180
const pendingActivationTerminalPrepCancels = new Map<string, () => void>()
const detachedHeadAutoDerivedDisplayNames = new Map<string, string>()
const folderWorkspaceWorktreeCache = new WeakMap<FolderWorkspace, Worktree>()
function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number {
if (!node) {
@ -387,9 +395,25 @@ function applyDetectedWorktreeUpdates(
}
function findKnownWorktreeById(
state: Pick<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo'>,
state: Pick<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces'>,
worktreeId: string
): Worktree | DetectedWorktreeListResult['worktrees'][number] | undefined {
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
const folderWorkspace = state.folderWorkspaces.find(
(workspace) => workspace.id === workspaceScope.folderWorkspaceId
)
if (!folderWorkspace) {
return undefined
}
const cached = folderWorkspaceWorktreeCache.get(folderWorkspace)
if (cached) {
return cached
}
const worktree = folderWorkspaceToWorktree(folderWorkspace)
folderWorkspaceWorktreeCache.set(folderWorkspace, worktree)
return worktree
}
const visible = findWorktreeById(state.worktreesByRepo, worktreeId)
if (visible) {
return visible
@ -403,6 +427,84 @@ function findKnownWorktreeById(
return undefined
}
function getFolderWorkspaceMetaUpdates(
updates: Partial<WorktreeMeta>
): Partial<
Pick<
FolderWorkspace,
| 'name'
| 'comment'
| 'isArchived'
| 'isUnread'
| 'isPinned'
| 'sortOrder'
| 'manualOrder'
| 'lastActivityAt'
| 'workspaceStatus'
| 'createdWithAgent'
| 'pendingFirstAgentMessageRename'
| 'firstAgentMessageRenameError'
>
> {
const next: Partial<
Pick<
FolderWorkspace,
| 'name'
| 'comment'
| 'isArchived'
| 'isUnread'
| 'isPinned'
| 'sortOrder'
| 'manualOrder'
| 'lastActivityAt'
| 'workspaceStatus'
| 'createdWithAgent'
| 'pendingFirstAgentMessageRename'
| 'firstAgentMessageRenameError'
>
> = {}
if (updates.displayName !== undefined) {
next.name = updates.displayName
next.pendingFirstAgentMessageRename = false
next.firstAgentMessageRenameError = null
}
if (updates.comment !== undefined) {
next.comment = updates.comment
next.lastActivityAt = Date.now()
}
if (updates.isArchived !== undefined) {
next.isArchived = updates.isArchived
}
if (updates.isUnread !== undefined) {
next.isUnread = updates.isUnread
}
if (updates.isPinned !== undefined) {
next.isPinned = updates.isPinned
}
if (updates.sortOrder !== undefined) {
next.sortOrder = updates.sortOrder
}
if (updates.manualOrder !== undefined) {
next.manualOrder = updates.manualOrder
}
if (updates.lastActivityAt !== undefined) {
next.lastActivityAt = updates.lastActivityAt
}
if (updates.workspaceStatus !== undefined) {
next.workspaceStatus = updates.workspaceStatus
}
if (updates.createdWithAgent !== undefined) {
next.createdWithAgent = updates.createdWithAgent
}
if (updates.pendingFirstAgentMessageRename !== undefined) {
next.pendingFirstAgentMessageRename = updates.pendingFirstAgentMessageRename
}
if (updates.firstAgentMessageRenameError !== undefined) {
next.firstAgentMessageRenameError = updates.firstAgentMessageRenameError
}
return next
}
function isRuntimeSelectorNotFoundError(error: unknown): boolean {
if (
error &&
@ -585,11 +687,15 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
// Collect every tab id (and removed file id) we are about to orphan.
const doomedTabIds = new Set<string>()
const doomedBrowserWorkspaceIds = new Set<string>()
const removedFileIds = new Set<string>()
for (const id of worktreeIdSet) {
for (const tab of s.tabsByWorktree[id] ?? []) {
doomedTabIds.add(tab.id)
}
for (const workspace of s.browserTabsByWorktree[id] ?? []) {
doomedBrowserWorkspaceIds.add(workspace.id)
}
}
for (const file of s.openFiles) {
if (worktreeIdSet.has(file.worktreeId)) {
@ -641,6 +747,17 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
}
return changed ? out : obj
}
const omitByBrowserWorkspaceId = <T>(obj: Record<string, T>): Record<string, T> => {
let changed = false
const out = { ...obj }
for (const workspaceId of doomedBrowserWorkspaceIds) {
if (workspaceId in out) {
delete out[workspaceId]
changed = true
}
}
return changed ? out : obj
}
const omitByFileId = <T>(obj: Record<string, T>): Record<string, T> => {
let changed = false
const out = { ...obj }
@ -694,6 +811,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
fileSearchStateByWorktree: omitByWorktree(s.fileSearchStateByWorktree),
// Browser state
browserTabsByWorktree: omitByWorktree(s.browserTabsByWorktree),
browserPagesByWorkspace: omitByBrowserWorkspaceId(s.browserPagesByWorkspace),
recentlyClosedBrowserTabsByWorktree: omitByWorktree(s.recentlyClosedBrowserTabsByWorktree),
activeBrowserTabIdByWorktree: omitByWorktree(s.activeBrowserTabIdByWorktree),
// Editor state
@ -728,6 +846,8 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<Ap
everActivatedWorktreeIds: nextEverActivatedWorktreeIds,
lastVisitedAtByWorktreeId: omitByWorktree(s.lastVisitedAtByWorktreeId),
activeWorktreeId: removedActive ? null : s.activeWorktreeId,
activeWorkspaceKey:
s.activeWorkspaceKey && worktreeIdSet.has(s.activeWorkspaceKey) ? null : s.activeWorkspaceKey,
activeFileId: activeFileCleared ? null : s.activeFileId,
activeBrowserTabId: removedActive ? null : s.activeBrowserTabId,
activeTabId: activeTabCleared ? null : s.activeTabId,
@ -740,6 +860,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
detectedWorktreesByRepo: {},
worktreeLineageById: {},
activeWorktreeId: null,
activeWorkspaceKey: null,
pendingWorktreeCreations: {},
activePendingCreationId: null,
renamingWorktreeId: null,
@ -1649,6 +1770,14 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
if (shouldApplyUpdate && !shouldApplyUpdate(existingWorktree)) {
return
}
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
const folderUpdates = getFolderWorkspaceMetaUpdates(updates)
if (Object.keys(folderUpdates).length > 0) {
await get().updateFolderWorkspace(workspaceScope.folderWorkspaceId, folderUpdates)
}
return
}
// Why: manual PR linking only supplies the PR number. Resolve the PR head
// branch here so Push targets the review branch, but don't repeat that
// network lookup for no-op linkedPR metadata saves.
@ -1863,7 +1992,12 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
if (!current || current.isPinned === isPinned) {
continue
}
updates.set(worktreeId, { isPinned })
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
void get().updateWorktreeMeta(worktreeId, { isPinned })
} else {
updates.set(worktreeId, { isPinned })
}
if (revealWorktreeId === null) {
revealWorktreeId = worktreeId
}
@ -2200,6 +2334,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
if (!worktreeId) {
return {
activeWorktreeId: null,
activeWorkspaceKey: null,
// Why: activating any real worktree (or clearing it) must dismiss the
// background-creation panel so the user isn't stranded on it.
activePendingCreationId: null
@ -2420,6 +2555,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
return {
activeWorktreeId: worktreeId,
activeWorkspaceKey: worktreeWorkspaceKey(worktreeId),
activePendingCreationId: null,
activeFileId,
activeBrowserTabId,
@ -2512,6 +2648,115 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
},
setActiveFolderWorkspace: (folderWorkspaceId) => {
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
const workspace = get().folderWorkspaces.find((entry) => entry.id === folderWorkspaceId)
if (!workspace) {
return
}
if (shouldDeferActivationTerminalPrep()) {
markInputQuietSchedulerInput()
}
if (get().activeWorktreeId !== workspaceKey) {
moveFocusToRendererBeforeFocusedWebviewHidden()
}
const reconciledActiveTabId =
get().reconcileWorktreeTabModel(workspaceKey).activeRenderableTabId
set((s) => {
const restoredFileId = s.activeFileIdByWorktree[workspaceKey] ?? null
const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[workspaceKey] ?? null
const restoredTabType = s.activeTabTypeByWorktree[workspaceKey] ?? 'terminal'
const activeGroupId =
s.activeGroupIdByWorktree[workspaceKey] ?? s.groupsByWorktree[workspaceKey]?.[0]?.id ?? null
const activeGroup = activeGroupId
? ((s.groupsByWorktree[workspaceKey] ?? []).find((group) => group.id === activeGroupId) ??
null)
: null
const activeUnifiedTabId = reconciledActiveTabId ?? activeGroup?.activeTabId ?? null
const activeUnifiedTab =
activeUnifiedTabId != null
? ((s.unifiedTabsByWorktree[workspaceKey] ?? []).find(
(tab) =>
tab.id === activeUnifiedTabId && (!activeGroup || tab.groupId === activeGroup.id)
) ?? null)
: null
const fileStillOpen = restoredFileId
? s.openFiles.some((file) => file.id === restoredFileId && file.worktreeId === workspaceKey)
: false
const browserTabs = s.browserTabsByWorktree[workspaceKey] ?? []
const browserTabStillOpen = restoredBrowserTabId
? browserTabs.some((tab) => tab.id === restoredBrowserTabId)
: false
const worktreeTabs = s.tabsByWorktree[workspaceKey] ?? []
const restoredTabId = s.activeTabIdByWorktree[workspaceKey] ?? null
const tabStillExists = restoredTabId
? worktreeTabs.some((tab) => tab.id === restoredTabId)
: false
const activeFileId =
activeUnifiedTab?.contentType === 'editor' ||
activeUnifiedTab?.contentType === 'diff' ||
activeUnifiedTab?.contentType === 'conflict-review'
? activeUnifiedTab.entityId
: fileStillOpen
? restoredFileId
: null
const activeBrowserTabId =
activeUnifiedTab?.contentType === 'browser'
? activeUnifiedTab.entityId
: browserTabStillOpen
? restoredBrowserTabId
: (browserTabs[0]?.id ?? null)
const activeTabType =
activeUnifiedTab?.contentType === 'terminal'
? 'terminal'
: activeUnifiedTab?.contentType === 'browser'
? 'browser'
: activeUnifiedTab
? 'editor'
: restoredTabType === 'browser' && browserTabStillOpen
? 'browser'
: restoredTabType === 'editor' && fileStillOpen
? 'editor'
: fileStillOpen
? 'editor'
: browserTabs.length > 0
? 'browser'
: 'terminal'
const activeTabId =
activeUnifiedTab?.contentType === 'terminal'
? activeUnifiedTab.entityId
: tabStillExists
? restoredTabId
: (worktreeTabs[0]?.id ?? null)
const nextEverActivated = s.everActivatedWorktreeIds.has(workspaceKey)
? s.everActivatedWorktreeIds
: new Set([...s.everActivatedWorktreeIds, workspaceKey])
return {
activeRepoId: null,
activeWorktreeId: workspaceKey,
activeWorkspaceKey: workspaceKey,
activePendingCreationId: null,
activeFileId,
activeBrowserTabId,
activeTabType,
activeTabTypeByWorktree:
s.activeTabTypeByWorktree[workspaceKey] === activeTabType
? s.activeTabTypeByWorktree
: { ...s.activeTabTypeByWorktree, [workspaceKey]: activeTabType },
activeTabId,
everActivatedWorktreeIds: nextEverActivated,
folderWorkspaces: workspace.isUnread
? s.folderWorkspaces.map((entry) =>
entry.id === folderWorkspaceId ? { ...entry, isUnread: false } : entry
)
: s.folderWorkspaces
}
})
if (workspace.isUnread) {
void get().updateFolderWorkspace(folderWorkspaceId, { isUnread: false })
}
},
allWorktrees: () => Object.values(get().worktreesByRepo).flat(),
getKnownWorktreeById: (worktreeId) => findKnownWorktreeById(get(), worktreeId),

View File

@ -382,6 +382,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
schemaVersion: SCHEMA_VERSION,
repos: [],
projectGroups: [],
folderWorkspaces: [],
sparsePresetsByRepo: {},
worktreeMeta: {},
worktreeLineageById: {},

View File

@ -0,0 +1,34 @@
export type FolderWorkspacePathStatusReason =
| 'missing'
| 'not-directory'
| 'unavailable'
| 'ambiguous-connection'
export const FOLDER_WORKSPACE_PATH_STATUS_TTL_MS = 10_000
export type FolderWorkspacePathStatusRequest =
| { scope: 'folder-workspace'; folderWorkspaceId: string }
| { scope: 'project-group'; projectGroupId: string }
export type FolderWorkspacePathStatus = {
path: string
exists: boolean
reason?: FolderWorkspacePathStatusReason
}
export function isConfirmedStaleFolderPathStatus(
status: FolderWorkspacePathStatus | null | undefined
): boolean {
return (
status?.exists === false && (status.reason === 'missing' || status.reason === 'not-directory')
)
}
export function blocksFolderWorkspaceActivation(
status: FolderWorkspacePathStatus | null | undefined
): boolean {
return (
isConfirmedStaleFolderPathStatus(status) ||
(status?.exists === false && status.reason === 'ambiguous-connection')
)
}

View File

@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest'
import type { FolderWorkspace } from './types'
import { folderWorkspaceToWorktree } from './folder-workspace-worktree'
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
return {
...overrides,
id: overrides.id ?? 'folder-workspace-1',
projectGroupId: overrides.projectGroupId ?? 'group-1',
name: overrides.name ?? 'Refund fix',
folderPath: overrides.folderPath ?? '/workspace/platform',
linkedTask: overrides.linkedTask ?? null,
comment: overrides.comment ?? '',
isArchived: overrides.isArchived ?? false,
isUnread: overrides.isUnread ?? false,
isPinned: overrides.isPinned ?? false,
sortOrder: overrides.sortOrder ?? 1,
manualOrder: overrides.manualOrder,
workspaceStatus: overrides.workspaceStatus,
lastActivityAt: overrides.lastActivityAt ?? 2,
createdAt: overrides.createdAt ?? 3,
updatedAt: overrides.updatedAt ?? 4
}
}
describe('folderWorkspaceToWorktree', () => {
it('projects attached issue tasks without creating linked PR metadata', () => {
const githubIssue = folderWorkspaceToWorktree(
makeFolderWorkspace({
linkedTask: {
provider: 'github',
type: 'issue',
number: 42,
title: 'Refund flow fails',
url: 'https://github.com/acme/app/issues/42'
}
})
)
const gitlabIssue = folderWorkspaceToWorktree(
makeFolderWorkspace({
linkedTask: {
provider: 'gitlab',
type: 'issue',
number: 7,
title: 'Import fails',
url: 'https://gitlab.com/acme/app/-/issues/7'
}
})
)
expect(githubIssue).toMatchObject({
linkedIssue: 42,
linkedPR: null,
linkedGitLabMR: null,
linkedGitLabIssue: null
})
expect(gitlabIssue).toMatchObject({
linkedIssue: null,
linkedPR: null,
linkedGitLabMR: null,
linkedGitLabIssue: 7
})
})
it('projects Linear tasks by identifier', () => {
const worktree = folderWorkspaceToWorktree(
makeFolderWorkspace({
linkedTask: {
provider: 'linear',
type: 'issue',
number: 0,
title: 'Polish folder workspaces',
url: 'https://linear.app/acme/issue/ENG-123',
linearIdentifier: 'ENG-123'
}
})
)
expect(worktree.linkedLinearIssue).toBe('ENG-123')
expect(worktree.linkedPR).toBeNull()
expect(worktree.linkedGitLabMR).toBeNull()
})
it('projects first-message rename state for folder workspace cards', () => {
const worktree = folderWorkspaceToWorktree(
makeFolderWorkspace({
createdWithAgent: 'codex',
pendingFirstAgentMessageRename: true,
firstAgentMessageRenameError: 'No model configured'
})
)
expect(worktree).toMatchObject({
createdWithAgent: 'codex',
pendingFirstAgentMessageRename: true,
firstAgentMessageRenameError: 'No model configured'
})
})
it('keeps review-style tasks attached only to the folder workspace record', () => {
const githubPr = folderWorkspaceToWorktree(
makeFolderWorkspace({
linkedTask: {
provider: 'github',
type: 'pr',
number: 99,
title: 'Feature branch',
url: 'https://github.com/acme/app/pull/99'
}
})
)
const gitlabMr = folderWorkspaceToWorktree(
makeFolderWorkspace({
linkedTask: {
provider: 'gitlab',
type: 'mr',
number: 12,
title: 'Feature branch',
url: 'https://gitlab.com/acme/app/-/merge_requests/12'
}
})
)
expect(githubPr.linkedPR).toBeNull()
expect(githubPr.linkedIssue).toBeNull()
expect(gitlabMr.linkedGitLabMR).toBeNull()
expect(gitlabMr.linkedGitLabIssue).toBeNull()
})
})

View File

@ -0,0 +1,37 @@
import type { FolderWorkspace, Worktree } from './types'
import { folderWorkspaceKey } from './workspace-scope'
export function folderWorkspaceToWorktree(folderWorkspace: FolderWorkspace): Worktree {
const linkedTask = folderWorkspace.linkedTask
return {
id: folderWorkspaceKey(folderWorkspace.id),
repoId: `folder-workspace:${folderWorkspace.projectGroupId}`,
displayName: folderWorkspace.name,
comment: folderWorkspace.comment,
linkedIssue:
linkedTask?.provider === 'github' && linkedTask.type === 'issue' ? linkedTask.number : null,
linkedPR: null,
linkedLinearIssue:
linkedTask?.provider === 'linear' ? (linkedTask.linearIdentifier ?? null) : null,
linkedGitLabMR: null,
linkedGitLabIssue:
linkedTask?.provider === 'gitlab' && linkedTask.type === 'issue' ? linkedTask.number : null,
isArchived: folderWorkspace.isArchived,
isUnread: folderWorkspace.isUnread,
isPinned: folderWorkspace.isPinned,
sortOrder: folderWorkspace.sortOrder,
manualOrder: folderWorkspace.manualOrder,
lastActivityAt: folderWorkspace.lastActivityAt,
createdAt: folderWorkspace.createdAt,
createdWithAgent: folderWorkspace.createdWithAgent,
pendingFirstAgentMessageRename: folderWorkspace.pendingFirstAgentMessageRename,
firstAgentMessageRenameError: folderWorkspace.firstAgentMessageRenameError,
workspaceStatus: folderWorkspace.workspaceStatus,
path: folderWorkspace.folderPath,
head: '',
branch: '',
isBare: false,
isSparse: false,
isMainWorktree: false
}
}

View File

@ -0,0 +1,144 @@
import type { FolderWorkspace, FolderWorkspaceLinkedTask, ProjectGroup } from './types'
import { isTuiAgent } from './tui-agent-config'
export function normalizeFolderWorkspaceName(
name: string | null | undefined,
fallback = 'Untitled workspace'
): string {
const trimmed = typeof name === 'string' ? name.trim() : ''
return trimmed.length > 0 ? trimmed : fallback
}
export function normalizeFolderWorkspaceLinkedTask(
value: unknown
): FolderWorkspaceLinkedTask | null {
if (!value || typeof value !== 'object') {
return null
}
const raw = value as Partial<FolderWorkspaceLinkedTask>
if (
raw.provider !== 'github' &&
raw.provider !== 'gitlab' &&
raw.provider !== 'linear' &&
raw.provider !== 'jira'
) {
return null
}
if (raw.type !== 'issue' && raw.type !== 'pr' && raw.type !== 'mr') {
return null
}
if (
typeof raw.number !== 'number' ||
!Number.isFinite(raw.number) ||
typeof raw.title !== 'string' ||
raw.title.trim().length === 0 ||
typeof raw.url !== 'string' ||
raw.url.trim().length === 0
) {
return null
}
return {
provider: raw.provider,
type: raw.type,
number: raw.number,
title: raw.title.trim(),
url: raw.url.trim(),
...(typeof raw.linearIdentifier === 'string' && raw.linearIdentifier.trim().length > 0
? { linearIdentifier: raw.linearIdentifier.trim() }
: {}),
...(typeof raw.jiraIdentifier === 'string' && raw.jiraIdentifier.trim().length > 0
? { jiraIdentifier: raw.jiraIdentifier.trim() }
: {}),
...(typeof raw.repoId === 'string' && raw.repoId.trim().length > 0
? { repoId: raw.repoId.trim() }
: {})
}
}
export function normalizeFolderWorkspaces(
value: unknown,
projectGroups: readonly ProjectGroup[]
): FolderWorkspace[] {
if (!Array.isArray(value)) {
return []
}
const folderGroups = new Map<string, ProjectGroup>()
for (const group of projectGroups) {
if (group.parentPath) {
folderGroups.set(group.id, group)
}
}
const workspaces: FolderWorkspace[] = []
const seen = new Set<string>()
for (const candidate of value) {
if (!candidate || typeof candidate !== 'object') {
continue
}
const raw = candidate as Partial<FolderWorkspace>
if (
typeof raw.id !== 'string' ||
raw.id.trim().length === 0 ||
seen.has(raw.id) ||
typeof raw.projectGroupId !== 'string' ||
!folderGroups.has(raw.projectGroupId)
) {
continue
}
const group = folderGroups.get(raw.projectGroupId)
const folderPath =
typeof raw.folderPath === 'string' && raw.folderPath.trim().length > 0
? raw.folderPath
: group?.parentPath
if (!folderPath) {
continue
}
const now = Date.now()
seen.add(raw.id)
workspaces.push({
id: raw.id,
projectGroupId: raw.projectGroupId,
name: normalizeFolderWorkspaceName(raw.name),
folderPath,
connectionId:
typeof raw.connectionId === 'string'
? raw.connectionId
: raw.connectionId === null
? null
: (group?.connectionId ?? null),
linkedTask: normalizeFolderWorkspaceLinkedTask(raw.linkedTask),
comment: typeof raw.comment === 'string' ? raw.comment : '',
isArchived: raw.isArchived === true,
isUnread: raw.isUnread === true,
isPinned: raw.isPinned === true,
sortOrder:
typeof raw.sortOrder === 'number' && Number.isFinite(raw.sortOrder) ? raw.sortOrder : now,
...(typeof raw.manualOrder === 'number' && Number.isFinite(raw.manualOrder)
? { manualOrder: raw.manualOrder }
: {}),
...(typeof raw.workspaceStatus === 'string' && raw.workspaceStatus.trim().length > 0
? { workspaceStatus: raw.workspaceStatus }
: {}),
...(isTuiAgent(raw.createdWithAgent) ? { createdWithAgent: raw.createdWithAgent } : {}),
...(raw.pendingFirstAgentMessageRename === true
? { pendingFirstAgentMessageRename: true }
: {}),
...(typeof raw.firstAgentMessageRenameError === 'string'
? { firstAgentMessageRenameError: raw.firstAgentMessageRenameError }
: raw.firstAgentMessageRenameError === null
? { firstAgentMessageRenameError: null }
: {}),
lastActivityAt:
typeof raw.lastActivityAt === 'number' && Number.isFinite(raw.lastActivityAt)
? raw.lastActivityAt
: 0,
createdAt:
typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : now,
updatedAt:
typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : now
})
}
return workspaces.sort(
(left, right) => right.sortOrder - left.sortOrder || left.name.localeCompare(right.name)
)
}

View File

@ -18,6 +18,7 @@ export function normalizeProjectGroupName(name: string, fallback = 'Untitled gro
export function createProjectGroup(input: {
name: string
parentPath?: string | null
connectionId?: string | null
parentGroupId?: string | null
createdFrom: ProjectGroupCreatedFrom
tabOrder: number
@ -28,6 +29,7 @@ export function createProjectGroup(input: {
id: createProjectGroupId(),
name: normalizeProjectGroupName(input.name),
parentPath: input.parentPath ?? null,
connectionId: input.connectionId ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: input.tabOrder,
@ -58,6 +60,12 @@ export function normalizeProjectGroups(value: unknown): ProjectGroup[] {
id: raw.id,
name: normalizeProjectGroupName(typeof raw.name === 'string' ? raw.name : ''),
parentPath: typeof raw.parentPath === 'string' ? raw.parentPath : null,
connectionId:
typeof raw.connectionId === 'string'
? raw.connectionId
: raw.connectionId === null
? null
: null,
parentGroupId: typeof raw.parentGroupId === 'string' ? raw.parentGroupId : null,
createdFrom:
raw.createdFrom === 'manual' ||

View File

@ -136,6 +136,8 @@ export type ProjectGroup = {
id: string
name: string
parentPath: string | null
/** SSH target ID for folder-backed groups imported from a remote root. */
connectionId?: string | null
parentGroupId: string | null
createdFrom: ProjectGroupCreatedFrom
tabOrder: number
@ -145,6 +147,47 @@ export type ProjectGroup = {
updatedAt: number
}
export type WorkspaceScope =
| { type: 'worktree'; worktreeId: string }
| { type: 'folder'; folderWorkspaceId: string }
export type WorkspaceKey = `worktree:${string}` | `folder:${string}`
export type FolderWorkspace = {
id: string
projectGroupId: string
name: string
folderPath: string
/** SSH target ID for folder workspaces whose folder path lives remotely. */
connectionId?: string | null
linkedTask: FolderWorkspaceLinkedTask | null
comment: string
isArchived: boolean
isUnread: boolean
isPinned: boolean
sortOrder: number
/** User-authored sidebar ordering. Higher values render earlier in Manual sort. */
manualOrder?: number
workspaceStatus?: WorkspaceStatus
createdWithAgent?: TuiAgent
pendingFirstAgentMessageRename?: boolean
firstAgentMessageRenameError?: string | null
lastActivityAt: number
createdAt: number
updatedAt: number
}
export type FolderWorkspaceLinkedTask = {
provider: 'github' | 'gitlab' | 'linear' | 'jira'
type: 'issue' | 'pr' | 'mr'
number: number
title: string
url: string
linearIdentifier?: string
jiraIdentifier?: string
repoId?: string
}
export type NestedRepoScanOptions = {
maxDepth?: number
maxRepos?: number
@ -699,8 +742,11 @@ export type PersistedOpenFile = {
export type WorkspaceSessionState = {
activeRepoId: string | null
/** Scope-aware active owner for folder workspaces. Legacy worktree UI still reads activeWorktreeId. */
activeWorkspaceKey?: WorkspaceKey | null
activeWorktreeId: string | null
activeTabId: string | null
/** Keys may be legacy raw worktree IDs or canonical WorkspaceKey values. */
tabsByWorktree: Record<string, TerminalTab[]>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
/** Worktree IDs that had at least one tab with a live PTY at shutdown.
@ -2890,6 +2936,7 @@ export type PersistedState = {
schemaVersion: number
repos: Repo[]
projectGroups: ProjectGroup[]
folderWorkspaces: FolderWorkspace[]
/** Sparse-checkout presets keyed by repoId. Empty record on first launch;
* presets are managed from the new-workspace composer and repo settings. */
sparsePresetsByRepo: Record<string, SparsePreset[]>

View File

@ -0,0 +1,31 @@
import type { WorkspaceKey, WorkspaceScope } from './types'
export function worktreeWorkspaceKey(worktreeId: string): WorkspaceKey {
return `worktree:${worktreeId}`
}
export function folderWorkspaceKey(folderWorkspaceId: string): WorkspaceKey {
return `folder:${folderWorkspaceId}`
}
export function workspaceKeyFromScope(scope: WorkspaceScope): WorkspaceKey {
return scope.type === 'worktree'
? worktreeWorkspaceKey(scope.worktreeId)
: folderWorkspaceKey(scope.folderWorkspaceId)
}
export function parseWorkspaceKey(value: string): WorkspaceScope | null {
if (value.startsWith('worktree:')) {
const worktreeId = value.slice('worktree:'.length)
return worktreeId.length > 0 ? { type: 'worktree', worktreeId } : null
}
if (value.startsWith('folder:')) {
const folderWorkspaceId = value.slice('folder:'.length)
return folderWorkspaceId.length > 0 ? { type: 'folder', folderWorkspaceId } : null
}
return null
}
export function isWorkspaceKey(value: string): value is WorkspaceKey {
return parseWorkspaceKey(value) !== null
}

View File

@ -14,12 +14,14 @@ import type {
TabGroupLayoutNode,
TerminalPaneLayoutNode,
TuiAgent,
WorkspaceKey,
WorkspaceSessionState
} from './types'
import { isValidTerminalTabId } from './terminal-tab-id'
import { isTuiAgent } from './tui-agent-config'
import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history'
import { normalizeAgentProviderSession, RESUMABLE_TUI_AGENTS } from './agent-session-resume'
import { isWorkspaceKey } from './workspace-scope'
// ─── Terminal pane layout (recursive) ───────────────────────────────
@ -28,6 +30,9 @@ const terminalTabIdSchema = z
.string()
.min(1)
.refine(isValidTerminalTabId, 'terminal tab id must not contain ":"')
const workspaceKeySchema = z.custom<WorkspaceKey>(
(value) => typeof value === 'string' && isWorkspaceKey(value)
)
// Why: z.lazy + type annotation keeps the recursive inference working without
// forcing zod to resolve the whole tree at definition time.
@ -262,6 +267,7 @@ const browserHistoryEntriesSchema = z
export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.object({
activeRepoId: z.string().nullable(),
activeWorkspaceKey: workspaceKeySchema.nullable().optional(),
activeWorktreeId: z.string().nullable(),
activeTabId: z.string().nullable(),
tabsByWorktree: z.record(z.string(), z.array(terminalTabSchema)),