diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index af387fbea..eddc9b801 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -276,13 +276,11 @@ export function AgentInputArea({ imageAttachments?: ImageAttachment[], forceSend?: boolean ) { - const socketConnected = isConnected; const trimmedMessage = message.trim(); if (!trimmedMessage) return; // When the parent controls submission (e.g. draft agent creation), let it // decide what to do even if the socket is currently disconnected (so we // don't no-op and lose deterministic error handling in the UI/tests). - if (!onSubmitMessageRef.current && !socketConnected) return; if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return; if (agent?.status === "running" && !forceSend) { @@ -485,7 +483,7 @@ export function AgentInputArea({ async function handleSendQueuedNow(id: string) { const item = queuedMessages.find((q) => q.id === id); - if (!item || !isConnected) return; + if (!item) return; if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return; updateQueue((current) => current.filter((q) => q.id !== id)); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index ea7463850..98a77ddd9 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -731,7 +731,7 @@ export const MessageInput = forwardRef( scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true} onContentSizeChange={handleContentSizeChange} editable={ - !isDictating && !isRealtimeVoiceForCurrentAgent && isConnected && !disabled + !isDictating && !isRealtimeVoiceForCurrentAgent && !disabled } onKeyPress={ shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined @@ -836,24 +836,22 @@ export const MessageInput = forwardRef( )} {shouldShowSendButton && ( - {isSubmitLoading ? ( diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx index b67786dd1..eb55b08a1 100644 --- a/packages/app/src/components/multi-daemon-session-host.tsx +++ b/packages/app/src/components/multi-daemon-session-host.tsx @@ -183,7 +183,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { return ( void; + onSearchQueryChange?: (query: string) => void; placeholder?: string; searchPlaceholder?: string; emptyText?: string; @@ -156,6 +157,7 @@ export function Combobox({ options, value, onSelect, + onSearchQueryChange, placeholder = "Search...", searchPlaceholder, emptyText = "No options match your search.", @@ -191,16 +193,24 @@ export function Combobox({ [isControlled, onOpenChange] ); + const setSearchQueryWithCallback = useCallback( + (nextQuery: string) => { + setSearchQuery(nextQuery); + onSearchQueryChange?.(nextQuery); + }, + [onSearchQueryChange] + ); + const handleClose = useCallback(() => { setOpen(false); - setSearchQuery(""); - }, [setOpen]); + setSearchQueryWithCallback(""); + }, [setOpen, setSearchQueryWithCallback]); useEffect(() => { if (isOpen) { - setSearchQuery(""); + setSearchQueryWithCallback(""); } - }, [isOpen]); + }, [isOpen, setSearchQueryWithCallback]); const collisionPadding = useMemo(() => { const basePadding = 16; @@ -426,7 +436,7 @@ export function Combobox({ diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index a5df16116..291945e82 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -45,7 +45,6 @@ import { import { usePanelStore } from "@/stores/panel-store"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; -import { formatConnectionStatus } from "@/utils/daemons"; import { useSessionStore } from "@/stores/session-store"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import type { Agent } from "@/contexts/session-context"; @@ -74,6 +73,8 @@ import { const DROPDOWN_WIDTH = 220; const EMPTY_STREAM_ITEMS: StreamItem[] = []; +const RECONNECT_NOTICE_DELAY_MS = 10_000; +const CONNECTED_NOTICE_DURATION_MS = 2_500; export function AgentReadyScreen({ serverId, @@ -101,7 +102,6 @@ export function AgentReadyScreen({ const isUnknownDaemon = Boolean(connectionServerId && !connection); const connectionStatus = connection?.status ?? (isUnknownDaemon ? "offline" : "idle"); - const connectionStatusLabel = formatConnectionStatus(connectionStatus); const lastConnectionError = connection?.lastError ?? null; const handleBackToHome = useCallback(() => { @@ -152,7 +152,6 @@ export function AgentReadyScreen({ onBack={handleBackToHome} serverLabel={serverLabel} connectionStatus={connectionStatus} - connectionStatusLabel={connectionStatusLabel} lastError={lastConnectionError} isUnknownDaemon={isUnknownDaemon} /> @@ -164,6 +163,7 @@ export function AgentReadyScreen({ ); @@ -172,6 +172,7 @@ export function AgentReadyScreen({ type AgentScreenContentProps = { serverId: string; agentId?: string; + connectionStatus: ConnectionStatus; }; type MissingAgentState = @@ -194,6 +195,7 @@ function isNotFoundErrorMessage(message: string): boolean { function AgentScreenContent({ serverId, agentId, + connectionStatus, }: AgentScreenContentProps) { const { theme } = useUnistyles(); const toast = useToast(); @@ -383,6 +385,11 @@ function AgentScreenContent({ const [missingAgentState, setMissingAgentState] = useState({ kind: "idle", }); + const [showReconnectNotice, setShowReconnectNotice] = useState(false); + const [dismissedReconnectNotice, setDismissedReconnectNotice] = useState(false); + const [showConnectedNotice, setShowConnectedNotice] = useState(false); + const reconnectNoticeTimeoutRef = useRef | null>(null); + const connectedNoticeTimeoutRef = useRef | null>(null); const initAttemptTokenRef = useRef(0); const setFocusedAgentId = useCallback( (agentId: string | null) => { @@ -410,6 +417,56 @@ function AgentScreenContent({ const agentModel = extractAgentModel(agent); const modelDisplayValue = agentModel ?? "Unknown"; + useEffect(() => { + if (reconnectNoticeTimeoutRef.current) { + clearTimeout(reconnectNoticeTimeoutRef.current); + reconnectNoticeTimeoutRef.current = null; + } + + if (connectionStatus === "online") { + if (showReconnectNotice || dismissedReconnectNotice) { + setShowConnectedNotice(true); + } + setShowReconnectNotice(false); + setDismissedReconnectNotice(false); + return; + } + + setShowConnectedNotice(false); + if (!showReconnectNotice && !dismissedReconnectNotice) { + reconnectNoticeTimeoutRef.current = setTimeout(() => { + setShowReconnectNotice(true); + }, RECONNECT_NOTICE_DELAY_MS); + } + + return () => { + if (reconnectNoticeTimeoutRef.current) { + clearTimeout(reconnectNoticeTimeoutRef.current); + reconnectNoticeTimeoutRef.current = null; + } + }; + }, [connectionStatus, dismissedReconnectNotice, showReconnectNotice]); + + useEffect(() => { + if (!showConnectedNotice) { + if (connectedNoticeTimeoutRef.current) { + clearTimeout(connectedNoticeTimeoutRef.current); + connectedNoticeTimeoutRef.current = null; + } + return; + } + connectedNoticeTimeoutRef.current = setTimeout(() => { + setShowConnectedNotice(false); + connectedNoticeTimeoutRef.current = null; + }, CONNECTED_NOTICE_DURATION_MS); + return () => { + if (connectedNoticeTimeoutRef.current) { + clearTimeout(connectedNoticeTimeoutRef.current); + connectedNoticeTimeoutRef.current = null; + } + }; + }, [showConnectedNotice]); + // Checkout status for header subtitle const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, @@ -902,6 +959,47 @@ function AgentScreenContent({ } /> + {(showReconnectNotice || showConnectedNotice) && ( + + {showConnectedNotice ? ( + + ) : null} + + {showReconnectNotice ? "Reconnecting..." : "Connected"} + + {showReconnectNotice ? ( + { + setShowReconnectNotice(false); + setDismissedReconnectNotice(true); + }} + style={styles.connectionNoticeDismiss} + accessibilityRole="button" + accessibilityLabel="Dismiss reconnecting notice" + > + Dismiss + + ) : null} + + )} + {/* Content Area with Keyboard Animation */} {shouldBlockForHistorySync ? ( @@ -967,14 +1065,12 @@ function AgentSessionUnavailableState({ onBack, serverLabel, connectionStatus, - connectionStatusLabel, lastError, isUnknownDaemon = false, }: { onBack: () => void; serverLabel: string; connectionStatus: ConnectionStatus; - connectionStatusLabel: string; lastError: string | null; isUnknownDaemon?: boolean; }) { @@ -1015,11 +1111,10 @@ function AgentSessionUnavailableState({ ) : ( <> - {serverLabel} is currently {connectionStatusLabel.toLowerCase()}. + Reconnecting to {serverLabel}... - We'll reconnect automatically and show this agent as soon as the - host comes back online. + We will show this agent again as soon as the host is reachable. {lastError ? ( {lastError} @@ -1049,6 +1144,49 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, overflow: "hidden", }, + connectionNotice: { + marginHorizontal: theme.spacing[4], + marginTop: theme.spacing[1], + marginBottom: theme.spacing[2], + minHeight: 32, + borderRadius: theme.borderRadius.full, + paddingHorizontal: theme.spacing[3], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + connectionNoticeReconnecting: { + backgroundColor: `${theme.colors.palette.yellow[400]}22`, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.palette.yellow[400], + }, + connectionNoticeConnected: { + backgroundColor: theme.colors.palette.green[100], + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.palette.green[400], + }, + connectionNoticeText: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + connectionNoticeTextReconnecting: { + color: theme.colors.foreground, + }, + connectionNoticeTextConnected: { + color: theme.colors.palette.green[800], + }, + connectionNoticeDismiss: { + marginLeft: "auto", + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface0, + }, + connectionNoticeDismissText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + }, content: { flex: 1, }, diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index b69022f36..201234156 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -30,6 +30,7 @@ import { import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { formatConnectionStatus } from "@/utils/daemons"; +import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions"; import { shortenPath } from "@/utils/shorten-path"; import { usePanelStore } from "@/stores/panel-store"; import { useSessionStore } from "@/stores/session-store"; @@ -249,6 +250,8 @@ export function DraftAgentScreen({ const [isWorkingDirOpen, setIsWorkingDirOpen] = useState(false); const [isWorktreePickerOpen, setIsWorktreePickerOpen] = useState(false); const [isBranchOpen, setIsBranchOpen] = useState(false); + const [branchSearchQuery, setBranchSearchQuery] = useState(""); + const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState(""); const hostAnchorRef = useRef(null); const workingDirAnchorRef = useRef(null); const worktreeAnchorRef = useRef(null); @@ -258,6 +261,12 @@ export function DraftAgentScreen({ const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId); const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear); + useEffect(() => { + const trimmed = branchSearchQuery.trim(); + const timer = setTimeout(() => setDebouncedBranchSearchQuery(trimmed), 180); + return () => clearTimeout(timer); + }, [branchSearchQuery]); + type CreateAttempt = { messageId: string; text: string; @@ -489,6 +498,40 @@ export function DraftAgentScreen({ ? "Select a worktree to attach" : null; + const branchSuggestionsQuery = useQuery({ + queryKey: [ + "branchSuggestions", + selectedServerId, + trimmedWorkingDir, + debouncedBranchSearchQuery, + ], + queryFn: async () => { + const client = sessionClient; + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.getBranchSuggestions({ + cwd: trimmedWorkingDir || ".", + query: debouncedBranchSearchQuery || undefined, + limit: 50, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload.branches ?? []; + }, + enabled: + isCreateWorktree && + isGitDirectory && + !isNonGitDirectory && + Boolean(trimmedWorkingDir) && + !repoAvailabilityError && + Boolean(sessionClient) && + isConnected, + retry: false, + staleTime: 15_000, + }); + const validateWorktreeName = useCallback( (name: string): { valid: boolean; error?: string } => { if (!name) { @@ -658,21 +701,36 @@ export function DraftAgentScreen({ ); const branchComboOptions = useMemo(() => { - const branchSet = new Set(); - const currentBranch = checkout?.isGit ? checkout.currentBranch?.trim() : null; - if (currentBranch && currentBranch !== "HEAD") { - branchSet.add(currentBranch); + const options = buildBranchComboOptions({ + suggestedBranches: branchSuggestionsQuery.data ?? [], + currentBranch: checkout?.isGit ? checkout.currentBranch : null, + baseRef: checkout?.isGit ? checkout.baseRef : null, + typedBaseBranch: baseBranch, + worktreeBranchLabels: worktreeOptions.map((option) => option.label), + }); + + const normalizedQuery = normalizeBranchOptionName(branchSearchQuery)?.toLowerCase() ?? ""; + if (!normalizedQuery) { + return options; } - if (baseBranch.trim()) { - branchSet.add(baseBranch.trim()); - } - for (const option of worktreeOptions) { - if (option.label) { - branchSet.add(option.label); + + return options.sort((a, b) => { + const aLower = a.label.toLowerCase(); + const bLower = b.label.toLowerCase(); + const aPrefix = aLower.startsWith(normalizedQuery); + const bPrefix = bLower.startsWith(normalizedQuery); + if (aPrefix !== bPrefix) { + return aPrefix ? -1 : 1; } - } - return Array.from(branchSet).map((name) => ({ id: name, label: name })); - }, [baseBranch, checkout, worktreeOptions]); + return aLower.localeCompare(bLower); + }); + }, [ + baseBranch, + branchSearchQuery, + branchSuggestionsQuery.data, + checkout, + worktreeOptions, + ]); const createAgentClient = useSessionStore((state) => selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null @@ -1130,13 +1188,19 @@ export function DraftAgentScreen({ options={branchComboOptions} value={baseBranch} onSelect={handleBaseBranchChange} + onSearchQueryChange={setBranchSearchQuery} searchPlaceholder="Choose a base branch..." allowCustomValue customValuePrefix="Use" customValueDescription="Use this branch name" title="Select base branch" open={isBranchOpen} - onOpenChange={setIsBranchOpen} + onOpenChange={(nextOpen) => { + setIsBranchOpen(nextOpen); + if (!nextOpen) { + setBranchSearchQuery(""); + } + }} anchorRef={branchAnchorRef} /> diff --git a/packages/app/src/utils/branch-suggestions.test.ts b/packages/app/src/utils/branch-suggestions.test.ts new file mode 100644 index 000000000..4ebc15c05 --- /dev/null +++ b/packages/app/src/utils/branch-suggestions.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { buildBranchComboOptions, normalizeBranchOptionName } from "./branch-suggestions"; + +describe("normalizeBranchOptionName", () => { + it("normalizes local and origin-prefixed refs", () => { + expect(normalizeBranchOptionName("refs/heads/main")).toBe("main"); + expect(normalizeBranchOptionName("refs/remotes/origin/main")).toBe("main"); + expect(normalizeBranchOptionName("origin/feature/test")).toBe("feature/test"); + expect(normalizeBranchOptionName("feature/test")).toBe("feature/test"); + }); + + it("filters out empty values and HEAD", () => { + expect(normalizeBranchOptionName("")).toBeNull(); + expect(normalizeBranchOptionName(" ")).toBeNull(); + expect(normalizeBranchOptionName("HEAD")).toBeNull(); + expect(normalizeBranchOptionName("origin/HEAD")).toBeNull(); + }); +}); + +describe("buildBranchComboOptions", () => { + it("merges branch sources and de-duplicates normalized names", () => { + const options = buildBranchComboOptions({ + suggestedBranches: ["origin/main", "refs/remotes/origin/main", "feature/a"], + currentBranch: "refs/heads/feature/a", + baseRef: "origin/main", + typedBaseBranch: "main", + worktreeBranchLabels: ["refs/heads/release/next"], + }); + + expect(options).toEqual([ + { id: "main", label: "main" }, + { id: "feature/a", label: "feature/a" }, + { id: "release/next", label: "release/next" }, + ]); + }); +}); diff --git a/packages/app/src/utils/branch-suggestions.ts b/packages/app/src/utils/branch-suggestions.ts new file mode 100644 index 000000000..40295a89f --- /dev/null +++ b/packages/app/src/utils/branch-suggestions.ts @@ -0,0 +1,51 @@ +export type BranchComboOption = { + id: string; + label: string; +}; + +export function normalizeBranchOptionName(input: string | null | undefined): string | null { + const trimmed = input?.trim(); + if (!trimmed || trimmed === "HEAD") { + return null; + } + + let normalized = trimmed; + if (normalized.startsWith("refs/heads/")) { + normalized = normalized.slice("refs/heads/".length); + } else if (normalized.startsWith("refs/remotes/")) { + normalized = normalized.slice("refs/remotes/".length); + } + if (normalized.startsWith("origin/")) { + normalized = normalized.slice("origin/".length); + } + + return normalized.length > 0 && normalized !== "HEAD" ? normalized : null; +} + +export function buildBranchComboOptions(input: { + suggestedBranches?: string[]; + currentBranch?: string | null; + baseRef?: string | null; + typedBaseBranch?: string | null; + worktreeBranchLabels?: string[]; +}): BranchComboOption[] { + const branchSet = new Set(); + const addBranch = (name: string | null | undefined) => { + const normalized = normalizeBranchOptionName(name); + if (normalized) { + branchSet.add(normalized); + } + }; + + for (const branch of input.suggestedBranches ?? []) { + addBranch(branch); + } + addBranch(input.currentBranch ?? null); + addBranch(input.baseRef ?? null); + addBranch(input.typedBaseBranch ?? null); + for (const label of input.worktreeBranchLabels ?? []) { + addBranch(label); + } + + return Array.from(branchSet).map((name) => ({ id: name, label: name })); +} diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index ce1440c6d..c20748d3b 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -287,6 +287,65 @@ describe("DaemonClient", () => { ); }); + test("requests branch suggestions via RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.getBranchSuggestions( + { cwd: "/tmp/project", query: "mai", limit: 5 }, + "req-branches" + ); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "branch_suggestions_request"; + cwd: string; + query?: string; + limit?: number; + requestId: string; + }; + }; + expect(request.message.type).toBe("branch_suggestions_request"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.query).toBe("mai"); + expect(request.message.limit).toBe(5); + expect(request.message.requestId).toBe("req-branches"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "branch_suggestions_response", + payload: { + branches: ["main"], + error: null, + requestId: "req-branches", + }, + }, + }) + ); + + await expect(promise).resolves.toEqual({ + branches: ["main"], + error: null, + requestId: "req-branches", + }); + }); + test("resubscribes checkout diff streams after reconnect", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index bf28be78a..c843b0616 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -24,6 +24,7 @@ import type { CheckoutPrCreateResponse, CheckoutPrStatusResponse, ValidateBranchResponse, + BranchSuggestionsResponse, PaseoWorktreeListResponse, PaseoWorktreeArchiveResponse, ProjectIconResponse, @@ -191,6 +192,7 @@ type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type ValidateBranchPayload = ValidateBranchResponse["payload"]; +type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"]; type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"]; type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"]; type FileExplorerPayload = FileExplorerResponse["payload"]; @@ -1936,6 +1938,35 @@ export class DaemonClient { }); } + async getBranchSuggestions( + options: { cwd: string; query?: string; limit?: number }, + requestId?: string + ): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "branch_suggestions_request", + cwd: options.cwd, + query: options.query, + limit: options.limit, + requestId: resolvedRequestId, + }); + return this.sendRequest({ + requestId: resolvedRequestId, + message, + timeout: 10000, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "branch_suggestions_response") { + return null; + } + if (msg.payload.requestId !== resolvedRequestId) { + return null; + } + return msg.payload; + }, + }); + } + // ============================================================================ // File Explorer // ============================================================================ diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 5d4480af4..b907f79a8 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -538,11 +538,11 @@ export async function createPaseoDaemon( relayTransport?.stop().catch(() => undefined); relayTransport = startRelayTransport({ logger, - attachSocket: (ws) => { + attachSocket: (ws, metadata) => { if (!wsServer) { throw new Error("WebSocket server not initialized"); } - return wsServer.attachExternalSocket(ws); + return wsServer.attachExternalSocket(ws, metadata); }, relayEndpoint, serverId, diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts index 919161cab..bfe1b6eec 100644 --- a/packages/server/src/server/relay-transport.test.ts +++ b/packages/server/src/server/relay-transport.test.ts @@ -188,4 +188,36 @@ describe("relay-transport control lifecycle", () => { expect(hasLogMessage(logger.warn, "relay_control_stale_terminating")).toBe(true); expect(control.terminateCalls).toBe(1); }); + + test("passes stable relay external session metadata when attaching data socket", async () => { + const logger = createMockLogger(); + const attachSocket = vi.fn(async () => {}); + const controller = startRelayTransport({ + logger: logger as any, + attachSocket, + relayEndpoint: "relay.paseo.sh:443", + serverId: "srv_test", + }); + controllers.push(controller); + + const control = MockWebSocket.instances[0]; + control.open(); + control.message(JSON.stringify({ type: "pong", ts: Date.now() })); + control.message(JSON.stringify({ type: "client_connected", clientId: "clt_test" })); + + const dataSocket = MockWebSocket.instances[1]; + expect(dataSocket).toBeDefined(); + dataSocket.open(); + + await Promise.resolve(); + + expect(attachSocket).toHaveBeenCalledTimes(1); + expect(attachSocket).toHaveBeenCalledWith( + dataSocket, + { + transport: "relay", + externalSessionKey: "relay:clt_test", + } + ); + }); }); diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 29eca1bfa..494942294 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -9,10 +9,11 @@ import { type KeyPair, } from "@getpaseo/relay/e2ee"; import { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js"; +import type { ExternalSocketMetadata } from "./websocket-server.js"; type RelayTransportOptions = { logger: pino.Logger; - attachSocket: (ws: RelaySocketLike) => Promise; + attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise; relayEndpoint: string; // "host:port" serverId: string; daemonKeyPair?: KeyPair; @@ -321,10 +322,20 @@ export function startRelayTransport({ relayLogger.info({ url, clientId }, "relay_data_connected"); if (attached) return; attached = true; + const externalMetadata: ExternalSocketMetadata = { + transport: "relay", + externalSessionKey: `relay:${clientId}`, + }; if (daemonKeyPair) { - void attachEncryptedSocket(socket, daemonKeyPair, relayLogger.child({ clientId }), attachSocket); + void attachEncryptedSocket( + socket, + daemonKeyPair, + relayLogger.child({ clientId }), + attachSocket, + externalMetadata + ); } else { - void attachSocket(socket); + void attachSocket(socket, externalMetadata); } }); @@ -353,7 +364,8 @@ async function attachEncryptedSocket( socket: WebSocket, daemonKeyPair: KeyPair, logger: pino.Logger, - attachSocket: (ws: RelaySocketLike) => Promise + attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise, + metadata?: ExternalSocketMetadata ): Promise { try { const relayTransport = createRelayTransportAdapter(socket); @@ -367,7 +379,7 @@ async function attachEncryptedSocket( }, }); const encryptedSocket = createEncryptedSocket(channel, emitter); - await attachSocket(encryptedSocket); + await attachSocket(encryptedSocket, metadata); } catch (error) { logger.warn({ err: error }, "relay_e2ee_handshake_failed"); try { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8ecb4a8b6..d8dfb0f8f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -103,6 +103,7 @@ import { getCheckoutDiff, getCheckoutStatus, getCheckoutStatusLite, + listBranchSuggestions, NotGitRepoError, MergeConflictError, MergeFromBaseConflictError, @@ -1232,6 +1233,10 @@ export class Session { await this.handleValidateBranchRequest(msg); break; + case "branch_suggestions_request": + await this.handleBranchSuggestionsRequest(msg); + break; + case "subscribe_checkout_diff_request": await this.handleSubscribeCheckoutDiffRequest(msg); break; @@ -3589,6 +3594,34 @@ export class Session { } } + private async handleBranchSuggestionsRequest( + msg: Extract + ): Promise { + const { cwd, query, limit, requestId } = msg; + + try { + const resolvedCwd = expandTilde(cwd); + const branches = await listBranchSuggestions(resolvedCwd, { query, limit }); + this.emit({ + type: "branch_suggestions_response", + payload: { + branches, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "branch_suggestions_response", + payload: { + branches: [], + error: error instanceof Error ? error.message : String(error), + requestId, + }, + }); + } + } + private normalizeCheckoutDiffCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput { if (compare.mode === "uncommitted") { return { mode: "uncommitted" }; diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts new file mode 100644 index 000000000..4056c1ad5 --- /dev/null +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const wsModuleMock = vi.hoisted(() => { + class MockWebSocketServer { + static instances: MockWebSocketServer[] = []; + readonly handlers = new Map void>(); + + constructor(_options: unknown) { + MockWebSocketServer.instances.push(this); + } + + on(event: string, handler: (...args: any[]) => void) { + this.handlers.set(event, handler); + return this; + } + + close() { + // no-op + } + } + + return { MockWebSocketServer }; +}); + +const sessionMock = vi.hoisted(() => { + const instances: MockSession[] = []; + + class MockSession { + cleanup = vi.fn(async () => {}); + handleMessage = vi.fn(async () => {}); + getClientActivity = vi.fn(() => null); + readonly args: Record; + + constructor(args: Record) { + this.args = args; + instances.push(this); + } + } + + return { MockSession, instances }; +}); + +vi.mock("ws", () => ({ + WebSocketServer: wsModuleMock.MockWebSocketServer, +})); + +vi.mock("./session.js", () => ({ + Session: sessionMock.MockSession, +})); + +vi.mock("./push/token-store.js", () => ({ + PushTokenStore: class { + getAllTokens(): string[] { + return []; + } + }, +})); + +vi.mock("./push/push-service.js", () => ({ + PushService: class { + async sendPush(): Promise { + // no-op + } + }, +})); + +import { + VoiceAssistantWebSocketServer, + type ExternalSocketMetadata, +} from "./websocket-server"; + +class MockSocket { + readyState = 1; + sent: string[] = []; + private listeners = new Map void>>(); + + on(event: "message" | "close" | "error", listener: (...args: any[]) => void): void { + const handlers = this.listeners.get(event) ?? []; + handlers.push(listener); + this.listeners.set(event, handlers); + } + + once(event: "close" | "error", listener: (...args: any[]) => void): void { + const wrapped = (...args: any[]) => { + this.off(event, wrapped); + listener(...args); + }; + this.on(event, wrapped); + } + + send(data: string): void { + this.sent.push(data); + } + + close(code?: number, reason?: string): void { + this.readyState = 3; + this.emit("close", code ?? 1000, reason ?? ""); + } + + emit(event: "message" | "close" | "error", ...args: any[]): void { + const handlers = this.listeners.get(event) ?? []; + for (const handler of [...handlers]) { + handler(...args); + } + } + + private off(event: "close" | "error", listener: (...args: any[]) => void): void { + const handlers = this.listeners.get(event) ?? []; + this.listeners.set( + event, + handlers.filter((handler) => handler !== listener) + ); + } +} + +function createLogger() { + const logger = { + child: vi.fn(() => logger), + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + return logger; +} + +function createServer() { + return new VoiceAssistantWebSocketServer( + {} as any, + createLogger() as any, + "srv_test", + { + setAgentAttentionCallback: vi.fn(), + getAgent: vi.fn(() => null), + } as any, + {} as any, + {} as any, + "/tmp/paseo-test", + async () => ({} as any), + { allowedOrigins: new Set() } + ); +} + +describe("relay external socket reconnect behavior", () => { + beforeEach(() => { + sessionMock.instances.length = 0; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("keeps the same session when relay reconnects within grace window", async () => { + const server = createServer(); + const metadata: ExternalSocketMetadata = { + transport: "relay", + externalSessionKey: "relay:client-1", + }; + + const socket1 = new MockSocket(); + await server.attachExternalSocket(socket1, metadata); + expect(sessionMock.instances).toHaveLength(1); + const session = sessionMock.instances[0]!; + + socket1.emit("close", 1006, ""); + await vi.advanceTimersByTimeAsync(1_000); + expect(session.cleanup).not.toHaveBeenCalled(); + + const socket2 = new MockSocket(); + await server.attachExternalSocket(socket2, metadata); + expect(sessionMock.instances).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(20_000); + expect(session.cleanup).not.toHaveBeenCalled(); + + await server.close(); + }); + + test("cleans up relay session when reconnect grace expires", async () => { + const server = createServer(); + const metadata: ExternalSocketMetadata = { + transport: "relay", + externalSessionKey: "relay:client-2", + }; + + const socket1 = new MockSocket(); + await server.attachExternalSocket(socket1, metadata); + expect(sessionMock.instances).toHaveLength(1); + const session = sessionMock.instances[0]!; + + socket1.emit("close", 1006, ""); + await vi.advanceTimersByTimeAsync(90_000); + expect(session.cleanup).toHaveBeenCalledTimes(1); + + await server.close(); + }); +}); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 14284da92..c4cbebe0d 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -29,6 +29,10 @@ import type { } from "./voice-types.js"; export type AgentMcpTransportFactory = () => Promise; +export type ExternalSocketMetadata = { + transport: "relay"; + externalSessionKey: string; +}; type WebSocketServerConfig = { allowedOrigins: Set; @@ -56,13 +60,25 @@ type WebSocketLike = { once: (event: "close" | "error", listener: (...args: any[]) => void) => void; }; +type SessionConnection = { + session: Session; + clientId: string; + connectionLogger: pino.Logger; + socketRef: { current: WebSocketLike }; + externalSessionKey: string | null; + externalDisconnectCleanupTimeout: ReturnType | null; +}; + +const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000; + /** * WebSocket server that only accepts sockets + parses/forwards messages to the session layer. */ export class VoiceAssistantWebSocketServer { private readonly logger: pino.Logger; private readonly wss: WebSocketServer; - private readonly sessions: Map = new Map(); + private readonly sessions: Map = new Map(); + private readonly externalSessionsByKey: Map = new Map(); private clientIdCounter = 0; private readonly serverId: string; private readonly agentManager: AgentManager; @@ -191,15 +207,27 @@ export class VoiceAssistantWebSocketServer { } public async attachExternalSocket( - ws: WebSocketLike + ws: WebSocketLike, + metadata?: ExternalSocketMetadata ): Promise { - await this.attachSocket(ws); + await this.attachSocket(ws, undefined, metadata); } public async close(): Promise { + const uniqueConnections = new Set([ + ...this.sessions.values(), + ...this.externalSessionsByKey.values(), + ]); + const cleanupPromises: Promise[] = []; - for (const [ws, session] of this.sessions) { - cleanupPromises.push(session.cleanup()); + for (const connection of uniqueConnections) { + if (connection.externalDisconnectCleanupTimeout) { + clearTimeout(connection.externalDisconnectCleanupTimeout); + connection.externalDisconnectCleanupTimeout = null; + } + + const ws = connection.socketRef.current; + cleanupPromises.push(connection.session.cleanup()); cleanupPromises.push( new Promise((resolve) => { // WebSocket.CLOSED = 3 @@ -214,6 +242,7 @@ export class VoiceAssistantWebSocketServer { } await Promise.all(cleanupPromises); this.sessions.clear(); + this.externalSessionsByKey.clear(); this.wss.close(); } @@ -224,14 +253,53 @@ export class VoiceAssistantWebSocketServer { } } - private async attachSocket(ws: WebSocketLike, _request?: unknown): Promise { + private async attachSocket( + ws: WebSocketLike, + _request?: unknown, + metadata?: ExternalSocketMetadata + ): Promise { + const externalSessionKey = + metadata?.transport === "relay" && metadata.externalSessionKey.trim().length > 0 + ? metadata.externalSessionKey + : null; + + if (externalSessionKey) { + const existing = this.externalSessionsByKey.get(externalSessionKey); + if (existing) { + if (existing.externalDisconnectCleanupTimeout) { + clearTimeout(existing.externalDisconnectCleanupTimeout); + existing.externalDisconnectCleanupTimeout = null; + } + + const previousSocket = existing.socketRef.current; + if (previousSocket !== ws) { + this.sessions.delete(previousSocket); + existing.socketRef.current = ws; + } + + this.sessions.set(ws, existing); + this.sendServerInfo(ws); + existing.connectionLogger.trace( + { + clientId: existing.clientId, + externalSessionKey, + totalSessions: this.sessions.size, + }, + "Client reconnected" + ); + this.bindSocketHandlers(ws, existing); + return; + } + } + const clientId = `client-${++this.clientIdCounter}`; const connectionLogger = this.logger.child({ clientId }); + const socketRef = { current: ws }; const session = new Session({ clientId, onMessage: (msg) => { - this.sendToClient(ws, wrapSessionMessage(msg)); + this.sendToClient(socketRef.current, wrapSessionMessage(msg)); }, logger: connectionLogger.child({ module: "session" }), downloadTokenStore: this.downloadTokenStore, @@ -264,8 +332,31 @@ export class VoiceAssistantWebSocketServer { agentProviderRuntimeSettings: this.agentProviderRuntimeSettings, }); - this.sessions.set(ws, session); + const connection: SessionConnection = { + session, + clientId, + connectionLogger, + socketRef, + externalSessionKey, + externalDisconnectCleanupTimeout: null, + }; + this.sessions.set(ws, connection); + if (externalSessionKey) { + this.externalSessionsByKey.set(externalSessionKey, connection); + } + + this.sendServerInfo(ws); + + connectionLogger.trace( + { clientId, externalSessionKey, totalSessions: this.sessions.size }, + "Client connected" + ); + + this.bindSocketHandlers(ws, connection); + } + + private sendServerInfo(ws: WebSocketLike): void { // Advertise stable server identity immediately on connect (used for URL/shareable IDs). this.sendToClient( ws, @@ -278,24 +369,27 @@ export class VoiceAssistantWebSocketServer { }, }) ); + } - connectionLogger.trace( - { clientId, totalSessions: this.sessions.size }, - "Client connected" - ); - + private bindSocketHandlers( + ws: WebSocketLike, + connection: SessionConnection + ): void { ws.on("message", (data) => { void this.handleRawMessage(ws, data); }); - ws.on("close", async () => { - await this.detachSocket(ws, connectionLogger, clientId); + ws.on("close", async (code: number, reason: unknown) => { + await this.detachSocket(ws, connection, { + code: typeof code === "number" ? code : undefined, + reason, + }); }); ws.on("error", async (error) => { const err = error instanceof Error ? error : new Error(String(error)); - connectionLogger.error({ err }, "Client error"); - await this.detachSocket(ws, connectionLogger, clientId); + connection.connectionLogger.error({ err }, "Client error"); + await this.detachSocket(ws, connection, { error: err }); }); } @@ -313,19 +407,72 @@ export class VoiceAssistantWebSocketServer { private async detachSocket( ws: WebSocketLike, - connectionLogger: pino.Logger, - clientId: string + connection: SessionConnection, + details: { + code?: number; + reason?: unknown; + error?: Error; + } ): Promise { - const session = this.sessions.get(ws); - if (!session) return; - - connectionLogger.trace( - { clientId, totalSessions: this.sessions.size - 1 }, - "Client disconnected" - ); - - await session.cleanup(); + const activeConnection = this.sessions.get(ws); + if (activeConnection !== connection) return; this.sessions.delete(ws); + + if ( + connection.externalSessionKey && + connection.socketRef.current === ws + ) { + if (connection.externalDisconnectCleanupTimeout) { + clearTimeout(connection.externalDisconnectCleanupTimeout); + } + const timeout = setTimeout(() => { + if (connection.externalDisconnectCleanupTimeout !== timeout) { + return; + } + connection.externalDisconnectCleanupTimeout = null; + void this.cleanupConnection(connection, "Client disconnected (grace timeout)"); + }, EXTERNAL_SESSION_DISCONNECT_GRACE_MS); + connection.externalDisconnectCleanupTimeout = timeout; + + connection.connectionLogger.trace( + { + clientId: connection.clientId, + externalSessionKey: connection.externalSessionKey, + code: details.code, + reason: stringifyCloseReason(details.reason), + reconnectGraceMs: EXTERNAL_SESSION_DISCONNECT_GRACE_MS, + }, + "Client disconnected; waiting for reconnect" + ); + return; + } + + await this.cleanupConnection(connection, "Client disconnected"); + } + + private async cleanupConnection( + connection: SessionConnection, + logMessage: string + ): Promise { + if (connection.externalDisconnectCleanupTimeout) { + clearTimeout(connection.externalDisconnectCleanupTimeout); + connection.externalDisconnectCleanupTimeout = null; + } + + const currentSocket = connection.socketRef.current; + this.sessions.delete(currentSocket); + if (connection.externalSessionKey) { + const existing = this.externalSessionsByKey.get(connection.externalSessionKey); + if (existing === connection) { + this.externalSessionsByKey.delete(connection.externalSessionKey); + } + } + + connection.connectionLogger.trace( + { clientId: connection.clientId, totalSessions: this.sessions.size }, + logMessage + ); + await connection.session.cleanup(); } private async handleRawMessage( @@ -395,14 +542,14 @@ export class VoiceAssistantWebSocketServer { return; } - const session = this.sessions.get(ws); - if (!session) { + const connection = this.sessions.get(ws); + if (!connection) { this.logger.error("No session found for client"); return; } if (message.type === "session") { - await session.handleMessage(message.message); + await connection.session.handleMessage(message.message); } } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); @@ -555,10 +702,10 @@ export class VoiceAssistantWebSocketServer { }; }> = []; - for (const [ws, session] of this.sessions) { + for (const [ws, connection] of this.sessions) { clientEntries.push({ ws, - state: this.getClientActivityState(session), + state: this.getClientActivityState(connection.session), }); } @@ -626,6 +773,21 @@ export class VoiceAssistantWebSocketServer { } } +function stringifyCloseReason(reason: unknown): string | null { + if (typeof reason === "string") { + return reason.length > 0 ? reason : null; + } + if (Buffer.isBuffer(reason)) { + const text = reason.toString(); + return text.length > 0 ? text : null; + } + if (reason == null) { + return null; + } + const text = String(reason); + return text.length > 0 ? text : null; +} + function extractRequestInfoFromUnknownWsInbound( payload: unknown ): { requestId: string; requestType?: string } | null { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 5df69c6c5..2041a9122 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -791,6 +791,14 @@ export const ValidateBranchRequestSchema = z.object({ requestId: z.string(), }); +export const BranchSuggestionsRequestSchema = z.object({ + type: z.literal("branch_suggestions_request"), + cwd: z.string(), + query: z.string().optional(), + limit: z.number().int().min(1).max(200).optional(), + requestId: z.string(), +}); + export const PaseoWorktreeListRequestSchema = z.object({ type: z.literal("paseo_worktree_list_request"), cwd: z.string().optional(), @@ -1015,6 +1023,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrCreateRequestSchema, CheckoutPrStatusRequestSchema, ValidateBranchRequestSchema, + BranchSuggestionsRequestSchema, PaseoWorktreeListRequestSchema, PaseoWorktreeArchiveRequestSchema, FileExplorerRequestSchema, @@ -1565,6 +1574,15 @@ export const ValidateBranchResponseSchema = z.object({ }), }); +export const BranchSuggestionsResponseSchema = z.object({ + type: z.literal("branch_suggestions_response"), + payload: z.object({ + branches: z.array(z.string()), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + const PaseoWorktreeSchema = z.object({ worktreePath: z.string(), branchName: z.string().nullable().optional(), @@ -1835,6 +1853,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrCreateResponseSchema, CheckoutPrStatusResponseSchema, ValidateBranchResponseSchema, + BranchSuggestionsResponseSchema, PaseoWorktreeListResponseSchema, PaseoWorktreeArchiveResponseSchema, FileExplorerResponseSchema, @@ -1966,6 +1985,8 @@ export type CheckoutPrStatusRequest = z.infer; export type ValidateBranchRequest = z.infer; export type ValidateBranchResponse = z.infer; +export type BranchSuggestionsRequest = z.infer; +export type BranchSuggestionsResponse = z.infer; export type PaseoWorktreeListRequest = z.infer; export type PaseoWorktreeListResponse = z.infer; export type PaseoWorktreeArchiveRequest = z.infer; diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index f6d4acfa3..5363407e2 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -9,6 +9,7 @@ import { getPullRequestStatus, getCheckoutStatus, getCheckoutStatusLite, + listBranchSuggestions, mergeToBase, mergeFromBase, MergeConflictError, @@ -402,6 +403,51 @@ describe("checkout git utilities", () => { execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`); }); + it("lists merged local and remote branch suggestions without duplicates", async () => { + const remoteDir = join(tempDir, "remote.git"); + execSync(`git init --bare -b main ${remoteDir}`); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); + execSync("git push -u origin main", { cwd: repoDir }); + + execSync("git checkout -b local-only", { cwd: repoDir }); + execSync("git checkout main", { cwd: repoDir }); + + const otherClone = join(tempDir, "other-clone"); + execSync(`git clone ${remoteDir} ${otherClone}`); + execSync("git config user.email 'test@test.com'", { cwd: otherClone }); + execSync("git config user.name 'Test'", { cwd: otherClone }); + execSync("git checkout -b remote-only", { cwd: otherClone }); + writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n"); + execSync("git add remote-only.txt", { cwd: otherClone }); + execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone }); + execSync("git push -u origin remote-only", { cwd: otherClone }); + execSync("git fetch origin", { cwd: repoDir }); + + const branches = await listBranchSuggestions(repoDir, { limit: 50 }); + expect(branches).toContain("main"); + expect(branches).toContain("local-only"); + expect(branches).toContain("remote-only"); + expect(branches.filter((name) => name === "main")).toHaveLength(1); + expect(branches).not.toContain("HEAD"); + expect(branches.some((name) => name.startsWith("origin/"))).toBe(false); + }); + + it("filters branch suggestions by query and enforces result limit", async () => { + execSync("git checkout -b feature/alpha", { cwd: repoDir }); + execSync("git checkout main", { cwd: repoDir }); + execSync("git checkout -b feature/beta", { cwd: repoDir }); + execSync("git checkout main", { cwd: repoDir }); + execSync("git checkout -b chore/docs", { cwd: repoDir }); + execSync("git checkout main", { cwd: repoDir }); + + const branches = await listBranchSuggestions(repoDir, { + query: "FEATURE/", + limit: 1, + }); + expect(branches).toHaveLength(1); + expect(branches[0]?.toLowerCase()).toContain("feature/"); + }); + it("disables GitHub features when gh is unavailable", async () => { execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 1792621dc..721b3f658 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -104,6 +104,128 @@ type CheckoutFileChange = { isUntracked?: boolean; }; +type BranchSuggestionRefOrigin = "local" | "remote"; + +function normalizeBranchSuggestionName(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + + let normalized = trimmed; + if (normalized.startsWith("refs/heads/")) { + normalized = normalized.slice("refs/heads/".length); + } else if (normalized.startsWith("refs/remotes/")) { + normalized = normalized.slice("refs/remotes/".length); + } + + if (normalized.startsWith("origin/")) { + normalized = normalized.slice("origin/".length); + } + + if (!normalized || normalized === "HEAD") { + return null; + } + + return normalized; +} + +async function listGitRefs(cwd: string, refPrefix: string): Promise { + const { stdout } = await execGit( + `git for-each-ref --format="%(refname:short)" ${refPrefix}`, + { + cwd, + env: READ_ONLY_GIT_ENV, + } + ); + return stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function sortBranchSuggestions( + branchNames: string[], + localBranchNames: Set, + query: string +): string[] { + const normalizedQuery = query.trim().toLowerCase(); + const hasQuery = normalizedQuery.length > 0; + return branchNames.sort((a, b) => { + const aLower = a.toLowerCase(); + const bLower = b.toLowerCase(); + + if (hasQuery) { + const aPrefix = aLower.startsWith(normalizedQuery); + const bPrefix = bLower.startsWith(normalizedQuery); + if (aPrefix !== bPrefix) { + return aPrefix ? -1 : 1; + } + } + + const aIsLocal = localBranchNames.has(a); + const bIsLocal = localBranchNames.has(b); + if (aIsLocal !== bIsLocal) { + return aIsLocal ? -1 : 1; + } + + return a.localeCompare(b); + }); +} + +export async function listBranchSuggestions( + cwd: string, + options?: { query?: string; limit?: number } +): Promise { + await requireGitRepo(cwd); + + const requestedLimit = options?.limit ?? 50; + const limit = Math.max(1, Math.min(200, requestedLimit)); + const query = options?.query?.trim().toLowerCase() ?? ""; + + const [localRefs, remoteRefs] = await Promise.all([ + listGitRefs(cwd, "refs/heads"), + listGitRefs(cwd, "refs/remotes/origin"), + ]); + + const merged = new Map>(); + for (const localRef of localRefs) { + const normalized = normalizeBranchSuggestionName(localRef); + if (!normalized) { + continue; + } + const origins = merged.get(normalized) ?? new Set(); + origins.add("local"); + merged.set(normalized, origins); + } + for (const remoteRef of remoteRefs) { + const normalized = normalizeBranchSuggestionName(remoteRef); + if (!normalized) { + continue; + } + const origins = merged.get(normalized) ?? new Set(); + origins.add("remote"); + merged.set(normalized, origins); + } + + const filteredNames = Array.from(merged.keys()).filter((name) => + query ? name.toLowerCase().includes(query) : true + ); + if (filteredNames.length === 0) { + return []; + } + + const localBranchNames = new Set(); + for (const [name, origins] of merged) { + if (origins.has("local")) { + localBranchNames.add(name); + } + } + + const ordered = sortBranchSuggestions(filteredNames, localBranchNames, query); + return ordered.slice(0, limit); +} + async function listCheckoutFileChanges(cwd: string, ref: string): Promise { const changes: CheckoutFileChange[] = []; diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 48672af21..8cd852ad8 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -171,7 +171,8 @@ describe("createWorktree", () => { const paseoConfig = { worktree: { setup: [ - 'echo "root=$PASEO_ROOT_PATH" > setup.log', + 'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > setup.log', + 'echo "root_alias=$PASEO_ROOT_PATH" >> setup.log', 'echo "worktree=$PASEO_WORKTREE_PATH" >> setup.log', 'echo "branch=$PASEO_BRANCH_NAME" >> setup.log', 'echo "port=$PASEO_WORKTREE_PORT" >> setup.log', @@ -193,7 +194,8 @@ describe("createWorktree", () => { // Verify setup ran and env vars were available const setupLog = readFileSync(join(result.worktreePath, "setup.log"), "utf8"); - expect(setupLog).toContain(`root=${repoDir}`); + expect(setupLog).toContain(`source=${repoDir}`); + expect(setupLog).toContain(`root_alias=${repoDir}`); expect(setupLog).toContain(`worktree=${result.worktreePath}`); expect(setupLog).toContain("branch=setup-test"); const portLine = setupLog @@ -335,9 +337,10 @@ describe("paseo worktree manager", () => { const paseoConfig = { worktree: { destroy: [ - 'echo "root=$PASEO_ROOT_PATH" > "$PASEO_ROOT_PATH/destroy.log"', - 'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_ROOT_PATH/destroy.log"', - 'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_ROOT_PATH/destroy.log"', + 'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', + 'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', + 'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', + 'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', ], }, }; @@ -359,7 +362,8 @@ describe("paseo worktree manager", () => { expect(existsSync(created.worktreePath)).toBe(false); const destroyLog = readFileSync(join(repoDir, "destroy.log"), "utf8"); - expect(destroyLog).toContain(`root=${repoDir}`); + expect(destroyLog).toContain(`source=${repoDir}`); + expect(destroyLog).toContain(`root_alias=${repoDir}`); expect(destroyLog).toContain(`worktree=${created.worktreePath}`); expect(destroyLog).toContain("branch=destroy-branch"); }); @@ -368,7 +372,7 @@ describe("paseo worktree manager", () => { const paseoConfig = { worktree: { destroy: [ - 'echo "started" > "$PASEO_ROOT_PATH/destroy-start.log"', + 'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy-start.log"', "echo boom 1>&2; exit 9", ], }, diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index c4ff7051b..037536ad4 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -204,8 +204,11 @@ export async function runWorktreeSetupCommands(options: { const setupEnv = { ...process.env, - // Root is the original git repo root (shared across worktrees), not the worktree itself. - // This allows setup scripts to copy uncommitted local files (e.g. .env) from the main checkout. + // Source checkout path is the original git repo root (shared across worktrees), not the + // worktree itself. This allows setup scripts to copy local files (e.g. .env) from the + // source checkout. + PASEO_SOURCE_CHECKOUT_PATH: repoRootPath, + // Backward-compatible alias. PASEO_ROOT_PATH: repoRootPath, PASEO_WORKTREE_PATH: options.worktreePath, PASEO_BRANCH_NAME: options.branchName, @@ -275,8 +278,11 @@ export async function runWorktreeDestroyCommands(options: { const destroyEnv = { ...process.env, - // Root is the original git repo root (shared across worktrees), not the worktree itself. - // This allows destroy scripts to clean resources using paths from the main checkout. + // Source checkout path is the original git repo root (shared across worktrees), not the + // worktree itself. This allows destroy scripts to clean resources using paths from the + // source checkout. + PASEO_SOURCE_CHECKOUT_PATH: repoRootPath, + // Backward-compatible alias. PASEO_ROOT_PATH: repoRootPath, PASEO_WORKTREE_PATH: options.worktreePath, PASEO_BRANCH_NAME: branchName, diff --git a/packages/website/src/routes/docs/worktrees.tsx b/packages/website/src/routes/docs/worktrees.tsx index 34fa5130a..0d8bbf0f4 100644 --- a/packages/website/src/routes/docs/worktrees.tsx +++ b/packages/website/src/routes/docs/worktrees.tsx @@ -110,7 +110,7 @@ function Worktrees() { "worktree": { "setup": [ "npm ci", - "cp \\"$PASEO_ROOT_PATH/.env\\" \\"$PASEO_WORKTREE_PATH/.env\\"" + "cp \\"$PASEO_SOURCE_CHECKOUT_PATH/.env\\" \\"$PASEO_WORKTREE_PATH/.env\\"" ] } }`} @@ -120,6 +120,13 @@ function Worktrees() { after the worktree is created. Use it to install dependencies, copy local config files, or run any other initialization.

+
+ Important: Setup commands come from{' '} + paseo.json in the selected base branch. If you pick{' '} + main, Paseo reads the committed file on{' '} + main. Local or uncommitted changes in another branch + are not used for that worktree. +
{/* Environment variables */} @@ -130,7 +137,12 @@ function Worktrees() {

  • - $PASEO_ROOT_PATH — your original repository root + $PASEO_SOURCE_CHECKOUT_PATH — your source checkout + path (original repository root) +
  • +
  • + $PASEO_ROOT_PATH — legacy alias of{' '} + $PASEO_SOURCE_CHECKOUT_PATH
  • $PASEO_WORKTREE_PATH — the new worktree directory @@ -143,9 +155,9 @@ function Worktrees() {

- Use $PASEO_ROOT_PATH to copy files that shouldn't - be in git (like .env) from your main checkout to - the worktree. + Use $PASEO_SOURCE_CHECKOUT_PATH to copy files that + shouldn't be in git (like .env) from your source + checkout to the worktree.

@@ -177,8 +189,8 @@ function Worktrees() { "worktree": { "setup": [ "npm ci", - "cp \\"$PASEO_ROOT_PATH/.env\\" \\"$PASEO_WORKTREE_PATH/.env\\"", - "cp \\"$PASEO_ROOT_PATH/.env.local\\" \\"$PASEO_WORKTREE_PATH/.env.local\\"" + "cp \\"$PASEO_SOURCE_CHECKOUT_PATH/.env\\" \\"$PASEO_WORKTREE_PATH/.env\\"", + "cp \\"$PASEO_SOURCE_CHECKOUT_PATH/.env.local\\" \\"$PASEO_WORKTREE_PATH/.env.local\\"" ] } }`} @@ -190,7 +202,7 @@ function Worktrees() { "worktree": { "setup": [ "npm ci", - "cp \\"$PASEO_ROOT_PATH/.env\\" \\"$PASEO_WORKTREE_PATH/.env\\"", + "cp \\"$PASEO_SOURCE_CHECKOUT_PATH/.env\\" \\"$PASEO_WORKTREE_PATH/.env\\"", "npm run db:migrate" ] } diff --git a/paseo.json b/paseo.json index 87b25baab..6ff7e37d5 100644 --- a/paseo.json +++ b/paseo.json @@ -2,7 +2,7 @@ "worktree": { "setup": [ "npm ci", - "cp \"$PASEO_ROOT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\"" + "cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\"" ] } }