fix: canonicalize workspace tab routes and prevent orphan runs
This commit is contained in:
@@ -1,14 +1,41 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import {
|
import {
|
||||||
|
buildHostRootRoute,
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
buildHostWorkspaceAgentTabRoute,
|
buildHostWorkspaceAgentTabRoute,
|
||||||
|
buildHostWorkspaceTabRoute,
|
||||||
|
parseHostWorkspaceTabRouteFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
|
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 pathname =
|
||||||
|
Platform.OS === "web" && typeof window !== "undefined"
|
||||||
|
? window.location.pathname
|
||||||
|
: routerPathname;
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||||
@@ -23,33 +50,77 @@ export default function HostIndexRoute() {
|
|||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const rootRoute = buildHostRootRoute(serverId);
|
||||||
const visibleAgents = sessionAgents
|
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
|
||||||
? Array.from(sessionAgents.values()).filter(
|
|
||||||
(agent) => !agent.archivedAt
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
visibleAgents.sort(
|
|
||||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
const primaryAgent = visibleAgents[0];
|
|
||||||
if (primaryAgent?.cwd?.trim()) {
|
|
||||||
router.replace(
|
|
||||||
buildHostWorkspaceAgentTabRoute(
|
|
||||||
serverId,
|
|
||||||
primaryAgent.cwd.trim(),
|
|
||||||
primaryAgent.id
|
|
||||||
) as any
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (Platform.OS === "web" && typeof window !== "undefined") {
|
||||||
|
const currentPathname = window.location.pathname;
|
||||||
|
if (currentPathname !== rootRoute && currentPathname !== `${rootRoute}/`) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const preferredWorkingDir =
|
const initialPathname = readInitialNavigationPathname();
|
||||||
preferences.serverId === serverId ? preferences.workingDir?.trim() : "";
|
const initialWorkspaceTabRoute = initialPathname
|
||||||
const workspaceId = preferredWorkingDir || ".";
|
? parseHostWorkspaceTabRouteFromPathname(initialPathname)
|
||||||
router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any);
|
: null;
|
||||||
}, [preferences.serverId, preferences.workingDir, preferencesLoading, router, serverId, sessionAgents]);
|
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
|
||||||
|
? Array.from(sessionAgents.values()).filter(
|
||||||
|
(agent) => !agent.archivedAt
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
visibleAgents.sort(
|
||||||
|
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryAgent = visibleAgents[0];
|
||||||
|
if (primaryAgent?.cwd?.trim()) {
|
||||||
|
router.replace(
|
||||||
|
buildHostWorkspaceAgentTabRoute(
|
||||||
|
serverId,
|
||||||
|
primaryAgent.cwd.trim(),
|
||||||
|
primaryAgent.id
|
||||||
|
) as any
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredWorkingDir =
|
||||||
|
preferences.serverId === serverId ? preferences.workingDir?.trim() : "";
|
||||||
|
const workspaceId = preferredWorkingDir || ".";
|
||||||
|
router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any);
|
||||||
|
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [
|
||||||
|
pathname,
|
||||||
|
preferences.serverId,
|
||||||
|
preferences.workingDir,
|
||||||
|
preferencesLoading,
|
||||||
|
router,
|
||||||
|
serverId,
|
||||||
|
sessionAgents,
|
||||||
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,38 @@
|
|||||||
import { useLocalSearchParams, usePathname } from "expo-router";
|
import { useEffect } from "react";
|
||||||
|
import { usePathname } from "expo-router";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import {
|
import {
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
parseHostWorkspaceTabRouteFromPathname,
|
parseHostWorkspaceTabRouteFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
function readNonEmptyParam(value: unknown): string | null {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useLocalSearchParams<{ tabId?: string }>();
|
const resolvedPathname =
|
||||||
const tabRoute = parseHostWorkspaceTabRouteFromPathname(pathname);
|
Platform.OS === "web" && typeof window !== "undefined"
|
||||||
const activeRoute = tabRoute ?? parseHostWorkspaceRouteFromPathname(pathname);
|
? `${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||||
|
: pathname;
|
||||||
|
const tabRoute = parseHostWorkspaceTabRouteFromPathname(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 ?? readNonEmptyParam(params.tabId);
|
const routeTabId = tabRoute?.tabId ?? null;
|
||||||
|
|
||||||
|
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}`}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
routeTabId={routeTabId}
|
routeTabId={routeTabId}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { ActivityIndicator, View } from "react-native";
|
import { ActivityIndicator, Platform, View } from "react-native";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
@@ -9,6 +9,11 @@ import { buildHostRootRoute } from "@/utils/host-routes";
|
|||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const routerPathname = usePathname();
|
||||||
|
const pathname =
|
||||||
|
Platform.OS === "web" && typeof window !== "undefined"
|
||||||
|
? window.location.pathname
|
||||||
|
: routerPathname;
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||||
@@ -45,8 +50,11 @@ export default function Index() {
|
|||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (pathname !== "/" && pathname !== "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.replace(buildHostRootRoute(targetServerId) as any);
|
router.replace(buildHostRootRoute(targetServerId) as any);
|
||||||
}, [preferencesLoading, registryLoading, router, targetServerId]);
|
}, [pathname, preferencesLoading, registryLoading, router, targetServerId]);
|
||||||
|
|
||||||
if (registryLoading || preferencesLoading) {
|
if (registryLoading || preferencesLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type UseWorkspaceTabLayoutInput = {
|
|||||||
tabHorizontalPadding: number;
|
tabHorizontalPadding: number;
|
||||||
estimatedCharWidth: number;
|
estimatedCharWidth: number;
|
||||||
closeButtonWidth: number;
|
closeButtonWidth: number;
|
||||||
|
compactLabelCharCap?: number;
|
||||||
|
compactDenseLabelCharCap?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
tabHorizontalPadding: theme.spacing[3],
|
tabHorizontalPadding: theme.spacing[3],
|
||||||
estimatedCharWidth: 7,
|
estimatedCharWidth: 7,
|
||||||
closeButtonWidth: 22,
|
closeButtonWidth: 22,
|
||||||
|
compactLabelCharCap: 10,
|
||||||
|
compactDenseLabelCharCap: 8,
|
||||||
}),
|
}),
|
||||||
[theme.spacing]
|
[theme.spacing]
|
||||||
);
|
);
|
||||||
@@ -190,6 +192,9 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
onHoverOut={() => {
|
onHoverOut={() => {
|
||||||
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||||
}}
|
}}
|
||||||
|
onPressIn={() => {
|
||||||
|
onNavigateTab(tab.tabId);
|
||||||
|
}}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onNavigateTab(tab.tabId);
|
onNavigateTab(tab.tabId);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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,7 @@ 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 { useRouter } from "expo-router";
|
import { usePathname, useRouter } from "expo-router";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
@@ -50,9 +50,9 @@ import {
|
|||||||
useWorkspaceTabsStore,
|
useWorkspaceTabsStore,
|
||||||
} from "@/stores/workspace-tabs-store";
|
} from "@/stores/workspace-tabs-store";
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
buildHostWorkspaceTabRoute,
|
buildHostWorkspaceTabRoute,
|
||||||
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,6 +82,7 @@ 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__";
|
||||||
@@ -111,6 +112,26 @@ 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,
|
||||||
@@ -136,6 +157,11 @@ function WorkspaceScreenContent({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const router = useRouter();
|
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";
|
||||||
|
|
||||||
@@ -215,13 +241,15 @@ function WorkspaceScreenContent({
|
|||||||
target: { kind: "terminal", terminalId: createdTerminal.id },
|
target: { kind: "terminal", terminalId: createdTerminal.id },
|
||||||
});
|
});
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
router.replace(
|
const href = buildWorkspaceTabHref(
|
||||||
buildHostWorkspaceTabRoute(
|
normalizedServerId,
|
||||||
normalizedServerId,
|
normalizedWorkspaceId,
|
||||||
normalizedWorkspaceId,
|
tabId
|
||||||
tabId
|
|
||||||
) as any
|
|
||||||
);
|
);
|
||||||
|
if (!href) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace(href as any);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -413,6 +441,7 @@ 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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalized = typeof routeTabId === "string" ? routeTabId.trim() : "";
|
const normalized = typeof routeTabId === "string" ? routeTabId.trim() : "";
|
||||||
@@ -447,6 +476,22 @@ function WorkspaceScreenContent({
|
|||||||
routeTabId,
|
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({
|
||||||
@@ -455,9 +500,9 @@ function WorkspaceScreenContent({
|
|||||||
uiTabs,
|
uiTabs,
|
||||||
tabOrder,
|
tabOrder,
|
||||||
focusedTabId,
|
focusedTabId,
|
||||||
routeTabId,
|
routeTabId: effectiveRouteTabId,
|
||||||
}),
|
}),
|
||||||
[focusedTabId, routeTabId, tabOrder, terminals, uiTabs, workspaceAgents]
|
[effectiveRouteTabId, focusedTabId, tabOrder, terminals, uiTabs, workspaceAgents]
|
||||||
);
|
);
|
||||||
const activeTabId = tabModel.activeTabId;
|
const activeTabId = tabModel.activeTabId;
|
||||||
|
|
||||||
@@ -468,23 +513,76 @@ 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]);
|
||||||
|
|
||||||
const lastCanonicalTabIdRef = useRef<string | null>(null);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeTabId || !normalizedServerId || !normalizedWorkspaceId) {
|
if (!pendingRouteTabId || !activeTabId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (routeTabId && routeTabId.trim() === activeTabId) {
|
if (pendingRouteTabId === activeTabId) {
|
||||||
lastCanonicalTabIdRef.current = 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;
|
return;
|
||||||
}
|
}
|
||||||
if (lastCanonicalTabIdRef.current === activeTabId) {
|
if (lastCanonicalPathRef.current === canonicalPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastCanonicalTabIdRef.current = activeTabId;
|
lastCanonicalPathRef.current = canonicalPath;
|
||||||
router.replace(
|
if (Platform.OS === "web" && typeof window !== "undefined") {
|
||||||
buildHostWorkspaceTabRoute(normalizedServerId, normalizedWorkspaceId, activeTabId) as any
|
if (
|
||||||
);
|
window.location.pathname !== canonicalPath ||
|
||||||
}, [activeTabId, normalizedServerId, normalizedWorkspaceId, router, routeTabId]);
|
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;
|
||||||
|
|
||||||
@@ -509,11 +607,23 @@ function WorkspaceScreenContent({
|
|||||||
if (!tabId || !normalizedServerId || !normalizedWorkspaceId) {
|
if (!tabId || !normalizedServerId || !normalizedWorkspaceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(
|
setPendingRouteTabId(tabId);
|
||||||
buildHostWorkspaceTabRoute(normalizedServerId, normalizedWorkspaceId, tabId) as any
|
focusTab({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
tabId,
|
||||||
|
});
|
||||||
|
const href = buildWorkspaceTabHref(
|
||||||
|
normalizedServerId,
|
||||||
|
normalizedWorkspaceId,
|
||||||
|
tabId
|
||||||
);
|
);
|
||||||
|
if (!href) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.replace(href as any);
|
||||||
},
|
},
|
||||||
[normalizedServerId, normalizedWorkspaceId, router]
|
[focusTab, normalizedServerId, normalizedWorkspaceId, router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const emptyWorkspaceSeedRef = useRef<string | null>(null);
|
const emptyWorkspaceSeedRef = useRef<string | null>(null);
|
||||||
@@ -521,6 +631,9 @@ 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;
|
||||||
@@ -545,6 +658,7 @@ function WorkspaceScreenContent({
|
|||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
openDraftTab,
|
openDraftTab,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
|
effectiveRouteTabId,
|
||||||
tabs.length,
|
tabs.length,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const metrics = {
|
|||||||
tabHorizontalPadding: 12,
|
tabHorizontalPadding: 12,
|
||||||
estimatedCharWidth: 7,
|
estimatedCharWidth: 7,
|
||||||
closeButtonWidth: 22,
|
closeButtonWidth: 22,
|
||||||
|
compactLabelCharCap: 10,
|
||||||
|
compactDenseLabelCharCap: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("computeWorkspaceTabLayout", () => {
|
describe("computeWorkspaceTabLayout", () => {
|
||||||
@@ -60,4 +62,28 @@ describe("computeWorkspaceTabLayout", () => {
|
|||||||
|
|
||||||
expect(result.mode).toBe("icon");
|
expect(result.mode).toBe("icon");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps compact-with-label mode for realistic desktop width with nine tabs", () => {
|
||||||
|
const result = computeWorkspaceTabLayout({
|
||||||
|
viewportWidth: 1180,
|
||||||
|
tabLabelLengths: [18, 21, 14, 15, 19, 12, 17, 16, 20],
|
||||||
|
metrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.mode).toBe("compact");
|
||||||
|
expect(result.showLabels).toBe(true);
|
||||||
|
expect(result.showCloseButtons).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses dense compact labels for larger tab counts before icon-only", () => {
|
||||||
|
const result = computeWorkspaceTabLayout({
|
||||||
|
viewportWidth: 1100,
|
||||||
|
tabLabelLengths: [18, 21, 14, 15, 19, 12, 17, 16, 20],
|
||||||
|
metrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.mode).toBe("compact");
|
||||||
|
expect(result.showLabels).toBe(true);
|
||||||
|
expect(result.showCloseButtons).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export type WorkspaceTabLayoutInput = {
|
|||||||
tabHorizontalPadding: number;
|
tabHorizontalPadding: number;
|
||||||
estimatedCharWidth: number;
|
estimatedCharWidth: number;
|
||||||
closeButtonWidth: number;
|
closeButtonWidth: number;
|
||||||
|
compactLabelCharCap?: number;
|
||||||
|
compactDenseLabelCharCap?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,6 +72,12 @@ export function computeWorkspaceTabLayout(
|
|||||||
);
|
);
|
||||||
const baseTabWidth = input.metrics.tabIconWidth + input.metrics.tabHorizontalPadding * 2;
|
const baseTabWidth = input.metrics.tabIconWidth + input.metrics.tabHorizontalPadding * 2;
|
||||||
const estimateLabelWidth = (labelLength: number) => labelLength * input.metrics.estimatedCharWidth;
|
const estimateLabelWidth = (labelLength: number) => labelLength * input.metrics.estimatedCharWidth;
|
||||||
|
const compactLabelCharCap = Math.max(1, input.metrics.compactLabelCharCap ?? 12);
|
||||||
|
const compactDenseLabelCharCap = Math.max(
|
||||||
|
1,
|
||||||
|
input.metrics.compactDenseLabelCharCap ?? Math.max(1, compactLabelCharCap - 2)
|
||||||
|
);
|
||||||
|
const effectiveCompactLabelCharCap = tabCount >= 8 ? compactDenseLabelCharCap : compactLabelCharCap;
|
||||||
|
|
||||||
const fullTabWidths = input.tabLabelLengths.map((rawLength) => {
|
const fullTabWidths = input.tabLabelLengths.map((rawLength) => {
|
||||||
const labelLength = Math.max(rawLength, 1);
|
const labelLength = Math.max(rawLength, 1);
|
||||||
@@ -87,7 +95,8 @@ export function computeWorkspaceTabLayout(
|
|||||||
|
|
||||||
const compactTabWidths = input.tabLabelLengths.map((rawLength) => {
|
const compactTabWidths = input.tabLabelLengths.map((rawLength) => {
|
||||||
const labelLength = Math.max(rawLength, 1);
|
const labelLength = Math.max(rawLength, 1);
|
||||||
const estimatedWidth = baseTabWidth + estimateLabelWidth(labelLength);
|
const estimatedWidth =
|
||||||
|
baseTabWidth + estimateLabelWidth(Math.min(labelLength, effectiveCompactLabelCharCap));
|
||||||
return clamp(estimatedWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth);
|
return clamp(estimatedWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth);
|
||||||
});
|
});
|
||||||
const compactTotal = computeTotalRowWidth({
|
const compactTotal = computeTotalRowWidth({
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ describe("deriveWorkspaceTabModel", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers route tab, then focused tab, then first tab for active selection", () => {
|
it("prefers route tab when present, then focused tab, then first tab for active selection", () => {
|
||||||
const base = {
|
const base = {
|
||||||
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
|
||||||
terminals: [],
|
terminals: [],
|
||||||
@@ -136,6 +136,54 @@ describe("deriveWorkspaceTabModel", () => {
|
|||||||
routeTabId: "agent_agent-a",
|
routeTabId: "agent_agent-a",
|
||||||
}).activeTabId
|
}).activeTabId
|
||||||
).toBe("agent_agent-a");
|
).toBe("agent_agent-a");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceTabModel({
|
||||||
|
...base,
|
||||||
|
focusedTabId: "agent_agent-b",
|
||||||
|
routeTabId: "agent_agent-missing",
|
||||||
|
}).activeTabId
|
||||||
|
).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", () => {
|
||||||
|
const model = deriveWorkspaceTabModel({
|
||||||
|
workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })],
|
||||||
|
terminals: [],
|
||||||
|
uiTabs: [],
|
||||||
|
tabOrder: ["agent_workspace-b-agent"],
|
||||||
|
focusedTabId: "agent_workspace-a-agent",
|
||||||
|
routeTabId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.activeTabId).toBe("agent_workspace-b-agent");
|
||||||
|
expect(model.activeTab?.target).toEqual({
|
||||||
|
kind: "agent",
|
||||||
|
agentId: "workspace-b-agent",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("covers regression: non-archived attention agent remains visible even if UI tabs omitted it", () => {
|
it("covers regression: non-archived attention agent remains visible even if UI tabs omitted it", () => {
|
||||||
|
|||||||
@@ -212,11 +212,12 @@ export function deriveWorkspaceTabModel(input: {
|
|||||||
const routeTabId = trimNonEmpty(input.routeTabId);
|
const routeTabId = trimNonEmpty(input.routeTabId);
|
||||||
const focusedTabId = trimNonEmpty(input.focusedTabId);
|
const focusedTabId = trimNonEmpty(input.focusedTabId);
|
||||||
|
|
||||||
const activeTabId = routeTabId && openTabIds.has(routeTabId)
|
const activeTabId =
|
||||||
? routeTabId
|
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
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ describe("workspace route parsing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
||||||
expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe("/h/local/workspace/L3RtcC9yZXBv");
|
expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe("/h/local/workspace/L3RtcC9yZXBv");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import { Buffer } from "buffer";
|
|||||||
|
|
||||||
type NullableString = string | null | undefined;
|
type NullableString = string | null | undefined;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
function trimNonEmpty(value: NullableString): string | null {
|
function trimNonEmpty(value: NullableString): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -155,7 +164,8 @@ export function decodeFilePathFromPathSegment(filePathSegment: string): string |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseServerIdFromPathname(pathname: string): string | null {
|
export function parseServerIdFromPathname(pathname: string): string | null {
|
||||||
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
|
const pathOnly = stripSearchAndHash(pathname);
|
||||||
|
const match = pathOnly.match(/^\/h\/([^/]+)(?:\/|$)/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -169,7 +179,8 @@ export function parseServerIdFromPathname(pathname: string): string | null {
|
|||||||
export function parseHostAgentRouteFromPathname(
|
export function parseHostAgentRouteFromPathname(
|
||||||
pathname: string
|
pathname: string
|
||||||
): { serverId: string; agentId: string } | null {
|
): { serverId: string; agentId: string } | null {
|
||||||
const match = pathname.match(/^\/h\/([^/]+)\/agent\/([^/]+)(?:\/|$)/);
|
const pathOnly = stripSearchAndHash(pathname);
|
||||||
|
const match = pathOnly.match(/^\/h\/([^/]+)\/agent\/([^/]+)(?:\/|$)/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -191,35 +202,36 @@ export function parseHostAgentRouteFromPathname(
|
|||||||
export function parseHostWorkspaceRouteFromPathname(
|
export function parseHostWorkspaceRouteFromPathname(
|
||||||
pathname: string
|
pathname: string
|
||||||
): { serverId: string; workspaceId: string } | null {
|
): { serverId: string; workspaceId: string } | null {
|
||||||
|
const pathOnly = stripSearchAndHash(pathname);
|
||||||
const prefix = "/h/";
|
const prefix = "/h/";
|
||||||
if (!pathname.startsWith(prefix)) {
|
if (!pathOnly.startsWith(prefix)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverIdStart = prefix.length;
|
const serverIdStart = prefix.length;
|
||||||
const serverIdEnd = pathname.indexOf("/", serverIdStart);
|
const serverIdEnd = pathOnly.indexOf("/", serverIdStart);
|
||||||
if (serverIdEnd < 0) {
|
if (serverIdEnd < 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
|
const rawServerId = pathOnly.slice(serverIdStart, serverIdEnd);
|
||||||
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspacePrefix = "/workspace/";
|
const workspacePrefix = "/workspace/";
|
||||||
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
|
if (!pathOnly.startsWith(workspacePrefix, serverIdEnd)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
||||||
let workspaceIdEnd = pathname.length;
|
let workspaceIdEnd = pathOnly.length;
|
||||||
const tabIdx = pathname.lastIndexOf("/tab/");
|
const tabIdx = pathOnly.lastIndexOf("/tab/");
|
||||||
if (tabIdx >= 0 && tabIdx > workspaceIdStart) {
|
if (tabIdx >= 0 && tabIdx > workspaceIdStart) {
|
||||||
workspaceIdEnd = tabIdx;
|
workspaceIdEnd = tabIdx;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawWorkspaceId = pathname.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, "");
|
const rawWorkspaceId = pathOnly.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, "");
|
||||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -230,44 +242,45 @@ export function parseHostWorkspaceRouteFromPathname(
|
|||||||
export function parseHostWorkspaceTabRouteFromPathname(
|
export function parseHostWorkspaceTabRouteFromPathname(
|
||||||
pathname: string
|
pathname: string
|
||||||
): { serverId: string; workspaceId: string; tabId: string } | null {
|
): { serverId: string; workspaceId: string; tabId: string } | null {
|
||||||
|
const pathOnly = stripSearchAndHash(pathname);
|
||||||
const prefix = "/h/";
|
const prefix = "/h/";
|
||||||
if (!pathname.startsWith(prefix)) {
|
if (!pathOnly.startsWith(prefix)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverIdStart = prefix.length;
|
const serverIdStart = prefix.length;
|
||||||
const serverIdEnd = pathname.indexOf("/", serverIdStart);
|
const serverIdEnd = pathOnly.indexOf("/", serverIdStart);
|
||||||
if (serverIdEnd < 0) {
|
if (serverIdEnd < 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
|
const rawServerId = pathOnly.slice(serverIdStart, serverIdEnd);
|
||||||
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspacePrefix = "/workspace/";
|
const workspacePrefix = "/workspace/";
|
||||||
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
|
if (!pathOnly.startsWith(workspacePrefix, serverIdEnd)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
||||||
const tabMarker = "/tab/";
|
const tabMarker = "/tab/";
|
||||||
const tabIdx = pathname.lastIndexOf(tabMarker);
|
const tabIdx = pathOnly.lastIndexOf(tabMarker);
|
||||||
if (tabIdx < 0 || tabIdx <= workspaceIdStart) {
|
if (tabIdx < 0 || tabIdx <= workspaceIdStart) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawWorkspaceId = pathname.slice(workspaceIdStart, tabIdx).replace(/\/+$/, "");
|
const rawWorkspaceId = pathOnly.slice(workspaceIdStart, tabIdx).replace(/\/+$/, "");
|
||||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabIdStart = tabIdx + tabMarker.length;
|
const tabIdStart = tabIdx + tabMarker.length;
|
||||||
const tabIdEnd = pathname.indexOf("/", tabIdStart);
|
const tabIdEnd = pathOnly.indexOf("/", tabIdStart);
|
||||||
const rawTabId =
|
const rawTabId =
|
||||||
tabIdEnd < 0 ? pathname.slice(tabIdStart) : pathname.slice(tabIdStart, tabIdEnd);
|
tabIdEnd < 0 ? pathOnly.slice(tabIdStart) : pathOnly.slice(tabIdStart, tabIdEnd);
|
||||||
const tabId = trimNonEmpty(decodeSegment(rawTabId));
|
const tabId = trimNonEmpty(decodeSegment(rawTabId));
|
||||||
if (!tabId) {
|
if (!tabId) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+155
@@ -824,6 +824,161 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
|||||||
await session.close();
|
await session.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not create an orphan autonomous run from pre-replay task_started metadata", async () => {
|
||||||
|
const logger = createTestLogger();
|
||||||
|
const keepQueryAlive = deferred<void>();
|
||||||
|
|
||||||
|
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||||
|
const readPromptUuid = createPromptUuidReader(prompt);
|
||||||
|
let step = 0;
|
||||||
|
return {
|
||||||
|
next: vi.fn(async () => {
|
||||||
|
if (step === 0) {
|
||||||
|
step += 1;
|
||||||
|
return {
|
||||||
|
done: false,
|
||||||
|
value: {
|
||||||
|
type: "system",
|
||||||
|
subtype: "init",
|
||||||
|
session_id: "task-started-fallback-session",
|
||||||
|
permissionMode: "default",
|
||||||
|
model: "opus",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (step === 1) {
|
||||||
|
step += 1;
|
||||||
|
return {
|
||||||
|
done: false,
|
||||||
|
value: {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
id: "tool-call-msg",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_1",
|
||||||
|
name: "Agent",
|
||||||
|
input: { description: "verify", prompt: "sub-task" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (step === 2) {
|
||||||
|
step += 1;
|
||||||
|
return {
|
||||||
|
done: false,
|
||||||
|
value: {
|
||||||
|
type: "system",
|
||||||
|
subtype: "task_started",
|
||||||
|
task_id: "task-1",
|
||||||
|
tool_use_id: "toolu_1",
|
||||||
|
description: "verify",
|
||||||
|
task_type: "local_agent",
|
||||||
|
session_id: "task-started-fallback-session",
|
||||||
|
uuid: "task-started-1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (step === 3) {
|
||||||
|
step += 1;
|
||||||
|
return {
|
||||||
|
done: false,
|
||||||
|
value: {
|
||||||
|
type: "stream_event",
|
||||||
|
event: {
|
||||||
|
type: "message_delta",
|
||||||
|
delta: { stop_reason: "tool_use", stop_sequence: null },
|
||||||
|
usage: buildUsage(),
|
||||||
|
},
|
||||||
|
session_id: "task-started-fallback-session",
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
uuid: "msg-delta-tool-use",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (step === 4) {
|
||||||
|
step += 1;
|
||||||
|
const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
|
||||||
|
return {
|
||||||
|
done: false,
|
||||||
|
value: {
|
||||||
|
type: "user",
|
||||||
|
message: { role: "user", content: "current prompt" },
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
uuid: promptUuid,
|
||||||
|
session_id: "task-started-fallback-session",
|
||||||
|
isReplay: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (step === 5) {
|
||||||
|
step += 1;
|
||||||
|
return {
|
||||||
|
done: false,
|
||||||
|
value: {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: "FOREGROUND_DONE",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (step === 6) {
|
||||||
|
step += 1;
|
||||||
|
return {
|
||||||
|
done: false,
|
||||||
|
value: {
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
usage: buildUsage(),
|
||||||
|
total_cost_usd: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (step === 7) {
|
||||||
|
await keepQueryAlive.promise;
|
||||||
|
return { done: true, value: undefined };
|
||||||
|
}
|
||||||
|
return { done: true, value: undefined };
|
||||||
|
}),
|
||||||
|
interrupt: vi.fn(async () => undefined),
|
||||||
|
return: vi.fn(async () => undefined),
|
||||||
|
setPermissionMode: vi.fn(async () => undefined),
|
||||||
|
setModel: vi.fn(async () => undefined),
|
||||||
|
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||||
|
supportedCommands: vi.fn(async () => []),
|
||||||
|
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||||
|
} satisfies QueryMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new ClaudeAgentClient({ logger });
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = await collectUntilTerminal(session.stream("current prompt"));
|
||||||
|
const assistantText = collectAssistantText(events);
|
||||||
|
|
||||||
|
expect(assistantText).toContain("FOREGROUND_DONE");
|
||||||
|
expect(
|
||||||
|
(session as unknown as { turnState?: string }).turnState ?? null
|
||||||
|
).toBe("idle");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
session as unknown as {
|
||||||
|
runTracker?: { listActiveRuns: (owner?: "foreground" | "autonomous") => unknown[] };
|
||||||
|
}
|
||||||
|
).runTracker?.listActiveRuns("autonomous") ?? []
|
||||||
|
).toHaveLength(0);
|
||||||
|
|
||||||
|
keepQueryAlive.resolve(undefined);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("emits autonomous live events from SDK stream when Claude wakes itself", async () => {
|
test("emits autonomous live events from SDK stream when Claude wakes itself", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
let queryCreateCount = 0;
|
let queryCreateCount = 0;
|
||||||
|
|||||||
@@ -2693,6 +2693,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
// first turn cannot be stranded in autonomous fallback. If metadata churn
|
// first turn cannot be stranded in autonomous fallback. If metadata churn
|
||||||
// was observed pre-replay, stay conservative and wait for replay.
|
// was observed pre-replay, stay conservative and wait for replay.
|
||||||
if (!run.promptReplaySeen) {
|
if (!run.promptReplaySeen) {
|
||||||
|
if (this.isToolUseBoundaryStreamEvent(input.message)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
// Keep pre-replay result events with the foreground run so stale result
|
// Keep pre-replay result events with the foreground run so stale result
|
||||||
// bursts cannot consume autonomous wake reservations.
|
// bursts cannot consume autonomous wake reservations.
|
||||||
if (message.type === "result") {
|
if (message.type === "result") {
|
||||||
@@ -2718,6 +2721,21 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isToolUseBoundaryStreamEvent(message: SDKMessage): boolean {
|
||||||
|
if (message.type !== "stream_event") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const event = (message as unknown as { event?: Record<string, unknown> }).event;
|
||||||
|
if (!event || event.type !== "message_delta") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const delta =
|
||||||
|
"delta" in event && event.delta && typeof event.delta === "object"
|
||||||
|
? (event.delta as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
return delta?.stop_reason === "tool_use";
|
||||||
|
}
|
||||||
|
|
||||||
private notePreReplayMetadata(message: SDKMessage): void {
|
private notePreReplayMetadata(message: SDKMessage): void {
|
||||||
if (this.turnState !== "foreground") {
|
if (this.turnState !== "foreground") {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user