feat: projects → workspaces → tabs

This commit is contained in:
Mohamed Boudra
2026-03-01 18:22:11 +07:00
parent 2e201cab09
commit 080fb74642
43 changed files with 4054 additions and 2654 deletions
+49 -2
View File
@@ -2,18 +2,22 @@ import { describe, expect, it } from "vitest";
import {
parseHostAgentDraftRouteFromPathname,
parseHostAgentRouteFromPathname,
parseHostDraftRouteFromPathname,
parseHostWorkspaceAgentRouteFromPathname,
parseHostWorkspaceTerminalRouteFromPathname,
parseHostWorkspaceRouteFromPathname,
} from "./host-routes";
describe("parseHostAgentDraftRouteFromPathname", () => {
it("parses draft route server id", () => {
expect(parseHostAgentDraftRouteFromPathname("/h/local/agent")).toEqual({
expect(parseHostAgentDraftRouteFromPathname("/h/local/new")).toEqual({
serverId: "local",
});
});
it("parses encoded server id", () => {
expect(
parseHostAgentDraftRouteFromPathname("/h/team%20host/agent")
parseHostAgentDraftRouteFromPathname("/h/team%20host/new")
).toEqual({
serverId: "team host",
});
@@ -24,6 +28,14 @@ describe("parseHostAgentDraftRouteFromPathname", () => {
});
});
describe("parseHostDraftRouteFromPathname", () => {
it("parses /new draft routes", () => {
expect(parseHostDraftRouteFromPathname("/h/local/new")).toEqual({
serverId: "local",
});
});
});
describe("parseHostAgentRouteFromPathname", () => {
it("continues parsing detail routes", () => {
expect(parseHostAgentRouteFromPathname("/h/local/agent/abc123")).toEqual({
@@ -32,3 +44,38 @@ describe("parseHostAgentRouteFromPathname", () => {
});
});
});
describe("workspace route parsing", () => {
it("parses workspace route", () => {
expect(
parseHostWorkspaceRouteFromPathname("/h/local/workspace/%2Ftmp%2Frepo")
).toEqual({
serverId: "local",
workspaceId: "/tmp/repo",
});
});
it("parses workspace agent route", () => {
expect(
parseHostWorkspaceAgentRouteFromPathname(
"/h/local/workspace/%2Ftmp%2Frepo/agent/agent-1"
)
).toEqual({
serverId: "local",
workspaceId: "/tmp/repo",
agentId: "agent-1",
});
});
it("parses workspace terminal route", () => {
expect(
parseHostWorkspaceTerminalRouteFromPathname(
"/h/local/workspace/%2Ftmp%2Frepo/terminal/term-1"
)
).toEqual({
serverId: "local",
workspaceId: "/tmp/repo",
terminalId: "term-1",
});
});
});
+147 -4
View File
@@ -57,7 +57,7 @@ export function parseHostAgentRouteFromPathname(
export function parseHostAgentDraftRouteFromPathname(
pathname: string
): { serverId: string } | null {
const match = pathname.match(/^\/h\/([^/]+)\/agent\/?$/);
const match = pathname.match(/^\/h\/([^/]+)\/(?:agent|new)\/?$/);
if (!match) {
return null;
}
@@ -73,17 +73,151 @@ export function parseHostAgentDraftRouteFromPathname(
}
export function buildHostAgentDraftRoute(serverId: string): string {
return buildHostDraftRoute(serverId);
}
export function parseHostDraftRouteFromPathname(
pathname: string
): { serverId: string } | null {
const match = pathname.match(/^\/h\/([^/]+)\/new\/?$/);
if (!match) {
return null;
}
const encodedServerId = match[1];
if (!encodedServerId) {
return null;
}
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
if (!serverId) {
return null;
}
return { serverId };
}
export function buildHostDraftRoute(serverId: string): string {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
return "/";
}
return `/h/${encodeSegment(normalized)}/agent`;
return `/h/${encodeSegment(normalized)}/new`;
}
export function parseHostWorkspaceRouteFromPathname(
pathname: string
): { serverId: string; workspaceId: string } | null {
const match = pathname.match(/^\/h\/([^/]+)\/workspace\/([^/]+)(?:\/|$)/);
if (!match) {
return null;
}
const [, encodedServerId, encodedWorkspaceId] = match;
if (!encodedServerId || !encodedWorkspaceId) {
return null;
}
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId));
if (!serverId || !workspaceId) {
return null;
}
return { serverId, workspaceId };
}
export function parseHostWorkspaceAgentRouteFromPathname(
pathname: string
): { serverId: string; workspaceId: string; agentId: string } | null {
const match = pathname.match(
/^\/h\/([^/]+)\/workspace\/([^/]+)\/agent\/([^/]+)(?:\/|$)/
);
if (!match) {
return null;
}
const [, encodedServerId, encodedWorkspaceId, encodedAgentId] = match;
if (!encodedServerId || !encodedWorkspaceId || !encodedAgentId) {
return null;
}
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId));
const agentId = trimNonEmpty(decodeSegment(encodedAgentId));
if (!serverId || !workspaceId || !agentId) {
return null;
}
return { serverId, workspaceId, agentId };
}
export function parseHostWorkspaceTerminalRouteFromPathname(
pathname: string
): { serverId: string; workspaceId: string; terminalId: string } | null {
const match = pathname.match(
/^\/h\/([^/]+)\/workspace\/([^/]+)\/terminal\/([^/]+)(?:\/|$)/
);
if (!match) {
return null;
}
const [, encodedServerId, encodedWorkspaceId, encodedTerminalId] = match;
if (!encodedServerId || !encodedWorkspaceId || !encodedTerminalId) {
return null;
}
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId));
const terminalId = trimNonEmpty(decodeSegment(encodedTerminalId));
if (!serverId || !workspaceId || !terminalId) {
return null;
}
return { serverId, workspaceId, terminalId };
}
export function buildHostWorkspaceRoute(
serverId: string,
workspaceId: string
): string {
const normalizedServerId = trimNonEmpty(serverId);
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (!normalizedServerId || !normalizedWorkspaceId) {
return "/";
}
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(
normalizedWorkspaceId
)}`;
}
export function buildHostWorkspaceAgentRoute(
serverId: string,
workspaceId: string,
agentId: string
): string {
const base = buildHostWorkspaceRoute(serverId, workspaceId);
const normalizedAgentId = trimNonEmpty(agentId);
if (base === "/" || !normalizedAgentId) {
return "/";
}
return `${base}/agent/${encodeSegment(normalizedAgentId)}`;
}
export function buildHostWorkspaceTerminalRoute(
serverId: string,
workspaceId: string,
terminalId: string
): string {
const base = buildHostWorkspaceRoute(serverId, workspaceId);
const normalizedTerminalId = trimNonEmpty(terminalId);
if (base === "/" || !normalizedTerminalId) {
return "/";
}
return `${base}/terminal/${encodeSegment(normalizedTerminalId)}`;
}
export function buildHostAgentDetailRoute(
serverId: string,
agentId: string
agentId: string,
workspaceId?: string
): string {
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (normalizedWorkspaceId) {
return buildHostWorkspaceAgentRoute(
serverId,
normalizedWorkspaceId,
agentId
);
}
const normalizedServerId = trimNonEmpty(serverId);
const normalizedAgentId = trimNonEmpty(agentId);
if (!normalizedServerId || !normalizedAgentId) {
@@ -127,5 +261,14 @@ export function mapPathnameToServer(
if (suffix.startsWith("agents")) {
return `${base}/agents`;
}
return `${base}/agent`;
if (suffix.startsWith("new")) {
return `${base}/new`;
}
if (suffix.startsWith("workspace/")) {
return `${base}/${suffix}`;
}
if (suffix.startsWith("agent/")) {
return `${base}/${suffix}`;
}
return base;
}
@@ -10,13 +10,13 @@ import {
describe("buildNewAgentRoute", () => {
it("falls back to host-scoped draft route when no working directory is provided", () => {
expect(buildNewAgentRoute("srv-1", undefined)).toBe("/h/srv-1/agent");
expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/agent");
expect(buildNewAgentRoute("srv-1", undefined)).toBe("/h/srv-1/new");
expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/new");
});
it("encodes the working directory query parameter", () => {
expect(buildNewAgentRoute("srv-1", "/Users/me/dev/paseo")).toBe(
"/h/srv-1/agent?workingDir=%2FUsers%2Fme%2Fdev%2Fpaseo"
"/h/srv-1/new?workingDir=%2FUsers%2Fme%2Fdev%2Fpaseo"
);
});
});
@@ -77,7 +77,7 @@ describe("resolveSelectedAgentForNewAgent", () => {
it("prefers the agent in the current route", () => {
expect(
resolveSelectedAgentForNewAgent({
pathname: "/h/srv-1/agent/agent-2",
pathname: "/h/srv-1/workspace/%2Frepo/agent/agent-2",
selectedAgentId: "srv-9:agent-9",
})
).toEqual({
@@ -2,6 +2,7 @@ import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
import {
buildHostAgentDraftRoute,
parseHostAgentRouteFromPathname,
parseHostWorkspaceAgentRouteFromPathname,
} from "@/utils/host-routes";
export function parseAgentKey(
@@ -26,6 +27,10 @@ export function resolveSelectedAgentForNewAgent(input: {
pathname: string;
selectedAgentId?: string;
}): { serverId: string; agentId: string } | null {
const workspaceRoute = parseHostWorkspaceAgentRouteFromPathname(input.pathname);
if (workspaceRoute) {
return { serverId: workspaceRoute.serverId, agentId: workspaceRoute.agentId };
}
return (
parseHostAgentRouteFromPathname(input.pathname) ??
parseAgentKey(input.selectedAgentId)
@@ -38,7 +38,7 @@ describe("buildNotificationRoute", () => {
});
it("falls back to host-scoped draft route when only serverId is present", () => {
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only/agent");
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only/new");
});
it("falls back to root when no server id is present", () => {
@@ -1,6 +1,6 @@
import {
buildHostDraftRoute,
buildHostAgentDetailRoute,
buildHostAgentDraftRoute,
} from "@/utils/host-routes";
type NotificationData = Record<string, unknown> | null | undefined;
@@ -33,7 +33,7 @@ export function buildNotificationRoute(data: NotificationData): string {
return buildHostAgentDetailRoute(serverId, agentId);
}
if (serverId) {
return buildHostAgentDraftRoute(serverId);
return buildHostDraftRoute(serverId);
}
return "/";
}