mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: service route branch handling, workspace hover card redesign, and agent form state improvements
- Add service-route-branch-handler for routing requests to branch-specific services - Add service-hostname utility for generating hostnames from branch names - Redesign workspace hover card with CI check status display - Update combined model selector and sidebar workspace list - Improve agent form state and input draft hooks - Update checkout-git with enhanced branch handling - Add workspace git watch and bootstrap improvements
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 <Check size={11} color={theme.colors.palette.green[500]} strokeWidth={3} />;
|
||||
}
|
||||
if (checksStatus === "failure") {
|
||||
return <X size={11} color={theme.colors.palette.red[500]} strokeWidth={3} />;
|
||||
}
|
||||
// pending
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 3.5,
|
||||
backgroundColor: theme.colors.palette.amber[500],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceStatusIndicator({
|
||||
bucket,
|
||||
@@ -950,7 +926,7 @@ function WorkspaceRowInner({
|
||||
const showGlobe = isDesktop && workspace.hasRunningServices;
|
||||
|
||||
return (
|
||||
<WorkspaceHoverCard workspace={workspace} isDragging={isDragging}>
|
||||
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
|
||||
<View
|
||||
style={styles.workspaceRowContainer}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
@@ -1067,7 +1043,7 @@ function WorkspaceRowInner({
|
||||
{prHint ? (
|
||||
<View style={styles.workspacePrBadgeRow}>
|
||||
<WorkspacePrBadge hint={prHint} />
|
||||
<ChecksStatusIcon checksStatus={prHint.checksStatus} />
|
||||
<CheckStatusIndicator status={prHint.checksStatus ?? "none"} size={11} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
|
||||
@@ -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<WorkspaceHoverCardProps>): ReactElement {
|
||||
@@ -84,7 +83,7 @@ export function WorkspaceHoverCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceHoverCardDesktop workspace={workspace} isDragging={isDragging}>
|
||||
<WorkspaceHoverCardDesktop workspace={workspace} prHint={prHint} isDragging={isDragging}>
|
||||
{children}
|
||||
</WorkspaceHoverCardDesktop>
|
||||
);
|
||||
@@ -92,6 +91,7 @@ export function WorkspaceHoverCard({
|
||||
|
||||
function WorkspaceHoverCardDesktop({
|
||||
workspace,
|
||||
prHint,
|
||||
isDragging,
|
||||
children,
|
||||
}: PropsWithChildren<WorkspaceHoverCardProps>): 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 ? (
|
||||
<WorkspaceHoverCardContent
|
||||
workspace={workspace}
|
||||
prHint={prHint}
|
||||
triggerRef={triggerRef}
|
||||
onContentEnter={handleContentEnter}
|
||||
onContentLeave={handleContentLeave}
|
||||
@@ -192,53 +194,94 @@ const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
|
||||
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 <SyncedLoader size={11} color={theme.colors.palette.amber[500]} />;
|
||||
if (!status || status === "none") return null;
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
borderWidth: 2,
|
||||
borderColor: theme.colors.palette.amber[500],
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const KindIcon =
|
||||
workspace.workspaceKind === "checkout"
|
||||
? Monitor
|
||||
: workspace.workspaceKind === "worktree"
|
||||
? FolderGit2
|
||||
: null;
|
||||
if (!KindIcon) return null;
|
||||
if (status === "success") {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: "rgba(34,197,94,0.15)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Check size={iconSize} color={theme.colors.palette.green[500]} strokeWidth={3} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const dotColor = getStatusDotColor({ theme, bucket: workspace.statusBucket, showDoneAsInactive: false });
|
||||
if (status === "failure") {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: "rgba(239,68,68,0.15)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<X size={iconSize} color={theme.colors.palette.red[500]} strokeWidth={3} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// skipped / cancelled / unknown
|
||||
return (
|
||||
<View style={styles.hoverStatusIcon}>
|
||||
<KindIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
{dotColor ? (
|
||||
<View
|
||||
style={[
|
||||
styles.hoverStatusDotOverlay,
|
||||
{
|
||||
backgroundColor: dotColor,
|
||||
borderColor: theme.colors.surface1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: "rgba(128,128,128,0.15)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Minus size={iconSize} color={theme.colors.foregroundMuted} strokeWidth={3} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceHoverCardContent({
|
||||
workspace,
|
||||
prHint,
|
||||
triggerRef,
|
||||
onContentEnter,
|
||||
onContentLeave,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
prHint: PrHint | null;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
onContentEnter: () => void;
|
||||
onContentLeave: () => void;
|
||||
@@ -248,11 +291,6 @@ function WorkspaceHoverCardContent({
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(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({
|
||||
]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<HoverCardStatusIndicator workspace={workspace} />
|
||||
<Text style={styles.cardTitle} numberOfLines={1} testID="hover-card-workspace-name">
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
{workspace.diffStat ? (
|
||||
<View style={styles.cardMetaRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{prHint ? (
|
||||
{prHint || workspace.diffStat ? (
|
||||
<Pressable
|
||||
style={styles.cardMetaRow}
|
||||
onPress={() => void openExternalUrl(prHint.url)}
|
||||
onPress={prHint ? () => void openExternalUrl(prHint.url) : undefined}
|
||||
disabled={!prHint}
|
||||
>
|
||||
<GitPullRequest size={12} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.prBadgeText} numberOfLines={1}>
|
||||
#{prHint.number} · {GITHUB_PR_STATE_LABELS[prHint.state]}
|
||||
</Text>
|
||||
{prHint ? (
|
||||
<>
|
||||
<GitPullRequest size={12} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.prBadgeText} numberOfLines={1}>
|
||||
#{prHint.number} · {GITHUB_PR_STATE_LABELS[prHint.state]}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{workspace.diffStat ? (
|
||||
<>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Pressable>
|
||||
) : null}
|
||||
{prHint?.checks && prHint.checks.length > 0 ? (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<Text style={styles.sectionLabel}>Checks</Text>
|
||||
<View style={styles.checksList}>
|
||||
{prHint.checks.map((check) => (
|
||||
<Pressable
|
||||
@@ -349,21 +395,7 @@ function WorkspaceHoverCardContent({
|
||||
onPress={check.url ? () => void openExternalUrl(check.url!) : undefined}
|
||||
disabled={!check.url}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor:
|
||||
check.status === "success"
|
||||
? theme.colors.palette.green[500]
|
||||
: check.status === "failure" || check.status === "cancelled"
|
||||
? theme.colors.palette.red[500]
|
||||
: check.status === "pending"
|
||||
? theme.colors.palette.amber[500]
|
||||
: theme.colors.foregroundMuted,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<CheckStatusIndicator status={check.status} size={14} />
|
||||
<Text
|
||||
style={styles.checkName}
|
||||
numberOfLines={1}
|
||||
@@ -376,59 +408,65 @@ function WorkspaceHoverCardContent({
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
{workspace.services.length > 0 ? (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<Text style={styles.sectionLabel}>Services</Text>
|
||||
<View style={styles.serviceList} testID="hover-card-service-list">
|
||||
{workspace.services.map((service) => (
|
||||
<Pressable
|
||||
key={service.hostname}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`${service.serviceName} service`}
|
||||
testID={`hover-card-service-${service.serviceName}`}
|
||||
style={({ hovered }) => [
|
||||
styles.serviceRow,
|
||||
hovered && styles.serviceRowHovered,
|
||||
]}
|
||||
onPress={() => {
|
||||
if (service.url) {
|
||||
void openExternalUrl(service.url);
|
||||
}
|
||||
}}
|
||||
disabled={!service.url}
|
||||
>
|
||||
<View
|
||||
testID={`hover-card-service-status-${service.serviceName}`}
|
||||
accessibilityLabel={service.status === "running" ? "Running" : "Stopped"}
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor:
|
||||
service.status === "running"
|
||||
? theme.colors.palette.green[500]
|
||||
: theme.colors.foregroundMuted,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.serviceName,
|
||||
{ color: service.status === "running" ? theme.colors.foreground : theme.colors.foregroundMuted },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{service.serviceName}
|
||||
</Text>
|
||||
{service.url ? (
|
||||
<Text style={styles.serviceUrl} numberOfLines={1}>
|
||||
{service.url.replace(/^https?:\/\//, "")}
|
||||
</Text>
|
||||
) : null}
|
||||
{service.url ? (
|
||||
<ExternalLink size={11} color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
<View style={styles.separator} />
|
||||
<View style={styles.serviceList} testID="hover-card-service-list">
|
||||
{workspace.services.map((service) => (
|
||||
<Pressable
|
||||
key={service.hostname}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`${service.serviceName} service`}
|
||||
testID={`hover-card-service-${service.serviceName}`}
|
||||
style={({ hovered }) => [
|
||||
styles.serviceRow,
|
||||
hovered && styles.serviceRowHovered,
|
||||
]}
|
||||
onPress={() => {
|
||||
if (service.url) {
|
||||
void openExternalUrl(service.url);
|
||||
}
|
||||
}}
|
||||
disabled={!service.url}
|
||||
>
|
||||
<View
|
||||
testID={`hover-card-service-status-${service.serviceName}`}
|
||||
accessibilityLabel={service.status === "running" ? "Running" : "Stopped"}
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor:
|
||||
service.status === "running"
|
||||
? theme.colors.palette.green[500]
|
||||
: theme.colors.foregroundMuted,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.serviceName,
|
||||
{ color: service.status === "running" ? theme.colors.foreground : theme.colors.foregroundMuted },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{service.serviceName}
|
||||
</Text>
|
||||
{service.url ? (
|
||||
<Text style={styles.serviceUrl} numberOfLines={1}>
|
||||
{service.url.replace(/^https?:\/\//, "")}
|
||||
</Text>
|
||||
) : null}
|
||||
{service.url ? (
|
||||
<ExternalLink size={11} color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Portal>
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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<string>(),
|
||||
);
|
||||
|
||||
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,
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
188
packages/server/src/server/service-route-branch-handler.test.ts
Normal file
188
packages/server/src/server/service-route-branch-handler.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
70
packages/server/src/server/service-route-branch-handler.ts
Normal file
70
packages/server/src/server/service-route-branch-handler.ts
Normal file
@@ -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),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -257,6 +257,7 @@ type WorkspaceGitWatchTarget = {
|
||||
refreshPromise: Promise<void> | 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<string>();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof CheckRunNodeSchema>;
|
||||
type StatusContextNode = z.infer<typeof StatusContextNodeSchema>;
|
||||
|
||||
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;
|
||||
|
||||
29
packages/server/src/utils/service-hostname.test.ts
Normal file
29
packages/server/src/utils/service-hostname.test.ts
Normal file
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
13
packages/server/src/utils/service-hostname.ts
Normal file
13
packages/server/src/utils/service-hostname.ts
Normal file
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user