Simplify workspace creation with inline worktree API

Replace the multi-step new-agent route flow with a single
create_paseo_worktree endpoint that registers the workspace immediately
and creates the git worktree in the background. The sidebar now calls
this endpoint directly and shows a creating spinner inline.

Also auto-resolves the base branch from origin/HEAD when not explicitly
provided, removes the unused Tauri WebSocket transport layer, and
normalizes loopback endpoints to localhost.

Closes #125
This commit is contained in:
Mohamed Boudra
2026-03-19 23:33:35 +07:00
parent 3d4ac57bd0
commit 6ab97c579e
21 changed files with 514 additions and 531 deletions
+2 -1
View File
@@ -618,7 +618,8 @@ const styles = StyleSheet.create((theme) => ({
alignItems: 'center',
gap: theme.spacing[2],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
paddingRight: theme.spacing[1],
paddingLeft: theme.spacing[3],
flexShrink: 0,
},
newAgentButtonHovered: {},
@@ -33,7 +33,7 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runt
import { getIsTauri } from '@/constants/layout'
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
import {
buildHostNewAgentRoute,
buildHostWorkspaceRouteWithOpenIntent,
parseHostWorkspaceRouteFromPathname,
} from '@/utils/host-routes'
import {
@@ -71,6 +71,8 @@ import {
} from '@/components/ui/tooltip'
import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model'
import { useNavigationActiveWorkspaceSelection } from '@/stores/navigation-active-workspace-store'
import { normalizeWorkspaceDescriptor, useSessionStore } from '@/stores/session-store'
import { createNameId } from 'mnemonic-id'
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -126,6 +128,7 @@ interface WorkspaceRowInnerProps {
drag: () => void
isDragging: boolean
isArchiving: boolean
isCreating?: boolean
dragHandleProps?: DraggableListDragHandleProps
menuController: ReturnType<typeof useContextMenu> | null
archiveLabel?: string
@@ -626,6 +629,7 @@ function WorkspaceRowInner({
drag,
isDragging,
isArchiving,
isCreating = false,
dragHandleProps,
menuController,
archiveLabel,
@@ -678,15 +682,23 @@ function WorkspaceRowInner({
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
loading={isArchiving || isCreating}
/>
<Text
style={[styles.workspaceBranchText, isHovered && styles.workspaceBranchTextHovered]}
style={[
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
]}
numberOfLines={1}
>
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isMobile) ? (
<DropdownMenu>
<DropdownMenuTrigger
@@ -763,6 +775,7 @@ function WorkspaceRowWithMenu({
isDragging,
dragHandleProps,
canCopyBranchName,
isCreating = false,
}: {
workspace: SidebarWorkspaceEntry
selected: boolean
@@ -773,6 +786,7 @@ function WorkspaceRowWithMenu({
isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps
canCopyBranchName: boolean
isCreating?: boolean
}) {
const toast = useToast()
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
@@ -873,6 +887,7 @@ function WorkspaceRowWithMenu({
drag={drag}
isDragging={isDragging}
isArchiving={isArchiving}
isCreating={isCreating}
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={isWorktree ? 'Archive worktree' : 'Hide from sidebar'}
@@ -1082,6 +1097,7 @@ function WorkspaceRow({
isDragging,
dragHandleProps,
canCopyBranchName,
isCreating = false,
}: {
workspace: SidebarWorkspaceEntry
selected: boolean
@@ -1092,6 +1108,7 @@ function WorkspaceRow({
isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps
canCopyBranchName: boolean
isCreating?: boolean
}) {
return (
<WorkspaceRowWithMenu
@@ -1104,6 +1121,7 @@ function WorkspaceRow({
isDragging={isDragging}
dragHandleProps={dragHandleProps}
canCopyBranchName={canCopyBranchName}
isCreating={isCreating}
/>
)
}
@@ -1126,6 +1144,7 @@ function ProjectBlock({
isDragging,
dragHandleProps,
useNestable,
creatingWorkspaceIds,
}: {
project: SidebarProjectEntry
collapsed: boolean
@@ -1144,6 +1163,7 @@ function ProjectBlock({
isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps
useNestable: boolean
creatingWorkspaceIds: ReadonlySet<string>
}) {
const rowModel = useMemo(
() =>
@@ -1170,6 +1190,7 @@ function ProjectBlock({
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={project.projectKind === 'git'}
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
onPress={() => {
if (!serverId) {
return
@@ -1186,6 +1207,7 @@ function ProjectBlock({
[
activeWorkspaceSelection,
project.projectKind,
creatingWorkspaceIds,
onWorkspacePress,
serverId,
shortcutIndexByWorkspaceKey,
@@ -1300,6 +1322,13 @@ export function SidebarWorkspaceList({
const isNative = Platform.OS !== 'web'
const pathname = usePathname()
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection()
const toast = useToast()
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces)
const [creatingProjectKey, setCreatingProjectKey] = useState<string | null>(null)
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set())
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map()
)
const isTauri = getIsTauri()
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
@@ -1379,6 +1408,51 @@ export function SidebarWorkspaceList({
return byProject
}, [projectIconQueries, projectIconRequests, projects, serverId])
useEffect(() => {
return () => {
for (const timeout of creatingWorkspaceTimeoutsRef.current.values()) {
clearTimeout(timeout)
}
creatingWorkspaceTimeoutsRef.current.clear()
}
}, [])
useEffect(() => {
if (creatingWorkspaceIds.size === 0) {
return
}
const visibleWorkspaceIds = new Set<string>()
for (const project of projects) {
for (const workspace of project.workspaces) {
visibleWorkspaceIds.add(workspace.workspaceId)
}
}
const removedWorkspaceIds = Array.from(creatingWorkspaceIds).filter(
(workspaceId) => !visibleWorkspaceIds.has(workspaceId)
)
if (removedWorkspaceIds.length === 0) {
return
}
for (const workspaceId of removedWorkspaceIds) {
const timeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId)
if (timeout) {
clearTimeout(timeout)
creatingWorkspaceTimeoutsRef.current.delete(workspaceId)
}
}
setCreatingWorkspaceIds((current) => {
const next = new Set(current)
for (const workspaceId of removedWorkspaceIds) {
next.delete(workspaceId)
}
return next
})
}, [creatingWorkspaceIds, projects])
const handleProjectDragEnd = useCallback(
(reorderedProjects: SidebarProjectEntry[]) => {
if (!serverId) {
@@ -1437,20 +1511,68 @@ export function SidebarWorkspaceList({
)
const handleCreateWorktree = useCallback(
(project: SidebarProjectEntry) => {
async (project: SidebarProjectEntry) => {
if (!serverId || project.projectKind !== 'git') {
return
}
if (creatingProjectKey) {
return
}
onSetProjectCollapsed(project.projectKey, false)
onWorkspacePress?.()
router.push(
buildHostNewAgentRoute(serverId, {
workingDir: project.iconWorkingDir,
worktreeMode: 'create',
}) as any
)
setCreatingProjectKey(project.projectKey)
try {
const client = getHostRuntimeStore().getClient(serverId)
if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) {
throw new Error('Host is not connected')
}
const payload = await client.createPaseoWorktree({
cwd: project.iconWorkingDir,
worktreeSlug: createNameId(),
})
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? 'Failed to create worktree')
}
const workspace = payload.workspace
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(workspace)])
setCreatingWorkspaceIds((current) => {
const next = new Set(current)
next.add(workspace.id)
return next
})
const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspace.id)
if (existingTimeout) {
clearTimeout(existingTimeout)
}
creatingWorkspaceTimeoutsRef.current.set(
workspace.id,
setTimeout(() => {
creatingWorkspaceTimeoutsRef.current.delete(workspace.id)
setCreatingWorkspaceIds((current) => {
if (!current.has(workspace.id)) {
return current
}
const next = new Set(current)
next.delete(workspace.id)
return next
})
}, 3000)
)
onWorkspacePress?.()
router.replace(
buildHostWorkspaceRouteWithOpenIntent(serverId, workspace.id, {
kind: 'draft',
draftId: 'new',
}) as any
)
} catch (error) {
toast.error(
error instanceof Error ? error.message : String(error)
)
} finally {
setCreatingProjectKey((current) => (current === project.projectKey ? null : current))
}
},
[onSetProjectCollapsed, serverId, onWorkspacePress]
[creatingProjectKey, mergeWorkspaces, onSetProjectCollapsed, onWorkspacePress, serverId, toast]
)
const renderProject = useCallback(
@@ -1474,6 +1596,7 @@ export function SidebarWorkspaceList({
isDragging={isActive}
dragHandleProps={dragHandleProps}
useNestable={isNative}
creatingWorkspaceIds={creatingWorkspaceIds}
/>
)
},
@@ -1490,6 +1613,7 @@ export function SidebarWorkspaceList({
shortcutIndexByWorkspaceKey,
showShortcutBadges,
isNative,
creatingWorkspaceIds,
]
)
@@ -1771,9 +1895,17 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
workspaceBranchTextCreating: {
opacity: 0.92,
},
workspaceBranchTextHovered: {
opacity: 1,
},
workspaceCreatingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
flexShrink: 0,
},
diffStatRow: {
flexDirection: 'row',
alignItems: 'center',