diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index e56e199a1..72f64412b 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -1,14 +1,41 @@ 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 { useFormPreferences } from "@/hooks/use-form-preferences"; import { + buildHostRootRoute, buildHostWorkspaceRoute, buildHostWorkspaceAgentTabRoute, + buildHostWorkspaceTabRoute, + parseHostWorkspaceTabRouteFromPathname, } 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() { const router = useRouter(); + const routerPathname = usePathname(); + const pathname = + Platform.OS === "web" && typeof window !== "undefined" + ? window.location.pathname + : routerPathname; const params = useLocalSearchParams<{ serverId?: string }>(); const serverId = typeof params.serverId === "string" ? params.serverId : ""; const { preferences, isLoading: preferencesLoading } = useFormPreferences(); @@ -23,33 +50,77 @@ export default function HostIndexRoute() { if (!serverId) { 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 - ); + const rootRoute = buildHostRootRoute(serverId); + if (pathname !== rootRoute && pathname !== `${rootRoute}/`) { return; } + const timer = setTimeout(() => { + if (Platform.OS === "web" && typeof window !== "undefined") { + const currentPathname = window.location.pathname; + if (currentPathname !== rootRoute && currentPathname !== `${rootRoute}/`) { + return; + } - const preferredWorkingDir = - preferences.serverId === serverId ? preferences.workingDir?.trim() : ""; - const workspaceId = preferredWorkingDir || "."; - router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any); - }, [preferences.serverId, preferences.workingDir, preferencesLoading, router, serverId, sessionAgents]); + 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 + ? 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; } diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index 3d0dff7a3..b21bad146 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -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 { parseHostWorkspaceRouteFromPathname, parseHostWorkspaceTabRouteFromPathname, } 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() { const pathname = usePathname(); - const params = useLocalSearchParams<{ tabId?: string }>(); - const tabRoute = parseHostWorkspaceTabRouteFromPathname(pathname); - const activeRoute = tabRoute ?? parseHostWorkspaceRouteFromPathname(pathname); + const resolvedPathname = + Platform.OS === "web" && typeof window !== "undefined" + ? `${window.location.pathname}${window.location.search}${window.location.hash}` + : pathname; + const tabRoute = parseHostWorkspaceTabRouteFromPathname(resolvedPathname); + const activeRoute = tabRoute ?? parseHostWorkspaceRouteFromPathname(resolvedPathname); const serverId = activeRoute?.serverId ?? ""; 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 ( (); const { theme } = useUnistyles(); const { daemons, isLoading: registryLoading } = useDaemonRegistry(); @@ -45,8 +50,11 @@ export default function Index() { if (!targetServerId) { return; } + if (pathname !== "/" && pathname !== "") { + return; + } router.replace(buildHostRootRoute(targetServerId) as any); - }, [preferencesLoading, registryLoading, router, targetServerId]); + }, [pathname, preferencesLoading, registryLoading, router, targetServerId]); if (registryLoading || preferencesLoading) { return ( diff --git a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts index f0a1f669c..e09fdc3d8 100644 --- a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts @@ -18,6 +18,8 @@ type UseWorkspaceTabLayoutInput = { tabHorizontalPadding: number; estimatedCharWidth: number; closeButtonWidth: number; + compactLabelCharCap?: number; + compactDenseLabelCharCap?: number; }; }; diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 724b9ee2b..04980a48d 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -86,6 +86,8 @@ export function WorkspaceDesktopTabsRow({ tabHorizontalPadding: theme.spacing[3], estimatedCharWidth: 7, closeButtonWidth: 22, + compactLabelCharCap: 10, + compactDenseLabelCharCap: 8, }), [theme.spacing] ); @@ -190,6 +192,9 @@ export function WorkspaceDesktopTabsRow({ onHoverOut={() => { setHoveredTabKey((current) => (current === tab.key ? null : current)); }} + onPressIn={() => { + onNavigateTab(tab.tabId); + }} onPress={() => { onNavigateTab(tab.tabId); }} diff --git a/packages/app/src/screens/workspace/workspace-route-canonicalization.test.ts b/packages/app/src/screens/workspace/workspace-route-canonicalization.test.ts new file mode 100644 index 000000000..1d5b0f237 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-route-canonicalization.test.ts @@ -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/ 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(); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-route-canonicalization.ts b/packages/app/src/screens/workspace/workspace-route-canonicalization.ts new file mode 100644 index 000000000..9d12975d9 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-route-canonicalization.ts @@ -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; +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 55a19d416..93aa782b1 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -8,7 +8,7 @@ import { View, } from "react-native"; 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 { Bot, @@ -50,9 +50,9 @@ import { useWorkspaceTabsStore, } from "@/stores/workspace-tabs-store"; import { - buildHostWorkspaceRoute, buildHostWorkspaceTabRoute, decodeWorkspaceIdFromPathSegment, + encodeWorkspaceIdForPathSegment, } from "@/utils/host-routes"; import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; import { useHostRuntimeSession } from "@/runtime/host-runtime"; @@ -82,6 +82,7 @@ import { import { deriveWorkspaceTabModel, } from "@/screens/workspace/workspace-tab-model"; +import { resolveWorkspaceTabCanonicalPath } from "@/screens/workspace/workspace-route-canonicalization"; const TERMINALS_QUERY_STALE_TIME = 5_000; 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({ serverId, @@ -136,6 +157,11 @@ function WorkspaceScreenContent({ const { theme } = useUnistyles(); 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 = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -215,13 +241,15 @@ function WorkspaceScreenContent({ target: { kind: "terminal", terminalId: createdTerminal.id }, }); if (tabId) { - router.replace( - buildHostWorkspaceTabRoute( - normalizedServerId, - normalizedWorkspaceId, - tabId - ) as any + const href = buildWorkspaceTabHref( + normalizedServerId, + normalizedWorkspaceId, + tabId ); + if (!href) { + return; + } + router.replace(href as any); } } }, @@ -413,6 +441,7 @@ function WorkspaceScreenContent({ const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab); const promoteDraftToAgent = useWorkspaceTabsStore((state) => state.promoteDraftToAgent); const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs); + const [pendingRouteTabId, setPendingRouteTabId] = useState(null); useEffect(() => { const normalized = typeof routeTabId === "string" ? routeTabId.trim() : ""; @@ -447,6 +476,22 @@ function WorkspaceScreenContent({ 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( () => deriveWorkspaceTabModel({ @@ -455,9 +500,9 @@ function WorkspaceScreenContent({ uiTabs, tabOrder, focusedTabId, - routeTabId, + routeTabId: effectiveRouteTabId, }), - [focusedTabId, routeTabId, tabOrder, terminals, uiTabs, workspaceAgents] + [effectiveRouteTabId, focusedTabId, tabOrder, terminals, uiTabs, workspaceAgents] ); const activeTabId = tabModel.activeTabId; @@ -468,23 +513,76 @@ function WorkspaceScreenContent({ focusTab({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: activeTabId }); }, [activeTabId, focusTab, normalizedServerId, normalizedWorkspaceId, persistenceKey]); - const lastCanonicalTabIdRef = useRef(null); useEffect(() => { - if (!activeTabId || !normalizedServerId || !normalizedWorkspaceId) { + if (!pendingRouteTabId || !activeTabId) { return; } - if (routeTabId && routeTabId.trim() === activeTabId) { - lastCanonicalTabIdRef.current = activeTabId; + if (pendingRouteTabId === activeTabId) { + setPendingRouteTabId(null); + } + }, [activeTabId, pendingRouteTabId]); + + const lastCanonicalPathRef = useRef(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 (lastCanonicalTabIdRef.current === activeTabId) { + if (lastCanonicalPathRef.current === canonicalPath) { return; } - lastCanonicalTabIdRef.current = activeTabId; - router.replace( - buildHostWorkspaceTabRoute(normalizedServerId, normalizedWorkspaceId, activeTabId) as any - ); - }, [activeTabId, normalizedServerId, normalizedWorkspaceId, router, routeTabId]); + 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; @@ -509,11 +607,23 @@ function WorkspaceScreenContent({ if (!tabId || !normalizedServerId || !normalizedWorkspaceId) { return; } - router.replace( - buildHostWorkspaceTabRoute(normalizedServerId, normalizedWorkspaceId, tabId) as any + setPendingRouteTabId(tabId); + 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(null); @@ -521,6 +631,9 @@ function WorkspaceScreenContent({ if (!persistenceKey) { return; } + if (effectiveRouteTabId) { + return; + } if (tabs.length > 0) { emptyWorkspaceSeedRef.current = null; return; @@ -545,6 +658,7 @@ function WorkspaceScreenContent({ normalizedWorkspaceId, openDraftTab, persistenceKey, + effectiveRouteTabId, tabs.length, ]); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index f414de057..249f8b7c1 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -12,6 +12,8 @@ const metrics = { tabHorizontalPadding: 12, estimatedCharWidth: 7, closeButtonWidth: 22, + compactLabelCharCap: 10, + compactDenseLabelCharCap: 8, }; describe("computeWorkspaceTabLayout", () => { @@ -60,4 +62,28 @@ describe("computeWorkspaceTabLayout", () => { 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); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index 1d0bd9fa2..f7c385159 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -14,6 +14,8 @@ export type WorkspaceTabLayoutInput = { tabHorizontalPadding: number; estimatedCharWidth: number; closeButtonWidth: number; + compactLabelCharCap?: number; + compactDenseLabelCharCap?: number; }; }; @@ -70,6 +72,12 @@ export function computeWorkspaceTabLayout( ); const baseTabWidth = input.metrics.tabIconWidth + input.metrics.tabHorizontalPadding * 2; 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 labelLength = Math.max(rawLength, 1); @@ -87,7 +95,8 @@ export function computeWorkspaceTabLayout( const compactTabWidths = input.tabLabelLengths.map((rawLength) => { 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); }); const compactTotal = computeTotalRowWidth({ diff --git a/packages/app/src/screens/workspace/workspace-tab-model.test.ts b/packages/app/src/screens/workspace/workspace-tab-model.test.ts index c29191a5a..01d2abb69 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.test.ts @@ -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 = { workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })], terminals: [], @@ -136,6 +136,54 @@ describe("deriveWorkspaceTabModel", () => { routeTabId: "agent_agent-a", }).activeTabId ).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", () => { diff --git a/packages/app/src/screens/workspace/workspace-tab-model.ts b/packages/app/src/screens/workspace/workspace-tab-model.ts index ed0e75524..6e29ff861 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.ts @@ -212,11 +212,12 @@ export function deriveWorkspaceTabModel(input: { const routeTabId = trimNonEmpty(input.routeTabId); const focusedTabId = trimNonEmpty(input.focusedTabId); - const activeTabId = routeTabId && openTabIds.has(routeTabId) - ? routeTabId - : focusedTabId && openTabIds.has(focusedTabId) - ? focusedTabId - : tabs[0]?.descriptor.tabId ?? null; + const activeTabId = + routeTabId && openTabIds.has(routeTabId) + ? routeTabId + : focusedTabId && openTabIds.has(focusedTabId) + ? focusedTabId + : tabs[0]?.descriptor.tabId ?? null; const activeTab = activeTabId ? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 24a075feb..6e75b1527 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -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", () => { expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe("/h/local/workspace/L3RtcC9yZXBv"); }); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index c873be8a3..2d91e0be3 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -2,6 +2,15 @@ import { Buffer } from "buffer"; 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 { if (typeof value !== "string") { return null; @@ -155,7 +164,8 @@ export function decodeFilePathFromPathSegment(filePathSegment: string): string | } export function parseServerIdFromPathname(pathname: string): string | null { - const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/); + const pathOnly = stripSearchAndHash(pathname); + const match = pathOnly.match(/^\/h\/([^/]+)(?:\/|$)/); if (!match) { return null; } @@ -169,7 +179,8 @@ export function parseServerIdFromPathname(pathname: string): string | null { export function parseHostAgentRouteFromPathname( pathname: string ): { serverId: string; agentId: string } | null { - const match = pathname.match(/^\/h\/([^/]+)\/agent\/([^/]+)(?:\/|$)/); + const pathOnly = stripSearchAndHash(pathname); + const match = pathOnly.match(/^\/h\/([^/]+)\/agent\/([^/]+)(?:\/|$)/); if (!match) { return null; } @@ -191,35 +202,36 @@ export function parseHostAgentRouteFromPathname( export function parseHostWorkspaceRouteFromPathname( pathname: string ): { serverId: string; workspaceId: string } | null { + const pathOnly = stripSearchAndHash(pathname); const prefix = "/h/"; - if (!pathname.startsWith(prefix)) { + if (!pathOnly.startsWith(prefix)) { return null; } const serverIdStart = prefix.length; - const serverIdEnd = pathname.indexOf("/", serverIdStart); + const serverIdEnd = pathOnly.indexOf("/", serverIdStart); if (serverIdEnd < 0) { return null; } - const rawServerId = pathname.slice(serverIdStart, serverIdEnd); + const rawServerId = pathOnly.slice(serverIdStart, serverIdEnd); const serverId = trimNonEmpty(decodeSegment(rawServerId)); if (!serverId) { return null; } const workspacePrefix = "/workspace/"; - if (!pathname.startsWith(workspacePrefix, serverIdEnd)) { + if (!pathOnly.startsWith(workspacePrefix, serverIdEnd)) { return null; } const workspaceIdStart = serverIdEnd + workspacePrefix.length; - let workspaceIdEnd = pathname.length; - const tabIdx = pathname.lastIndexOf("/tab/"); + let workspaceIdEnd = pathOnly.length; + const tabIdx = pathOnly.lastIndexOf("/tab/"); if (tabIdx >= 0 && tabIdx > workspaceIdStart) { workspaceIdEnd = tabIdx; } - const rawWorkspaceId = pathname.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, ""); + const rawWorkspaceId = pathOnly.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, ""); const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId); if (!workspaceId) { return null; @@ -230,44 +242,45 @@ export function parseHostWorkspaceRouteFromPathname( export function parseHostWorkspaceTabRouteFromPathname( pathname: string ): { serverId: string; workspaceId: string; tabId: string } | null { + const pathOnly = stripSearchAndHash(pathname); const prefix = "/h/"; - if (!pathname.startsWith(prefix)) { + if (!pathOnly.startsWith(prefix)) { return null; } const serverIdStart = prefix.length; - const serverIdEnd = pathname.indexOf("/", serverIdStart); + const serverIdEnd = pathOnly.indexOf("/", serverIdStart); if (serverIdEnd < 0) { return null; } - const rawServerId = pathname.slice(serverIdStart, serverIdEnd); + const rawServerId = pathOnly.slice(serverIdStart, serverIdEnd); const serverId = trimNonEmpty(decodeSegment(rawServerId)); if (!serverId) { return null; } const workspacePrefix = "/workspace/"; - if (!pathname.startsWith(workspacePrefix, serverIdEnd)) { + if (!pathOnly.startsWith(workspacePrefix, serverIdEnd)) { return null; } const workspaceIdStart = serverIdEnd + workspacePrefix.length; const tabMarker = "/tab/"; - const tabIdx = pathname.lastIndexOf(tabMarker); + const tabIdx = pathOnly.lastIndexOf(tabMarker); if (tabIdx < 0 || tabIdx <= workspaceIdStart) { return null; } - const rawWorkspaceId = pathname.slice(workspaceIdStart, tabIdx).replace(/\/+$/, ""); + const rawWorkspaceId = pathOnly.slice(workspaceIdStart, tabIdx).replace(/\/+$/, ""); const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId); if (!workspaceId) { return null; } const tabIdStart = tabIdx + tabMarker.length; - const tabIdEnd = pathname.indexOf("/", tabIdStart); + const tabIdEnd = pathOnly.indexOf("/", tabIdStart); 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)); if (!tabId) { return null; diff --git a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts index f7ea461ba..29b115b23 100644 --- a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts @@ -824,6 +824,161 @@ describe("ClaudeAgentSession interrupt restart regression", () => { await session.close(); }); + test("does not create an orphan autonomous run from pre-replay task_started metadata", async () => { + const logger = createTestLogger(); + const keepQueryAlive = deferred(); + + sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + 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 () => { const logger = createTestLogger(); let queryCreateCount = 0; diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 65745249e..12e9cb20f 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -2693,6 +2693,9 @@ class ClaudeAgentSession implements AgentSession { // first turn cannot be stranded in autonomous fallback. If metadata churn // was observed pre-replay, stay conservative and wait for replay. if (!run.promptReplaySeen) { + if (this.isToolUseBoundaryStreamEvent(input.message)) { + return true; + } // Keep pre-replay result events with the foreground run so stale result // bursts cannot consume autonomous wake reservations. if (message.type === "result") { @@ -2718,6 +2721,21 @@ class ClaudeAgentSession implements AgentSession { return true; } + private isToolUseBoundaryStreamEvent(message: SDKMessage): boolean { + if (message.type !== "stream_event") { + return false; + } + const event = (message as unknown as { event?: Record }).event; + if (!event || event.type !== "message_delta") { + return false; + } + const delta = + "delta" in event && event.delta && typeof event.delta === "object" + ? (event.delta as Record) + : null; + return delta?.stop_reason === "tool_use"; + } + private notePreReplayMetadata(message: SDKMessage): void { if (this.turnState !== "foreground") { return;