feat: canonical workspace tab IDs with URL routing and draft agent tabs
Rewrite workspace tab management to use stable tab IDs (agent_, terminal_, draft_, file_) with dedicated /tab/[tabId] routes, replace separate file-tabs store with unified workspace-tabs store, support multiple concurrent draft agent tabs, and detect text files by content instead of extension allowlist. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
97f0f4b266
commit
73eabd2eea
@@ -11,6 +11,7 @@ import {
|
||||
parseHostDraftRouteFromPathname,
|
||||
parseHostWorkspaceAgentRouteFromPathname,
|
||||
parseHostWorkspaceFileRouteFromPathname,
|
||||
parseHostWorkspaceTabRouteFromPathname,
|
||||
parseHostWorkspaceTerminalRouteFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "./host-routes";
|
||||
@@ -73,6 +74,15 @@ describe("workspace route parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace route for /tab targets", () => {
|
||||
expect(
|
||||
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123")
|
||||
).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace file route", () => {
|
||||
const encodedPath = encodeFilePathForPathSegment("src/index.ts");
|
||||
expect(
|
||||
@@ -110,6 +120,18 @@ describe("workspace route parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace tab route", () => {
|
||||
expect(
|
||||
parseHostWorkspaceTabRouteFromPathname(
|
||||
"/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123"
|
||||
)
|
||||
).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
tabId: "draft_abc123",
|
||||
});
|
||||
});
|
||||
|
||||
it("still parses legacy percent-encoded workspace routes", () => {
|
||||
expect(
|
||||
parseHostWorkspaceAgentRouteFromPathname(
|
||||
|
||||
@@ -231,6 +231,10 @@ export function parseHostWorkspaceRouteFromPathname(
|
||||
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);
|
||||
@@ -344,6 +348,55 @@ export function parseHostWorkspaceTerminalRouteFromPathname(
|
||||
return { serverId, workspaceId, terminalId };
|
||||
}
|
||||
|
||||
export function parseHostWorkspaceTabRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; workspaceId: string; tabId: 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 tabMarker = "/tab/";
|
||||
const tabIdx = pathname.lastIndexOf(tabMarker);
|
||||
if (tabIdx < 0 || tabIdx <= workspaceIdStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawWorkspaceId = pathname.slice(workspaceIdStart, tabIdx).replace(/\/+$/, "");
|
||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabIdStart = tabIdx + tabMarker.length;
|
||||
const tabIdEnd = pathname.indexOf("/", tabIdStart);
|
||||
const rawTabId =
|
||||
tabIdEnd < 0 ? pathname.slice(tabIdStart) : pathname.slice(tabIdStart, tabIdEnd);
|
||||
const tabId = trimNonEmpty(decodeSegment(rawTabId));
|
||||
if (!tabId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, workspaceId, tabId };
|
||||
}
|
||||
|
||||
export function parseHostWorkspaceFileRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; workspaceId: string; filePath: string } | null {
|
||||
@@ -435,6 +488,19 @@ export function buildHostWorkspaceTerminalRoute(
|
||||
return `${base}/terminal/${encodeSegment(normalizedTerminalId)}`;
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceTabRoute(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
tabId: string
|
||||
): string {
|
||||
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (base === "/" || !normalizedTabId) {
|
||||
return "/";
|
||||
}
|
||||
return `${base}/tab/${encodeSegment(normalizedTabId)}`;
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceFileRoute(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
|
||||
Reference in New Issue
Block a user