feat: add service start capability with health monitoring
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user