feat: sortable workspace tabs, tab context menus, and terminal auto-create removal

- Add drag-and-drop tab reordering with persistent tab order
- Add right-click context menu with copy agent ID, copy resume command, close to right
- Switch workspace route encoding from percent-encoding to base64url
- Remove terminal auto-creation on directory listing (explicit create only)
- Add swipe gestures on terminal emulator for mobile sidebar navigation
- Highlight active workspace row in sidebar
This commit is contained in:
Mohamed Boudra
2026-03-02 15:59:13 +07:00
parent c8cef9bec8
commit 223c8d7d0b
19 changed files with 1971 additions and 285 deletions
+29 -3
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
encodeWorkspaceIdForPathSegment,
parseHostAgentDraftRouteFromPathname,
parseHostAgentRouteFromPathname,
parseHostDraftRouteFromPathname,
@@ -46,9 +49,14 @@ describe("parseHostAgentRouteFromPathname", () => {
});
describe("workspace route parsing", () => {
it("encodes workspace IDs as base64url (no padding)", () => {
expect(encodeWorkspaceIdForPathSegment("/tmp/repo")).toBe("L3RtcC9yZXBv");
expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo");
});
it("parses workspace route", () => {
expect(
parseHostWorkspaceRouteFromPathname("/h/local/workspace/%2Ftmp%2Frepo")
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv")
).toEqual({
serverId: "local",
workspaceId: "/tmp/repo",
@@ -58,7 +66,7 @@ describe("workspace route parsing", () => {
it("parses workspace agent route", () => {
expect(
parseHostWorkspaceAgentRouteFromPathname(
"/h/local/workspace/%2Ftmp%2Frepo/agent/agent-1"
"/h/local/workspace/L3RtcC9yZXBv/agent/agent-1"
)
).toEqual({
serverId: "local",
@@ -70,7 +78,7 @@ describe("workspace route parsing", () => {
it("parses workspace terminal route", () => {
expect(
parseHostWorkspaceTerminalRouteFromPathname(
"/h/local/workspace/%2Ftmp%2Frepo/terminal/term-1"
"/h/local/workspace/L3RtcC9yZXBv/terminal/term-1"
)
).toEqual({
serverId: "local",
@@ -78,4 +86,22 @@ describe("workspace route parsing", () => {
terminalId: "term-1",
});
});
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"
);
});
});
+187 -30
View File
@@ -1,3 +1,5 @@
import { Buffer } from "buffer";
type NullableString = string | null | undefined;
function trimNonEmpty(value: NullableString): string | null {
@@ -20,6 +22,78 @@ function decodeSegment(value: string): string {
}
}
function toBase64UrlNoPad(input: string): string {
return Buffer.from(input, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function tryDecodeBase64UrlNoPadUtf8(input: string): string | null {
const normalized = input.trim();
if (normalized.length === 0) {
return null;
}
if (!/^[A-Za-z0-9_-]+$/.test(normalized)) {
return null;
}
const base64 = normalized.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
let decoded: string;
try {
decoded = Buffer.from(padded, "base64").toString("utf8");
} catch {
return null;
}
// Validate via round-trip to avoid false positives ("workspace-1" etc).
if (toBase64UrlNoPad(decoded) !== normalized) {
return null;
}
return decoded;
}
function normalizeWorkspaceId(value: string): string {
return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
}
export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
const normalized = trimNonEmpty(workspaceId);
if (!normalized) {
return "";
}
return toBase64UrlNoPad(normalizeWorkspaceId(normalized));
}
export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): string | null {
const normalizedSegment = trimNonEmpty(workspaceIdSegment);
if (!normalizedSegment) {
return null;
}
// Decode %2F etc first (legacy scheme), but keep the raw segment to decide if base64 applies.
const decoded = trimNonEmpty(decodeSegment(normalizedSegment));
if (!decoded) {
return null;
}
// Legacy: if it already looks like a path after decoding, keep it.
if (decoded.includes("/") || decoded.includes("\\")) {
return normalizeWorkspaceId(decoded);
}
const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded);
if (base64Decoded) {
return normalizeWorkspaceId(base64Decoded);
}
return normalizeWorkspaceId(decoded);
}
export function parseServerIdFromPathname(pathname: string): string | null {
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
if (!match) {
@@ -105,17 +179,42 @@ export function buildHostDraftRoute(serverId: string): string {
export function parseHostWorkspaceRouteFromPathname(
pathname: string
): { serverId: string; workspaceId: string } | null {
const match = pathname.match(/^\/h\/([^/]+)\/workspace\/([^/]+)(?:\/|$)/);
if (!match) {
const prefix = "/h/";
if (!pathname.startsWith(prefix)) {
return null;
}
const [, encodedServerId, encodedWorkspaceId] = match;
if (!encodedServerId || !encodedWorkspaceId) {
const serverIdStart = prefix.length;
const serverIdEnd = pathname.indexOf("/", serverIdStart);
if (serverIdEnd < 0) {
return null;
}
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId));
if (!serverId || !workspaceId) {
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;
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 rawWorkspaceId = pathname.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, "");
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
if (!workspaceId) {
return null;
}
return { serverId, workspaceId };
@@ -124,44 +223,100 @@ export function parseHostWorkspaceRouteFromPathname(
export function parseHostWorkspaceAgentRouteFromPathname(
pathname: string
): { serverId: string; workspaceId: string; agentId: string } | null {
const match = pathname.match(
/^\/h\/([^/]+)\/workspace\/([^/]+)\/agent\/([^/]+)(?:\/|$)/
);
if (!match) {
const prefix = "/h/";
if (!pathname.startsWith(prefix)) {
return null;
}
const [, encodedServerId, encodedWorkspaceId, encodedAgentId] = match;
if (!encodedServerId || !encodedWorkspaceId || !encodedAgentId) {
const serverIdStart = prefix.length;
const serverIdEnd = pathname.indexOf("/", serverIdStart);
if (serverIdEnd < 0) {
return null;
}
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId));
const agentId = trimNonEmpty(decodeSegment(encodedAgentId));
if (!serverId || !workspaceId || !agentId) {
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 match = pathname.match(
/^\/h\/([^/]+)\/workspace\/([^/]+)\/terminal\/([^/]+)(?:\/|$)/
);
if (!match) {
const prefix = "/h/";
if (!pathname.startsWith(prefix)) {
return null;
}
const [, encodedServerId, encodedWorkspaceId, encodedTerminalId] = match;
if (!encodedServerId || !encodedWorkspaceId || !encodedTerminalId) {
const serverIdStart = prefix.length;
const serverIdEnd = pathname.indexOf("/", serverIdStart);
if (serverIdEnd < 0) {
return null;
}
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId));
const terminalId = trimNonEmpty(decodeSegment(encodedTerminalId));
if (!serverId || !workspaceId || !terminalId) {
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 };
}
@@ -174,9 +329,11 @@ export function buildHostWorkspaceRoute(
if (!normalizedServerId || !normalizedWorkspaceId) {
return "/";
}
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(
normalizedWorkspaceId
)}`;
const encodedWorkspaceId = encodeWorkspaceIdForPathSegment(normalizedWorkspaceId);
if (!encodedWorkspaceId) {
return "/";
}
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
}
export function buildHostWorkspaceAgentRoute(
@@ -0,0 +1,40 @@
export type ProviderCommandId = "resume";
/**
* Declarative command templates for provider-native CLIs.
*
* Note: these are NOT Paseo agent IDs. They take provider-native session IDs.
* Example placeholders:
* - {sessionId}
*/
export const PROVIDER_COMMAND_TEMPLATES: Record<
string,
Partial<Record<ProviderCommandId, string>>
> = {
codex: {
resume: "codex resume {sessionId}",
},
claude: {
resume: "claude --resume {sessionId}",
},
};
function renderTemplate(
template: string,
vars: Record<string, string>
): string {
return template.replace(/\{(\w+)\}/g, (_match, key: string) => vars[key] ?? "");
}
export function buildProviderCommand(input: {
provider: string;
id: ProviderCommandId;
sessionId: string;
}): string | null {
const template = PROVIDER_COMMAND_TEMPLATES[input.provider]?.[input.id] ?? null;
if (!template) {
return null;
}
return renderTemplate(template, { sessionId: input.sessionId });
}