feat: unify workspace routing through tab-based URLs and remove legacy per-type routes
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHostWorkspaceAgentTabRoute,
|
||||
buildHostWorkspaceFileTabRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildHostWorkspaceFileRoute,
|
||||
buildHostWorkspaceTerminalTabRoute,
|
||||
decodeFilePathFromPathSegment,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
encodeFilePathForPathSegment,
|
||||
@@ -9,10 +11,7 @@ import {
|
||||
parseHostAgentDraftRouteFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseHostDraftRouteFromPathname,
|
||||
parseHostWorkspaceAgentRouteFromPathname,
|
||||
parseHostWorkspaceFileRouteFromPathname,
|
||||
parseHostWorkspaceTabRouteFromPathname,
|
||||
parseHostWorkspaceTerminalRouteFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "./host-routes";
|
||||
|
||||
@@ -24,9 +23,7 @@ describe("parseHostAgentDraftRouteFromPathname", () => {
|
||||
});
|
||||
|
||||
it("parses encoded server id", () => {
|
||||
expect(
|
||||
parseHostAgentDraftRouteFromPathname("/h/team%20host/new")
|
||||
).toEqual({
|
||||
expect(parseHostAgentDraftRouteFromPathname("/h/team%20host/new")).toEqual({
|
||||
serverId: "team host",
|
||||
});
|
||||
});
|
||||
@@ -66,9 +63,7 @@ describe("workspace route parsing", () => {
|
||||
});
|
||||
|
||||
it("parses workspace route", () => {
|
||||
expect(
|
||||
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv")
|
||||
).toEqual({
|
||||
expect(parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv")).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
});
|
||||
@@ -83,48 +78,9 @@ describe("workspace route parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace file route", () => {
|
||||
const encodedPath = encodeFilePathForPathSegment("src/index.ts");
|
||||
expect(
|
||||
parseHostWorkspaceFileRouteFromPathname(
|
||||
`/h/local/workspace/L3RtcC9yZXBv/file/${encodedPath}`
|
||||
)
|
||||
).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
filePath: "src/index.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace agent route", () => {
|
||||
expect(
|
||||
parseHostWorkspaceAgentRouteFromPathname(
|
||||
"/h/local/workspace/L3RtcC9yZXBv/agent/agent-1"
|
||||
)
|
||||
).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace terminal route", () => {
|
||||
expect(
|
||||
parseHostWorkspaceTerminalRouteFromPathname(
|
||||
"/h/local/workspace/L3RtcC9yZXBv/terminal/term-1"
|
||||
)
|
||||
).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
terminalId: "term-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace tab route", () => {
|
||||
expect(
|
||||
parseHostWorkspaceTabRouteFromPathname(
|
||||
"/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123"
|
||||
)
|
||||
parseHostWorkspaceTabRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123")
|
||||
).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
@@ -132,27 +88,25 @@ describe("workspace route parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("still parses legacy percent-encoded workspace routes", () => {
|
||||
expect(
|
||||
parseHostWorkspaceAgentRouteFromPathname(
|
||||
"/h/local/workspace/%2Ftmp%2Frepo/agent/agent-1"
|
||||
)
|
||||
).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
it("builds base64url workspace routes", () => {
|
||||
expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe("/h/local/workspace/L3RtcC9yZXBv");
|
||||
});
|
||||
|
||||
it("builds base64url workspace routes", () => {
|
||||
expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv"
|
||||
it("builds workspace agent tab routes", () => {
|
||||
expect(buildHostWorkspaceAgentTabRoute("local", "/tmp/repo", "agent-1")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv/tab/agent_agent-1"
|
||||
);
|
||||
});
|
||||
|
||||
it("builds base64url workspace file routes", () => {
|
||||
expect(buildHostWorkspaceFileRoute("local", "/tmp/repo", "src/index.ts")).toBe(
|
||||
`/h/local/workspace/L3RtcC9yZXBv/file/${encodeFilePathForPathSegment("src/index.ts")}`
|
||||
it("builds workspace terminal tab routes", () => {
|
||||
expect(buildHostWorkspaceTerminalTabRoute("local", "/tmp/repo", "term-1")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv/tab/terminal_term-1"
|
||||
);
|
||||
});
|
||||
|
||||
it("builds workspace file tab routes", () => {
|
||||
expect(buildHostWorkspaceFileTabRoute("local", "/tmp/repo", "src/index.ts")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv/tab/file_src%2Findex.ts"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,21 @@ function normalizeWorkspaceId(value: string): string {
|
||||
return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function buildWorkspaceAgentTabId(agentId: string): string | null {
|
||||
const normalized = trimNonEmpty(agentId);
|
||||
return normalized ? `agent_${normalized}` : null;
|
||||
}
|
||||
|
||||
function buildWorkspaceTerminalTabId(terminalId: string): string | null {
|
||||
const normalized = trimNonEmpty(terminalId);
|
||||
return normalized ? `terminal_${normalized}` : null;
|
||||
}
|
||||
|
||||
function buildWorkspaceFileTabId(filePath: string): string | null {
|
||||
const normalized = trimNonEmpty(filePath);
|
||||
return normalized ? `file_${normalized.replace(/\\/g, "/")}` : null;
|
||||
}
|
||||
|
||||
export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
|
||||
const normalized = trimNonEmpty(workspaceId);
|
||||
if (!normalized) {
|
||||
@@ -222,22 +237,9 @@ export function parseHostWorkspaceRouteFromPathname(
|
||||
|
||||
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
||||
let workspaceIdEnd = pathname.length;
|
||||
|
||||
const agentIdx = pathname.lastIndexOf("/agent/");
|
||||
if (agentIdx >= 0 && agentIdx > workspaceIdStart) {
|
||||
workspaceIdEnd = Math.min(workspaceIdEnd, agentIdx);
|
||||
}
|
||||
const terminalIdx = pathname.lastIndexOf("/terminal/");
|
||||
if (terminalIdx >= 0 && terminalIdx > workspaceIdStart) {
|
||||
workspaceIdEnd = Math.min(workspaceIdEnd, terminalIdx);
|
||||
}
|
||||
const tabIdx = pathname.lastIndexOf("/tab/");
|
||||
if (tabIdx >= 0 && tabIdx > workspaceIdStart) {
|
||||
workspaceIdEnd = Math.min(workspaceIdEnd, tabIdx);
|
||||
}
|
||||
const fileIdx = pathname.lastIndexOf("/file/");
|
||||
if (fileIdx >= 0 && fileIdx > workspaceIdStart) {
|
||||
workspaceIdEnd = Math.min(workspaceIdEnd, fileIdx);
|
||||
workspaceIdEnd = tabIdx;
|
||||
}
|
||||
|
||||
const rawWorkspaceId = pathname.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, "");
|
||||
@@ -248,106 +250,6 @@ export function parseHostWorkspaceRouteFromPathname(
|
||||
return { serverId, workspaceId };
|
||||
}
|
||||
|
||||
export function parseHostWorkspaceAgentRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; workspaceId: string; agentId: string } | null {
|
||||
const prefix = "/h/";
|
||||
if (!pathname.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverIdStart = prefix.length;
|
||||
const serverIdEnd = pathname.indexOf("/", serverIdStart);
|
||||
if (serverIdEnd < 0) {
|
||||
return null;
|
||||
}
|
||||
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
|
||||
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspacePrefix = "/workspace/";
|
||||
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
||||
const agentMarker = "/agent/";
|
||||
const agentIdx = pathname.lastIndexOf(agentMarker);
|
||||
if (agentIdx < 0 || agentIdx <= workspaceIdStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawWorkspaceId = pathname.slice(workspaceIdStart, agentIdx).replace(/\/+$/, "");
|
||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const agentIdStart = agentIdx + agentMarker.length;
|
||||
const agentIdEnd = pathname.indexOf("/", agentIdStart);
|
||||
const rawAgentId =
|
||||
agentIdEnd < 0 ? pathname.slice(agentIdStart) : pathname.slice(agentIdStart, agentIdEnd);
|
||||
const agentId = trimNonEmpty(decodeSegment(rawAgentId));
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, workspaceId, agentId };
|
||||
}
|
||||
|
||||
export function parseHostWorkspaceTerminalRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; workspaceId: string; terminalId: string } | null {
|
||||
const prefix = "/h/";
|
||||
if (!pathname.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverIdStart = prefix.length;
|
||||
const serverIdEnd = pathname.indexOf("/", serverIdStart);
|
||||
if (serverIdEnd < 0) {
|
||||
return null;
|
||||
}
|
||||
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
|
||||
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspacePrefix = "/workspace/";
|
||||
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
||||
const terminalMarker = "/terminal/";
|
||||
const terminalIdx = pathname.lastIndexOf(terminalMarker);
|
||||
if (terminalIdx < 0 || terminalIdx <= workspaceIdStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawWorkspaceId = pathname.slice(workspaceIdStart, terminalIdx).replace(/\/+$/, "");
|
||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const terminalIdStart = terminalIdx + terminalMarker.length;
|
||||
const terminalIdEnd = pathname.indexOf("/", terminalIdStart);
|
||||
const rawTerminalId =
|
||||
terminalIdEnd < 0
|
||||
? pathname.slice(terminalIdStart)
|
||||
: pathname.slice(terminalIdStart, terminalIdEnd);
|
||||
const terminalId = trimNonEmpty(decodeSegment(rawTerminalId));
|
||||
if (!terminalId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, workspaceId, terminalId };
|
||||
}
|
||||
|
||||
export function parseHostWorkspaceTabRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; workspaceId: string; tabId: string } | null {
|
||||
@@ -397,55 +299,6 @@ export function parseHostWorkspaceTabRouteFromPathname(
|
||||
return { serverId, workspaceId, tabId };
|
||||
}
|
||||
|
||||
export function parseHostWorkspaceFileRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; workspaceId: string; filePath: string } | null {
|
||||
const prefix = "/h/";
|
||||
if (!pathname.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverIdStart = prefix.length;
|
||||
const serverIdEnd = pathname.indexOf("/", serverIdStart);
|
||||
if (serverIdEnd < 0) {
|
||||
return null;
|
||||
}
|
||||
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
|
||||
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspacePrefix = "/workspace/";
|
||||
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
||||
const fileMarker = "/file/";
|
||||
const fileIdx = pathname.lastIndexOf(fileMarker);
|
||||
if (fileIdx < 0 || fileIdx <= workspaceIdStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawWorkspaceId = pathname.slice(workspaceIdStart, fileIdx).replace(/\/+$/, "");
|
||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePathStart = fileIdx + fileMarker.length;
|
||||
const filePathEnd = pathname.indexOf("/", filePathStart);
|
||||
const rawFilePath =
|
||||
filePathEnd < 0 ? pathname.slice(filePathStart) : pathname.slice(filePathStart, filePathEnd);
|
||||
const filePath = decodeFilePathFromPathSegment(rawFilePath);
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, workspaceId, filePath };
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceRoute(
|
||||
serverId: string,
|
||||
workspaceId: string
|
||||
@@ -462,30 +315,28 @@ export function buildHostWorkspaceRoute(
|
||||
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceAgentRoute(
|
||||
export function buildHostWorkspaceAgentTabRoute(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
agentId: string
|
||||
): string {
|
||||
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
if (base === "/" || !normalizedAgentId) {
|
||||
const tabId = buildWorkspaceAgentTabId(agentId);
|
||||
if (!tabId) {
|
||||
return "/";
|
||||
}
|
||||
return `${base}/agent/${encodeSegment(normalizedAgentId)}`;
|
||||
return buildHostWorkspaceTabRoute(serverId, workspaceId, tabId);
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceTerminalRoute(
|
||||
export function buildHostWorkspaceTerminalTabRoute(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
terminalId: string
|
||||
): string {
|
||||
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
const normalizedTerminalId = trimNonEmpty(terminalId);
|
||||
if (base === "/" || !normalizedTerminalId) {
|
||||
const tabId = buildWorkspaceTerminalTabId(terminalId);
|
||||
if (!tabId) {
|
||||
return "/";
|
||||
}
|
||||
return `${base}/terminal/${encodeSegment(normalizedTerminalId)}`;
|
||||
return buildHostWorkspaceTabRoute(serverId, workspaceId, tabId);
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceTabRoute(
|
||||
@@ -501,17 +352,16 @@ export function buildHostWorkspaceTabRoute(
|
||||
return `${base}/tab/${encodeSegment(normalizedTabId)}`;
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceFileRoute(
|
||||
export function buildHostWorkspaceFileTabRoute(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
filePath: string
|
||||
): string {
|
||||
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
const encodedFilePath = encodeFilePathForPathSegment(filePath);
|
||||
if (base === "/" || !encodedFilePath) {
|
||||
const tabId = buildWorkspaceFileTabId(filePath);
|
||||
if (!tabId) {
|
||||
return "/";
|
||||
}
|
||||
return `${base}/file/${encodeSegment(encodedFilePath)}`;
|
||||
return buildHostWorkspaceTabRoute(serverId, workspaceId, tabId);
|
||||
}
|
||||
|
||||
export function buildHostAgentDetailRoute(
|
||||
@@ -521,7 +371,7 @@ export function buildHostAgentDetailRoute(
|
||||
): string {
|
||||
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
|
||||
if (normalizedWorkspaceId) {
|
||||
return buildHostWorkspaceAgentRoute(
|
||||
return buildHostWorkspaceAgentTabRoute(
|
||||
serverId,
|
||||
normalizedWorkspaceId,
|
||||
agentId
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("resolveSelectedAgentForNewAgent", () => {
|
||||
it("prefers the agent in the current route", () => {
|
||||
expect(
|
||||
resolveSelectedAgentForNewAgent({
|
||||
pathname: "/h/srv-1/workspace/%2Frepo/agent/agent-2",
|
||||
pathname: "/h/srv-1/workspace/%2Frepo/tab/agent_agent-2",
|
||||
selectedAgentId: "srv-9:agent-9",
|
||||
})
|
||||
).toEqual({
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||
import {
|
||||
buildHostAgentDraftRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseHostWorkspaceAgentRouteFromPathname,
|
||||
parseHostWorkspaceTabRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export function parseAgentKey(
|
||||
@@ -27,9 +27,12 @@ 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 };
|
||||
const workspaceTabRoute = parseHostWorkspaceTabRouteFromPathname(input.pathname);
|
||||
if (workspaceTabRoute?.tabId?.startsWith("agent_")) {
|
||||
const agentId = workspaceTabRoute.tabId.slice("agent_".length).trim();
|
||||
if (agentId) {
|
||||
return { serverId: workspaceTabRoute.serverId, agentId };
|
||||
}
|
||||
}
|
||||
return (
|
||||
parseHostAgentRouteFromPathname(input.pathname) ??
|
||||
|
||||
Reference in New Issue
Block a user