Fix mobile folder workspace visibility (#5679)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-17 23:18:34 -07:00 committed by GitHub
parent c9bd61376f
commit 06d674b35d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 188 additions and 31 deletions

View File

@ -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}
/>
)}
/>

View File

@ -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() {
>
<Folder size={18} color={colors.textSecondary} strokeWidth={2.1} />
</Pressable>
<Pressable
style={({ pressed }) => [
styles.filesButton,
pressed && styles.filesButtonPressed,
activePanel === 'sourceControl' && styles.filesButtonActive
]}
onPress={() => handlePanelTap('sourceControl')}
hitSlop={8}
accessibilityLabel="Open source control"
>
<GitBranch size={18} color={colors.textSecondary} strokeWidth={2.1} />
</Pressable>
{!isFolderWorkspaceRoute && (
<Pressable
style={({ pressed }) => [
styles.filesButton,
pressed && styles.filesButtonPressed,
activePanel === 'sourceControl' && styles.filesButtonActive
]}
onPress={() => handlePanelTap('sourceControl')}
hitSlop={8}
accessibilityLabel="Open source control"
>
<GitBranch size={18} color={colors.textSecondary} strokeWidth={2.1} />
</Pressable>
)}
{prRepoContextLoaded && prIsGithubRepo ? (
<Pressable
style={({ pressed }) => [

View File

@ -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<T extends WorktreeListRowItem> = {
hideRepo?: boolean
status: WorktreeRollupStatus
onPress: (item: T) => void
onLongPress: (item: T) => void
onLongPress?: (item: T) => void
}
export function WorktreeListRow<T extends WorktreeListRowItem>({
@ -62,6 +64,10 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
onPress,
onLongPress
}: Props<T>) {
const isFolderWorkspace = item.workspaceKind === 'folder-workspace'
const folderMeta = item.comment?.trim() || item.path || 'Folder'
const metaText = isFolderWorkspace ? folderMeta : displayBranch(item.branch)
return (
<Pressable
style={({ pressed }) => [
@ -71,10 +77,14 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
]}
disabled={isReadOnly}
onPress={() => onPress(item)}
onLongPress={() => {
triggerMediumImpact()
onLongPress(item)
}}
onLongPress={
onLongPress
? () => {
triggerMediumImpact()
onLongPress(item)
}
: undefined
}
delayLongPress={400}
>
<View style={styles.indicatorCol}>
@ -109,6 +119,11 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
</Text>
</View>
)}
{isFolderWorkspace && (
<View style={styles.folderBadge}>
<Text style={styles.folderBadgeText}>Folder</Text>
</View>
)}
<WorktreeMetaGlyphs
comment={item.comment}
linkedLinearIssue={item.linkedLinearIssue}
@ -130,7 +145,7 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
</>
)}
<Text style={styles.branchName} numberOfLines={1}>
{displayBranch(item.branch)}
{metaText}
</Text>
</View>
{/* 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',

View File

@ -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

View File

@ -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<string, string>; 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'

View File

@ -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<string>()
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<RuntimeMobileSessionCreateTerminalResult> {
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<string | undefined> {
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<RuntimeMobileSessionCreateTerminalResult> {
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
}
}

View File

@ -433,6 +433,7 @@ export type RuntimeWorktreeAgentRow = {
}
export type RuntimeWorktreePsSummary = {
workspaceKind?: 'git' | 'folder-workspace'
worktreeId: string
repoId: string
repo: string