diff --git a/package.json b/package.json index 6ccb4d380..a33618549 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,9 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.11", - "@modelcontextprotocol/sdk": "^1.27.1" + "@modelcontextprotocol/sdk": "^1.27.1", + "expo": "~54.0.33", + "react": "19.1.0", + "react-native": "0.81.5" } } diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 8cccf0248..e5cd47ede 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -494,26 +494,37 @@ export function CombinedModelSelector({ return { kind: "provider", providerId, providerLabel: label }; }, [allProviderModels, providerDefinitions]); + const computeInitialView = useCallback((): SelectorView => { + if (singleProviderView) return singleProviderView; + + const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`; + if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) { + const label = resolveProviderLabel(providerDefinitions, selectedProvider); + return { kind: "provider", providerId: selectedProvider, providerLabel: label }; + } + + return { kind: "all" }; + }, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providerDefinitions]); + const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); - setView(singleProviderView ?? { kind: "all" }); + setView(computeInitialView()); if (!open) { setSearchQuery(""); onClose?.(); } }, - [onClose, singleProviderView], + [onClose, computeInitialView], ); const handleSelect = useCallback( (provider: string, modelId: string) => { onSelect(provider as AgentProvider, modelId); setIsOpen(false); - setView(singleProviderView ?? { kind: "all" }); setSearchQuery(""); }, - [onSelect, singleProviderView], + [onSelect], ); const ProviderIcon = getProviderIcon(selectedProvider); @@ -523,6 +534,9 @@ export function CombinedModelSelector({ ); const selectedModelLabel = useMemo(() => { + if (!selectedModel) { + return isLoading ? "Loading..." : "Select model"; + } const models = allProviderModels.get(selectedProvider); if (!models) { return isLoading ? "Loading..." : "Select model"; diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index b035803e1..48c07b8b2 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -27,7 +27,6 @@ import { type GestureType } from "react-native-gesture-handler"; import * as Clipboard from "expo-clipboard"; import { Archive, - Check, CircleAlert, ChevronDown, ChevronRight, @@ -41,7 +40,6 @@ import { MoreVertical, Plus, Trash2, - X, } from "lucide-react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; @@ -95,7 +93,7 @@ import { requireWorkspaceExecutionDirectory, resolveWorkspaceExecutionDirectory, } from "@/utils/workspace-execution"; -import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; +import { CheckStatusIndicator, WorkspaceHoverCard } from "@/components/workspace-hover-card"; import { createNameId } from "mnemonic-id"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { @@ -226,28 +224,6 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) { ); } -function ChecksStatusIcon({ checksStatus }: { checksStatus?: PrHint["checksStatus"] }) { - const { theme } = useUnistyles(); - if (!checksStatus || checksStatus === "none") return null; - - if (checksStatus === "success") { - return ; - } - if (checksStatus === "failure") { - return ; - } - // pending - return ( - - ); -} function WorkspaceStatusIndicator({ bucket, @@ -950,7 +926,7 @@ function WorkspaceRowInner({ const showGlobe = isDesktop && workspace.hasRunningServices; return ( - + setIsHovered(true)} @@ -1067,7 +1043,7 @@ function WorkspaceRowInner({ {prHint ? ( - + ) : null} diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index a2936931b..cc3d59ea4 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -9,16 +9,13 @@ import { import { Dimensions, Platform, Text, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ExternalLink, FolderGit2, GitPullRequest, Monitor } from "lucide-react-native"; +import { Check, ExternalLink, GitPullRequest, Minus, X } from "lucide-react-native"; import { Pressable } from "react-native"; import { Portal } from "@gorhom/portal"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; -import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query"; +import type { PrHint } from "@/hooks/use-checkout-pr-status-query"; import { openExternalUrl } from "@/utils/open-external-url"; -import { getStatusDotColor } from "@/utils/status-dot-color"; -import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; -import { SyncedLoader } from "@/components/synced-loader"; interface Rect { x: number; @@ -70,11 +67,13 @@ const HOVER_CARD_WIDTH = 260; interface WorkspaceHoverCardProps { workspace: SidebarWorkspaceEntry; + prHint: PrHint | null; isDragging: boolean; } export function WorkspaceHoverCard({ workspace, + prHint, isDragging, children, }: PropsWithChildren): ReactElement { @@ -84,7 +83,7 @@ export function WorkspaceHoverCard({ } return ( - + {children} ); @@ -92,6 +91,7 @@ export function WorkspaceHoverCard({ function WorkspaceHoverCardDesktop({ workspace, + prHint, isDragging, children, }: PropsWithChildren): ReactElement { @@ -102,6 +102,7 @@ function WorkspaceHoverCardDesktop({ const contentHoveredRef = useRef(false); const hasServices = workspace.services.length > 0; + const hasContent = hasServices || prHint !== null; const clearGraceTimer = useCallback(() => { if (graceTimerRef.current) { @@ -123,10 +124,10 @@ function WorkspaceHoverCardDesktop({ const handleTriggerEnter = useCallback(() => { triggerHoveredRef.current = true; clearGraceTimer(); - if (!isDragging && hasServices) { + if (!isDragging && hasContent) { setOpen(true); } - }, [clearGraceTimer, isDragging, hasServices]); + }, [clearGraceTimer, isDragging, hasContent]); const handleTriggerLeave = useCallback(() => { triggerHoveredRef.current = false; @@ -151,13 +152,13 @@ function WorkspaceHoverCardDesktop({ } }, [isDragging, clearGraceTimer]); - // When hasServices becomes true while trigger is already hovered, open the card. + // When content becomes available while trigger is already hovered, open the card. useEffect(() => { - if (!hasServices || isDragging) return; + if (!hasContent || isDragging) return; if (triggerHoveredRef.current) { setOpen(true); } - }, [hasServices, isDragging]); + }, [hasContent, isDragging]); // Cleanup on unmount useEffect(() => { @@ -174,9 +175,10 @@ function WorkspaceHoverCardDesktop({ onPointerLeave={handleTriggerLeave} > {children} - {open && hasServices ? ( + {open && hasContent ? ( = { closed: "Closed", }; -function HoverCardStatusIndicator({ - workspace, + +export function CheckStatusIndicator({ + status, + size = 14, }: { - workspace: SidebarWorkspaceEntry; + status: string; + size?: number; }): ReactElement | null { const { theme } = useUnistyles(); - const showSyncedLoader = shouldRenderSyncedStatusLoader({ bucket: workspace.statusBucket }); + const iconSize = Math.round(size * 0.6); - if (showSyncedLoader) { - return ; + if (!status || status === "none") return null; + + if (status === "pending") { + return ( + + ); } - const KindIcon = - workspace.workspaceKind === "checkout" - ? Monitor - : workspace.workspaceKind === "worktree" - ? FolderGit2 - : null; - if (!KindIcon) return null; + if (status === "success") { + return ( + + + + ); + } - const dotColor = getStatusDotColor({ theme, bucket: workspace.statusBucket, showDoneAsInactive: false }); + if (status === "failure") { + return ( + + + + ); + } + // skipped / cancelled / unknown return ( - - - {dotColor ? ( - - ) : null} + + ); } function WorkspaceHoverCardContent({ workspace, + prHint, triggerRef, onContentEnter, onContentLeave, }: { workspace: SidebarWorkspaceEntry; + prHint: PrHint | null; triggerRef: React.RefObject; onContentEnter: () => void; onContentLeave: () => void; @@ -248,11 +291,6 @@ function WorkspaceHoverCardContent({ const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); - const prHint = useWorkspacePrHint({ - serverId: workspace.serverId, - cwd: workspace.workspaceId, - enabled: workspace.projectKind !== "directory", - }); // Measure trigger — same pattern as tooltip.tsx useEffect(() => { @@ -315,29 +353,37 @@ function WorkspaceHoverCardContent({ ]} > - {workspace.name} - {workspace.diffStat ? ( - - +{workspace.diffStat.additions} - -{workspace.diffStat.deletions} - - ) : null} - {prHint ? ( + {prHint || workspace.diffStat ? ( void openExternalUrl(prHint.url)} + onPress={prHint ? () => void openExternalUrl(prHint.url) : undefined} + disabled={!prHint} > - - - #{prHint.number} · {GITHUB_PR_STATE_LABELS[prHint.state]} - + {prHint ? ( + <> + + + #{prHint.number} · {GITHUB_PR_STATE_LABELS[prHint.state]} + + + ) : null} + + {workspace.diffStat ? ( + <> + +{workspace.diffStat.additions} + -{workspace.diffStat.deletions} + + ) : null} ) : null} {prHint?.checks && prHint.checks.length > 0 ? ( + <> + + Checks {prHint.checks.map((check) => ( void openExternalUrl(check.url!) : undefined} disabled={!check.url} > - + ))} + + ) : null} + {workspace.services.length > 0 ? ( + <> + + Services + + {workspace.services.map((service) => ( + [ + styles.serviceRow, + hovered && styles.serviceRowHovered, + ]} + onPress={() => { + if (service.url) { + void openExternalUrl(service.url); + } + }} + disabled={!service.url} + > + + + {service.serviceName} + + {service.url ? ( + + {service.url.replace(/^https?:\/\//, "")} + + ) : null} + {service.url ? ( + + ) : null} + + ))} + + ) : null} - - - {workspace.services.map((service) => ( - [ - styles.serviceRow, - hovered && styles.serviceRowHovered, - ]} - onPress={() => { - if (service.url) { - void openExternalUrl(service.url); - } - }} - disabled={!service.url} - > - - - {service.serviceName} - - {service.url ? ( - - {service.url.replace(/^https?:\/\//, "")} - - ) : null} - {service.url ? ( - - ) : null} - - ))} - @@ -467,7 +505,7 @@ const styles = StyleSheet.create((theme) => ({ cardTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, + fontWeight: theme.fontWeight.normal, flex: 1, minWidth: 0, }, @@ -492,22 +530,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, - hoverStatusIcon: { - width: 14, - height: 14, - alignItems: "center", - justifyContent: "center", - position: "relative", - }, - hoverStatusDotOverlay: { - position: "absolute", - bottom: -1, - right: -1, - width: 6, - height: 6, - borderRadius: 3, - borderWidth: 1, - }, separator: { height: 1, backgroundColor: theme.colors.border, @@ -542,6 +564,14 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minWidth: 0, }, + sectionLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + paddingHorizontal: theme.spacing[3], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, checksList: { paddingBottom: theme.spacing[1], }, diff --git a/packages/app/src/hooks/use-agent-form-state.test.ts b/packages/app/src/hooks/use-agent-form-state.test.ts index d909d817c..57df59356 100644 --- a/packages/app/src/hooks/use-agent-form-state.test.ts +++ b/packages/app/src/hooks/use-agent-form-state.test.ts @@ -56,7 +56,7 @@ describe("useAgentFormState", () => { }, ]; - it("auto-selects the model's default thinking option when none is configured", () => { + it("does not auto-select a model on fresh drafts without preferences", () => { const resolved = __private__.resolveFormState( undefined, { provider: "codex" }, @@ -80,14 +80,14 @@ describe("useAgentFormState", () => { new Set(), ); - expect(resolved.model).toBe("gpt-5.3-codex"); - expect(resolved.thinkingOptionId).toBe("xhigh"); + expect(resolved.model).toBe(""); + expect(resolved.thinkingOptionId).toBe(""); }); - it("prefers provider defaults on fresh drafts", () => { + it("auto-selects the model's default thinking option when model is preferred but thinking is not", () => { const resolved = __private__.resolveFormState( undefined, - { provider: "codex" }, + { provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } }, codexModels, { serverId: false, @@ -115,7 +115,7 @@ describe("useAgentFormState", () => { it("falls back to model default when saved thinking preference is invalid", () => { const resolved = __private__.resolveFormState( undefined, - { provider: "codex" }, + { provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } }, codexModels, { serverId: false, @@ -195,7 +195,7 @@ describe("useAgentFormState", () => { it("keeps an explicit initial thinking option when it is valid", () => { const resolved = __private__.resolveFormState( - { thinkingOptionId: "low" }, + { model: "gpt-5.3-codex", thinkingOptionId: "low" }, { provider: "codex" }, codexModels, { @@ -237,7 +237,7 @@ describe("useAgentFormState", () => { const resolved = __private__.resolveFormState( undefined, - { provider: "claude" }, + { provider: "claude", providerPreferences: { claude: { model: "default" } } }, claudeModels, { serverId: false, diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 09cecb11c..5a9fb1add 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -138,7 +138,7 @@ function resolveEffectiveModel( } const normalizedModelId = modelId.trim(); if (!normalizedModelId) { - return resolveDefaultModel(availableModels); + return null; } return ( availableModels.find((model) => model.id === normalizedModelId) ?? @@ -243,8 +243,6 @@ function resolveFormState( } else { result.model = defaultModelId; } - } else if (defaultModelId) { - result.model = defaultModelId; } else { result.model = ""; } diff --git a/packages/app/src/hooks/use-agent-input-draft.test.ts b/packages/app/src/hooks/use-agent-input-draft.test.ts index 75cd673d8..b766b0ffd 100644 --- a/packages/app/src/hooks/use-agent-input-draft.test.ts +++ b/packages/app/src/hooks/use-agent-input-draft.test.ts @@ -112,13 +112,13 @@ describe("useAgentInputDraft", () => { ).toBe("gpt-5.4-mini"); }); - it("falls back to the provider default model", () => { + it("returns empty string when no model selected", () => { expect( __private__.resolveEffectiveComposerModelId({ selectedModel: "", availableModels: models, }), - ).toBe("gpt-5.4"); + ).toBe(""); }); }); diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index 78fb2c742..8550aa2bd 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -88,12 +88,7 @@ function resolveEffectiveComposerModelId(input: { selectedModel: string; availableModels: AgentModelDefinition[]; }): string { - const selectedModel = input.selectedModel.trim(); - if (selectedModel) { - return selectedModel; - } - - return input.availableModels.find((model) => model.isDefault)?.id ?? input.availableModels[0]?.id ?? ""; + return input.selectedModel.trim(); } function resolveEffectiveComposerThinkingOptionId(input: { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2f8f9752e..e2ebaa28d 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -9,6 +9,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import type { Logger } from "pino"; +import { createBranchChangeRouteHandler } from "./service-route-branch-handler.js"; export type ListenTarget = | { type: "tcp"; host: string; port: number } @@ -244,6 +245,20 @@ export async function createPaseoDaemon( daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), }), }); + const handleBranchChange = createBranchChangeRouteHandler({ + routeStore: serviceRouteStore, + emitServiceStatusUpdate: (workspaceId, services) => { + const message = { + type: "service_status_update" as const, + payload: { workspaceId, services }, + }; + const activeSessions = wsServer?.listActiveSessions() ?? []; + for (const session of activeSessions) { + session.emitServerMessage(message); + } + }, + logger, + }); // Host allowlist / DNS rebinding protection (vite-like semantics). // For non-TCP (unix sockets), skip host validation. @@ -687,6 +702,7 @@ export async function createPaseoDaemon( scheduleService, checkoutDiffManager, serviceRouteStore, + handleBranchChange, () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), (hostname) => serviceHealthMonitor.getStatusForHostname(hostname), ); diff --git a/packages/server/src/server/service-route-branch-handler.test.ts b/packages/server/src/server/service-route-branch-handler.test.ts new file mode 100644 index 000000000..cbb1e13e4 --- /dev/null +++ b/packages/server/src/server/service-route-branch-handler.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from "vitest"; +import { ServiceRouteStore } from "./service-proxy.js"; +import { createBranchChangeRouteHandler } from "./service-route-branch-handler.js"; + +function registerRoute( + routeStore: ServiceRouteStore, + { + hostname, + port, + workspaceId = "workspace-a", + serviceName, + }: { + hostname: string; + port: number; + workspaceId?: string; + serviceName: string; + }, +): void { + routeStore.registerRoute({ + hostname, + port, + workspaceId, + serviceName, + }); +} + +describe("service-route-branch-handler", () => { + it("updates routes on branch rename by removing old hostnames and registering new ones", () => { + const routeStore = new ServiceRouteStore(); + registerRoute(routeStore, { + hostname: "feature-auth.api.localhost", + port: 3001, + serviceName: "api", + }); + + const emitServiceStatusUpdate = vi.fn(); + const handleBranchChange = createBranchChangeRouteHandler({ + routeStore, + emitServiceStatusUpdate, + }); + + handleBranchChange("workspace-a", "feature/auth", "feature/billing"); + + expect(routeStore.findRoute("feature-auth.api.localhost")).toBeNull(); + expect(routeStore.findRoute("feature-billing.api.localhost")).toEqual({ + hostname: "feature-billing.api.localhost", + port: 3001, + }); + }); + + it("is a no-op when the workspace has no routes", () => { + const routeStore = new ServiceRouteStore(); + const emitServiceStatusUpdate = vi.fn(); + const handleBranchChange = createBranchChangeRouteHandler({ + routeStore, + emitServiceStatusUpdate, + }); + + handleBranchChange("workspace-a", "feature/auth", "feature/billing"); + + expect(routeStore.listRoutes()).toEqual([]); + expect(emitServiceStatusUpdate).not.toHaveBeenCalled(); + }); + + it("is a no-op when the resolved hostnames do not change", () => { + const routeStore = new ServiceRouteStore(); + registerRoute(routeStore, { + hostname: "api.localhost", + port: 3001, + serviceName: "api", + }); + + const emitServiceStatusUpdate = vi.fn(); + const handleBranchChange = createBranchChangeRouteHandler({ + routeStore, + emitServiceStatusUpdate, + }); + + handleBranchChange("workspace-a", "main", "master"); + + expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([ + { + hostname: "api.localhost", + port: 3001, + workspaceId: "workspace-a", + serviceName: "api", + }, + ]); + expect(emitServiceStatusUpdate).not.toHaveBeenCalled(); + }); + + it("emits a status update with the refreshed route payload after a route change", () => { + const routeStore = new ServiceRouteStore(); + registerRoute(routeStore, { + hostname: "feature-auth.api.localhost", + port: 3001, + serviceName: "api", + }); + + const emitServiceStatusUpdate = vi.fn(); + const handleBranchChange = createBranchChangeRouteHandler({ + routeStore, + emitServiceStatusUpdate, + }); + + handleBranchChange("workspace-a", "feature/auth", "feature/billing"); + + expect(emitServiceStatusUpdate).toHaveBeenCalledWith("workspace-a", [ + { + serviceName: "api", + hostname: "feature-billing.api.localhost", + port: 3001, + url: null, + status: "stopped", + }, + ]); + }); + + it("updates all services for a workspace when multiple routes are registered", () => { + const routeStore = new ServiceRouteStore(); + registerRoute(routeStore, { + hostname: "feature-auth.api.localhost", + port: 3001, + serviceName: "api", + }); + registerRoute(routeStore, { + hostname: "feature-auth.web.localhost", + port: 3002, + serviceName: "web", + }); + registerRoute(routeStore, { + hostname: "docs.localhost", + port: 3003, + workspaceId: "workspace-b", + serviceName: "docs", + }); + + const emitServiceStatusUpdate = vi.fn(); + const handleBranchChange = createBranchChangeRouteHandler({ + routeStore, + emitServiceStatusUpdate, + }); + + handleBranchChange("workspace-a", "feature/auth", "feature/billing"); + + expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([ + { + hostname: "feature-billing.api.localhost", + port: 3001, + workspaceId: "workspace-a", + serviceName: "api", + }, + { + hostname: "feature-billing.web.localhost", + port: 3002, + workspaceId: "workspace-a", + serviceName: "web", + }, + ]); + expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([ + { + hostname: "docs.localhost", + port: 3003, + workspaceId: "workspace-b", + serviceName: "docs", + }, + ]); + }); + + it("does not emit a status update when no changes are needed", () => { + const routeStore = new ServiceRouteStore(); + registerRoute(routeStore, { + hostname: "web.localhost", + port: 3002, + serviceName: "web", + }); + + const emitServiceStatusUpdate = vi.fn(); + const handleBranchChange = createBranchChangeRouteHandler({ + routeStore, + emitServiceStatusUpdate, + }); + + handleBranchChange("workspace-a", null, "main"); + + expect(emitServiceStatusUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/server/service-route-branch-handler.ts b/packages/server/src/server/service-route-branch-handler.ts new file mode 100644 index 000000000..ed4039607 --- /dev/null +++ b/packages/server/src/server/service-route-branch-handler.ts @@ -0,0 +1,70 @@ +import type { Logger } from "pino"; +import type { WorkspaceServicePayload } from "../shared/messages.js"; +import { buildServiceHostname } from "../utils/service-hostname.js"; +import { buildWorkspaceServicePayloads } from "./service-status-projection.js"; +import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js"; + +interface BranchChangeRouteHandlerOptions { + routeStore: ServiceRouteStore; + emitServiceStatusUpdate: ( + workspaceId: string, + services: WorkspaceServicePayload[], + ) => void; + logger?: Logger; +} + +interface RouteHostnameUpdate { + oldHostname: string; + newHostname: string; + route: ServiceRouteEntry; +} + +export function createBranchChangeRouteHandler( + options: BranchChangeRouteHandlerOptions, +): (workspaceId: string, oldBranch: string | null, newBranch: string | null) => void { + return (workspaceId, _oldBranch, newBranch) => { + const routes = options.routeStore.listRoutesForWorkspace(workspaceId); + if (routes.length === 0) { + return; + } + + const updates: RouteHostnameUpdate[] = []; + for (const route of routes) { + const newHostname = buildServiceHostname(newBranch, route.serviceName); + if (newHostname !== route.hostname) { + updates.push({ + oldHostname: route.hostname, + newHostname, + route, + }); + } + } + + if (updates.length === 0) { + return; + } + + for (const { oldHostname, newHostname, route } of updates) { + options.routeStore.removeRoute(oldHostname); + options.routeStore.registerRoute({ + hostname: newHostname, + port: route.port, + workspaceId: route.workspaceId, + serviceName: route.serviceName, + }); + options.logger?.info( + { + oldHostname, + newHostname, + serviceName: route.serviceName, + }, + "Updated service route for branch rename", + ); + } + + options.emitServiceStatusUpdate( + workspaceId, + buildWorkspaceServicePayloads(options.routeStore, workspaceId, null), + ); + }; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 47c9fce81..03d92b8a3 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -257,6 +257,7 @@ type WorkspaceGitWatchTarget = { refreshPromise: Promise | null; refreshQueued: boolean; latestFingerprint: string | null; + lastBranchName: string | null; }; type ActiveTerminalStream = { @@ -397,6 +398,11 @@ export type SessionOptions = { terminalManager: TerminalManager | null; providerSnapshotManager?: ProviderSnapshotManager; serviceRouteStore?: ServiceRouteStore; + onBranchChanged?: ( + workspaceId: string, + oldBranch: string | null, + newBranch: string | null, + ) => void; getDaemonTcpPort?: () => number | null; resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null; voice?: { @@ -575,6 +581,11 @@ export class Session { private readonly providerSnapshotManager: ProviderSnapshotManager | null; private unsubscribeProviderSnapshotEvents: (() => void) | null = null; private readonly serviceRouteStore: ServiceRouteStore | null; + private readonly onBranchChanged?: ( + workspaceId: string, + oldBranch: string | null, + newBranch: string | null, + ) => void; private readonly getDaemonTcpPort: (() => number | null) | null; private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null; private readonly subscribedTerminalDirectories = new Set(); @@ -633,6 +644,7 @@ export class Session { terminalManager, providerSnapshotManager, serviceRouteStore, + onBranchChanged, getDaemonTcpPort, resolveServiceStatus, voice, @@ -674,6 +686,7 @@ export class Session { this.terminalManager = terminalManager; this.providerSnapshotManager = providerSnapshotManager ?? null; this.serviceRouteStore = serviceRouteStore ?? null; + this.onBranchChanged = onBranchChanged; this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.resolveServiceStatus = resolveServiceStatus ?? null; if (this.terminalManager) { @@ -4087,6 +4100,7 @@ export class Session { return; } target.latestFingerprint = this.workspaceGitDescriptorFingerprint(workspace); + target.lastBranchName = workspace?.name ?? null; } private async primeWorkspaceGitWatchFingerprints( @@ -4155,6 +4169,7 @@ export class Session { refreshPromise: null, refreshQueued: false, latestFingerprint: null, + lastBranchName: null, }; for (const watchPath of new Set([join(gitDir, "HEAD"), join(refsRoot, "refs", "heads")])) { @@ -5703,6 +5718,13 @@ export class Session { ) { continue; } + const watchTarget = this.workspaceGitWatchTargets.get(normalizedCwd); + if (watchTarget && this.onBranchChanged) { + const newBranchName = nextWorkspace?.name ?? null; + if (newBranchName !== watchTarget.lastBranchName) { + this.onBranchChanged(normalizedCwd, watchTarget.lastBranchName, newBranchName); + } + } this.rememberWorkspaceGitWatchFingerprint(normalizedCwd, nextWorkspace); if (!nextWorkspace) { diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 8e52d4655..b2b6c5cc7 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -233,8 +233,8 @@ describe("workspace git watch targets", () => { }; let descriptor = { - id: 10, - projectId: 1, + id: "10", + projectId: "1", projectDisplayName: "repo", projectRootPath: "/tmp/repo", projectKind: "git", @@ -274,7 +274,7 @@ describe("workspace git watch targets", () => { expect(workspaceUpdates[0]?.payload).toMatchObject({ kind: "upsert", workspace: { - id: 10, + id: "10", name: "renamed-branch", diffStat: { additions: 1, deletions: 0 }, }, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index df5f5a523..b82bef31f 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -262,6 +262,9 @@ export class VoiceAssistantWebSocketServer { private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private readonly providerSnapshotManager: ProviderSnapshotManager; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; + private readonly onBranchChanged: + | ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void) + | null; private serverCapabilities: ServerCapabilities | undefined; private runtimeWindowStartedAt = Date.now(); private readonly runtimeCounters: WebSocketRuntimeCounters = { @@ -317,6 +320,11 @@ export class VoiceAssistantWebSocketServer { scheduleService?: ScheduleService, checkoutDiffManager?: CheckoutDiffManager, serviceRouteStore?: ServiceRouteStore | null, + onBranchChanged?: ( + workspaceId: string, + oldBranch: string | null, + newBranch: string | null, + ) => void, getDaemonTcpPort?: () => number | null, resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null, ) { @@ -363,6 +371,7 @@ export class VoiceAssistantWebSocketServer { ); this.onLifecycleIntent = onLifecycleIntent ?? null; this.serviceRouteStore = serviceRouteStore ?? null; + this.onBranchChanged = onBranchChanged ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.resolveServiceStatus = resolveServiceStatus ?? null; this.serverCapabilities = buildServerCapabilities({ @@ -682,6 +691,7 @@ export class VoiceAssistantWebSocketServer { terminalManager: this.terminalManager, providerSnapshotManager: this.providerSnapshotManager, serviceRouteStore: this.serviceRouteStore ?? undefined, + onBranchChanged: this.onBranchChanged ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, resolveServiceStatus: this.resolveServiceStatus ?? undefined, voice: { diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index aeebbff03..80a1303d7 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -5,6 +5,7 @@ import { promisify } from "node:util"; import { sep } from "node:path"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; +import { buildServiceHostname } from "../utils/service-hostname.js"; import { createWorktree, getServiceConfigs, @@ -13,7 +14,6 @@ import { processCarriageReturns, resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, - slugify, WorktreeSetupError, type WorktreeConfig, type WorktreeSetupCommandResult, @@ -792,13 +792,7 @@ export async function spawnWorktreeServices(options: { try { port = config.port ?? (await findFreePort()); - const branchHostnameLabel = branchName ? slugify(branchName) : null; - - const isDefaultBranch = - branchName === null || branchName === "main" || branchName === "master"; - hostname = isDefaultBranch - ? `${serviceName}.localhost` - : `${branchHostnameLabel}.${serviceName}.localhost`; + hostname = buildServiceHostname(branchName, serviceName); routeStore.registerRoute({ hostname, diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 0e1cd0c2d..59da636b2 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -33,6 +33,7 @@ import { pushCurrentBranch, resolveRepositoryDefaultBranch, parseWorktreeList, + parseStatusCheckRollup, isPaseoWorktreePath, isDescendantPath, warmCheckoutShortstatInBackground, @@ -622,6 +623,101 @@ const x = 1; } }); + it("parses real gh status check rollup output and dedupes by latest check run", () => { + expect( + parseStatusCheckRollup([ + { + __typename: "CheckRun", + completedAt: "2026-04-02T13:53:59Z", + conclusion: "SUCCESS", + detailsUrl: "https://github.com/org/repo/actions/runs/123", + name: "review_app", + startedAt: "2026-04-02T13:49:31Z", + status: "COMPLETED", + workflowName: "Deploy PR Preview", + }, + { + __typename: "CheckRun", + completedAt: "2026-04-02T13:58:59Z", + conclusion: "FAILURE", + detailsUrl: "https://github.com/org/repo/actions/runs/124", + name: "review_app", + startedAt: "2026-04-02T13:55:31Z", + status: "COMPLETED", + }, + ]), + ).toEqual([ + { + name: "review_app", + status: "failure", + url: "https://github.com/org/repo/actions/runs/124", + }, + ]); + }); + + it("parses mixed check run and status context entries", () => { + expect( + parseStatusCheckRollup([ + { + __typename: "CheckRun", + name: "unit-tests", + status: "IN_PROGRESS", + conclusion: null, + detailsUrl: "https://github.com/org/repo/actions/runs/200", + startedAt: "2026-04-02T13:49:31Z", + }, + { + __typename: "StatusContext", + context: "lint", + state: "SUCCESS", + targetUrl: "https://github.com/org/repo/status/300", + createdAt: "2026-04-02T13:48:00Z", + }, + ]), + ).toEqual([ + { + name: "unit-tests", + status: "pending", + url: "https://github.com/org/repo/actions/runs/200", + }, + { + name: "lint", + status: "success", + url: "https://github.com/org/repo/status/300", + }, + ]); + }); + + it("returns an empty list for nullish or empty status check rollups", () => { + expect(parseStatusCheckRollup(undefined)).toEqual([]); + expect(parseStatusCheckRollup(null)).toEqual([]); + expect(parseStatusCheckRollup([])).toEqual([]); + }); + + it("ignores unknown status check rollup node types", () => { + expect( + parseStatusCheckRollup([ + { + __typename: "Commit", + oid: "abc123", + }, + { + __typename: "CheckRun", + name: "build", + status: "COMPLETED", + conclusion: "SUCCESS", + detailsUrl: "https://github.com/org/repo/actions/runs/500", + }, + ]), + ).toEqual([ + { + name: "build", + status: "success", + url: "https://github.com/org/repo/actions/runs/500", + }, + ]); + }); + it("returns merged PR status when no open PR exists for the current branch", async () => { execSync("git checkout -b feature", { cwd: repoDir }); execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); @@ -640,8 +736,8 @@ const x = 1; " exit 0", "fi", 'args="$*"', - 'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then', - ' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":"2026-02-18T00:00:00Z"}\'', + 'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then', + ' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":"2026-02-18T00:00:00Z","statusCheckRollup":[],"reviewDecision":""}\'', " exit 0", "fi", 'echo "unexpected gh args: $args" >&2', @@ -686,8 +782,8 @@ const x = 1; " exit 0", "fi", 'args="$*"', - 'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then', - ' echo \'{"url":"https://github.com/getpaseo/paseo/pull/999","title":"Closed without merge","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":null}\'', + 'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then', + ' echo \'{"url":"https://github.com/getpaseo/paseo/pull/999","title":"Closed without merge","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\'', " exit 0", "fi", 'echo "unexpected gh args: $args" >&2', @@ -735,10 +831,10 @@ const x = 1; " exit 0", "fi", 'args="$*"', - 'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then', + 'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then', ' count="$(cat "$count_file")"', ' printf "%s\\n" "$((count + 1))" > "$count_file"', - ' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null}\'', + ' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\'', " exit 0", "fi", 'echo "unexpected gh args: $args" >&2', @@ -783,11 +879,11 @@ const x = 1; " exit 0", "fi", 'args="$*"', - 'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then', + 'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then', ' count="$(cat "$count_file")"', ' next="$((count + 1))"', ' printf "%s\\n" "$next" > "$count_file"', - ' printf \'{"url":"https://github.com/getpaseo/paseo/pull/%s","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null}\\n\' "$next"', + ' printf \'{"url":"https://github.com/getpaseo/paseo/pull/%s","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\\n\' "$next"', " exit 0", "fi", 'echo "unexpected gh args: $args" >&2', @@ -834,11 +930,11 @@ const x = 1; " exit 0", "fi", 'args="$*"', - 'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then', + 'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then', ' count="$(cat "$count_file")"', ' printf "%s\\n" "$((count + 1))" > "$count_file"', " sleep 0.2", - ' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null}\'', + ' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\'', " exit 0", "fi", 'echo "unexpected gh args: $args" >&2', diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index fb3c17911..00830f8ef 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -4,6 +4,7 @@ import { resolve, dirname, basename } from "path"; import { realpathSync } from "fs"; import { open as openFile, stat as statFile } from "fs/promises"; import { TTLCache } from "@isaacs/ttlcache"; +import { z } from "zod"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; import { findExecutable } from "./executable.js"; @@ -1851,24 +1852,52 @@ export type ChecksStatus = "none" | "pending" | "success" | "failure"; export type ReviewDecision = "approved" | "changes_requested" | "pending" | null; -type StatusCheckRollupContext = { - __typename?: unknown; - name?: unknown; - conclusion?: unknown; - status?: unknown; - detailsUrl?: unknown; - startedAt?: unknown; - completedAt?: unknown; - checkSuite?: { - workflowRun?: { - databaseId?: unknown; - } | null; - } | null; - context?: unknown; - state?: unknown; - targetUrl?: unknown; - createdAt?: unknown; -}; +const CheckRunNodeSchema = z.object({ + __typename: z.literal("CheckRun"), + name: z.string(), + conclusion: z.string().nullable().optional(), + status: z.string().nullable().optional(), + detailsUrl: z.string().nullable().optional(), + startedAt: z.string().nullable().optional(), + completedAt: z.string().nullable().optional(), + checkSuite: z + .object({ + workflowRun: z + .object({ + databaseId: z.number().nullable().optional(), + }) + .nullable() + .optional(), + }) + .nullable() + .optional(), +}); + +const StatusContextNodeSchema = z.object({ + __typename: z.literal("StatusContext"), + context: z.string(), + state: z.string().nullable().optional(), + targetUrl: z.string().nullable().optional(), + createdAt: z.string().nullable().optional(), +}); + +const StatusCheckRollupNodeSchema = z.discriminatedUnion("__typename", [ + CheckRunNodeSchema, + StatusContextNodeSchema, +]); + +const StatusCheckRollupArraySchema = z.array(z.unknown()); +const LegacyStatusCheckRollupSchema = z.object({ + contexts: z.array(z.unknown()), +}); + +const ReviewDecisionSchema = z + .enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]) + .nullable() + .catch(null); + +type CheckRunNode = z.infer; +type StatusContextNode = z.infer; function resolveGhPath(): string { if (cachedGhPath === undefined) { @@ -1941,7 +1970,7 @@ function mapStatusContextState(state: unknown): PullRequestCheck["status"] { } } -function getCheckRunRecency(context: StatusCheckRollupContext): number { +function getCheckRunRecency(context: CheckRunNode): number { const workflowRunId = context.checkSuite?.workflowRun?.databaseId; if (typeof workflowRunId === "number") { return workflowRunId; @@ -1961,7 +1990,7 @@ function getCheckRunRecency(context: StatusCheckRollupContext): number { return Number.isNaN(time) ? 0 : time; } -function getStatusContextRecency(context: StatusCheckRollupContext): number { +function getStatusContextRecency(context: StatusContextNode): number { if (typeof context.createdAt !== "string" || context.createdAt.length === 0) { return 0; } @@ -1970,15 +1999,17 @@ function getStatusContextRecency(context: StatusCheckRollupContext): number { return Number.isNaN(time) ? 0 : time; } -function parseStatusCheckRollup(value: unknown): PullRequestCheck[] { - if (!value || typeof value !== "object") { - return []; - } +export function parseStatusCheckRollup(value: unknown): PullRequestCheck[] { + const directContexts = StatusCheckRollupArraySchema.safeParse(value); + if (!directContexts.success) { + const legacyContexts = LegacyStatusCheckRollupSchema.safeParse(value); + if (!legacyContexts.success) { + return []; + } - const contexts = (value as { contexts?: unknown }).contexts; - if (!Array.isArray(contexts)) { - return []; + return parseStatusCheckRollup(legacyContexts.data.contexts); } + const contexts = directContexts.data; const dedupedChecks = new Map< string, @@ -1988,21 +2019,22 @@ function parseStatusCheckRollup(value: unknown): PullRequestCheck[] { >(); for (const entry of contexts) { - if (!entry || typeof entry !== "object") { + const parsed = StatusCheckRollupNodeSchema.safeParse(entry); + if (!parsed.success) { continue; } - const context = entry as StatusCheckRollupContext; + const context = parsed.data; let check: (PullRequestCheck & { recency: number }) | null = null; - if (context.__typename === "CheckRun" && typeof context.name === "string") { + if (context.__typename === "CheckRun") { check = { name: context.name, status: mapCheckRunStatus(context.status, context.conclusion), url: typeof context.detailsUrl === "string" ? context.detailsUrl : null, recency: getCheckRunRecency(context), }; - } else if (context.__typename === "StatusContext" && typeof context.context === "string") { + } else if (context.__typename === "StatusContext") { check = { name: context.context, status: mapStatusContextState(context.state), @@ -2038,13 +2070,14 @@ function computeChecksStatus(checks: PullRequestCheck[]): ChecksStatus { } function mapReviewDecision(value: unknown): ReviewDecision { - if (value === "APPROVED") { + const reviewDecision = ReviewDecisionSchema.parse(value); + if (reviewDecision === "APPROVED") { return "approved"; } - if (value === "CHANGES_REQUESTED") { + if (reviewDecision === "CHANGES_REQUESTED") { return "changes_requested"; } - if (value === "REVIEW_REQUIRED") { + if (reviewDecision === "REVIEW_REQUIRED") { return "pending"; } return null; diff --git a/packages/server/src/utils/service-hostname.test.ts b/packages/server/src/utils/service-hostname.test.ts new file mode 100644 index 000000000..da92ab79d --- /dev/null +++ b/packages/server/src/utils/service-hostname.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { buildServiceHostname } from "./service-hostname.js"; + +describe("buildServiceHostname", () => { + it("slugifies service names with spaces on default branches", () => { + expect(buildServiceHostname(null, "npm run dev")).toBe("npm-run-dev.localhost"); + }); + + it("slugifies service names with special characters", () => { + expect(buildServiceHostname(null, "Web/API @ Dev")).toBe("web-api-dev.localhost"); + }); + + it("omits the branch prefix for main and master", () => { + expect(buildServiceHostname("main", "npm run dev")).toBe("npm-run-dev.localhost"); + expect(buildServiceHostname("master", "npm run dev")).toBe("npm-run-dev.localhost"); + }); + + it("adds a slugified branch prefix for non-default branches", () => { + expect(buildServiceHostname("feature/cool stuff", "api")).toBe( + "feature-cool-stuff.api.localhost", + ); + }); + + it("slugifies both the branch name and service name together", () => { + expect(buildServiceHostname("feat/add auth", "npm run dev")).toBe( + "feat-add-auth.npm-run-dev.localhost", + ); + }); +}); diff --git a/packages/server/src/utils/service-hostname.ts b/packages/server/src/utils/service-hostname.ts new file mode 100644 index 000000000..49687d8a6 --- /dev/null +++ b/packages/server/src/utils/service-hostname.ts @@ -0,0 +1,13 @@ +import { slugify } from "./worktree.js"; + +export function buildServiceHostname(branchName: string | null, serviceName: string): string { + const serviceHostnameLabel = slugify(serviceName); + const isDefaultBranch = + branchName === null || branchName === "main" || branchName === "master"; + + if (isDefaultBranch) { + return `${serviceHostnameLabel}.localhost`; + } + + return `${slugify(branchName)}.${serviceHostnameLabel}.localhost`; +}