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`;
+}