feat: add service start capability with health monitoring

This commit is contained in:
Mohamed Boudra
2026-04-06 20:16:32 +07:00
parent 7173342bcb
commit 7b19dd736f
28 changed files with 995 additions and 417 deletions
@@ -262,7 +262,7 @@ function WorkspaceStatusIndicator({
}
const KindIcon =
workspaceKind === "checkout"
workspaceKind === "local_checkout"
? Monitor
: workspaceKind === "worktree"
? FolderGit2
@@ -6,15 +6,18 @@ import {
type PropsWithChildren,
type ReactElement,
} from "react";
import { useMutation } from "@tanstack/react-query";
import { Dimensions, Platform, Text, View } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, ExternalLink, GitPullRequest, Minus, X } from "lucide-react-native";
import { Check, ExternalLink, GitPullRequest, LoaderCircle, Minus, Play, 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 } from "@/hooks/use-checkout-pr-status-query";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { openExternalUrl } from "@/utils/open-external-url";
interface Rect {
@@ -194,6 +197,31 @@ const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
closed: "Closed",
};
function getServiceHealthColor(input: {
health: SidebarWorkspaceEntry["services"][number]["health"];
theme: ReturnType<typeof useUnistyles>["theme"];
}): string {
if (input.health === "healthy") {
return input.theme.colors.palette.green[500];
}
if (input.health === "unhealthy") {
return input.theme.colors.palette.red[500];
}
return input.theme.colors.foregroundMuted;
}
function getServiceHealthLabel(
health: SidebarWorkspaceEntry["services"][number]["health"],
): "Healthy" | "Unhealthy" | "Unknown" {
if (health === "healthy") {
return "Healthy";
}
if (health === "unhealthy") {
return "Unhealthy";
}
return "Unknown";
}
export function CheckStatusIndicator({
status,
@@ -287,10 +315,30 @@ function WorkspaceHoverCardContent({
onContentLeave: () => void;
}): ReactElement | null {
const { theme } = useUnistyles();
const toast = useToast();
const client = useSessionStore((state) => state.sessions[workspace.serverId]?.client ?? null);
const bottomSheetInternal = useBottomSheetModalInternal(true);
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 startServiceMutation = useMutation({
mutationFn: async (serviceName: string) => {
if (!client) {
throw new Error("Daemon client not available");
}
const result = await client.startWorkspaceService(workspace.workspaceId, serviceName);
if (result.error) {
throw new Error(result.error);
}
return result;
},
onError: (error, serviceName) => {
toast.show(
error instanceof Error ? error.message : `Failed to start ${serviceName}`,
{ variant: "error" },
);
},
});
// Measure trigger — same pattern as tooltip.tsx
useEffect(() => {
@@ -418,50 +466,95 @@ function WorkspaceHoverCardContent({
{workspace.services.map((service) => (
<Pressable
key={service.hostname}
accessibilityRole="link"
accessibilityRole={service.lifecycle === "running" && service.url ? "link" : undefined}
accessibilityLabel={`${service.serviceName} service`}
testID={`hover-card-service-${service.serviceName}`}
style={({ hovered }) => [
styles.serviceRow,
hovered && styles.serviceRowHovered,
hovered &&
service.lifecycle === "running" &&
service.url &&
styles.serviceRowHovered,
]}
onPress={() => {
if (service.url) {
if (service.lifecycle === "running" && 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 },
{
color:
service.lifecycle === "running"
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]}
numberOfLines={1}
>
{service.serviceName}
</Text>
{service.url ? (
<View style={styles.serviceMeta}>
<Text
testID={`hover-card-service-status-${service.serviceName}`}
accessibilityLabel={service.lifecycle === "running" ? "Running" : "Stopped"}
style={styles.serviceLifecycleText}
>
{service.lifecycle === "running" ? "Running" : "Stopped"}
</Text>
<View
testID={`hover-card-service-health-${service.serviceName}`}
accessibilityLabel={getServiceHealthLabel(service.health)}
style={[
styles.statusDot,
{
backgroundColor: getServiceHealthColor({
health: service.health,
theme,
}),
},
]}
/>
<Text style={styles.serviceHealthText}>{getServiceHealthLabel(service.health)}</Text>
</View>
{service.lifecycle === "running" && service.url ? (
<Text style={styles.serviceUrl} numberOfLines={1}>
{service.url.replace(/^https?:\/\//, "")}
</Text>
) : null}
{service.url ? (
) : (
<View style={styles.serviceUrlSpacer} />
)}
{service.lifecycle === "running" && service.url ? (
<ExternalLink size={11} color={theme.colors.foregroundMuted} />
) : null}
) : (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Start ${service.serviceName} service`}
testID={`hover-card-service-start-${service.serviceName}`}
style={({ hovered, pressed }) => [
styles.startServiceButton,
(hovered || pressed) && styles.startServiceButtonHovered,
]}
disabled={startServiceMutation.isPending}
onPress={(event) => {
event.stopPropagation();
startServiceMutation.mutate(service.serviceName);
}}
>
{startServiceMutation.isPending &&
startServiceMutation.variables === service.serviceName ? (
<LoaderCircle
size={12}
color={theme.colors.foregroundMuted}
style={styles.startServiceSpinner}
/>
) : (
<Play size={12} color={theme.colors.foregroundMuted} fill="transparent" />
)}
</Pressable>
)}
</Pressable>
))}
</View>
@@ -558,12 +651,44 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
flexShrink: 0,
},
serviceMeta: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
serviceLifecycleText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
serviceHealthText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
serviceUrl: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
flex: 1,
minWidth: 0,
},
serviceUrlSpacer: {
flex: 1,
minWidth: 0,
},
startServiceButton: {
width: 22,
height: 22,
borderRadius: 11,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
startServiceButtonHovered: {
backgroundColor: theme.colors.surface3,
},
startServiceSpinner: {
transform: [{ rotate: "0deg" }],
},
sectionLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
@@ -16,7 +16,6 @@ import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import {
requireWorkspaceExecutionAuthority,
requireWorkspaceRecordId,
} from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "./message-input";
@@ -179,7 +178,7 @@ export function WorkspaceSetupDialog() {
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspaceDirectory,
workspaceId: requireWorkspaceRecordId(workspace.id),
workspaceId: workspace.id,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
@@ -28,7 +28,8 @@ const runningService: WorkspaceServicePayload = {
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
lifecycle: "running",
health: "healthy",
};
describe("patchWorkspaceServices", () => {
@@ -50,7 +50,8 @@ const runningService: WorkspaceServicePayload = {
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
lifecycle: "running",
health: "healthy",
};
const stoppedService: WorkspaceServicePayload = {
@@ -58,7 +59,8 @@ const stoppedService: WorkspaceServicePayload = {
hostname: "main.api.localhost",
port: 3001,
url: "http://main.api.localhost:6767",
status: "stopped",
lifecycle: "stopped",
health: null,
};
describe("applyStoredOrdering", () => {
@@ -145,7 +145,7 @@ export function buildSidebarProjectsFromWorkspaces(input: {
statusBucket: workspace.status,
diffStat: workspace.diffStat,
services: workspace.services,
hasRunningServices: workspace.services.some((service) => service.status === "running"),
hasRunningServices: workspace.services.some((service) => service.lifecycle === "running"),
};
project.workspaces.push(row);
@@ -25,7 +25,6 @@ import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import {
requireWorkspaceExecutionAuthority,
requireWorkspaceRecordId,
} from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
@@ -151,7 +150,7 @@ export function NewWorkspaceScreen({
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspaceDirectory,
workspaceId: requireWorkspaceRecordId(workspace.id),
workspaceId: workspace.id,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
@@ -13,10 +13,7 @@ import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-dr
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { type Agent, useSessionStore } from "@/stores/session-store";
import { encodeImages } from "@/utils/encode-images";
import {
getWorkspaceExecutionAuthority,
requireWorkspaceRecordId,
} from "@/utils/workspace-execution";
import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages";
@@ -173,7 +170,7 @@ export function WorkspaceDraftAgentTab({
const imagesData = await encodeImages(images);
const result = await client.createAgent({
config,
workspaceId: requireWorkspaceRecordId(workspaceExecutionAuthority.workspaceId),
workspaceId: workspaceExecutionAuthority.workspaceId,
...(text ? { initialPrompt: text } : {}),
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
@@ -38,7 +38,8 @@ describe("normalizeWorkspaceDescriptor", () => {
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running" as const,
lifecycle: "running" as const,
health: "healthy" as const,
},
];
const workspace = normalizeWorkspaceDescriptor({
@@ -63,7 +64,8 @@ describe("normalizeWorkspaceDescriptor", () => {
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
lifecycle: "running",
health: "healthy",
},
]);
expect(workspace.services).not.toBe(services);
@@ -109,7 +111,8 @@ describe("mergeWorkspaces", () => {
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
lifecycle: "running",
health: "healthy",
},
],
}),
@@ -121,7 +124,8 @@ describe("mergeWorkspaces", () => {
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
lifecycle: "running",
health: "healthy",
},
]);
});
@@ -26,7 +26,7 @@ export function resolveWorkspaceArchiveRedirectWorkspaceId(input: {
const rootCheckoutWorkspace =
sameProjectWorkspaces.find(
(workspace) =>
workspace.workspaceKind === "checkout" && workspace.id !== archivedWorkspace.id,
workspace.workspaceKind === "local_checkout" && workspace.id !== archivedWorkspace.id,
) ?? null;
if (rootCheckoutWorkspace) {
return rootCheckoutWorkspace.id;