app: hard-cut workspace routing to workspace-only URLs
This commit is contained in:
@@ -47,7 +47,7 @@ import { buildNotificationRoute } from "@/utils/notification-routing";
|
|||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostWorkspaceTabRouteFromPathname,
|
parseHostWorkspaceOpenIntentFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { getTauri } from "@/utils/tauri";
|
import { getTauri } from "@/utils/tauri";
|
||||||
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
|
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
|
||||||
@@ -381,10 +381,12 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
// Parse selectedAgentKey directly from pathname
|
// Parse selectedAgentKey directly from pathname
|
||||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||||
const selectedAgentKey = useMemo(() => {
|
const selectedAgentKey = useMemo(() => {
|
||||||
const workspaceTab = parseHostWorkspaceTabRouteFromPathname(pathname);
|
const workspaceMatch = pathname.match(/^\/h\/([^/]+)\/workspace\/[^/]+(?:\/|$)/);
|
||||||
if (workspaceTab?.tabId?.startsWith("agent_")) {
|
const workspaceServerId = workspaceMatch?.[1]?.trim() ?? "";
|
||||||
const agentId = workspaceTab.tabId.slice("agent_".length).trim();
|
const openIntent = parseHostWorkspaceOpenIntentFromPathname(pathname);
|
||||||
return agentId ? `${workspaceTab.serverId}:${agentId}` : undefined;
|
if (workspaceServerId && openIntent?.kind === "agent") {
|
||||||
|
const agentId = openIntent.agentId.trim();
|
||||||
|
return agentId ? `${workspaceServerId}:${agentId}` : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = parseHostAgentRouteFromPathname(pathname);
|
const match = parseHostAgentRouteFromPathname(pathname);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useSessionStore } from "@/stores/session-store";
|
|||||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
buildHostWorkspaceAgentTabRoute,
|
buildHostWorkspaceAgentRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
@@ -38,7 +38,7 @@ export default function HostAgentReadyRoute() {
|
|||||||
if (normalizedCwd) {
|
if (normalizedCwd) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(
|
router.replace(
|
||||||
buildHostWorkspaceAgentTabRoute(serverId, normalizedCwd, agentId) as any
|
buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, router, serverId]);
|
}, [agentCwd, agentId, router, serverId]);
|
||||||
@@ -77,7 +77,7 @@ export default function HostAgentReadyRoute() {
|
|||||||
const cwd = result?.agent?.cwd?.trim();
|
const cwd = result?.agent?.cwd?.trim();
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
if (cwd) {
|
if (cwd) {
|
||||||
router.replace(buildHostWorkspaceAgentTabRoute(serverId, cwd, agentId) as any);
|
router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId) as any);
|
||||||
|
|||||||
@@ -5,30 +5,12 @@ import { useSessionStore } from "@/stores/session-store";
|
|||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
|
buildHostWorkspaceAgentRoute,
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
buildHostWorkspaceAgentTabRoute,
|
|
||||||
buildHostWorkspaceTabRoute,
|
|
||||||
parseHostWorkspaceTabRouteFromPathname,
|
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
||||||
|
|
||||||
function readInitialNavigationPathname(): string | null {
|
|
||||||
if (Platform.OS !== "web" || typeof window === "undefined") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const entry = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined;
|
|
||||||
const name = typeof entry?.name === "string" ? entry.name : null;
|
|
||||||
if (!name) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return new URL(name).pathname;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HostIndexRoute() {
|
export default function HostIndexRoute() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const routerPathname = usePathname();
|
const routerPathname = usePathname();
|
||||||
@@ -60,28 +42,6 @@ export default function HostIndexRoute() {
|
|||||||
if (currentPathname !== rootRoute && currentPathname !== `${rootRoute}/`) {
|
if (currentPathname !== rootRoute && currentPathname !== `${rootRoute}/`) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialPathname = readInitialNavigationPathname();
|
|
||||||
const initialWorkspaceTabRoute = initialPathname
|
|
||||||
? parseHostWorkspaceTabRouteFromPathname(initialPathname)
|
|
||||||
: null;
|
|
||||||
if (
|
|
||||||
initialWorkspaceTabRoute &&
|
|
||||||
initialWorkspaceTabRoute.serverId === serverId
|
|
||||||
) {
|
|
||||||
const canonicalInitialTabPath = buildHostWorkspaceTabRoute(
|
|
||||||
initialWorkspaceTabRoute.serverId,
|
|
||||||
initialWorkspaceTabRoute.workspaceId,
|
|
||||||
initialWorkspaceTabRoute.tabId
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
canonicalInitialTabPath !== "/" &&
|
|
||||||
window.location.pathname !== canonicalInitialTabPath
|
|
||||||
) {
|
|
||||||
router.replace(canonicalInitialTabPath as any);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleAgents = sessionAgents
|
const visibleAgents = sessionAgents
|
||||||
@@ -96,7 +56,7 @@ export default function HostIndexRoute() {
|
|||||||
const primaryAgent = visibleAgents[0];
|
const primaryAgent = visibleAgents[0];
|
||||||
if (primaryAgent?.cwd?.trim()) {
|
if (primaryAgent?.cwd?.trim()) {
|
||||||
router.replace(
|
router.replace(
|
||||||
buildHostWorkspaceAgentTabRoute(
|
buildHostWorkspaceAgentRoute(
|
||||||
serverId,
|
serverId,
|
||||||
primaryAgent.cwd.trim(),
|
primaryAgent.cwd.trim(),
|
||||||
primaryAgent.id
|
primaryAgent.id
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { usePathname } from "expo-router";
|
import { usePathname } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import {
|
import {
|
||||||
|
parseHostWorkspaceOpenIntentFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
parseHostWorkspaceTabRouteFromPathname,
|
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
@@ -13,29 +12,17 @@ export default function HostWorkspaceLayout() {
|
|||||||
Platform.OS === "web" && typeof window !== "undefined"
|
Platform.OS === "web" && typeof window !== "undefined"
|
||||||
? `${window.location.pathname}${window.location.search}${window.location.hash}`
|
? `${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||||
: pathname;
|
: pathname;
|
||||||
const tabRoute = parseHostWorkspaceTabRouteFromPathname(resolvedPathname);
|
const activeRoute = parseHostWorkspaceRouteFromPathname(resolvedPathname);
|
||||||
const activeRoute = tabRoute ?? parseHostWorkspaceRouteFromPathname(resolvedPathname);
|
|
||||||
const serverId = activeRoute?.serverId ?? "";
|
const serverId = activeRoute?.serverId ?? "";
|
||||||
const workspaceId = activeRoute?.workspaceId ?? "";
|
const workspaceId = activeRoute?.workspaceId ?? "";
|
||||||
const routeTabId = tabRoute?.tabId ?? null;
|
const openIntent = parseHostWorkspaceOpenIntentFromPathname(resolvedPathname);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.OS !== "web" || !tabRoute || typeof window === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
if (!params.has("tabId") && !params.has("workspaceId") && !params.has("serverId")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.history.replaceState(window.history.state, "", window.location.pathname);
|
|
||||||
}, [resolvedPathname, tabRoute]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceScreen
|
<WorkspaceScreen
|
||||||
key={`${serverId}:${workspaceId}`}
|
key={`${serverId}:${workspaceId}`}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
routeTabId={routeTabId}
|
openIntent={openIntent}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function HostWorkspaceTabRoute() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
startNavigationTiming,
|
startNavigationTiming,
|
||||||
} from "@/utils/navigation-timing";
|
} from "@/utils/navigation-timing";
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceAgentTabRoute,
|
buildHostWorkspaceAgentRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
@@ -189,7 +189,7 @@ export function AgentList({
|
|||||||
|
|
||||||
onAgentSelect?.();
|
onAgentSelect?.();
|
||||||
|
|
||||||
const route: Href = buildHostWorkspaceAgentTabRoute(
|
const route: Href = buildHostWorkspaceAgentRoute(
|
||||||
serverId,
|
serverId,
|
||||||
agent.cwd,
|
agent.cwd,
|
||||||
agentId
|
agentId
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
|
|||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||||
import { buildHostWorkspaceFileTabRoute } from "@/utils/host-routes";
|
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||||
const isToolSequenceItem = (item?: StreamItem) =>
|
const isToolSequenceItem = (item?: StreamItem) =>
|
||||||
@@ -213,7 +213,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (normalized.file) {
|
if (normalized.file) {
|
||||||
const route = buildHostWorkspaceFileTabRoute(
|
const route = buildHostWorkspaceFileRoute(
|
||||||
resolvedServerId,
|
resolvedServerId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
normalized.file
|
normalized.file
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/utils/command-center-focus-restore";
|
} from "@/utils/command-center-focus-restore";
|
||||||
import {
|
import {
|
||||||
buildHostNewAgentRoute,
|
buildHostNewAgentRoute,
|
||||||
buildHostWorkspaceAgentTabRoute,
|
buildHostWorkspaceAgentRoute,
|
||||||
buildHostSettingsRoute,
|
buildHostSettingsRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
@@ -198,7 +198,7 @@ export function useCommandCenter() {
|
|||||||
// Don't restore focus back to the prior element after we navigate.
|
// Don't restore focus back to the prior element after we navigate.
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
const route: Href = buildHostWorkspaceAgentTabRoute(
|
const route: Href = buildHostWorkspaceAgentRoute(
|
||||||
agent.serverId,
|
agent.serverId,
|
||||||
agent.cwd,
|
agent.cwd,
|
||||||
agent.id
|
agent.id
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ describe("keyboard-shortcut-routing", () => {
|
|||||||
it("returns selected agent key when provided", () => {
|
it("returns selected agent key when provided", () => {
|
||||||
const key = resolveSelectedOrRouteAgentKey({
|
const key = resolveSelectedOrRouteAgentKey({
|
||||||
selectedAgentId: "server-1:agent-1",
|
selectedAgentId: "server-1:agent-1",
|
||||||
pathname: "/h/server-1/workspace/workspace-1/tab/draft_123",
|
pathname: "/h/server-1/workspace/workspace-1?open=draft%3Adraft_123",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(key).toBe("server-1:agent-1");
|
expect(key).toBe("server-1:agent-1");
|
||||||
@@ -18,7 +18,7 @@ describe("keyboard-shortcut-routing", () => {
|
|||||||
|
|
||||||
it("maps workspace draft tabs to the draft tab id key", () => {
|
it("maps workspace draft tabs to the draft tab id key", () => {
|
||||||
const key = resolveSelectedOrRouteAgentKey({
|
const key = resolveSelectedOrRouteAgentKey({
|
||||||
pathname: "/h/server-1/workspace/workspace-1/tab/draft_123",
|
pathname: "/h/server-1/workspace/workspace-1?open=draft%3Adraft_123",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(key).toBe("server-1:draft_123");
|
expect(key).toBe("server-1:draft_123");
|
||||||
@@ -31,7 +31,7 @@ describe("keyboard-shortcut-routing", () => {
|
|||||||
it("allows the shortcut on selected-agent routes", () => {
|
it("allows the shortcut on selected-agent routes", () => {
|
||||||
const canToggle = canToggleFileExplorerShortcut({
|
const canToggle = canToggleFileExplorerShortcut({
|
||||||
selectedAgentId: "server-1:agent-1",
|
selectedAgentId: "server-1:agent-1",
|
||||||
pathname: "/h/server-1/workspace/workspace-1/tab/agent_agent-1",
|
pathname: "/h/server-1/workspace/workspace-1?open=agent%3Aagent-1",
|
||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,18 +47,18 @@ describe("keyboard-shortcut-routing", () => {
|
|||||||
expect(canToggle).toBe(true);
|
expect(canToggle).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows the shortcut on workspace terminal routes", () => {
|
it("allows the shortcut on workspace routes with intent query", () => {
|
||||||
const canToggle = canToggleFileExplorerShortcut({
|
const canToggle = canToggleFileExplorerShortcut({
|
||||||
pathname: "/h/server-1/workspace/workspace-1/tab/terminal_terminal-1",
|
pathname: "/h/server-1/workspace/workspace-1?open=terminal%3Aterminal-1",
|
||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(canToggle).toBe(true);
|
expect(canToggle).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows the shortcut on workspace draft-tab routes", () => {
|
it("allows the shortcut on workspace draft-intent routes", () => {
|
||||||
const canToggle = canToggleFileExplorerShortcut({
|
const canToggle = canToggleFileExplorerShortcut({
|
||||||
pathname: "/h/server-1/workspace/workspace-1/tab/draft_123",
|
pathname: "/h/server-1/workspace/workspace-1?open=draft%3Adraft_123",
|
||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ describe("keyboard-shortcut-routing", () => {
|
|||||||
|
|
||||||
it("blocks the shortcut when no toggle handler exists", () => {
|
it("blocks the shortcut when no toggle handler exists", () => {
|
||||||
const canToggle = canToggleFileExplorerShortcut({
|
const canToggle = canToggleFileExplorerShortcut({
|
||||||
pathname: "/h/server-1/workspace/workspace-1/tab/draft_123",
|
pathname: "/h/server-1/workspace/workspace-1?open=draft%3Adraft_123",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(canToggle).toBe(false);
|
expect(canToggle).toBe(false);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
|
parseHostWorkspaceOpenIntentFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
parseHostWorkspaceTabRouteFromPathname,
|
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
export function resolveSelectedOrRouteAgentKey(input: {
|
export function resolveSelectedOrRouteAgentKey(input: {
|
||||||
@@ -12,15 +12,16 @@ export function resolveSelectedOrRouteAgentKey(input: {
|
|||||||
return input.selectedAgentId;
|
return input.selectedAgentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceTabRoute = parseHostWorkspaceTabRouteFromPathname(input.pathname);
|
const workspaceRoute = parseHostWorkspaceRouteFromPathname(input.pathname);
|
||||||
if (workspaceTabRoute?.tabId) {
|
const workspaceOpenIntent = parseHostWorkspaceOpenIntentFromPathname(input.pathname);
|
||||||
const tabId = workspaceTabRoute.tabId;
|
if (workspaceRoute && workspaceOpenIntent) {
|
||||||
if (tabId.startsWith("agent_")) {
|
if (workspaceOpenIntent.kind === "agent") {
|
||||||
const agentId = tabId.slice("agent_".length).trim();
|
const agentId = workspaceOpenIntent.agentId.trim();
|
||||||
return agentId ? `${workspaceTabRoute.serverId}:${agentId}` : null;
|
return agentId ? `${workspaceRoute.serverId}:${agentId}` : null;
|
||||||
}
|
}
|
||||||
if (tabId.startsWith("draft_")) {
|
if (workspaceOpenIntent.kind === "draft") {
|
||||||
return `${workspaceTabRoute.serverId}:${tabId}`;
|
const draftId = workspaceOpenIntent.draftId.trim();
|
||||||
|
return draftId ? `${workspaceRoute.serverId}:${draftId}` : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import type {
|
|||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
} from '@server/server/agent/agent-sdk-types'
|
} from '@server/server/agent/agent-sdk-types'
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
|
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
|
||||||
import { buildHostWorkspaceAgentTabRoute } from '@/utils/host-routes'
|
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
|
||||||
import { useTauriDragHandlers } from '@/utils/tauri-window'
|
import { useTauriDragHandlers } from '@/utils/tauri-window'
|
||||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
||||||
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
|
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
|
||||||
@@ -1055,7 +1055,7 @@ function DraftAgentScreenContent({
|
|||||||
const configuredWorkingDir = config.cwd.trim()
|
const configuredWorkingDir = config.cwd.trim()
|
||||||
const workspaceId =
|
const workspaceId =
|
||||||
createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir
|
createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir
|
||||||
const route: Href = buildHostWorkspaceAgentTabRoute(
|
const route: Href = buildHostWorkspaceAgentRoute(
|
||||||
selectedServerId,
|
selectedServerId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
agentId
|
agentId
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
buildHostWorkspaceTabRoute,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
import { resolveWorkspaceTabCanonicalPath } from "@/screens/workspace/workspace-route-canonicalization";
|
|
||||||
|
|
||||||
describe("resolveWorkspaceTabCanonicalPath", () => {
|
|
||||||
it("canonicalizes workspace root routes to /tab/<tabId> when a tab is active", () => {
|
|
||||||
const serverId = "local";
|
|
||||||
const workspaceId = "/tmp/repo";
|
|
||||||
|
|
||||||
expect(
|
|
||||||
resolveWorkspaceTabCanonicalPath({
|
|
||||||
activeTabId: "draft_abc123",
|
|
||||||
pathname: buildHostWorkspaceRoute(serverId, workspaceId),
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
})
|
|
||||||
).toBe(buildHostWorkspaceTabRoute(serverId, workspaceId, "draft_abc123"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not skip canonical path detection due to query-string tabId", () => {
|
|
||||||
const serverId = "local";
|
|
||||||
const workspaceId = "/tmp/repo";
|
|
||||||
|
|
||||||
expect(
|
|
||||||
resolveWorkspaceTabCanonicalPath({
|
|
||||||
activeTabId: "draft_abc123",
|
|
||||||
pathname: `${buildHostWorkspaceRoute(serverId, workspaceId)}?tabId=draft_abc123`,
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
})
|
|
||||||
).toBe(buildHostWorkspaceTabRoute(serverId, workspaceId, "draft_abc123"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips redundant query params from canonical /tab routes", () => {
|
|
||||||
const serverId = "local";
|
|
||||||
const workspaceId = "/tmp/repo";
|
|
||||||
const canonicalPath = buildHostWorkspaceTabRoute(
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
"draft_abc123"
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
resolveWorkspaceTabCanonicalPath({
|
|
||||||
activeTabId: "draft_abc123",
|
|
||||||
pathname: `${canonicalPath}?tabId=not_used`,
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
})
|
|
||||||
).toBe(canonicalPath);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when the pathname is exactly canonical", () => {
|
|
||||||
const serverId = "local";
|
|
||||||
const workspaceId = "/tmp/repo";
|
|
||||||
const canonicalPath = buildHostWorkspaceTabRoute(
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
"draft_abc123"
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
resolveWorkspaceTabCanonicalPath({
|
|
||||||
activeTabId: "draft_abc123",
|
|
||||||
pathname: canonicalPath,
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
})
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not canonicalize when the pathname belongs to another workspace", () => {
|
|
||||||
const serverId = "local";
|
|
||||||
const workspaceId = "/tmp/repo";
|
|
||||||
const otherWorkspacePath = buildHostWorkspaceRoute(serverId, "/tmp/other");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
resolveWorkspaceTabCanonicalPath({
|
|
||||||
activeTabId: "draft_abc123",
|
|
||||||
pathname: otherWorkspacePath,
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
})
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import {
|
|
||||||
buildHostWorkspaceTabRoute,
|
|
||||||
parseHostWorkspaceRouteFromPathname,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
|
|
||||||
function stripSearchAndHash(pathname: string): string {
|
|
||||||
const hashIndex = pathname.indexOf("#");
|
|
||||||
const queryIndex = pathname.indexOf("?");
|
|
||||||
const end = [hashIndex, queryIndex]
|
|
||||||
.filter((index) => index >= 0)
|
|
||||||
.reduce((min, index) => Math.min(min, index), pathname.length);
|
|
||||||
return pathname.slice(0, end);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ResolveWorkspaceTabCanonicalPathInput = {
|
|
||||||
activeTabId: string | null;
|
|
||||||
pathname: string;
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resolveWorkspaceTabCanonicalPath(
|
|
||||||
input: ResolveWorkspaceTabCanonicalPathInput
|
|
||||||
): string | null {
|
|
||||||
const { activeTabId, pathname, serverId, workspaceId } = input;
|
|
||||||
if (!activeTabId || !serverId || !workspaceId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canonicalPath = buildHostWorkspaceTabRoute(serverId, workspaceId, activeTabId);
|
|
||||||
if (canonicalPath === "/") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
|
|
||||||
if (
|
|
||||||
!activeWorkspaceRoute ||
|
|
||||||
activeWorkspaceRoute.serverId !== serverId ||
|
|
||||||
activeWorkspaceRoute.workspaceId !== workspaceId
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathOnly = stripSearchAndHash(pathname);
|
|
||||||
if (pathOnly !== canonicalPath) {
|
|
||||||
return canonicalPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep workspace tab URLs clean: no query/hash source-of-truth for tab routing.
|
|
||||||
if (pathname !== canonicalPath) {
|
|
||||||
return canonicalPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { usePathname, useRouter } from "expo-router";
|
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
@@ -50,9 +49,9 @@ import {
|
|||||||
useWorkspaceTabsStore,
|
useWorkspaceTabsStore,
|
||||||
} from "@/stores/workspace-tabs-store";
|
} from "@/stores/workspace-tabs-store";
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceTabRoute,
|
buildWorkspaceOpenIntentParam,
|
||||||
|
type WorkspaceOpenIntent,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
encodeWorkspaceIdForPathSegment,
|
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||||
@@ -82,7 +81,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
deriveWorkspaceTabModel,
|
deriveWorkspaceTabModel,
|
||||||
} from "@/screens/workspace/workspace-tab-model";
|
} from "@/screens/workspace/workspace-tab-model";
|
||||||
import { resolveWorkspaceTabCanonicalPath } from "@/screens/workspace/workspace-route-canonicalization";
|
|
||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||||
@@ -93,7 +91,7 @@ const EMPTY_TAB_ORDER: string[] = [];
|
|||||||
type WorkspaceScreenProps = {
|
type WorkspaceScreenProps = {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
routeTabId?: string | null;
|
openIntent?: WorkspaceOpenIntent | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
@@ -112,38 +110,17 @@ function decodeSegment(value: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWorkspaceTabHref(
|
|
||||||
serverId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
tabId: string
|
|
||||||
): { pathname: string; params: { serverId: string; workspaceId: string; tabId: string } } | null {
|
|
||||||
const encodedWorkspaceId = encodeWorkspaceIdForPathSegment(workspaceId);
|
|
||||||
const trimmedTabId = tabId.trim();
|
|
||||||
const trimmedServerId = serverId.trim();
|
|
||||||
if (!encodedWorkspaceId || !trimmedTabId || !trimmedServerId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
pathname: "/h/[serverId]/workspace/[workspaceId]/tab/[tabId]",
|
|
||||||
params: {
|
|
||||||
serverId: trimmedServerId,
|
|
||||||
workspaceId: encodedWorkspaceId,
|
|
||||||
tabId: trimmedTabId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkspaceScreen({
|
export function WorkspaceScreen({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
routeTabId,
|
openIntent,
|
||||||
}: WorkspaceScreenProps) {
|
}: WorkspaceScreenProps) {
|
||||||
return (
|
return (
|
||||||
<ExplorerSidebarAnimationProvider>
|
<ExplorerSidebarAnimationProvider>
|
||||||
<WorkspaceScreenContent
|
<WorkspaceScreenContent
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
routeTabId={routeTabId}
|
openIntent={openIntent}
|
||||||
/>
|
/>
|
||||||
</ExplorerSidebarAnimationProvider>
|
</ExplorerSidebarAnimationProvider>
|
||||||
);
|
);
|
||||||
@@ -152,16 +129,10 @@ export function WorkspaceScreen({
|
|||||||
function WorkspaceScreenContent({
|
function WorkspaceScreenContent({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
routeTabId,
|
openIntent,
|
||||||
}: WorkspaceScreenProps) {
|
}: WorkspaceScreenProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const router = useRouter();
|
|
||||||
const routePathname = usePathname();
|
|
||||||
const pathname =
|
|
||||||
Platform.OS === "web" && typeof window !== "undefined"
|
|
||||||
? `${window.location.pathname}${window.location.search}${window.location.hash}`
|
|
||||||
: routePathname;
|
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
@@ -241,15 +212,11 @@ function WorkspaceScreenContent({
|
|||||||
target: { kind: "terminal", terminalId: createdTerminal.id },
|
target: { kind: "terminal", terminalId: createdTerminal.id },
|
||||||
});
|
});
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
const href = buildWorkspaceTabHref(
|
useWorkspaceTabsStore.getState().focusTab({
|
||||||
normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
tabId
|
tabId,
|
||||||
);
|
});
|
||||||
if (!href) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace(href as any);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -441,23 +408,37 @@ function WorkspaceScreenContent({
|
|||||||
const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab);
|
const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab);
|
||||||
const promoteDraftToAgent = useWorkspaceTabsStore((state) => state.promoteDraftToAgent);
|
const promoteDraftToAgent = useWorkspaceTabsStore((state) => state.promoteDraftToAgent);
|
||||||
const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs);
|
const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs);
|
||||||
const [pendingRouteTabId, setPendingRouteTabId] = useState<string | null>(null);
|
const consumedOpenIntentsRef = useRef(new Set<string>());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalized = typeof routeTabId === "string" ? routeTabId.trim() : "";
|
if (!openIntent || !persistenceKey) {
|
||||||
if (!normalized || !persistenceKey) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (normalized.startsWith("file_")) {
|
|
||||||
const path = normalized.slice("file_".length).trim();
|
const openParam = buildWorkspaceOpenIntentParam(openIntent);
|
||||||
if (!path) {
|
if (!openParam) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tabId = openOrFocusTab({
|
const intentKey = `${normalizedServerId}:${normalizedWorkspaceId}:${openParam}`;
|
||||||
serverId: normalizedServerId,
|
if (consumedOpenIntentsRef.current.has(intentKey)) {
|
||||||
workspaceId: normalizedWorkspaceId,
|
return;
|
||||||
target: { kind: "file", path },
|
}
|
||||||
});
|
consumedOpenIntentsRef.current.add(intentKey);
|
||||||
|
|
||||||
|
if (openIntent.kind === "draft") {
|
||||||
|
const draftId = openIntent.draftId.trim();
|
||||||
|
const tabId =
|
||||||
|
draftId === "new"
|
||||||
|
? openDraftTab({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
draftId: generateDraftId(),
|
||||||
|
})
|
||||||
|
: openDraftTab({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
draftId,
|
||||||
|
});
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
focusTab({
|
focusTab({
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
@@ -465,33 +446,36 @@ function WorkspaceScreenContent({
|
|||||||
tabId,
|
tabId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabId = openOrFocusTab({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
target:
|
||||||
|
openIntent.kind === "agent"
|
||||||
|
? { kind: "agent", agentId: openIntent.agentId }
|
||||||
|
: openIntent.kind === "terminal"
|
||||||
|
? { kind: "terminal", terminalId: openIntent.terminalId }
|
||||||
|
: { kind: "file", path: openIntent.path },
|
||||||
|
});
|
||||||
|
if (tabId) {
|
||||||
|
focusTab({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
tabId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
focusTab,
|
focusTab,
|
||||||
openDraftTab,
|
openDraftTab,
|
||||||
|
openIntent,
|
||||||
openOrFocusTab,
|
openOrFocusTab,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
routeTabId,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const normalizedRouteTabId = trimNonEmpty(routeTabId);
|
|
||||||
const effectiveRouteTabId = pendingRouteTabId ?? normalizedRouteTabId;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingRouteTabId || !normalizedRouteTabId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (normalizedRouteTabId === pendingRouteTabId) {
|
|
||||||
setPendingRouteTabId(null);
|
|
||||||
}
|
|
||||||
}, [normalizedRouteTabId, pendingRouteTabId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPendingRouteTabId(null);
|
|
||||||
}, [normalizedServerId, normalizedWorkspaceId]);
|
|
||||||
|
|
||||||
const tabModel = useMemo(
|
const tabModel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
deriveWorkspaceTabModel({
|
deriveWorkspaceTabModel({
|
||||||
@@ -500,9 +484,8 @@ function WorkspaceScreenContent({
|
|||||||
uiTabs,
|
uiTabs,
|
||||||
tabOrder,
|
tabOrder,
|
||||||
focusedTabId,
|
focusedTabId,
|
||||||
routeTabId: effectiveRouteTabId,
|
|
||||||
}),
|
}),
|
||||||
[effectiveRouteTabId, focusedTabId, tabOrder, terminals, uiTabs, workspaceAgents]
|
[focusedTabId, tabOrder, terminals, uiTabs, workspaceAgents]
|
||||||
);
|
);
|
||||||
const activeTabId = tabModel.activeTabId;
|
const activeTabId = tabModel.activeTabId;
|
||||||
|
|
||||||
@@ -513,77 +496,6 @@ function WorkspaceScreenContent({
|
|||||||
focusTab({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: activeTabId });
|
focusTab({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: activeTabId });
|
||||||
}, [activeTabId, focusTab, normalizedServerId, normalizedWorkspaceId, persistenceKey]);
|
}, [activeTabId, focusTab, normalizedServerId, normalizedWorkspaceId, persistenceKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingRouteTabId || !activeTabId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pendingRouteTabId === activeTabId) {
|
|
||||||
setPendingRouteTabId(null);
|
|
||||||
}
|
|
||||||
}, [activeTabId, pendingRouteTabId]);
|
|
||||||
|
|
||||||
const lastCanonicalPathRef = useRef<string | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const hasRouteIdentitySearchParams =
|
|
||||||
Platform.OS === "web" &&
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
return (
|
|
||||||
params.has("tabId") ||
|
|
||||||
params.has("workspaceId") ||
|
|
||||||
params.has("serverId")
|
|
||||||
);
|
|
||||||
})();
|
|
||||||
const derivedCanonicalPath = resolveWorkspaceTabCanonicalPath({
|
|
||||||
activeTabId,
|
|
||||||
pathname,
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
});
|
|
||||||
const canonicalPath =
|
|
||||||
derivedCanonicalPath ??
|
|
||||||
(hasRouteIdentitySearchParams && activeTabId
|
|
||||||
? buildHostWorkspaceTabRoute(
|
|
||||||
normalizedServerId,
|
|
||||||
normalizedWorkspaceId,
|
|
||||||
activeTabId
|
|
||||||
)
|
|
||||||
: null);
|
|
||||||
if (!canonicalPath) {
|
|
||||||
lastCanonicalPathRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lastCanonicalPathRef.current === canonicalPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastCanonicalPathRef.current = canonicalPath;
|
|
||||||
if (Platform.OS === "web" && typeof window !== "undefined") {
|
|
||||||
if (
|
|
||||||
window.location.pathname !== canonicalPath ||
|
|
||||||
window.location.search.length > 0 ||
|
|
||||||
window.location.hash.length > 0
|
|
||||||
) {
|
|
||||||
window.history.replaceState(window.history.state, "", canonicalPath);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const href = activeTabId
|
|
||||||
? buildWorkspaceTabHref(normalizedServerId, normalizedWorkspaceId, activeTabId)
|
|
||||||
: null;
|
|
||||||
if (href) {
|
|
||||||
router.replace(href as any);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace(canonicalPath as any);
|
|
||||||
}, [
|
|
||||||
activeTabId,
|
|
||||||
normalizedServerId,
|
|
||||||
normalizedWorkspaceId,
|
|
||||||
pathname,
|
|
||||||
router,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const activeTab = tabModel.activeTab;
|
const activeTab = tabModel.activeTab;
|
||||||
|
|
||||||
const tabs = useMemo<WorkspaceTabDescriptor[]>(
|
const tabs = useMemo<WorkspaceTabDescriptor[]>(
|
||||||
@@ -607,23 +519,13 @@ function WorkspaceScreenContent({
|
|||||||
if (!tabId || !normalizedServerId || !normalizedWorkspaceId) {
|
if (!tabId || !normalizedServerId || !normalizedWorkspaceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPendingRouteTabId(tabId);
|
|
||||||
focusTab({
|
focusTab({
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
tabId,
|
tabId,
|
||||||
});
|
});
|
||||||
const href = buildWorkspaceTabHref(
|
|
||||||
normalizedServerId,
|
|
||||||
normalizedWorkspaceId,
|
|
||||||
tabId
|
|
||||||
);
|
|
||||||
if (!href) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace(href as any);
|
|
||||||
},
|
},
|
||||||
[focusTab, normalizedServerId, normalizedWorkspaceId, router]
|
[focusTab, normalizedServerId, normalizedWorkspaceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const emptyWorkspaceSeedRef = useRef<string | null>(null);
|
const emptyWorkspaceSeedRef = useRef<string | null>(null);
|
||||||
@@ -631,9 +533,6 @@ function WorkspaceScreenContent({
|
|||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (effectiveRouteTabId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (tabs.length > 0) {
|
if (tabs.length > 0) {
|
||||||
emptyWorkspaceSeedRef.current = null;
|
emptyWorkspaceSeedRef.current = null;
|
||||||
return;
|
return;
|
||||||
@@ -658,7 +557,6 @@ function WorkspaceScreenContent({
|
|||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
openDraftTab,
|
openDraftTab,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
effectiveRouteTabId,
|
|
||||||
tabs.length,
|
tabs.length,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ describe("deriveWorkspaceTabModel", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers route tab when present, then focused tab, then first tab for active selection", () => {
|
it("uses focused tab when present, otherwise falls back to first tab", () => {
|
||||||
const base = {
|
const base = {
|
||||||
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
||||||
terminals: [],
|
terminals: [],
|
||||||
@@ -133,42 +133,10 @@ describe("deriveWorkspaceTabModel", () => {
|
|||||||
deriveWorkspaceTabModel({
|
deriveWorkspaceTabModel({
|
||||||
...base,
|
...base,
|
||||||
focusedTabId: "agent_agent-b",
|
focusedTabId: "agent_agent-b",
|
||||||
routeTabId: "agent_agent-a",
|
|
||||||
}).activeTabId
|
|
||||||
).toBe("agent_agent-a");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
deriveWorkspaceTabModel({
|
|
||||||
...base,
|
|
||||||
focusedTabId: "agent_agent-b",
|
|
||||||
routeTabId: "agent_agent-missing",
|
|
||||||
}).activeTabId
|
}).activeTabId
|
||||||
).toBe("agent_agent-b");
|
).toBe("agent_agent-b");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches active tab target when route tab id changes", () => {
|
|
||||||
const modelA = deriveWorkspaceTabModel({
|
|
||||||
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
|
||||||
terminals: [{ id: "term-1", name: "shell" }],
|
|
||||||
uiTabs: [],
|
|
||||||
tabOrder: ["agent_agent-a", "terminal_term-1", "agent_agent-b"],
|
|
||||||
routeTabId: "terminal_term-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const modelB = deriveWorkspaceTabModel({
|
|
||||||
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
|
||||||
terminals: [{ id: "term-1", name: "shell" }],
|
|
||||||
uiTabs: [],
|
|
||||||
tabOrder: ["agent_agent-a", "terminal_term-1", "agent_agent-b"],
|
|
||||||
routeTabId: "agent_agent-b",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(modelA.activeTab?.descriptor.tabId).toBe("terminal_term-1");
|
|
||||||
expect(modelA.activeTab?.target.kind).toBe("terminal");
|
|
||||||
expect(modelB.activeTab?.descriptor.tabId).toBe("agent_agent-b");
|
|
||||||
expect(modelB.activeTab?.target.kind).toBe("agent");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-resolves active content for a new workspace when prior focused tab is not available", () => {
|
it("re-resolves active content for a new workspace when prior focused tab is not available", () => {
|
||||||
const model = deriveWorkspaceTabModel({
|
const model = deriveWorkspaceTabModel({
|
||||||
workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })],
|
workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })],
|
||||||
@@ -176,7 +144,6 @@ describe("deriveWorkspaceTabModel", () => {
|
|||||||
uiTabs: [],
|
uiTabs: [],
|
||||||
tabOrder: ["agent_workspace-b-agent"],
|
tabOrder: ["agent_workspace-b-agent"],
|
||||||
focusedTabId: "agent_workspace-a-agent",
|
focusedTabId: "agent_workspace-a-agent",
|
||||||
routeTabId: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(model.activeTabId).toBe("agent_workspace-b-agent");
|
expect(model.activeTabId).toBe("agent_workspace-b-agent");
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ export function deriveWorkspaceTabModel(input: {
|
|||||||
uiTabs: WorkspaceTab[];
|
uiTabs: WorkspaceTab[];
|
||||||
tabOrder: string[];
|
tabOrder: string[];
|
||||||
focusedTabId?: string | null;
|
focusedTabId?: string | null;
|
||||||
routeTabId?: string | null;
|
|
||||||
}): WorkspaceTabModel {
|
}): WorkspaceTabModel {
|
||||||
const tabsById = new Map<string, WorkspaceDerivedTab>();
|
const tabsById = new Map<string, WorkspaceDerivedTab>();
|
||||||
|
|
||||||
@@ -209,15 +208,12 @@ export function deriveWorkspaceTabModel(input: {
|
|||||||
.filter((tab): tab is WorkspaceDerivedTab => tab !== null);
|
.filter((tab): tab is WorkspaceDerivedTab => tab !== null);
|
||||||
|
|
||||||
const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId));
|
const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId));
|
||||||
const routeTabId = trimNonEmpty(input.routeTabId);
|
|
||||||
const focusedTabId = trimNonEmpty(input.focusedTabId);
|
const focusedTabId = trimNonEmpty(input.focusedTabId);
|
||||||
|
|
||||||
const activeTabId =
|
const activeTabId =
|
||||||
routeTabId && openTabIds.has(routeTabId)
|
focusedTabId && openTabIds.has(focusedTabId)
|
||||||
? routeTabId
|
? focusedTabId
|
||||||
: focusedTabId && openTabIds.has(focusedTabId)
|
: tabs[0]?.descriptor.tabId ?? null;
|
||||||
? focusedTabId
|
|
||||||
: tabs[0]?.descriptor.tabId ?? null;
|
|
||||||
|
|
||||||
const activeTab = activeTabId
|
const activeTab = activeTabId
|
||||||
? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null
|
? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
buildHostAgentDetailRoute,
|
||||||
buildHostNewAgentRoute,
|
buildHostNewAgentRoute,
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
buildHostWorkspaceAgentTabRoute,
|
buildHostWorkspaceAgentRoute,
|
||||||
buildHostWorkspaceFileTabRoute,
|
buildHostWorkspaceFileRoute,
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
buildHostWorkspaceTerminalTabRoute,
|
buildHostWorkspaceRouteWithOpenIntent,
|
||||||
|
buildHostWorkspaceTerminalRoute,
|
||||||
decodeFilePathFromPathSegment,
|
decodeFilePathFromPathSegment,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
encodeFilePathForPathSegment,
|
encodeFilePathForPathSegment,
|
||||||
encodeWorkspaceIdForPathSegment,
|
encodeWorkspaceIdForPathSegment,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostWorkspaceTabRouteFromPathname,
|
parseHostWorkspaceOpenIntentFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
|
parseWorkspaceOpenIntent,
|
||||||
} from "./host-routes";
|
} from "./host-routes";
|
||||||
|
|
||||||
describe("parseHostAgentRouteFromPathname", () => {
|
describe("parseHostAgentRouteFromPathname", () => {
|
||||||
@@ -49,35 +52,10 @@ describe("workspace route parsing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses workspace route for /tab targets", () => {
|
it("does not treat /tab routes as valid workspace routes", () => {
|
||||||
expect(
|
expect(
|
||||||
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123")
|
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123")
|
||||||
).toEqual({
|
).toBeNull();
|
||||||
serverId: "local",
|
|
||||||
workspaceId: "/tmp/repo",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses workspace tab route", () => {
|
|
||||||
expect(
|
|
||||||
parseHostWorkspaceTabRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123")
|
|
||||||
).toEqual({
|
|
||||||
serverId: "local",
|
|
||||||
workspaceId: "/tmp/repo",
|
|
||||||
tabId: "draft_abc123",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers path tab segment and ignores query params for workspace tab routes", () => {
|
|
||||||
expect(
|
|
||||||
parseHostWorkspaceTabRouteFromPathname(
|
|
||||||
"/h/local/workspace/L3RtcC9yZXBv/tab/draft_from_path?tabId=draft_from_query&workspaceId=wrong"
|
|
||||||
)
|
|
||||||
).toEqual({
|
|
||||||
serverId: "local",
|
|
||||||
workspaceId: "/tmp/repo",
|
|
||||||
tabId: "draft_from_path",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds base64url workspace routes", () => {
|
it("builds base64url workspace routes", () => {
|
||||||
@@ -92,21 +70,50 @@ describe("workspace route parsing", () => {
|
|||||||
expect(buildHostNewAgentRoute("local")).toBe("/h/local/new-agent");
|
expect(buildHostNewAgentRoute("local")).toBe("/h/local/new-agent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds workspace agent tab routes", () => {
|
it("builds workspace routes with open intent query", () => {
|
||||||
expect(buildHostWorkspaceAgentTabRoute("local", "/tmp/repo", "agent-1")).toBe(
|
expect(buildHostWorkspaceAgentRoute("local", "/tmp/repo", "agent-1")).toBe(
|
||||||
"/h/local/workspace/L3RtcC9yZXBv/tab/agent_agent-1"
|
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
|
||||||
);
|
);
|
||||||
|
expect(buildHostWorkspaceTerminalRoute("local", "/tmp/repo", "term-1")).toBe(
|
||||||
|
"/h/local/workspace/L3RtcC9yZXBv?open=terminal%3Aterm-1"
|
||||||
|
);
|
||||||
|
expect(buildHostWorkspaceFileRoute("local", "/tmp/repo", "src/index.ts")).toBe(
|
||||||
|
"/h/local/workspace/L3RtcC9yZXBv?open=file%3Ac3JjL2luZGV4LnRz"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
buildHostWorkspaceRouteWithOpenIntent("local", "/tmp/repo", {
|
||||||
|
kind: "draft",
|
||||||
|
draftId: "new",
|
||||||
|
})
|
||||||
|
).toBe("/h/local/workspace/L3RtcC9yZXBv?open=draft%3Anew");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds workspace terminal tab routes", () => {
|
it("parses workspace open intent from pathname query", () => {
|
||||||
expect(buildHostWorkspaceTerminalTabRoute("local", "/tmp/repo", "term-1")).toBe(
|
expect(
|
||||||
"/h/local/workspace/L3RtcC9yZXBv/tab/terminal_term-1"
|
parseHostWorkspaceOpenIntentFromPathname(
|
||||||
);
|
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
kind: "agent",
|
||||||
|
agentId: "agent-1",
|
||||||
|
});
|
||||||
|
expect(parseWorkspaceOpenIntent("terminal:term-1")).toEqual({
|
||||||
|
kind: "terminal",
|
||||||
|
terminalId: "term-1",
|
||||||
|
});
|
||||||
|
expect(parseWorkspaceOpenIntent("draft:new")).toEqual({
|
||||||
|
kind: "draft",
|
||||||
|
draftId: "new",
|
||||||
|
});
|
||||||
|
expect(parseWorkspaceOpenIntent("file:c3JjL2luZGV4LnRz")).toEqual({
|
||||||
|
kind: "file",
|
||||||
|
path: "src/index.ts",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds workspace file tab routes", () => {
|
it("keeps agent detail workspace routing on workspace path with open intent", () => {
|
||||||
expect(buildHostWorkspaceFileTabRoute("local", "/tmp/repo", "src/index.ts")).toBe(
|
expect(buildHostAgentDetailRoute("local", "agent-1", "/tmp/repo")).toBe(
|
||||||
"/h/local/workspace/L3RtcC9yZXBv/tab/file_src%2Findex.ts"
|
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ function stripSearchAndHash(pathname: string): string {
|
|||||||
return pathname.slice(0, end);
|
return pathname.slice(0, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractSearch(pathname: string): string {
|
||||||
|
const queryIndex = pathname.indexOf("?");
|
||||||
|
if (queryIndex < 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const hashIndex = pathname.indexOf("#", queryIndex);
|
||||||
|
return hashIndex >= 0
|
||||||
|
? pathname.slice(queryIndex + 1, hashIndex)
|
||||||
|
: pathname.slice(queryIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
function trimNonEmpty(value: NullableString): string | null {
|
function trimNonEmpty(value: NullableString): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -88,19 +99,82 @@ function normalizeWorkspaceId(value: string): string {
|
|||||||
return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWorkspaceAgentTabId(agentId: string): string | null {
|
export type WorkspaceOpenIntent =
|
||||||
const normalized = trimNonEmpty(agentId);
|
| { kind: "agent"; agentId: string }
|
||||||
return normalized ? `agent_${normalized}` : null;
|
| { kind: "terminal"; terminalId: string }
|
||||||
|
| { kind: "file"; path: string }
|
||||||
|
| { kind: "draft"; draftId: string };
|
||||||
|
|
||||||
|
export function parseWorkspaceOpenIntent(
|
||||||
|
value: string | null | undefined
|
||||||
|
): WorkspaceOpenIntent | null {
|
||||||
|
const normalized = trimNonEmpty(value);
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = normalized.indexOf(":");
|
||||||
|
if (separator <= 0 || separator >= normalized.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = normalized.slice(0, separator);
|
||||||
|
const payload = trimNonEmpty(normalized.slice(separator + 1));
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "agent") {
|
||||||
|
return { kind: "agent", agentId: payload };
|
||||||
|
}
|
||||||
|
if (kind === "terminal") {
|
||||||
|
return { kind: "terminal", terminalId: payload };
|
||||||
|
}
|
||||||
|
if (kind === "draft") {
|
||||||
|
return { kind: "draft", draftId: payload };
|
||||||
|
}
|
||||||
|
if (kind === "file") {
|
||||||
|
const decodedPath = decodeFilePathFromPathSegment(payload);
|
||||||
|
if (!decodedPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { kind: "file", path: decodedPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWorkspaceTerminalTabId(terminalId: string): string | null {
|
export function buildWorkspaceOpenIntentParam(
|
||||||
const normalized = trimNonEmpty(terminalId);
|
intent: WorkspaceOpenIntent
|
||||||
return normalized ? `terminal_${normalized}` : null;
|
): string | null {
|
||||||
|
if (intent.kind === "agent") {
|
||||||
|
const agentId = trimNonEmpty(intent.agentId);
|
||||||
|
return agentId ? `agent:${agentId}` : null;
|
||||||
|
}
|
||||||
|
if (intent.kind === "terminal") {
|
||||||
|
const terminalId = trimNonEmpty(intent.terminalId);
|
||||||
|
return terminalId ? `terminal:${terminalId}` : null;
|
||||||
|
}
|
||||||
|
if (intent.kind === "draft") {
|
||||||
|
const draftId = trimNonEmpty(intent.draftId);
|
||||||
|
return draftId ? `draft:${draftId}` : null;
|
||||||
|
}
|
||||||
|
const normalizedPath = trimNonEmpty(intent.path);
|
||||||
|
if (!normalizedPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const encodedPath = encodeFilePathForPathSegment(normalizedPath);
|
||||||
|
return encodedPath ? `file:${encodedPath}` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWorkspaceFileTabId(filePath: string): string | null {
|
export function parseHostWorkspaceOpenIntentFromPathname(
|
||||||
const normalized = trimNonEmpty(filePath);
|
pathname: string
|
||||||
return normalized ? `file_${normalized.replace(/\\/g, "/")}` : null;
|
): WorkspaceOpenIntent | null {
|
||||||
|
const search = extractSearch(pathname);
|
||||||
|
if (!search) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseWorkspaceOpenIntent(new URLSearchParams(search).get("open"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
|
export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
|
||||||
@@ -203,35 +277,17 @@ export function parseHostWorkspaceRouteFromPathname(
|
|||||||
pathname: string
|
pathname: string
|
||||||
): { serverId: string; workspaceId: string } | null {
|
): { serverId: string; workspaceId: string } | null {
|
||||||
const pathOnly = stripSearchAndHash(pathname);
|
const pathOnly = stripSearchAndHash(pathname);
|
||||||
const prefix = "/h/";
|
const match = pathOnly.match(/^\/h\/([^/]+)\/workspace\/([^/]+)\/?$/);
|
||||||
if (!pathOnly.startsWith(prefix)) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverIdStart = prefix.length;
|
const serverId = trimNonEmpty(decodeSegment(match[1]));
|
||||||
const serverIdEnd = pathOnly.indexOf("/", serverIdStart);
|
|
||||||
if (serverIdEnd < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const rawServerId = pathOnly.slice(serverIdStart, serverIdEnd);
|
|
||||||
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspacePrefix = "/workspace/";
|
const rawWorkspaceId = match[2];
|
||||||
if (!pathOnly.startsWith(workspacePrefix, serverIdEnd)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
|
||||||
let workspaceIdEnd = pathOnly.length;
|
|
||||||
const tabIdx = pathOnly.lastIndexOf("/tab/");
|
|
||||||
if (tabIdx >= 0 && tabIdx > workspaceIdStart) {
|
|
||||||
workspaceIdEnd = tabIdx;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawWorkspaceId = pathOnly.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, "");
|
|
||||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -239,56 +295,6 @@ export function parseHostWorkspaceRouteFromPathname(
|
|||||||
return { serverId, workspaceId };
|
return { serverId, workspaceId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseHostWorkspaceTabRouteFromPathname(
|
|
||||||
pathname: string
|
|
||||||
): { serverId: string; workspaceId: string; tabId: string } | null {
|
|
||||||
const pathOnly = stripSearchAndHash(pathname);
|
|
||||||
const prefix = "/h/";
|
|
||||||
if (!pathOnly.startsWith(prefix)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverIdStart = prefix.length;
|
|
||||||
const serverIdEnd = pathOnly.indexOf("/", serverIdStart);
|
|
||||||
if (serverIdEnd < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const rawServerId = pathOnly.slice(serverIdStart, serverIdEnd);
|
|
||||||
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
|
||||||
if (!serverId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspacePrefix = "/workspace/";
|
|
||||||
if (!pathOnly.startsWith(workspacePrefix, serverIdEnd)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
|
||||||
const tabMarker = "/tab/";
|
|
||||||
const tabIdx = pathOnly.lastIndexOf(tabMarker);
|
|
||||||
if (tabIdx < 0 || tabIdx <= workspaceIdStart) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawWorkspaceId = pathOnly.slice(workspaceIdStart, tabIdx).replace(/\/+$/, "");
|
|
||||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
|
||||||
if (!workspaceId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabIdStart = tabIdx + tabMarker.length;
|
|
||||||
const tabIdEnd = pathOnly.indexOf("/", tabIdStart);
|
|
||||||
const rawTabId =
|
|
||||||
tabIdEnd < 0 ? pathOnly.slice(tabIdStart) : pathOnly.slice(tabIdStart, tabIdEnd);
|
|
||||||
const tabId = trimNonEmpty(decodeSegment(rawTabId));
|
|
||||||
if (!tabId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { serverId, workspaceId, tabId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildHostWorkspaceRoute(
|
export function buildHostWorkspaceRoute(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
@@ -305,53 +311,53 @@ export function buildHostWorkspaceRoute(
|
|||||||
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
|
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHostWorkspaceAgentTabRoute(
|
export function buildHostWorkspaceRouteWithOpenIntent(
|
||||||
|
serverId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
intent: WorkspaceOpenIntent
|
||||||
|
): string {
|
||||||
|
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||||
|
if (base === "/") {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
const open = buildWorkspaceOpenIntentParam(intent);
|
||||||
|
if (!open) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
return `${base}?open=${encodeURIComponent(open)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHostWorkspaceAgentRoute(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
agentId: string
|
agentId: string
|
||||||
): string {
|
): string {
|
||||||
const tabId = buildWorkspaceAgentTabId(agentId);
|
return buildHostWorkspaceRouteWithOpenIntent(serverId, workspaceId, {
|
||||||
if (!tabId) {
|
kind: "agent",
|
||||||
return "/";
|
agentId,
|
||||||
}
|
});
|
||||||
return buildHostWorkspaceTabRoute(serverId, workspaceId, tabId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHostWorkspaceTerminalTabRoute(
|
export function buildHostWorkspaceTerminalRoute(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
terminalId: string
|
terminalId: string
|
||||||
): string {
|
): string {
|
||||||
const tabId = buildWorkspaceTerminalTabId(terminalId);
|
return buildHostWorkspaceRouteWithOpenIntent(serverId, workspaceId, {
|
||||||
if (!tabId) {
|
kind: "terminal",
|
||||||
return "/";
|
terminalId,
|
||||||
}
|
});
|
||||||
return buildHostWorkspaceTabRoute(serverId, workspaceId, tabId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHostWorkspaceTabRoute(
|
export function buildHostWorkspaceFileRoute(
|
||||||
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 buildHostWorkspaceFileTabRoute(
|
|
||||||
serverId: string,
|
serverId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
filePath: string
|
filePath: string
|
||||||
): string {
|
): string {
|
||||||
const tabId = buildWorkspaceFileTabId(filePath);
|
return buildHostWorkspaceRouteWithOpenIntent(serverId, workspaceId, {
|
||||||
if (!tabId) {
|
kind: "file",
|
||||||
return "/";
|
path: filePath,
|
||||||
}
|
});
|
||||||
return buildHostWorkspaceTabRoute(serverId, workspaceId, tabId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHostAgentDetailRoute(
|
export function buildHostAgentDetailRoute(
|
||||||
@@ -361,7 +367,7 @@ export function buildHostAgentDetailRoute(
|
|||||||
): string {
|
): string {
|
||||||
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
|
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
|
||||||
if (normalizedWorkspaceId) {
|
if (normalizedWorkspaceId) {
|
||||||
return buildHostWorkspaceAgentTabRoute(
|
return buildHostWorkspaceAgentRoute(
|
||||||
serverId,
|
serverId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
agentId
|
agentId
|
||||||
@@ -429,8 +435,21 @@ export function mapPathnameToServer(
|
|||||||
if (suffix.startsWith("new-agent")) {
|
if (suffix.startsWith("new-agent")) {
|
||||||
return `${base}/new-agent`;
|
return `${base}/new-agent`;
|
||||||
}
|
}
|
||||||
if (suffix.startsWith("workspace/")) {
|
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
|
||||||
return `${base}/${suffix}`;
|
if (workspaceRoute) {
|
||||||
|
const workspacePath = buildHostWorkspaceRoute(
|
||||||
|
normalized,
|
||||||
|
workspaceRoute.workspaceId
|
||||||
|
);
|
||||||
|
const openIntent = parseHostWorkspaceOpenIntentFromPathname(pathname);
|
||||||
|
if (!openIntent) {
|
||||||
|
return workspacePath;
|
||||||
|
}
|
||||||
|
return buildHostWorkspaceRouteWithOpenIntent(
|
||||||
|
normalized,
|
||||||
|
workspaceRoute.workspaceId,
|
||||||
|
openIntent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (suffix.startsWith("agent/")) {
|
if (suffix.startsWith("agent/")) {
|
||||||
return `${base}/${suffix}`;
|
return `${base}/${suffix}`;
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ describe("resolveSelectedAgentForNewAgent", () => {
|
|||||||
it("prefers the agent in the current route", () => {
|
it("prefers the agent in the current route", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveSelectedAgentForNewAgent({
|
resolveSelectedAgentForNewAgent({
|
||||||
pathname: "/h/srv-1/workspace/%2Frepo/tab/agent_agent-2",
|
pathname: "/h/srv-1/workspace/L3JlcG8?open=agent%3Aagent-2",
|
||||||
selectedAgentId: "srv-9:agent-9",
|
selectedAgentId: "srv-9:agent-9",
|
||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||||
import {
|
import {
|
||||||
|
parseHostWorkspaceOpenIntentFromPathname,
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostWorkspaceTabRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
export function parseAgentKey(
|
export function parseAgentKey(
|
||||||
@@ -27,11 +28,12 @@ export function resolveSelectedAgentForNewAgent(input: {
|
|||||||
pathname: string;
|
pathname: string;
|
||||||
selectedAgentId?: string;
|
selectedAgentId?: string;
|
||||||
}): { serverId: string; agentId: string } | null {
|
}): { serverId: string; agentId: string } | null {
|
||||||
const workspaceTabRoute = parseHostWorkspaceTabRouteFromPathname(input.pathname);
|
const workspaceRoute = parseHostWorkspaceRouteFromPathname(input.pathname);
|
||||||
if (workspaceTabRoute?.tabId?.startsWith("agent_")) {
|
const openIntent = parseHostWorkspaceOpenIntentFromPathname(input.pathname);
|
||||||
const agentId = workspaceTabRoute.tabId.slice("agent_".length).trim();
|
if (workspaceRoute && openIntent?.kind === "agent") {
|
||||||
|
const agentId = openIntent.agentId.trim();
|
||||||
if (agentId) {
|
if (agentId) {
|
||||||
return { serverId: workspaceTabRoute.serverId, agentId };
|
return { serverId: workspaceRoute.serverId, agentId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ describe("resolveNotificationTarget", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildNotificationRoute", () => {
|
describe("buildNotificationRoute", () => {
|
||||||
it("routes directly to workspace tab when workspace id is present", () => {
|
it("routes to workspace with one-shot open intent when workspace id is present", () => {
|
||||||
expect(
|
expect(
|
||||||
buildNotificationRoute({
|
buildNotificationRoute({
|
||||||
serverId: "srv-1",
|
serverId: "srv-1",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
workspaceId: "/tmp/repo",
|
workspaceId: "/tmp/repo",
|
||||||
})
|
})
|
||||||
).toBe("/h/srv-1/workspace/L3RtcC9yZXBv/tab/agent_agent-1");
|
).toBe("/h/srv-1/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes directly to server-scoped agent path when both ids are present", () => {
|
it("routes directly to server-scoped agent path when both ids are present", () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
buildHostAgentDetailRoute,
|
buildHostAgentDetailRoute,
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
buildHostWorkspaceAgentTabRoute,
|
buildHostWorkspaceAgentRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
type NotificationData = Record<string, unknown> | null | undefined;
|
type NotificationData = Record<string, unknown> | null | undefined;
|
||||||
@@ -36,7 +36,7 @@ export function buildNotificationRoute(data: NotificationData): string {
|
|||||||
const { serverId, agentId, workspaceId } = resolveNotificationTarget(data);
|
const { serverId, agentId, workspaceId } = resolveNotificationTarget(data);
|
||||||
if (serverId && agentId) {
|
if (serverId && agentId) {
|
||||||
if (workspaceId) {
|
if (workspaceId) {
|
||||||
return buildHostWorkspaceAgentTabRoute(serverId, workspaceId, agentId);
|
return buildHostWorkspaceAgentRoute(serverId, workspaceId, agentId);
|
||||||
}
|
}
|
||||||
return buildHostAgentDetailRoute(serverId, agentId);
|
return buildHostAgentDetailRoute(serverId, agentId);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user