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:
@@ -620,7 +620,6 @@ export default function RootLayout() {
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<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]/settings" />
|
||||
<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} />;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostSettingsRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
@@ -55,7 +54,7 @@ type CommandCenterActionDefinition = {
|
||||
icon?: "plus" | "settings";
|
||||
shortcutKeys?: ShortcutKey[];
|
||||
keywords: string[];
|
||||
buildRoute: (params: { newAgentRoute: Href; settingsRoute: Href }) => Href;
|
||||
routeKind: "settings" | "none";
|
||||
};
|
||||
|
||||
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
@@ -65,14 +64,14 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
icon: "plus",
|
||||
shortcutKeys: ["mod", "shift", "O"],
|
||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||
buildRoute: ({ newAgentRoute }) => newAgentRoute,
|
||||
routeKind: "none",
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
icon: "settings",
|
||||
keywords: ["settings", "preferences", "config", "configuration"],
|
||||
buildRoute: ({ settingsRoute }) => settingsRoute,
|
||||
routeKind: "settings",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -93,7 +92,7 @@ export type CommandCenterActionItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: "plus" | "settings";
|
||||
route: Href;
|
||||
route?: Href;
|
||||
shortcutKeys?: ShortcutKey[];
|
||||
};
|
||||
|
||||
@@ -149,11 +148,6 @@ export function useCommandCenter() {
|
||||
return filtered;
|
||||
}, [agents, open, query]);
|
||||
|
||||
const newAgentRoute = useMemo<Href>(() => {
|
||||
const serverIdFromPath = activeServerId;
|
||||
return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/";
|
||||
}, [activeServerId]);
|
||||
|
||||
const settingsRoute = useMemo<Href>(() => {
|
||||
const serverIdFromPath = activeServerId;
|
||||
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/";
|
||||
@@ -170,10 +164,10 @@ export function useCommandCenter() {
|
||||
id: action.id,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
route: action.buildRoute({ newAgentRoute, settingsRoute }),
|
||||
route: action.routeKind === "settings" ? settingsRoute : undefined,
|
||||
shortcutKeys: action.shortcutKeys,
|
||||
}));
|
||||
}, [newAgentRoute, open, query, settingsRoute]);
|
||||
}, [open, query, settingsRoute]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!open) {
|
||||
@@ -227,6 +221,9 @@ export function useCommandCenter() {
|
||||
void openProjectPicker();
|
||||
return;
|
||||
}
|
||||
if (!action.route) {
|
||||
return;
|
||||
}
|
||||
didNavigateRef.current = true;
|
||||
router.push(action.route);
|
||||
}, [openProjectPicker, setOpen]);
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createTauriLocalDaemonTransportFactory,
|
||||
} from "@/utils/managed-tauri-daemon-transport";
|
||||
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
|
||||
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
|
||||
@@ -433,7 +432,6 @@ function probeIntervalForConnection(
|
||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
return {
|
||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
||||
const base = {
|
||||
suppressSendErrors: true,
|
||||
@@ -454,17 +452,11 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
if (connection.type === "directTcp") {
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint),
|
||||
});
|
||||
}
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: connection.relayEndpoint,
|
||||
serverId: host.serverId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeHostPort } from '@server/shared/daemon-endpoints'
|
||||
import { normalizeHostPort, normalizeLoopbackToLocalhost } from '@server/shared/daemon-endpoints'
|
||||
|
||||
export type DirectTcpHostConnection = {
|
||||
id: string
|
||||
@@ -188,7 +188,7 @@ export function connectionFromListen(listen: string): HostConnection | null {
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = normalizeHostPort(normalizedListen)
|
||||
const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(normalizedListen))
|
||||
return {
|
||||
id: `direct:${endpoint}`,
|
||||
type: 'directTcp',
|
||||
@@ -207,7 +207,7 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
||||
const type = typeof record.type === 'string' ? record.type : null
|
||||
if (type === 'directTcp') {
|
||||
try {
|
||||
const endpoint = normalizeHostPort(String(record.endpoint ?? ''))
|
||||
const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(String(record.endpoint ?? '')))
|
||||
return { id: `direct:${endpoint}`, type: 'directTcp', endpoint }
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostNewAgentRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceFileRoute,
|
||||
@@ -66,16 +65,6 @@ describe("workspace route parsing", () => {
|
||||
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", () => {
|
||||
expect(buildHostWorkspaceAgentRoute("local", "/tmp/repo", "agent-1")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
|
||||
|
||||
@@ -399,40 +399,6 @@ export function buildHostAgentsRoute(serverId: string): string {
|
||||
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 {
|
||||
const base = buildHostRootRoute(serverId);
|
||||
if (base === "/") {
|
||||
@@ -466,9 +432,6 @@ export function mapPathnameToServer(
|
||||
if (suffix.startsWith("agents")) {
|
||||
return `${base}/agents`;
|
||||
}
|
||||
if (suffix.startsWith("new-agent")) {
|
||||
return `${base}/new-agent`;
|
||||
}
|
||||
if (suffix.startsWith("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,
|
||||
createTauriLocalDaemonTransportFactory,
|
||||
} from "./managed-tauri-daemon-transport";
|
||||
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
@@ -50,7 +49,6 @@ export async function buildClientConfig(
|
||||
serverId?: string
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientId = await getOrCreateClientId();
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
||||
const base = {
|
||||
clientId,
|
||||
@@ -61,9 +59,7 @@ export async function buildClientConfig(
|
||||
? localTransportFactory
|
||||
? { transportFactory: localTransportFactory }
|
||||
: {}
|
||||
: tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
|
||||
Reference in New Issue
Block a user