Add mobile Tasks parity (#2452)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-21 20:26:07 -07:00
committed by GitHub
co-authored by Orca
parent 3efc669bd4
commit b1973657ea
123 changed files with 26492 additions and 1461 deletions
+37
View File
@@ -0,0 +1,37 @@
export type ComposerBranchSelection = {
baseBranch: string
branchNameOverride: string | undefined
branchAutoName: string
name: string | undefined
lastAutoName: string | undefined
}
export function resolveComposerBranchSelection(args: {
refName: string
localBranchName: string
currentName: string
lastAutoName: string
}): ComposerBranchSelection {
const trimmedCurrentName = args.currentName.trim()
const shouldAutoName =
!trimmedCurrentName ||
args.currentName === args.lastAutoName ||
args.localBranchName.startsWith(trimmedCurrentName) ||
args.refName.startsWith(trimmedCurrentName)
if (!shouldAutoName) {
return {
baseBranch: args.refName,
branchNameOverride: undefined,
branchAutoName: '',
name: undefined,
lastAutoName: undefined
}
}
return {
baseBranch: args.refName,
branchNameOverride: args.localBranchName,
branchAutoName: args.localBranchName,
name: args.localBranchName,
lastAutoName: args.localBranchName
}
}
+237
View File
@@ -0,0 +1,237 @@
// Why: grouping and sorting of Project rows is deterministic shared logic
// driven by `selectedView` — it must not depend on fetch ordering. Keeping it
// in a pure shared module lets desktop and mobile render Project views the same.
import type {
GitHubProjectField,
GitHubProjectRow,
GitHubProjectSort,
GitHubProjectTable
} from './github-project-types'
export type ProjectGroup = {
/** Stable key used for React reconciliation. */
key: string
/** Human-readable label used in the group header. */
label: string
/** Iteration metadata for headers that render a date range + Current pill. */
iteration: {
startDate: string
duration: number
completed: boolean
} | null
rows: GitHubProjectRow[]
}
const EMPTY_GROUP_KEY = '__empty__'
// Why: use a finite sentinel instead of Infinity so subtractions in the sort
// comparator stay finite. `Infinity - Infinity` is NaN, which makes
// Array.sort's behavior implementation-defined and skips later tie-breaks.
const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER
function getFieldValueForGrouping(
row: GitHubProjectRow,
field: GitHubProjectField
): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } {
const value = row.fieldValuesByFieldId[field.id]
if (!value) {
return {
key: EMPTY_GROUP_KEY,
label: labelForEmpty(field),
orderHint: UNKNOWN_INDEX_SENTINEL,
iteration: null
}
}
if (field.kind === 'iteration' && value.kind === 'iteration') {
const idx = field.iterations.findIndex((it) => it.id === value.iterationId)
const meta = field.iterations.find((it) => it.id === value.iterationId)
return {
key: value.iterationId,
label: value.title || meta?.title || 'Iteration',
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
iteration: meta
? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed }
: null
}
}
if (field.kind === 'single-select' && value.kind === 'single-select') {
const idx = field.options.findIndex((o) => o.id === value.optionId)
return {
key: value.optionId,
label: value.name,
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
iteration: null
}
}
const label = deriveStringValue(value)
return { key: `raw:${label}`, label, orderHint: 0, iteration: null }
}
function labelForEmpty(field: GitHubProjectField): string {
return `No ${field.name}`
}
function deriveStringValue(value: GitHubProjectRow['fieldValuesByFieldId'][string]): string {
switch (value.kind) {
case 'text':
return value.text
case 'number':
return String(value.number)
case 'date':
return value.date
case 'single-select':
return value.name
case 'iteration':
return value.title
case 'labels':
return value.labels.map((l) => l.name).join(', ')
case 'users':
return value.users.map((u) => u.login).join(', ')
default:
return ''
}
}
export function groupRows(
table: GitHubProjectTable,
rowsInOrder: GitHubProjectRow[]
): ProjectGroup[] {
const groupField = table.selectedView.groupByFields[0]
if (!groupField) {
return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }]
}
const buckets = new Map<
string,
{
label: string
orderHint: number
iteration: ProjectGroup['iteration']
rows: GitHubProjectRow[]
}
>()
for (const row of rowsInOrder) {
const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField)
let bucket = buckets.get(key)
if (!bucket) {
bucket = { label, orderHint, iteration, rows: [] }
buckets.set(key, bucket)
}
bucket.rows.push(row)
}
const entries = Array.from(buckets.entries())
// Ordering rules per design doc §Grouping.
entries.sort((a, b) => {
if (a[0] === EMPTY_GROUP_KEY) {
return 1
}
if (b[0] === EMPTY_GROUP_KEY) {
return -1
}
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
return a[1].orderHint - b[1].orderHint
}
return a[1].label.localeCompare(b[1].label)
})
return entries.map(([key, v]) => ({
key,
label: v.label,
iteration: v.iteration,
rows: v.rows
}))
}
function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number {
const field = sort.field
const aValue = a.fieldValuesByFieldId[field.id]
const bValue = b.fieldValuesByFieldId[field.id]
if (!aValue && !bValue) {
return 0
}
if (!aValue) {
return 1
}
if (!bValue) {
return -1
}
let cmp = 0
if (
field.kind === 'single-select' &&
aValue.kind === 'single-select' &&
bValue.kind === 'single-select'
) {
const aIdx = field.options.findIndex((o) => o.id === aValue.optionId)
const bIdx = field.options.findIndex((o) => o.id === bValue.optionId)
cmp =
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
} else if (
field.kind === 'iteration' &&
aValue.kind === 'iteration' &&
bValue.kind === 'iteration'
) {
const aIdx = field.iterations.findIndex((it) => it.id === aValue.iterationId)
const bIdx = field.iterations.findIndex((it) => it.id === bValue.iterationId)
cmp =
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
} else if (aValue.kind === 'number' && bValue.kind === 'number') {
cmp = aValue.number - bValue.number
} else if (aValue.kind === 'date' && bValue.kind === 'date') {
cmp = aValue.date.localeCompare(bValue.date)
} else if (aValue.kind === 'text' && bValue.kind === 'text') {
cmp = aValue.text.localeCompare(bValue.text)
} else if (aValue.kind === 'users' && bValue.kind === 'users') {
const aLogin = aValue.users[0]?.login ?? ''
const bLogin = bValue.users[0]?.login ?? ''
if (!aLogin && !bLogin) {
cmp = 0
} else if (!aLogin) {
cmp = 1
} else if (!bLogin) {
cmp = -1
} else {
cmp = aLogin.localeCompare(bLogin)
}
} else if (aValue.kind === 'labels' && bValue.kind === 'labels') {
const aName = aValue.labels[0]?.name ?? ''
const bName = bValue.labels[0]?.name ?? ''
if (!aName && !bName) {
cmp = 0
} else if (!aName) {
cmp = 1
} else if (!bName) {
cmp = -1
} else {
cmp = aName.localeCompare(bName)
}
} else {
// Why: unknown sort-field kind — ignore this sort field and fall through
// to tie-breaks (and eventually row.position).
return 0
}
return sort.direction === 'DESC' ? -cmp : cmp
}
export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] {
const sorts = table.selectedView.sortByFields
const out = [...rows]
out.sort((a, b) => {
for (const sort of sorts) {
const cmp = compareSort(a, b, sort)
if (cmp !== 0) {
return cmp
}
}
return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL)
})
return out
}
export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean {
const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime()
if (Number.isNaN(start)) {
return false
}
const end = start + iteration.duration * 86_400_000
const now = Date.now()
return now >= start && now < end
}
+5 -3
View File
@@ -146,6 +146,9 @@ export type GitLabWorkItem = {
/** Stamped by the renderer fetcher / optimistic stubs so cross-project
* views can attribute rows. Mirrors GitHubWorkItem.repoId. */
repoId: string
/** Exact GitLab project that produced this row. Mutations/details must use it
* instead of re-resolving the repo preference later. */
projectRef?: GitLabProjectRef
}
export type GitLabMRFile = {
@@ -245,9 +248,8 @@ export type GitLabWorkItemDetails = {
export type GitLabIssueUpdate = {
state?: 'opened' | 'closed'
title?: string
/** Mirrors GitHubIssueUpdate.body — kept ignored on the repoPath-based
* flow for the same backward-compat reason. Slug-addressed write paths
* cover the body-edit case end-to-end. */
/** Why: `glab issue update` handles title/labels/assignees, while body edits
* use the REST issue endpoint so mobile can save the markdown description. */
body?: string
addLabels?: string[]
removeLabels?: string[]
+2 -1
View File
@@ -27,7 +27,8 @@ export const RUNTIME_CAPABILITIES = [
'browser.screencast.v1',
'terminal.binary-stream.v1',
'terminal.multiplex.v1',
'workspace-ports.v1'
'workspace-ports.v1',
'mobile.tasks.v1'
] as const
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
+3 -9
View File
@@ -16,6 +16,7 @@
import { z } from 'zod'
import { FEATURE_WALL_MAX_DWELL_MS } from './feature-wall-telemetry'
import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers'
import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source'
import { AGENT_HOOK_TARGETS } from './agent-hook-types'
import { ONBOARDING_FINAL_STEP } from './constants'
@@ -129,15 +130,8 @@ export const workspaceCreateErrorClassSchema = z.enum([
])
export type WorkspaceCreateErrorClass = z.infer<typeof workspaceCreateErrorClassSchema>
export const workspaceSourceSchema = z.enum([
'command_palette',
'sidebar',
'shortcut',
'drag_drop',
'onboarding',
'unknown'
])
export type WorkspaceSource = z.infer<typeof workspaceSourceSchema>
export const workspaceSourceSchema = z.enum(WORKSPACE_SOURCE_VALUES)
export type { WorkspaceSource }
export const launchSourceSchema = z.enum([
'command_palette',
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { pickTuiAgent } from './tui-agent-selection'
describe('pickTuiAgent', () => {
it('uses an installed preferred agent', () => {
expect(pickTuiAgent('codex', ['claude', 'codex'])).toBe('codex')
})
it('falls back in desktop catalog order when the preference is absent or stale', () => {
expect(pickTuiAgent(null, ['cursor', 'codex'])).toBe('codex')
expect(pickTuiAgent('gemini', ['cursor', 'codex'])).toBe('codex')
})
it('respects the explicit blank terminal preference', () => {
expect(pickTuiAgent('blank', ['cursor', 'claude'])).toBeNull()
})
})
+52
View File
@@ -0,0 +1,52 @@
import type { TuiAgent } from './types'
// Keep this order in sync with the desktop agent catalog. It defines the
// automatic fallback priority when the user has not chosen a default agent.
export const TUI_AGENT_AUTO_PICK_ORDER = [
'claude',
'codex',
'grok',
'copilot',
'opencode',
'pi',
'gemini',
'antigravity',
'aider',
'goose',
'amp',
'kilo',
'kiro',
'crush',
'aug',
'autohand',
'cline',
'codebuff',
'continue',
'cursor',
'droid',
'kimi',
'mistral-vibe',
'qwen-code',
'rovo',
'hermes',
'openclaw'
] as const satisfies readonly TuiAgent[]
export function pickTuiAgent(
preferred: TuiAgent | 'blank' | null | undefined,
detected: Iterable<TuiAgent>
): TuiAgent | null {
if (preferred === 'blank') {
return null
}
const detectedSet = detected instanceof Set ? detected : new Set(detected)
if (preferred && detectedSet.has(preferred)) {
return preferred
}
for (const agent of TUI_AGENT_AUTO_PICK_ORDER) {
if (detectedSet.has(agent)) {
return agent
}
}
return null
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from './tui-agent-startup'
describe('tui agent startup plans', () => {
it('uses POSIX quoting when the target shell is Linux', () => {
const plan = buildAgentStartupPlan({
agent: 'claude',
prompt: "fix Bob's branch",
cmdOverrides: {},
platform: 'linux'
})
expect(plan?.launchCommand).toBe("claude 'fix Bob'\\''s branch'")
})
it('uses PowerShell quoting by default when the target shell is Windows', () => {
const plan = buildAgentStartupPlan({
agent: 'claude',
prompt: 'fix Bob\'s "quoted" branch',
cmdOverrides: {},
platform: 'win32'
})
expect(plan?.launchCommand).toBe("claude 'fix Bob''s \"quoted\" branch'")
})
it('uses cmd escaping when requested explicitly', () => {
const plan = buildAgentStartupPlan({
agent: 'claude',
prompt: 'fix "quoted" & %PATH%',
cmdOverrides: {},
platform: 'win32',
shell: 'cmd'
})
expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"')
})
it('clears draft environment variables with the target shell syntax', () => {
expect(
buildAgentDraftLaunchPlan({
agent: 'pi',
draft: 'https://github.com/acme/repo/issues/42',
cmdOverrides: {},
platform: 'win32'
})?.launchCommand
).toBe('pi; Remove-Item Env:ORCA_PI_PREFILL -ErrorAction SilentlyContinue')
expect(
buildAgentDraftLaunchPlan({
agent: 'pi',
draft: 'https://github.com/acme/repo/issues/42',
cmdOverrides: {},
platform: 'win32',
shell: 'cmd'
})?.launchCommand
).toBe('pi & set "ORCA_PI_PREFILL="')
})
})
+161
View File
@@ -0,0 +1,161 @@
import { isShellProcess } from './agent-detection'
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import type { TuiAgent } from './types'
export type AgentStartupPlan = {
agent: TuiAgent
launchCommand: string
expectedProcess: string
followupPrompt: string | null
draftPrompt?: string | null
env?: Record<string, string>
}
export type AgentStartupShell = 'posix' | 'powershell' | 'cmd'
function resolveStartupShell(
platform: NodeJS.Platform,
shell?: AgentStartupShell
): AgentStartupShell {
return shell ?? (platform === 'win32' ? 'powershell' : 'posix')
}
function quoteStartupArg(value: string, shell: AgentStartupShell): string {
if (shell === 'powershell') {
return `'${value.replace(/'/g, "''")}'`
}
if (shell === 'cmd') {
return `"${value.replace(/([\^&|<>()%!"])/g, '^$1')}"`
}
return `'${value.replace(/'/g, `'\\''`)}'`
}
function clearEnvCommand(name: string, shell: AgentStartupShell): string {
if (shell === 'powershell') {
return `Remove-Item Env:${name} -ErrorAction SilentlyContinue`
}
if (shell === 'cmd') {
return `set "${name}="`
}
return `unset ${name}`
}
function commandSeparator(shell: AgentStartupShell): string {
return shell === 'cmd' ? ' & ' : '; '
}
export function buildAgentStartupPlan(args: {
agent: TuiAgent
prompt: string
cmdOverrides: Partial<Record<TuiAgent, string>>
platform: NodeJS.Platform
shell?: AgentStartupShell
allowEmptyPromptLaunch?: boolean
}): AgentStartupPlan | null {
const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args
const shell = resolveStartupShell(platform, args.shell)
const trimmedPrompt = prompt.trim()
const config = TUI_AGENT_CONFIG[agent]
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
if (!trimmedPrompt) {
if (!allowEmptyPromptLaunch) {
return null
}
return {
agent,
launchCommand: baseCommand,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
const quotedPrompt = quoteStartupArg(trimmedPrompt, shell)
if (config.promptInjectionMode === 'argv') {
return {
agent,
launchCommand: `${baseCommand} ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
if (config.promptInjectionMode === 'flag-prompt') {
return {
agent,
launchCommand: `${baseCommand} --prompt ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
if (config.promptInjectionMode === 'flag-prompt-interactive') {
return {
agent,
launchCommand: `${baseCommand} --prompt-interactive ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
if (config.promptInjectionMode === 'flag-interactive') {
return {
agent,
launchCommand: `${baseCommand} -i ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
}
}
return {
agent,
launchCommand: baseCommand,
expectedProcess: config.expectedProcess,
followupPrompt: trimmedPrompt
}
}
export type AgentDraftLaunchPlan = {
agent: TuiAgent
launchCommand: string
expectedProcess: string
env?: Record<string, string>
}
export function buildAgentDraftLaunchPlan(args: {
agent: TuiAgent
draft: string
cmdOverrides: Partial<Record<TuiAgent, string>>
platform: NodeJS.Platform
shell?: AgentStartupShell
}): AgentDraftLaunchPlan | null {
const { agent, draft, cmdOverrides, platform } = args
const shell = resolveStartupShell(platform, args.shell)
const config = TUI_AGENT_CONFIG[agent]
const trimmed = draft.trim()
if (!trimmed) {
return null
}
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
if (config.draftPromptFlag) {
const quoted = quoteStartupArg(trimmed, shell)
return {
agent,
launchCommand: `${baseCommand} ${config.draftPromptFlag} ${quoted}`,
expectedProcess: config.expectedProcess
}
}
if (config.draftPromptEnvVar) {
const clearVar = clearEnvCommand(config.draftPromptEnvVar, shell)
return {
agent,
launchCommand: `${baseCommand}${commandSeparator(shell)}${clearVar}`,
expectedProcess: config.expectedProcess,
env: { [config.draftPromptEnvVar]: trimmed }
}
}
return null
}
export { isShellProcess }
+4 -3
View File
@@ -1,7 +1,7 @@
/* eslint-disable max-lines */
import type { SshRemotePtyLease, SshTarget } from './ssh-types'
import type { Automation, AutomationRun } from './automations-types'
import type { WorkspaceSource } from './telemetry-events'
import type { WorkspaceSource } from './workspace-source'
import type { GitHubProjectSettings } from './github-project-types'
import type {
AgentStatusState,
@@ -18,7 +18,7 @@ import type { KeybindingOverrides } from './keybindings'
// Re-exported for backward compat with renderer call sites that import
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './telemetry-events'
export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './workspace-source'
export type { TaskProvider } from './task-providers'
export type {
GitBranchChangeStatus,
@@ -1255,8 +1255,8 @@ export type CreateWorktreeArgs = {
linkedIssue?: number
linkedPR?: number
linkedLinearIssue?: string
linkedGitLabMR?: number
linkedGitLabIssue?: number
linkedGitLabMR?: number
pushTarget?: GitPushTarget
workspaceStatus?: WorkspaceStatus
manualOrder?: number
@@ -1997,6 +1997,7 @@ export type TaskResumeState = {
githubMode?: 'items' | 'project'
githubItemsPreset?: TaskViewPresetId | null
githubItemsQuery?: string
githubProjectHiddenFieldIdsByView?: Record<string, string[]>
linearPreset?: 'assigned' | 'created' | 'all' | 'completed'
linearQuery?: string
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
getLinkedWorkItemSuggestedName,
resolveWorkspaceCreateName,
slugifyForWorkspaceName
} from './workspace-name'
describe('slugifyForWorkspaceName', () => {
it('keeps workspace seed slugs short, ascii-safe, and git-ref-safe', () => {
expect(slugifyForWorkspaceName('../../Fix mobile Tasks 🚀')).toBe('fix-mobile-tasks')
expect(slugifyForWorkspaceName('feature/add issue drawer')).toBe('feature-add-issue-drawer')
expect(slugifyForWorkspaceName('a'.repeat(80))).toBe('a'.repeat(48))
})
})
describe('getLinkedWorkItemSuggestedName', () => {
it('removes duplicated issue and PR numbers from linked titles', () => {
expect(getLinkedWorkItemSuggestedName({ title: 'Issue #123: Fix mobile Tasks' })).toBe(
'fix-mobile-tasks'
)
expect(getLinkedWorkItemSuggestedName({ title: 'Add mobile drawer (#812)' })).toBe(
'add-mobile-drawer'
)
})
})
describe('resolveWorkspaceCreateName', () => {
it('preserves explicit user-entered names for the host worktree sanitizer', () => {
expect(
resolveWorkspaceCreateName({
draft: 'feature/something',
fallback: 'issue-123'
})
).toBe('feature/something')
expect(
resolveWorkspaceCreateName({
draft: '日本語 テスト',
fallback: 'issue-123'
})
).toBe('日本語 テスト')
})
it('uses the stable fallback when the draft is blank', () => {
expect(resolveWorkspaceCreateName({ draft: ' ', fallback: 'pr-9' })).toBe('pr-9')
expect(resolveWorkspaceCreateName({ draft: undefined, fallback: 'issue-4' })).toBe('issue-4')
})
})
+36
View File
@@ -0,0 +1,36 @@
export function slugifyForWorkspaceName(input: string): string {
return (
input
.trim()
.toLowerCase()
.replace(/[\\/]+/g, '-')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
// Why: git check-ref-format rejects any ref containing `..`, so previews
// must match the main-process sanitizer before workspace creation.
.replace(/\.{2,}/g, '.')
.replace(/^[.-]+|[.-]+$/g, '')
.slice(0, 48)
.replace(/[-._]+$/g, '')
)
}
export function getLinkedWorkItemSuggestedName(item: { title: string }): string {
const withoutLeadingNumber = item.title
.trim()
.replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '')
.replace(/^#\d+\s*[:-]\s*/, '')
.replace(/\(#\d+\)/gi, '')
.replace(/\b#\d+\b/g, '')
.trim()
const seed = withoutLeadingNumber || item.title.trim()
return slugifyForWorkspaceName(seed)
}
export function resolveWorkspaceCreateName(args: {
draft: string | undefined
fallback: string
}): string {
return args.draft?.trim() || args.fallback
}
+10
View File
@@ -0,0 +1,10 @@
export const WORKSPACE_SOURCE_VALUES = [
'command_palette',
'sidebar',
'shortcut',
'drag_drop',
'onboarding',
'unknown'
] as const
export type WorkspaceSource = (typeof WORKSPACE_SOURCE_VALUES)[number]