feat: service proxy, health monitor, hover card, and homepage section

Built-in service proxy with branch-based URLs, service health
monitoring, workspace hover card with service status, and
"Forget about ports" homepage section.
This commit is contained in:
Mohamed Boudra
2026-04-01 10:31:15 +07:00
parent 806079c7ad
commit 5b3ec572cb
39 changed files with 3776 additions and 498 deletions
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceServicePayload } from "@server/shared/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { patchWorkspaceServices } from "./session-workspace-services";
function workspace(input: {
id: string;
services?: WorkspaceDescriptor["services"];
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "running",
activityAt: null,
diffStat: null,
services: input.services ?? [],
};
}
const runningService: WorkspaceServicePayload = {
serviceName: "web",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
};
describe("patchWorkspaceServices", () => {
it("patches only the matching workspace services", () => {
const other = workspace({ id: "/repo/other", services: [] });
const current = new Map<string, WorkspaceDescriptor>([
["/repo/main", workspace({ id: "/repo/main", services: [] })],
[other.id, other],
]);
const next = patchWorkspaceServices(current, {
workspaceId: "/repo/main",
services: [runningService],
});
expect(next).not.toBe(current);
expect(next.get("/repo/main")?.services).toEqual([runningService]);
expect(next.get("/repo/other")).toBe(other);
});
it("ignores updates for unknown workspaces", () => {
const current = new Map<string, WorkspaceDescriptor>([
["/repo/main", workspace({ id: "/repo/main", services: [] })],
]);
const next = patchWorkspaceServices(current, {
workspaceId: "/repo/missing",
services: [runningService],
});
expect(next).toBe(current);
expect(next.get("/repo/main")?.services).toEqual([]);
});
});
@@ -51,6 +51,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceServices } from "@/contexts/session-workspace-services";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -1112,11 +1113,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
});
const unsubServiceStatusUpdate = client.on("service_status_update", (message) => {
if (message.type !== "service_status_update") return;
setWorkspaces(serverId, (prev) => patchWorkspaceServices(prev, message.payload));
});
const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => {
if (message.type !== "workspace_setup_progress") return;
applyWorkspaceSetupProgress(message.payload);
});
const unsubWorkspaceSetupStatusResponse = client.on(
"workspace_setup_status_response",
(message) => {
if (message.type !== "workspace_setup_status_response") return;
const { workspaceId, snapshot } = message.payload;
if (snapshot) {
applyWorkspaceSetupProgress({ workspaceId, ...snapshot });
}
},
);
const unsubStatus = client.on("status", (message) => {
if (message.type !== "status") return;
const serverInfo = parseServerInfoStatusPayload(message.payload);
@@ -1465,7 +1482,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubAgentStream();
unsubAgentTimeline();
unsubWorkspaceUpdate();
unsubServiceStatusUpdate();
unsubWorkspaceSetupProgress();
unsubWorkspaceSetupStatusResponse();
unsubStatus();
unsubPermissionRequest();
unsubPermissionResolved();
@@ -1491,6 +1510,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgentTimelineCursor,
setInitializingAgents,
setAgents,
setWorkspaces,
mergeWorkspaces,
removeWorkspace,
removeWorkspaceSetup,
@@ -0,0 +1,19 @@
import type { ServiceStatusUpdateMessage } from "@server/shared/messages";
import type { WorkspaceDescriptor } from "@/stores/session-store";
export function patchWorkspaceServices(
workspaces: Map<string, WorkspaceDescriptor>,
update: ServiceStatusUpdateMessage["payload"],
): Map<string, WorkspaceDescriptor> {
const existing = workspaces.get(update.workspaceId);
if (!existing) {
return workspaces;
}
const next = new Map(workspaces);
next.set(update.workspaceId, {
...existing,
services: update.services.map((s) => ({ ...s })),
});
return next;
}