diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 5f2d5c07e..9a3b9b3ec 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1242,7 +1242,7 @@ export function HostScreen({ repoIcon={repoIconsByName.get(item.repo) ?? null} hideRepo={groupMode === 'repo'} onPress={openWorktreeSession} - onLongPress={setActionTarget} + onLongPress={item.workspaceKind === 'folder-workspace' ? undefined : setActionTarget} /> )} /> diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index bb9640693..6a66420f8 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -792,6 +792,7 @@ export default function SessionScreen() { created?: string warning?: string }>() + const isFolderWorkspaceRoute = worktreeId.startsWith('folder:') const router = useRouter() const insets = useSafeAreaInsets() // Why: shared client per host owned by RpcClientProvider. See @@ -4209,18 +4210,20 @@ export default function SessionScreen() { > - [ - styles.filesButton, - pressed && styles.filesButtonPressed, - activePanel === 'sourceControl' && styles.filesButtonActive - ]} - onPress={() => handlePanelTap('sourceControl')} - hitSlop={8} - accessibilityLabel="Open source control" - > - - + {!isFolderWorkspaceRoute && ( + [ + styles.filesButton, + pressed && styles.filesButtonPressed, + activePanel === 'sourceControl' && styles.filesButtonActive + ]} + onPress={() => handlePanelTap('sourceControl')} + hitSlop={8} + accessibilityLabel="Open source control" + > + + + )} {prRepoContextLoaded && prIsGithubRepo ? ( [ diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index 0695a49e9..aa4d50fdc 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -18,10 +18,12 @@ function displayBranch(branch: string): string { // Minimal row shape needed for rendering — a structural subset of the screen's // Worktree so this component stays decoupled from the screen's local type. export type WorktreeListRowItem = { + workspaceKind?: 'git' | 'folder-workspace' worktreeId: string repo: string branch: string displayName: string + path?: string liveTerminalCount: number preview: string unread: boolean @@ -48,7 +50,7 @@ type Props = { hideRepo?: boolean status: WorktreeRollupStatus onPress: (item: T) => void - onLongPress: (item: T) => void + onLongPress?: (item: T) => void } export function WorktreeListRow({ @@ -62,6 +64,10 @@ export function WorktreeListRow({ onPress, onLongPress }: Props) { + const isFolderWorkspace = item.workspaceKind === 'folder-workspace' + const folderMeta = item.comment?.trim() || item.path || 'Folder' + const metaText = isFolderWorkspace ? folderMeta : displayBranch(item.branch) + return ( [ @@ -71,10 +77,14 @@ export function WorktreeListRow({ ]} disabled={isReadOnly} onPress={() => onPress(item)} - onLongPress={() => { - triggerMediumImpact() - onLongPress(item) - }} + onLongPress={ + onLongPress + ? () => { + triggerMediumImpact() + onLongPress(item) + } + : undefined + } delayLongPress={400} > @@ -109,6 +119,11 @@ export function WorktreeListRow({ )} + {isFolderWorkspace && ( + + Folder + + )} ({ )} - {displayBranch(item.branch)} + {metaText} {/* Only agents get a secondary activity line, matching desktop. A plain @@ -212,6 +227,16 @@ const styles = StyleSheet.create({ fontSize: 10, color: colors.textSecondary }, + folderBadge: { + backgroundColor: colors.bgRaised, + paddingHorizontal: 5, + paddingVertical: 1, + borderRadius: 4 + }, + folderBadgeText: { + fontSize: 10, + color: colors.textSecondary + }, worktreeMetaRow: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index bc1ea0525..6ca8aeee7 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -7,6 +7,7 @@ import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings' export type Worktree = { + workspaceKind?: 'git' | 'folder-workspace' worktreeId: string repoId: string repo: string diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 32e2e84dd..20f030526 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -9940,6 +9940,45 @@ describe('OrcaRuntimeService', () => { ]) }) + it('creates mobile session terminals for folder workspaces in a headless runtime server', async () => { + const folderPath = await mkdtemp(join(tmpdir(), 'orca-mobile-folder-workspace-')) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-mobile-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.syncWindowGraph(0, { tabs: [], leaves: [] }) + + const result = await runtime.createMobileSessionTerminal(`id:${TEST_FOLDER_WORKSPACE_KEY}`) + + const spawnCall = spawn.mock.calls[0]?.[0] as + | { cwd?: string; env?: Record; worktreeId?: string } + | undefined + const spawnedEnv = spawnCall?.env ?? {} + expect(spawnCall).toMatchObject({ + cwd: folderPath, + worktreeId: TEST_FOLDER_WORKSPACE_KEY, + persistHostSessionBinding: true + }) + 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(result.tab).toMatchObject({ + type: 'terminal', + status: 'ready', + terminal: expect.stringMatching(/^term_/), + isActive: true + }) + }) + it('spawns fresh headless SSH mobile session terminals instead of reattaching synthetic local ids', async () => { const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } const remoteStore = { @@ -12208,6 +12247,7 @@ describe('OrcaRuntimeService', () => { expect(summaries).toEqual({ worktrees: [ { + workspaceKind: 'git', worktreeId: 'repo-1::/tmp/worktree-a', repoId: 'repo-1', repo: 'repo', @@ -12280,6 +12320,38 @@ describe('OrcaRuntimeService', () => { expect(summary?.linkedPR).toEqual({ number: 7, state: 'open' }) }) + it('includes folder workspaces in compact worktree summaries for mobile', async () => { + const folderWorkspace = makeFolderWorkspace({ + name: 'GG', + comment: 'dujiao-next-eval' + }) + const projectGroup = makeFolderProjectGroup({ name: 'Store' }) + const runtime = new OrcaRuntimeService( + createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never + ) + + const { worktrees } = await runtime.getWorktreePs() + const folderSummary = worktrees.find( + (worktree) => worktree.worktreeId === TEST_FOLDER_WORKSPACE_KEY + ) + + expect(folderSummary).toMatchObject({ + workspaceKind: 'folder-workspace', + worktreeId: TEST_FOLDER_WORKSPACE_KEY, + repoId: `folder-workspace:${TEST_FOLDER_PROJECT_GROUP_ID}`, + repo: 'Store', + path: TEST_FOLDER_WORKSPACE_PATH, + branch: '', + displayName: 'GG', + comment: 'dujiao-next-eval', + isPinned: false, + unread: false, + liveTerminalCount: 0, + hasAttachedPty: false, + status: 'inactive' + }) + }) + it('attaches inline agent rows from the latest OSC 9999 status', async () => { const runtime = new OrcaRuntimeService(store) const leafId = '22222222-2222-4222-8222-222222222222' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 338af45ed..e6a63c232 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -173,12 +173,14 @@ import { isPathInsideOrEqual, normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { isWslUncPath } from '../../shared/wsl-paths' import { folderWorkspaceKey, isWorkspaceKey, parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' +import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree' import type { FolderWorkspacePathStatus, FolderWorkspacePathStatusRequest @@ -1534,6 +1536,7 @@ type TerminalWorkspaceLaunchScope = { id: string path: string connectionId: string | null + repo: Repo | null folderWorkspace: FolderWorkspace | null } @@ -7044,6 +7047,7 @@ export class OrcaRuntimeService { linkedPR = { number: meta.linkedPR, state: 'unknown' } } summaries.set(worktree.id, { + workspaceKind: 'git', worktreeId: worktree.id, repoId: worktree.repoId, repo: repo?.displayName ?? worktree.repoId, @@ -7070,6 +7074,43 @@ export class OrcaRuntimeService { }) } + const projectGroupById = new Map( + (this.store?.getProjectGroups?.() ?? []).map((group) => [group.id, group]) + ) + for (const folderWorkspace of this.store?.getFolderWorkspaces?.() ?? []) { + const projectGroup = projectGroupById.get(folderWorkspace.projectGroupId) + if (!projectGroup?.parentPath) { + continue + } + const worktree = folderWorkspaceToWorktree(folderWorkspace) + summaries.set(worktree.id, { + workspaceKind: 'folder-workspace', + worktreeId: worktree.id, + repoId: worktree.repoId, + repo: projectGroup.name, + path: worktree.path, + branch: worktree.branch, + parentWorktreeId: null, + childWorktreeIds: [], + displayName: worktree.displayName, + linkedIssue: worktree.linkedIssue ?? null, + linkedPR: null, + linkedLinearIssue: worktree.linkedLinearIssue ?? null, + linkedGitLabMR: worktree.linkedGitLabMR ?? null, + linkedGitLabIssue: worktree.linkedGitLabIssue ?? null, + comment: worktree.comment, + isPinned: worktree.isPinned, + isActive: false, + unread: worktree.isUnread, + liveTerminalCount: 0, + hasAttachedPty: false, + lastOutputAt: null, + preview: '', + status: 'inactive', + agents: [] + }) + } + const countedPtyIds = new Set() for (const leaf of this.leaves.values()) { const summary = this.getSummaryForRuntimeWorktreeId( @@ -8302,6 +8343,16 @@ export class OrcaRuntimeService { return getAgentLaunchPlatformForRepo(repo, projectRuntime) } + private getAgentLaunchPlatformForWorkspace(scope: TerminalWorkspaceLaunchScope): NodeJS.Platform { + if (scope.repo) { + return this.getAgentLaunchPlatformForRepo(scope.repo) + } + if (scope.connectionId) { + return isWindowsAbsolutePathLike(scope.path) ? 'win32' : 'linux' + } + return isWslUncPath(scope.path) ? 'linux' : process.platform + } + async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> { const repo = await this.resolveRepoSelector(repoSelector) const options = this.getHostedReviewExecutionOptions(repo) @@ -12939,8 +12990,8 @@ export class OrcaRuntimeService { } = {} ): Promise { this.assertGraphReady() - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - const worktreeId = worktree.id + const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) + const worktreeId = workspace.id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) let afterDesktopTabId: string | undefined if (opts.afterTabId) { @@ -12951,7 +13002,7 @@ export class OrcaRuntimeService { } afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id } - const command = await this.resolveMobileSessionTerminalCommand(worktree, opts) + const command = await this.resolveMobileSessionTerminalCommand(workspace, opts) const win = this.getAvailableAuthoritativeWindow() if (!win) { @@ -13004,7 +13055,7 @@ export class OrcaRuntimeService { } private async resolveMobileSessionTerminalCommand( - worktree: Worktree, + workspace: TerminalWorkspaceLaunchScope, opts: { command?: string; agent?: TuiAgent } ): Promise { if (opts.command || !opts.agent) { @@ -13017,10 +13068,9 @@ export class OrcaRuntimeService { if (!isTuiAgentEnabled(opts.agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } - const repo = this.store.getRepo(worktree.repoId) // Why: mobile may be running on iOS while the actual terminal shell is // Windows/macOS/Linux or an SSH Linux host; quote for the host shell. - const platform = repo ? this.getAgentLaunchPlatformForRepo(repo) : process.platform + const platform = this.getAgentLaunchPlatformForWorkspace(workspace) const startupPlan = buildAgentStartupPlan({ agent: opts.agent, prompt: '', @@ -13033,10 +13083,14 @@ export class OrcaRuntimeService { if (!startupPlan) { throw new Error(`Could not build launch command for ${opts.agent}.`) } - if (repo?.connectionId) { - await this.markRemoteWorkspaceTrustedForAgent(opts.agent, repo.connectionId, worktree.path) + if (workspace.connectionId) { + await this.markRemoteWorkspaceTrustedForAgent( + opts.agent, + workspace.connectionId, + workspace.path + ) } else { - this.markLocalWorkspaceTrustedForAgent(opts.agent, worktree.path) + this.markLocalWorkspaceTrustedForAgent(opts.agent, workspace.path) } return startupPlan.launchCommand } @@ -13049,12 +13103,11 @@ export class OrcaRuntimeService { identity?: { tabId: string; leafId: string; sessionId?: string }, launchAgent?: TuiAgent ): Promise { - const worktree = await this.resolveWorktreeSelector(`id:${worktreeId}`) - const repo = this.store?.getRepo(worktree.repoId) + const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) // Why: SshPtyProvider treats sessionId as a relay reattach request. Only // synthesize local serve ids; SSH fresh terminals must call pty.spawn. const stableSessionId = - identity?.sessionId ?? (repo?.connectionId ? undefined : `serve-${randomUUID()}`) + identity?.sessionId ?? (workspace.connectionId ? undefined : `serve-${randomUUID()}`) const terminal = await this.createTerminal(`id:${worktreeId}`, { focus: false, command, @@ -13833,6 +13886,7 @@ export class OrcaRuntimeService { id: folderWorkspaceKey(workspace.id), path: workspace.folderPath, connectionId: this.resolveFolderWorkspaceConnectionId(workspace), + repo: null, folderWorkspace: workspace } } @@ -13854,6 +13908,7 @@ export class OrcaRuntimeService { id: worktree.id, path: worktree.path, connectionId: repo?.connectionId ?? null, + repo, folderWorkspace: null } } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 181885f73..a6e511a11 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -433,6 +433,7 @@ export type RuntimeWorktreeAgentRow = { } export type RuntimeWorktreePsSummary = { + workspaceKind?: 'git' | 'folder-workspace' worktreeId: string repoId: string repo: string