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
-1
View File
@@ -620,7 +620,6 @@ export default function RootLayout() {
/> />
<Stack.Screen name="h/[serverId]/index" /> <Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/agents" /> <Stack.Screen name="h/[serverId]/agents" />
<Stack.Screen name="h/[serverId]/new-agent" />
<Stack.Screen name="h/[serverId]/open-project" /> <Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" /> <Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" /> <Stack.Screen name="pair-scan" />
@@ -1,9 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
export default function HostNewAgentRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <DraftAgentScreen forcedServerId={serverId} />;
}
+2 -1
View File
@@ -618,7 +618,8 @@ const styles = StyleSheet.create((theme) => ({
alignItems: 'center', alignItems: 'center',
gap: theme.spacing[2], gap: theme.spacing[2],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1], paddingRight: theme.spacing[1],
paddingLeft: theme.spacing[3],
flexShrink: 0, flexShrink: 0,
}, },
newAgentButtonHovered: {}, newAgentButtonHovered: {},
@@ -33,7 +33,7 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runt
import { getIsTauri } from '@/constants/layout' import { getIsTauri } from '@/constants/layout'
import { projectIconQueryKey } from '@/hooks/use-project-icon-query' import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
import { import {
buildHostNewAgentRoute, buildHostWorkspaceRouteWithOpenIntent,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
} from '@/utils/host-routes' } from '@/utils/host-routes'
import { import {
@@ -71,6 +71,8 @@ import {
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model' import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model'
import { useNavigationActiveWorkspaceSelection } from '@/stores/navigation-active-workspace-store' 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 { function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) { if (!icon) {
@@ -126,6 +128,7 @@ interface WorkspaceRowInnerProps {
drag: () => void drag: () => void
isDragging: boolean isDragging: boolean
isArchiving: boolean isArchiving: boolean
isCreating?: boolean
dragHandleProps?: DraggableListDragHandleProps dragHandleProps?: DraggableListDragHandleProps
menuController: ReturnType<typeof useContextMenu> | null menuController: ReturnType<typeof useContextMenu> | null
archiveLabel?: string archiveLabel?: string
@@ -626,6 +629,7 @@ function WorkspaceRowInner({
drag, drag,
isDragging, isDragging,
isArchiving, isArchiving,
isCreating = false,
dragHandleProps, dragHandleProps,
menuController, menuController,
archiveLabel, archiveLabel,
@@ -678,15 +682,23 @@ function WorkspaceRowInner({
ref={dragHandleProps?.setActivatorNodeRef as any} ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft} style={styles.workspaceRowLeft}
> >
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} /> <WorkspaceStatusIndicator
bucket={workspace.statusBucket}
loading={isArchiving || isCreating}
/>
<Text <Text
style={[styles.workspaceBranchText, isHovered && styles.workspaceBranchTextHovered]} style={[
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
]}
numberOfLines={1} numberOfLines={1}
> >
{workspace.name} {workspace.name}
</Text> </Text>
</View> </View>
<View style={styles.workspaceRowRight}> <View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isMobile) ? ( {onArchive && (isHovered || isMobile) ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
@@ -763,6 +775,7 @@ function WorkspaceRowWithMenu({
isDragging, isDragging,
dragHandleProps, dragHandleProps,
canCopyBranchName, canCopyBranchName,
isCreating = false,
}: { }: {
workspace: SidebarWorkspaceEntry workspace: SidebarWorkspaceEntry
selected: boolean selected: boolean
@@ -773,6 +786,7 @@ function WorkspaceRowWithMenu({
isDragging: boolean isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps dragHandleProps?: DraggableListDragHandleProps
canCopyBranchName: boolean canCopyBranchName: boolean
isCreating?: boolean
}) { }) {
const toast = useToast() const toast = useToast()
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree) const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
@@ -873,6 +887,7 @@ function WorkspaceRowWithMenu({
drag={drag} drag={drag}
isDragging={isDragging} isDragging={isDragging}
isArchiving={isArchiving} isArchiving={isArchiving}
isCreating={isCreating}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
menuController={null} menuController={null}
archiveLabel={isWorktree ? 'Archive worktree' : 'Hide from sidebar'} archiveLabel={isWorktree ? 'Archive worktree' : 'Hide from sidebar'}
@@ -1082,6 +1097,7 @@ function WorkspaceRow({
isDragging, isDragging,
dragHandleProps, dragHandleProps,
canCopyBranchName, canCopyBranchName,
isCreating = false,
}: { }: {
workspace: SidebarWorkspaceEntry workspace: SidebarWorkspaceEntry
selected: boolean selected: boolean
@@ -1092,6 +1108,7 @@ function WorkspaceRow({
isDragging: boolean isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps dragHandleProps?: DraggableListDragHandleProps
canCopyBranchName: boolean canCopyBranchName: boolean
isCreating?: boolean
}) { }) {
return ( return (
<WorkspaceRowWithMenu <WorkspaceRowWithMenu
@@ -1104,6 +1121,7 @@ function WorkspaceRow({
isDragging={isDragging} isDragging={isDragging}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
canCopyBranchName={canCopyBranchName} canCopyBranchName={canCopyBranchName}
isCreating={isCreating}
/> />
) )
} }
@@ -1126,6 +1144,7 @@ function ProjectBlock({
isDragging, isDragging,
dragHandleProps, dragHandleProps,
useNestable, useNestable,
creatingWorkspaceIds,
}: { }: {
project: SidebarProjectEntry project: SidebarProjectEntry
collapsed: boolean collapsed: boolean
@@ -1144,6 +1163,7 @@ function ProjectBlock({
isDragging: boolean isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps dragHandleProps?: DraggableListDragHandleProps
useNestable: boolean useNestable: boolean
creatingWorkspaceIds: ReadonlySet<string>
}) { }) {
const rowModel = useMemo( const rowModel = useMemo(
() => () =>
@@ -1170,6 +1190,7 @@ function ProjectBlock({
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null} shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges} showShortcutBadge={showShortcutBadges}
canCopyBranchName={project.projectKind === 'git'} canCopyBranchName={project.projectKind === 'git'}
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
onPress={() => { onPress={() => {
if (!serverId) { if (!serverId) {
return return
@@ -1186,6 +1207,7 @@ function ProjectBlock({
[ [
activeWorkspaceSelection, activeWorkspaceSelection,
project.projectKind, project.projectKind,
creatingWorkspaceIds,
onWorkspacePress, onWorkspacePress,
serverId, serverId,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
@@ -1300,6 +1322,13 @@ export function SidebarWorkspaceList({
const isNative = Platform.OS !== 'web' const isNative = Platform.OS !== 'web'
const pathname = usePathname() const pathname = usePathname()
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection() 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 isTauri = getIsTauri()
const altDown = useKeyboardShortcutsStore((state) => state.altDown) const altDown = useKeyboardShortcutsStore((state) => state.altDown)
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown) const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
@@ -1379,6 +1408,51 @@ export function SidebarWorkspaceList({
return byProject return byProject
}, [projectIconQueries, projectIconRequests, projects, serverId]) }, [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( const handleProjectDragEnd = useCallback(
(reorderedProjects: SidebarProjectEntry[]) => { (reorderedProjects: SidebarProjectEntry[]) => {
if (!serverId) { if (!serverId) {
@@ -1437,20 +1511,68 @@ export function SidebarWorkspaceList({
) )
const handleCreateWorktree = useCallback( const handleCreateWorktree = useCallback(
(project: SidebarProjectEntry) => { async (project: SidebarProjectEntry) => {
if (!serverId || project.projectKind !== 'git') { if (!serverId || project.projectKind !== 'git') {
return return
} }
if (creatingProjectKey) {
return
}
onSetProjectCollapsed(project.projectKey, false) onSetProjectCollapsed(project.projectKey, false)
onWorkspacePress?.() setCreatingProjectKey(project.projectKey)
router.push( try {
buildHostNewAgentRoute(serverId, { const client = getHostRuntimeStore().getClient(serverId)
workingDir: project.iconWorkingDir, if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) {
worktreeMode: 'create', throw new Error('Host is not connected')
}) as any }
) 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( const renderProject = useCallback(
@@ -1474,6 +1596,7 @@ export function SidebarWorkspaceList({
isDragging={isActive} isDragging={isActive}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
useNestable={isNative} useNestable={isNative}
creatingWorkspaceIds={creatingWorkspaceIds}
/> />
) )
}, },
@@ -1490,6 +1613,7 @@ export function SidebarWorkspaceList({
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
showShortcutBadges, showShortcutBadges,
isNative, isNative,
creatingWorkspaceIds,
] ]
) )
@@ -1771,9 +1895,17 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
workspaceBranchTextCreating: {
opacity: 0.92,
},
workspaceBranchTextHovered: { workspaceBranchTextHovered: {
opacity: 1, opacity: 1,
}, },
workspaceCreatingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
flexShrink: 0,
},
diffStatRow: { diffStatRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
+9 -12
View File
@@ -12,7 +12,6 @@ import {
takeCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore"; } from "@/utils/command-center-focus-restore";
import { import {
buildHostOpenProjectRoute,
buildHostWorkspaceAgentRoute, buildHostWorkspaceAgentRoute,
buildHostSettingsRoute, buildHostSettingsRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
@@ -55,7 +54,7 @@ type CommandCenterActionDefinition = {
icon?: "plus" | "settings"; icon?: "plus" | "settings";
shortcutKeys?: ShortcutKey[]; shortcutKeys?: ShortcutKey[];
keywords: string[]; keywords: string[];
buildRoute: (params: { newAgentRoute: Href; settingsRoute: Href }) => Href; routeKind: "settings" | "none";
}; };
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
@@ -65,14 +64,14 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
icon: "plus", icon: "plus",
shortcutKeys: ["mod", "shift", "O"], shortcutKeys: ["mod", "shift", "O"],
keywords: ["open", "project", "folder", "workspace", "repo"], keywords: ["open", "project", "folder", "workspace", "repo"],
buildRoute: ({ newAgentRoute }) => newAgentRoute, routeKind: "none",
}, },
{ {
id: "settings", id: "settings",
title: "Settings", title: "Settings",
icon: "settings", icon: "settings",
keywords: ["settings", "preferences", "config", "configuration"], keywords: ["settings", "preferences", "config", "configuration"],
buildRoute: ({ settingsRoute }) => settingsRoute, routeKind: "settings",
}, },
]; ];
@@ -93,7 +92,7 @@ export type CommandCenterActionItem = {
id: string; id: string;
title: string; title: string;
icon?: "plus" | "settings"; icon?: "plus" | "settings";
route: Href; route?: Href;
shortcutKeys?: ShortcutKey[]; shortcutKeys?: ShortcutKey[];
}; };
@@ -149,11 +148,6 @@ export function useCommandCenter() {
return filtered; return filtered;
}, [agents, open, query]); }, [agents, open, query]);
const newAgentRoute = useMemo<Href>(() => {
const serverIdFromPath = activeServerId;
return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/";
}, [activeServerId]);
const settingsRoute = useMemo<Href>(() => { const settingsRoute = useMemo<Href>(() => {
const serverIdFromPath = activeServerId; const serverIdFromPath = activeServerId;
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/"; return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/";
@@ -170,10 +164,10 @@ export function useCommandCenter() {
id: action.id, id: action.id,
title: action.title, title: action.title,
icon: action.icon, icon: action.icon,
route: action.buildRoute({ newAgentRoute, settingsRoute }), route: action.routeKind === "settings" ? settingsRoute : undefined,
shortcutKeys: action.shortcutKeys, shortcutKeys: action.shortcutKeys,
})); }));
}, [newAgentRoute, open, query, settingsRoute]); }, [open, query, settingsRoute]);
const items = useMemo(() => { const items = useMemo(() => {
if (!open) { if (!open) {
@@ -227,6 +221,9 @@ export function useCommandCenter() {
void openProjectPicker(); void openProjectPicker();
return; return;
} }
if (!action.route) {
return;
}
didNavigateRef.current = true; didNavigateRef.current = true;
router.push(action.route); router.push(action.route);
}, [openProjectPicker, setOpen]); }, [openProjectPicker, setOpen]);
-8
View File
@@ -34,7 +34,6 @@ import {
buildLocalDaemonTransportUrl, buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory, createTauriLocalDaemonTransportFactory,
} from "@/utils/managed-tauri-daemon-transport"; } from "@/utils/managed-tauri-daemon-transport";
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore, type Agent } from "@/stores/session-store"; import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -433,7 +432,6 @@ function probeIntervalForConnection(
function createDefaultDeps(): HostRuntimeControllerDeps { function createDefaultDeps(): HostRuntimeControllerDeps {
return { return {
createClient: ({ host, connection, clientId, runtimeGeneration }) => { createClient: ({ host, connection, clientId, runtimeGeneration }) => {
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const localTransportFactory = createTauriLocalDaemonTransportFactory(); const localTransportFactory = createTauriLocalDaemonTransportFactory();
const base = { const base = {
suppressSendErrors: true, suppressSendErrors: true,
@@ -454,17 +452,11 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
if (connection.type === "directTcp") { if (connection.type === "directTcp") {
return new DaemonClient({ return new DaemonClient({
...base, ...base,
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
url: buildDaemonWebSocketUrl(connection.endpoint), url: buildDaemonWebSocketUrl(connection.endpoint),
}); });
} }
return new DaemonClient({ return new DaemonClient({
...base, ...base,
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
url: buildRelayWebSocketUrl({ url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint, endpoint: connection.relayEndpoint,
serverId: host.serverId, serverId: host.serverId,
+3 -3
View File
@@ -1,4 +1,4 @@
import { normalizeHostPort } from '@server/shared/daemon-endpoints' import { normalizeHostPort, normalizeLoopbackToLocalhost } from '@server/shared/daemon-endpoints'
export type DirectTcpHostConnection = { export type DirectTcpHostConnection = {
id: string id: string
@@ -188,7 +188,7 @@ export function connectionFromListen(listen: string): HostConnection | null {
} }
try { try {
const endpoint = normalizeHostPort(normalizedListen) const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(normalizedListen))
return { return {
id: `direct:${endpoint}`, id: `direct:${endpoint}`,
type: 'directTcp', type: 'directTcp',
@@ -207,7 +207,7 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
const type = typeof record.type === 'string' ? record.type : null const type = typeof record.type === 'string' ? record.type : null
if (type === 'directTcp') { if (type === 'directTcp') {
try { try {
const endpoint = normalizeHostPort(String(record.endpoint ?? '')) const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(String(record.endpoint ?? '')))
return { id: `direct:${endpoint}`, type: 'directTcp', endpoint } return { id: `direct:${endpoint}`, type: 'directTcp', endpoint }
} catch { } catch {
return null return null
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildHostAgentDetailRoute, buildHostAgentDetailRoute,
buildHostNewAgentRoute,
buildHostRootRoute, buildHostRootRoute,
buildHostWorkspaceAgentRoute, buildHostWorkspaceAgentRoute,
buildHostWorkspaceFileRoute, buildHostWorkspaceFileRoute,
@@ -66,16 +65,6 @@ describe("workspace route parsing", () => {
expect(buildHostRootRoute("local")).toBe("/h/local"); expect(buildHostRootRoute("local")).toBe("/h/local");
}); });
it("builds host new-agent routes", () => {
expect(buildHostNewAgentRoute("local")).toBe("/h/local/new-agent");
expect(
buildHostNewAgentRoute("local", {
workingDir: "/tmp/repo",
worktreeMode: "create",
})
).toBe("/h/local/new-agent?workingDir=%2Ftmp%2Frepo&worktreeMode=create");
});
it("builds workspace routes with open intent query", () => { it("builds workspace routes with open intent query", () => {
expect(buildHostWorkspaceAgentRoute("local", "/tmp/repo", "agent-1")).toBe( expect(buildHostWorkspaceAgentRoute("local", "/tmp/repo", "agent-1")).toBe(
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1" "/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
-37
View File
@@ -399,40 +399,6 @@ export function buildHostAgentsRoute(serverId: string): string {
return `${base}/agents`; return `${base}/agents`;
} }
export function buildHostNewAgentRoute(
serverId: string,
options?: {
workingDir?: string;
provider?: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
worktreeMode?: "create" | "attach";
}
): string {
const base = buildHostRootRoute(serverId);
if (base === "/") {
return "/";
}
const searchParams = new URLSearchParams();
const entries: Array<[string, string | undefined]> = [
["workingDir", options?.workingDir],
["provider", options?.provider],
["modeId", options?.modeId],
["model", options?.model],
["thinkingOptionId", options?.thinkingOptionId],
["worktreeMode", options?.worktreeMode],
];
for (const [key, value] of entries) {
const normalized = trimNonEmpty(value);
if (normalized) {
searchParams.set(key, normalized);
}
}
const search = searchParams.toString();
return search ? `${base}/new-agent?${search}` : `${base}/new-agent`;
}
export function buildHostOpenProjectRoute(serverId: string): string { export function buildHostOpenProjectRoute(serverId: string): string {
const base = buildHostRootRoute(serverId); const base = buildHostRootRoute(serverId);
if (base === "/") { if (base === "/") {
@@ -466,9 +432,6 @@ export function mapPathnameToServer(
if (suffix.startsWith("agents")) { if (suffix.startsWith("agents")) {
return `${base}/agents`; return `${base}/agents`;
} }
if (suffix.startsWith("new-agent")) {
return `${base}/new-agent`;
}
if (suffix.startsWith("open-project")) { if (suffix.startsWith("open-project")) {
return `${base}/open-project`; return `${base}/open-project`;
} }
@@ -1,143 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const tauriMock = vi.hoisted(() => {
let listenerCleanup: (() => void) | null = null;
let disconnectCount = 0;
const connect = vi.fn(async () => {
let listenerActive = false;
return {
addListener: vi.fn(() => {
listenerActive = true;
listenerCleanup = vi.fn(() => {
if (!listenerActive) {
throw new TypeError(
"undefined is not an object (evaluating 'listeners[eventId].handlerId')"
);
}
listenerActive = false;
});
return listenerCleanup;
}),
send: vi.fn(async () => undefined),
disconnect: vi.fn(async () => {
disconnectCount += 1;
listenerCleanup?.();
}),
};
});
return {
connect,
getListenerCleanup: () => listenerCleanup,
getDisconnectCount: () => disconnectCount,
resetState: () => {
listenerCleanup = null;
disconnectCount = 0;
},
};
});
vi.mock("./tauri", () => ({
getTauri: () => ({
websocket: {
connect: tauriMock.connect,
},
}),
}));
describe("tauri-daemon-transport", () => {
beforeEach(() => {
tauriMock.connect.mockClear();
tauriMock.resetState();
});
it("does not unregister the websocket listener twice during close", async () => {
const mod = await import("./tauri-daemon-transport");
const transportFactory = mod.createTauriWebSocketTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ url: "ws://localhost:6767/ws" });
await Promise.resolve();
expect(() => transport.close()).not.toThrow();
await Promise.resolve();
expect(tauriMock.getListenerCleanup()).toHaveBeenCalledTimes(1);
});
it("disconnects the websocket only once after the connection is already open", async () => {
const mod = await import("./tauri-daemon-transport");
const transportFactory = mod.createTauriWebSocketTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ url: "ws://localhost:6767/ws" });
await Promise.resolve();
await Promise.resolve();
expect(() => transport.close()).not.toThrow();
await Promise.resolve();
expect(tauriMock.getDisconnectCount()).toBe(1);
expect(tauriMock.getListenerCleanup()).toHaveBeenCalledTimes(1);
});
it("disconnects only once when close happens during async connect cleanup", async () => {
let resolveConnect!: (socket: any) => void;
let resolveSend!: () => void;
tauriMock.connect.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveConnect = resolve;
})
);
const mod = await import("./tauri-daemon-transport");
const transportFactory = mod.createTauriWebSocketTransportFactory();
expect(transportFactory).not.toBeNull();
const disconnect = vi.fn(async () => undefined);
const addListener = vi.fn(() => vi.fn());
const send = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveSend = resolve;
})
);
const transport = transportFactory!({ url: "ws://localhost:6767/ws" });
transport.send("queued-before-connect");
resolveConnect({ addListener, send, disconnect });
await Promise.resolve();
transport.close();
resolveSend();
await Promise.resolve();
await Promise.resolve();
expect(disconnect).toHaveBeenCalledTimes(1);
});
it("swallows the tauri duplicate-listener cleanup error when disconnect throws synchronously", async () => {
tauriMock.connect.mockImplementationOnce(async () => ({
addListener: vi.fn(() => vi.fn()),
send: vi.fn(async () => undefined),
disconnect: vi.fn(() => {
throw new TypeError(
"undefined is not an object (evaluating 'listeners[eventId].handlerId')"
);
}),
}));
const mod = await import("./tauri-daemon-transport");
const transportFactory = mod.createTauriWebSocketTransportFactory();
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ url: "ws://localhost:6767/ws" });
await Promise.resolve();
expect(() => transport.close()).not.toThrow();
});
});
@@ -1,256 +0,0 @@
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
import { getTauri } from "@/utils/tauri";
type TauriWebSocketMessage =
| { type: "Text"; data: string }
| { type: "Binary"; data: number[] }
| { type: "Ping"; data: number[] }
| { type: "Pong"; data: number[] }
| { type: "Close"; data: { code: number; reason: string } | null };
type TauriWebSocketConnection = {
addListener(cb: (msg: TauriWebSocketMessage) => void): () => void;
send(message: string | number[] | TauriWebSocketMessage): Promise<void>;
disconnect(): Promise<void>;
};
type TauriWebSocketModule = {
connect(url: string, config?: unknown): Promise<TauriWebSocketConnection>;
};
function isTauriDuplicateListenerCleanupError(error: unknown): boolean {
return (
error instanceof TypeError &&
error.message.includes("listeners[eventId].handlerId")
);
}
function toTauriOutgoingMessage(
data: string | Uint8Array | ArrayBuffer
): string | number[] {
if (typeof data === "string") {
return data;
}
if (data instanceof ArrayBuffer) {
return Array.from(new Uint8Array(data));
}
return Array.from(data);
}
function getTauriWebSocketModule(): TauriWebSocketModule | null {
const ws = getTauri()?.websocket;
if (ws && typeof ws.connect === "function") {
return ws as TauriWebSocketModule;
}
return null;
}
export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null {
if (getTauri() === null) {
return null;
}
return ({ url }) => {
let ws: TauriWebSocketConnection | null = null;
let wsListenerCleanup: (() => void) | null = null;
let disposed = false;
let opened = false;
let closeEmitted = false;
let disconnectPromise: Promise<void> | null = null;
const openHandlers = new Set<() => void>();
const closeHandlers = new Set<(event?: unknown) => void>();
const errorHandlers = new Set<(event?: unknown) => void>();
const messageHandlers = new Set<(data: unknown) => void>();
const pendingSends: Array<string | number[]> = [];
let closeRequested: { code?: number; reason?: string } | null = null;
const emitOpen = () => {
if (disposed || opened) return;
opened = true;
for (const handler of openHandlers) {
try {
handler();
} catch {
// no-op
}
}
};
const emitClose = (event?: unknown) => {
if (disposed || closeEmitted) return;
closeEmitted = true;
for (const handler of closeHandlers) {
try {
handler(event);
} catch {
// no-op
}
}
};
const emitError = (event?: unknown) => {
if (disposed) return;
for (const handler of errorHandlers) {
try {
handler(event);
} catch {
// no-op
}
}
};
const emitMessage = (data: unknown) => {
if (disposed) return;
for (const handler of messageHandlers) {
try {
handler(data);
} catch {
// no-op
}
}
};
const disconnectSocket = (socket: TauriWebSocketConnection) => {
if (disconnectPromise) {
return disconnectPromise;
}
disconnectPromise = Promise.resolve()
.then(() => socket.disconnect())
.catch((error) => {
if (!isTauriDuplicateListenerCleanupError(error)) {
emitError(error);
}
})
.then(() => undefined);
return disconnectPromise;
};
const connect = async () => {
try {
let module: TauriWebSocketModule | null = getTauriWebSocketModule();
if (!module) {
const start = Date.now();
while (!module && Date.now() - start < 2000) {
await new Promise((r) => setTimeout(r, 50));
module = getTauriWebSocketModule();
}
}
if (!module) {
throw new Error(
"Tauri WebSocket plugin is not available (expected getTauri().websocket). Did you enable tauri-plugin-websocket and websocket:default capability?"
);
}
const socket = await module.connect(url);
if (disposed) {
await disconnectSocket(socket);
return;
}
ws = socket;
wsListenerCleanup = ws.addListener((msg) => {
if (msg.type === "Text") {
emitMessage({ data: msg.data });
return;
}
if (msg.type === "Binary") {
emitMessage({ data: new Uint8Array(msg.data) });
return;
}
if (msg.type === "Close") {
emitClose(msg.data ?? undefined);
return;
}
});
emitOpen();
while (pendingSends.length > 0) {
const next = pendingSends.shift();
if (!next) break;
try {
await ws.send(next);
} catch (error) {
emitError(error);
}
}
if (closeRequested) {
await disconnectSocket(ws);
emitClose(closeRequested);
}
} catch (error) {
emitError(error);
}
};
void connect();
const transport: DaemonTransport = {
send: (data) => {
if (disposed) return;
const outgoing = toTauriOutgoingMessage(data);
if (!ws) {
pendingSends.push(outgoing);
return;
}
void ws.send(outgoing).catch((error) => emitError(error));
},
close: (code?: number, reason?: string) => {
if (disposed) return;
closeRequested = { code, reason };
if (!ws) return;
void disconnectSocket(ws).finally(() => emitClose(closeRequested));
},
onMessage: (handler) => {
messageHandlers.add(handler);
return () => messageHandlers.delete(handler);
},
onOpen: (handler) => {
openHandlers.add(handler);
if (opened) {
try {
handler();
} catch {
// no-op
}
}
return () => openHandlers.delete(handler);
},
onClose: (handler) => {
closeHandlers.add(handler);
if (closeEmitted) {
try {
handler(closeRequested ?? undefined);
} catch {
// no-op
}
}
return () => closeHandlers.delete(handler);
},
onError: (handler) => {
errorHandlers.add(handler);
return () => errorHandlers.delete(handler);
},
};
return {
...transport,
close: (code?: number, reason?: string) => {
wsListenerCleanup = null;
try {
transport.close(code, reason);
} catch (error) {
if (!isTauriDuplicateListenerCleanupError(error)) {
throw error;
}
}
disposed = true;
ws = null;
},
};
};
}
@@ -7,7 +7,6 @@ import {
buildLocalDaemonTransportUrl, buildLocalDaemonTransportUrl,
createTauriLocalDaemonTransportFactory, createTauriLocalDaemonTransportFactory,
} from "./managed-tauri-daemon-transport"; } from "./managed-tauri-daemon-transport";
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
function normalizeNonEmptyString(value: unknown): string | null { function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
@@ -50,7 +49,6 @@ export async function buildClientConfig(
serverId?: string serverId?: string
): Promise<DaemonClientConfig> { ): Promise<DaemonClientConfig> {
const clientId = await getOrCreateClientId(); const clientId = await getOrCreateClientId();
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const localTransportFactory = createTauriLocalDaemonTransportFactory(); const localTransportFactory = createTauriLocalDaemonTransportFactory();
const base = { const base = {
clientId, clientId,
@@ -61,9 +59,7 @@ export async function buildClientConfig(
? localTransportFactory ? localTransportFactory
? { transportFactory: localTransportFactory } ? { transportFactory: localTransportFactory }
: {} : {}
: tauriTransportFactory : {}),
? { transportFactory: tauriTransportFactory }
: {}),
}; };
if (connection.type === "directSocket" || connection.type === "directPipe") { if (connection.type === "directSocket" || connection.type === "directPipe") {
@@ -213,6 +213,10 @@ type BranchSuggestionsPayload = BranchSuggestionsResponse['payload']
type DirectorySuggestionsPayload = DirectorySuggestionsResponse['payload'] type DirectorySuggestionsPayload = DirectorySuggestionsResponse['payload']
type PaseoWorktreeListPayload = PaseoWorktreeListResponse['payload'] type PaseoWorktreeListPayload = PaseoWorktreeListResponse['payload']
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse['payload'] type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse['payload']
type CreatePaseoWorktreePayload = Extract<
SessionOutboundMessage,
{ type: 'create_paseo_worktree_response' }
>['payload']
type FileExplorerPayload = FileExplorerResponse['payload'] type FileExplorerPayload = FileExplorerResponse['payload']
type FileDownloadTokenPayload = FileDownloadTokenResponse['payload'] type FileDownloadTokenPayload = FileDownloadTokenResponse['payload']
type ListProviderModelsPayload = ListProviderModelsResponseMessage['payload'] type ListProviderModelsPayload = ListProviderModelsResponseMessage['payload']
@@ -2148,6 +2152,22 @@ export class DaemonClient {
}) })
} }
async createPaseoWorktree(
input: { cwd: string; worktreeSlug?: string },
requestId?: string
): Promise<CreatePaseoWorktreePayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: 'create_paseo_worktree_request',
cwd: input.cwd,
worktreeSlug: input.worktreeSlug,
},
responseType: 'create_paseo_worktree_response',
timeout: 60000,
})
}
async validateBranch( async validateBranch(
options: { cwd: string; branchName: string }, options: { cwd: string; branchName: string },
requestId?: string requestId?: string
@@ -30,7 +30,6 @@ export function buildOfferEndpoints({
} }
endpoints.push(`localhost:${port}`); endpoints.push(`localhost:${port}`);
endpoints.push(`127.0.0.1:${port}`);
return dedupePreserveOrder(endpoints); return dedupePreserveOrder(endpoints);
} }
+198 -9
View File
@@ -132,6 +132,9 @@ import { DownloadTokenStore } from './file-download/token-store.js'
import { PushTokenStore } from './push/token-store.js' import { PushTokenStore } from './push/token-store.js'
import { import {
type WorktreeConfig, type WorktreeConfig,
computeWorktreePath,
getWorktreeSetupCommands,
resolveWorktreeRuntimeEnv,
slugify, slugify,
validateBranchSlug, validateBranchSlug,
listPaseoWorktrees, listPaseoWorktrees,
@@ -144,6 +147,7 @@ import {
getCheckoutDiff, getCheckoutDiff,
getCheckoutShortstat, getCheckoutShortstat,
getCheckoutStatus, getCheckoutStatus,
getCheckoutStatusLite,
listBranchSuggestions, listBranchSuggestions,
NotGitRepoError, NotGitRepoError,
MergeConflictError, MergeConflictError,
@@ -154,6 +158,7 @@ import {
pushCurrentBranch, pushCurrentBranch,
createPullRequest, createPullRequest,
getPullRequestStatus, getPullRequestStatus,
resolveRepositoryDefaultBranch,
} from '../utils/checkout-git.js' } from '../utils/checkout-git.js'
import { getProjectIcon } from '../utils/project-icon.js' import { getProjectIcon } from '../utils/project-icon.js'
import { expandTilde } from '../utils/path.js' import { expandTilde } from '../utils/path.js'
@@ -1596,6 +1601,10 @@ export class Session {
await this.handlePaseoWorktreeArchiveRequest(msg) await this.handlePaseoWorktreeArchiveRequest(msg)
break break
case 'create_paseo_worktree_request':
await this.handleCreatePaseoWorktreeRequest(msg)
break
case 'open_project_request': case 'open_project_request':
await this.handleOpenProjectRequest(msg) await this.handleOpenProjectRequest(msg)
break break
@@ -2870,19 +2879,21 @@ export class Session {
`Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}` `Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`
) )
const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd))
const createdWorktree = await createAgentWorktree({ const createdWorktree = await createAgentWorktree({
branchName: targetBranch, branchName: targetBranch,
cwd, cwd,
baseBranch: normalized.baseBranch!, baseBranch,
worktreeSlug: normalized.worktreeSlug ?? targetBranch, worktreeSlug: normalized.worktreeSlug ?? targetBranch,
paseoHome: this.paseoHome, paseoHome: this.paseoHome,
}) })
cwd = createdWorktree.worktreePath cwd = createdWorktree.worktreePath
worktreeConfig = createdWorktree worktreeConfig = createdWorktree
} else if (normalized.createNewBranch) { } else if (normalized.createNewBranch) {
const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd))
await this.createBranchFromBase({ await this.createBranchFromBase({
cwd, cwd,
baseBranch: normalized.baseBranch!, baseBranch,
newBranchName: normalized.newBranchName!, newBranchName: normalized.newBranchName!,
}) })
} else if (normalized.baseBranch) { } else if (normalized.baseBranch) {
@@ -3094,13 +3105,6 @@ export class Session {
this.assertSafeGitRef(baseBranch, 'base branch') this.assertSafeGitRef(baseBranch, 'base branch')
} }
if (createWorktree && !baseBranch) {
throw new Error('Base branch is required when creating a worktree')
}
if (createNewBranch && !baseBranch) {
throw new Error('Base branch is required when creating a new branch')
}
if (createNewBranch) { if (createNewBranch) {
if (!normalizedBranchName) { if (!normalizedBranchName) {
throw new Error('New branch name is required') throw new Error('New branch name is required')
@@ -3133,6 +3137,20 @@ export class Session {
} }
} }
private async resolveGitCreateBaseBranch(cwd: string): Promise<string> {
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
if (!checkout.isGit) {
throw new Error('Cannot create a worktree outside a git repository')
}
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot)
if (!baseBranch) {
throw new Error('Unable to resolve repository default branch')
}
return baseBranch
}
private toCheckoutError(error: unknown): CheckoutErrorPayload { private toCheckoutError(error: unknown): CheckoutErrorPayload {
if (error instanceof NotGitRepoError) { if (error instanceof NotGitRepoError) {
return { code: 'NOT_GIT_REPO', message: error.message } return { code: 'NOT_GIT_REPO', message: error.message }
@@ -5677,6 +5695,54 @@ export class Session {
return (await this.reconcileWorkspaceRecord(workspaceId)).workspace return (await this.reconcileWorkspaceRecord(workspaceId)).workspace
} }
private async registerPendingWorktreeWorkspace(options: {
repoRoot: string
worktreePath: string
branchName: string
}): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath)
const basePlacement = await this.buildProjectPlacement(options.repoRoot)
const placement: ProjectPlacementPayload = {
...basePlacement,
checkout: {
cwd: workspaceId,
isGit: true,
currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: options.repoRoot,
},
}
const now = new Date().toISOString()
const existingWorkspace = await this.workspaceRegistry.get(workspaceId)
const existingProject = await this.projectRegistry.get(placement.projectKey)
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId,
placement,
createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
})
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId,
placement,
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
})
await this.projectRegistry.upsert(nextProjectRecord)
await this.workspaceRegistry.upsert(nextWorkspaceRecord)
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now)
}
return nextWorkspaceRecord
}
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> { private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const existing = await this.workspaceRegistry.get(workspaceId) const existing = await this.workspaceRegistry.get(workspaceId)
if (!existing || existing.archivedAt) { if (!existing || existing.archivedAt) {
@@ -5910,6 +5976,129 @@ export class Session {
} }
} }
private async handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: 'create_paseo_worktree_request' }>
): Promise<void> {
try {
const checkout = await getCheckoutStatusLite(request.cwd, { paseoHome: this.paseoHome })
if (!checkout.isGit) {
throw new Error('Create worktree requires a git repository')
}
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot)
if (!baseBranch) {
throw new Error('Unable to resolve repository default branch')
}
const normalizedSlug = request.worktreeSlug ? slugify(request.worktreeSlug) : uuidv4()
const validation = validateBranchSlug(normalizedSlug)
if (!validation.valid) {
throw new Error(`Invalid worktree name: ${validation.error}`)
}
const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, this.paseoHome)
const workspace = await this.registerPendingWorktreeWorkspace({
repoRoot,
worktreePath,
branchName: normalizedSlug,
})
const descriptor = await this.describeWorkspaceRecord(workspace)
this.emit({
type: 'create_paseo_worktree_response',
payload: {
workspace: descriptor,
error: null,
setupTerminalId: null,
requestId: request.requestId,
},
})
void this.createPaseoWorktreeInBackground({
requestCwd: request.cwd,
repoRoot,
baseBranch,
slug: normalizedSlug,
worktreePath,
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create worktree'
this.sessionLogger.error(
{ err: error, cwd: request.cwd, worktreeSlug: request.worktreeSlug },
'Failed to create worktree'
)
this.emit({
type: 'create_paseo_worktree_response',
payload: {
workspace: null,
error: message,
setupTerminalId: null,
requestId: request.requestId,
},
})
}
}
private async createPaseoWorktreeInBackground(options: {
requestCwd: string
repoRoot: string
baseBranch: string
slug: string
worktreePath: string
}): Promise<void> {
let setupTerminalId: string | null = null
try {
await createAgentWorktree({
cwd: options.repoRoot,
branchName: options.slug,
baseBranch: options.baseBranch,
worktreeSlug: options.slug,
paseoHome: this.paseoHome,
})
const setupCommands = getWorktreeSetupCommands(options.worktreePath)
if (setupCommands.length > 0 && this.terminalManager) {
const runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: options.worktreePath,
branchName: options.slug,
repoRootPath: options.repoRoot,
})
this.terminalManager.registerCwdEnv({
cwd: options.worktreePath,
env: runtimeEnv,
})
const terminal = await this.terminalManager.createTerminal({
cwd: options.worktreePath,
name: `setup-${options.slug}`,
env: runtimeEnv,
})
setupTerminalId = terminal.id
for (const command of setupCommands) {
terminal.send({
type: 'input',
data: `${command}\r`,
})
}
}
} catch (error) {
this.sessionLogger.error(
{
err: error,
cwd: options.requestCwd,
repoRoot: options.repoRoot,
worktreeSlug: options.slug,
worktreePath: options.worktreePath,
setupTerminalId,
},
'Background worktree creation failed'
)
} finally {
await this.emitWorkspaceUpdateForCwd(options.worktreePath)
}
}
private async handleArchiveWorkspaceRequest( private async handleArchiveWorkspaceRequest(
request: Extract<SessionInboundMessage, { type: 'archive_workspace_request' }> request: Extract<SessionInboundMessage, { type: 'archive_workspace_request' }>
): Promise<void> { ): Promise<void> {
@@ -1,3 +1,7 @@
import { execSync } from 'node:child_process'
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, test, vi } from 'vitest' import { describe, expect, test, vi } from 'vitest'
import { Session } from './session.js' import { Session } from './session.js'
import type { AgentSnapshotPayload } from '../shared/messages.js' import type { AgentSnapshotPayload } from '../shared/messages.js'
@@ -335,55 +339,110 @@ describe('workspace aggregation', () => {
}) })
}) })
test('create paseo worktree request returns a registered workspace descriptor', async () => {
const emitted: Array<{ type: string; payload: unknown }> = []
const session = createSessionForWorkspaceTests() as any
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), 'session-worktree-test-')))
const repoDir = path.join(tempDir, 'repo')
const paseoHome = path.join(tempDir, 'paseo-home')
execSync(`mkdir -p ${repoDir}`)
execSync('git init -b main', { cwd: repoDir, stdio: 'pipe' })
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: 'pipe' })
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: 'pipe' })
writeFileSync(path.join(repoDir, 'file.txt'), 'hello\n')
execSync('git add .', { cwd: repoDir, stdio: 'pipe' })
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: 'pipe' })
const workspaces = new Map()
const projects = new Map()
session.paseoHome = paseoHome
session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null
session.workspaceRegistry.list = async () => Array.from(workspaces.values())
session.workspaceRegistry.upsert = async (record: any) => {
workspaces.set(record.workspaceId, record)
}
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null
session.projectRegistry.list = async () => Array.from(projects.values())
session.projectRegistry.upsert = async (record: any) => {
projects.set(record.projectId, record)
}
session.emit = (message: { type: string; payload: unknown }) => {
emitted.push(message)
}
try {
await session.handleCreatePaseoWorktreeRequest({
type: 'create_paseo_worktree_request',
cwd: repoDir,
worktreeSlug: 'worktree-123',
requestId: 'req-worktree',
})
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
const response = emitted.find((message) => message.type === 'create_paseo_worktree_response') as
| { type: 'create_paseo_worktree_response'; payload: any }
| undefined
expect(response?.payload.error).toBeNull()
expect(response?.payload.workspace).toMatchObject({
projectDisplayName: 'repo',
projectKind: 'git',
workspaceKind: 'worktree',
name: 'worktree-123',
status: 'done',
})
expect(response?.payload.workspace?.id).toContain(path.join('worktree-123'))
expect(workspaces.has(response?.payload.workspace?.id)).toBe(true)
expect(projects.has(response?.payload.workspace?.projectId)).toBe(true)
})
test('workspace update fanout for multiple cwd values is deduplicated', async () => { test('workspace update fanout for multiple cwd values is deduplicated', async () => {
const emitted: Array<{ type: string; payload: unknown }> = [] const emitted: Array<{ type: string; payload: unknown }> = []
const session = createSessionForWorkspaceTests() as any const session = createSessionForWorkspaceTests() as any
session.emit = (message: any) => emitted.push(message)
session.workspaceUpdatesSubscription = { session.workspaceUpdatesSubscription = {
subscriptionId: 'sub-dedupe', subscriptionId: 'sub-dedup',
filter: undefined, filter: undefined,
isBootstrapping: false, isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(), pendingUpdatesByWorkspaceId: new Map(),
} }
session.reconcileActiveWorkspaceRecords = async () => new Set() session.reconcileActiveWorkspaceRecords = async () => new Set(['/tmp/repo', '/tmp/repo/worktree'])
session.listWorkspaceDescriptorsSnapshot = async () => [ session.listWorkspaceDescriptorsSnapshot = async () => [
{ {
id: '/tmp/repo', id: '/tmp/repo',
projectId: '/tmp/repo', projectId: '/tmp/repo',
projectDisplayName: 'repo', projectDisplayName: 'repo',
projectRootPath: '/tmp/repo', projectRootPath: '/tmp/repo',
projectKind: 'non_git', projectKind: 'git',
workspaceKind: 'directory', workspaceKind: 'local_checkout',
name: 'repo', name: 'main',
status: 'done', status: 'done',
activityAt: null, activityAt: null,
}, },
{ {
id: '/tmp/repo/sub', id: '/tmp/repo/worktree',
projectId: '/tmp/repo', projectId: '/tmp/repo',
projectDisplayName: 'repo', projectDisplayName: 'repo',
projectRootPath: '/tmp/repo', projectRootPath: '/tmp/repo',
projectKind: 'non_git', projectKind: 'git',
workspaceKind: 'directory', workspaceKind: 'worktree',
name: 'sub', name: 'feature',
status: 'done', status: 'running',
activityAt: null, activityAt: '2026-03-01T12:00:00.000Z',
}, },
] ]
session.onMessage = (message: { type: string; payload: unknown }) => {
emitted.push(message)
}
await session.emitWorkspaceUpdatesForCwds([ await session.emitWorkspaceUpdateForCwd('/tmp/repo/worktree')
'/tmp/repo',
'/tmp/repo/',
' /tmp/repo ',
'/tmp/repo/sub',
'/tmp/repo/sub/',
])
const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[] const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[]
expect(workspaceUpdates).toHaveLength(2) expect(workspaceUpdates).toHaveLength(2)
expect(workspaceUpdates.map((message) => message.payload.workspace.id)).toEqual([ expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(['upsert', 'upsert'])
expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([
'/tmp/repo', '/tmp/repo',
'/tmp/repo/sub', '/tmp/repo/worktree',
]) ])
}) })
@@ -69,6 +69,17 @@ export function normalizeHostPort(input: string): string {
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`; return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
} }
export function normalizeLoopbackToLocalhost(endpoint: string): string {
const { host, port, isIpv6 } = parseHostPort(endpoint);
if (host === "127.0.0.1" || (!isIpv6 && host === "0.0.0.0")) {
return `localhost:${port}`;
}
if (isIpv6 && (host === "::1" || host === "::")) {
return `localhost:${port}`;
}
return endpoint;
}
export function deriveLabelFromEndpoint(endpoint: string): string { export function deriveLabelFromEndpoint(endpoint: string): string {
try { try {
const { host } = parseHostPort(endpoint); const { host } = parseHostPort(endpoint);
+19
View File
@@ -936,6 +936,13 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({
requestId: z.string(), requestId: z.string(),
}) })
export const CreatePaseoWorktreeRequestSchema = z.object({
type: z.literal('create_paseo_worktree_request'),
cwd: z.string(),
worktreeSlug: z.string().optional(),
requestId: z.string(),
})
export const OpenProjectRequestSchema = z.object({ export const OpenProjectRequestSchema = z.object({
type: z.literal('open_project_request'), type: z.literal('open_project_request'),
cwd: z.string(), cwd: z.string(),
@@ -1186,6 +1193,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion('type', [
DirectorySuggestionsRequestSchema, DirectorySuggestionsRequestSchema,
PaseoWorktreeListRequestSchema, PaseoWorktreeListRequestSchema,
PaseoWorktreeArchiveRequestSchema, PaseoWorktreeArchiveRequestSchema,
CreatePaseoWorktreeRequestSchema,
OpenProjectRequestSchema, OpenProjectRequestSchema,
ArchiveWorkspaceRequestSchema, ArchiveWorkspaceRequestSchema,
FileExplorerRequestSchema, FileExplorerRequestSchema,
@@ -1961,6 +1969,16 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({
}), }),
}) })
export const CreatePaseoWorktreeResponseSchema = z.object({
type: z.literal('create_paseo_worktree_response'),
payload: z.object({
workspace: WorkspaceDescriptorPayloadSchema.nullable(),
error: z.string().nullable(),
setupTerminalId: z.string().nullable(),
requestId: z.string(),
}),
})
export const FileExplorerResponseSchema = z.object({ export const FileExplorerResponseSchema = z.object({
type: z.literal('file_explorer_response'), type: z.literal('file_explorer_response'),
payload: z.object({ payload: z.object({
@@ -2237,6 +2255,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion('type', [
DirectorySuggestionsResponseSchema, DirectorySuggestionsResponseSchema,
PaseoWorktreeListResponseSchema, PaseoWorktreeListResponseSchema,
PaseoWorktreeArchiveResponseSchema, PaseoWorktreeArchiveResponseSchema,
CreatePaseoWorktreeResponseSchema,
FileExplorerResponseSchema, FileExplorerResponseSchema,
ProjectIconResponseSchema, ProjectIconResponseSchema,
FileDownloadTokenResponseSchema, FileDownloadTokenResponseSchema,
@@ -17,6 +17,7 @@ import {
MergeFromBaseConflictError, MergeFromBaseConflictError,
NotGitRepoError, NotGitRepoError,
pushCurrentBranch, pushCurrentBranch,
resolveRepositoryDefaultBranch,
} from "./checkout-git.js"; } from "./checkout-git.js";
import { createWorktree } from "./worktree.js"; import { createWorktree } from "./worktree.js";
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
@@ -707,6 +708,17 @@ describe("checkout git utilities", () => {
expect(baseDiff.diff).not.toContain("file.txt"); expect(baseDiff.diff).not.toContain("file.txt");
}); });
it("resolves the repository default branch from origin HEAD", async () => {
execSync("git checkout -b develop", { cwd: repoDir });
execSync("git checkout main", { cwd: repoDir });
execSync("git remote add origin https://github.com/acme/repo.git", { cwd: repoDir });
execSync("git update-ref refs/remotes/origin/main refs/heads/main", { cwd: repoDir });
execSync("git update-ref refs/remotes/origin/develop refs/heads/develop", { cwd: repoDir });
execSync("git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main", { cwd: repoDir });
await expect(resolveRepositoryDefaultBranch(repoDir)).resolves.toBe("main");
});
it("merges to stored baseRefName when baseRef is not provided", async () => { it("merges to stored baseRefName when baseRef is not provided", async () => {
// Create a non-default base branch with a unique commit. // Create a non-default base branch with a unique commit.
execSync("git checkout -b develop", { cwd: repoDir }); execSync("git checkout -b develop", { cwd: repoDir });
+5 -1
View File
@@ -708,7 +708,7 @@ async function hasOriginRemote(cwd: string): Promise<boolean> {
return url !== null; return url !== null;
} }
async function resolveBaseRef(repoRoot: string): Promise<string | null> { export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<string | null> {
try { try {
const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", { const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", {
cwd: repoRoot, cwd: repoRoot,
@@ -755,6 +755,10 @@ async function resolveBaseRef(repoRoot: string): Promise<string | null> {
return null; return null;
} }
async function resolveBaseRef(repoRoot: string): Promise<string | null> {
return resolveRepositoryDefaultBranch(repoRoot);
}
function normalizeLocalBranchRefName(input: string): string { function normalizeLocalBranchRefName(input: string): string {
return input.startsWith("origin/") ? input.slice("origin/".length) : input; return input.startsWith("origin/") ? input.slice("origin/".length) : input;
} }
+10 -1
View File
@@ -668,12 +668,21 @@ export async function deriveWorktreeProjectHash(cwd: string): Promise<string> {
} }
} }
async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise<string> { export async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise<string> {
const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome(); const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome();
const projectHash = await deriveWorktreeProjectHash(cwd); const projectHash = await deriveWorktreeProjectHash(cwd);
return join(home, "worktrees", projectHash); return join(home, "worktrees", projectHash);
} }
export async function computeWorktreePath(
cwd: string,
slug: string,
paseoHome?: string
): Promise<string> {
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
return join(worktreesRoot, slug);
}
function normalizePathForOwnership(input: string): string { function normalizePathForOwnership(input: string): string {
try { try {
return realpathSync(input); return realpathSync(input);