feat: unify workspace routing through tab-based URLs and remove legacy per-type routes
This commit is contained in:
@@ -47,7 +47,6 @@ import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
import {
|
||||
buildHostDraftRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseHostWorkspaceAgentRouteFromPathname,
|
||||
parseHostWorkspaceTabRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
@@ -388,9 +387,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
return agentId ? `${workspaceTab.serverId}:${agentId}` : undefined;
|
||||
}
|
||||
|
||||
const match =
|
||||
parseHostWorkspaceAgentRouteFromPathname(pathname) ??
|
||||
parseHostAgentRouteFromPathname(pathname);
|
||||
const match = parseHostAgentRouteFromPathname(pathname);
|
||||
return match ? `${match.serverId}:${match.agentId}` : undefined;
|
||||
}, [pathname]);
|
||||
|
||||
@@ -478,13 +475,6 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="h/[serverId]/new" />
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/tab/[tabId]" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/workspace/[workspaceId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/workspace/[workspaceId]/terminal/[terminalId]"
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import {
|
||||
buildHostDraftRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceAgentTabRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
@@ -38,7 +38,7 @@ export default function HostAgentReadyRoute() {
|
||||
if (normalizedCwd) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(
|
||||
buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any
|
||||
buildHostWorkspaceAgentTabRoute(serverId, normalizedCwd, agentId) as any
|
||||
);
|
||||
}
|
||||
}, [agentCwd, agentId, router, serverId]);
|
||||
@@ -77,7 +77,7 @@ export default function HostAgentReadyRoute() {
|
||||
const cwd = result?.agent?.cwd?.trim();
|
||||
redirectedRef.current = true;
|
||||
if (cwd) {
|
||||
router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any);
|
||||
router.replace(buildHostWorkspaceAgentTabRoute(serverId, cwd, agentId) as any);
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostDraftRoute(serverId) as any);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
buildHostDraftRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceAgentTabRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export default function HostIndexRoute() {
|
||||
@@ -31,7 +31,7 @@ export default function HostIndexRoute() {
|
||||
const primaryAgent = visibleAgents[0];
|
||||
if (primaryAgent?.cwd?.trim()) {
|
||||
router.replace(
|
||||
buildHostWorkspaceAgentRoute(
|
||||
buildHostWorkspaceAgentTabRoute(
|
||||
serverId,
|
||||
primaryAgent.cwd.trim(),
|
||||
primaryAgent.id
|
||||
|
||||
+4
-4
@@ -1,20 +1,20 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
|
||||
export default function HostWorkspaceAgentRoute() {
|
||||
export default function HostWorkspaceLayout() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
agentId?: string;
|
||||
tabId?: string;
|
||||
}>();
|
||||
|
||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
||||
const tabId = typeof params.tabId === "string" ? params.tabId : "";
|
||||
|
||||
return (
|
||||
<WorkspaceScreen
|
||||
serverId={typeof params.serverId === "string" ? params.serverId : ""}
|
||||
workspaceId={typeof params.workspaceId === "string" ? params.workspaceId : ""}
|
||||
routeTab={agentId ? { kind: "agent", agentId } : null}
|
||||
routeTabId={tabId || null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import { decodeFilePathFromPathSegment } from "@/utils/host-routes";
|
||||
|
||||
export default function HostWorkspaceFileRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
fileId?: string;
|
||||
}>();
|
||||
|
||||
const fileId = typeof params.fileId === "string" ? params.fileId : "";
|
||||
const filePath = fileId ? decodeFilePathFromPathSegment(fileId) ?? "" : "";
|
||||
|
||||
return (
|
||||
<WorkspaceScreen
|
||||
serverId={typeof params.serverId === "string" ? params.serverId : ""}
|
||||
workspaceId={typeof params.workspaceId === "string" ? params.workspaceId : ""}
|
||||
routeTab={filePath ? { kind: "file", path: filePath } : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
|
||||
export default function HostWorkspaceIndexRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string; workspaceId?: string }>();
|
||||
|
||||
return (
|
||||
<WorkspaceScreen
|
||||
serverId={typeof params.serverId === "string" ? params.serverId : ""}
|
||||
workspaceId={typeof params.workspaceId === "string" ? params.workspaceId : ""}
|
||||
routeTab={null}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
|
||||
export default function HostWorkspaceTabRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
tabId?: string;
|
||||
}>();
|
||||
|
||||
const tabId = typeof params.tabId === "string" ? params.tabId : "";
|
||||
|
||||
return (
|
||||
<WorkspaceScreen
|
||||
serverId={typeof params.serverId === "string" ? params.serverId : ""}
|
||||
workspaceId={typeof params.workspaceId === "string" ? params.workspaceId : ""}
|
||||
routeTab={null}
|
||||
routeTabId={tabId || null}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
|
||||
export default function HostWorkspaceTerminalRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
terminalId?: string;
|
||||
}>();
|
||||
|
||||
const terminalId =
|
||||
typeof params.terminalId === "string" ? params.terminalId : "";
|
||||
|
||||
return (
|
||||
<WorkspaceScreen
|
||||
serverId={typeof params.serverId === "string" ? params.serverId : ""}
|
||||
workspaceId={typeof params.workspaceId === "string" ? params.workspaceId : ""}
|
||||
routeTab={terminalId ? { kind: "terminal", terminalId } : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -82,7 +82,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
|
||||
import { buildHostWorkspaceFileTabRoute } from "@/utils/host-routes";
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
@@ -213,7 +213,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
}
|
||||
|
||||
if (normalized.file) {
|
||||
const route = buildHostWorkspaceFileRoute(
|
||||
const route = buildHostWorkspaceFileTabRoute(
|
||||
resolvedServerId,
|
||||
workspaceId,
|
||||
normalized.file
|
||||
|
||||
@@ -19,9 +19,7 @@ import { getIsTauri } from '@/constants/layout'
|
||||
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
parseHostWorkspaceAgentRouteFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
parseHostWorkspaceTerminalRouteFromPathname,
|
||||
} from '@/utils/host-routes'
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
@@ -516,10 +514,7 @@ export function SidebarAgentList({
|
||||
if (!pathname) {
|
||||
return null
|
||||
}
|
||||
const parsed =
|
||||
parseHostWorkspaceAgentRouteFromPathname(pathname) ??
|
||||
parseHostWorkspaceTerminalRouteFromPathname(pathname) ??
|
||||
parseHostWorkspaceRouteFromPathname(pathname)
|
||||
const parsed = parseHostWorkspaceRouteFromPathname(pathname)
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("keyboard-shortcut-routing", () => {
|
||||
it("allows the shortcut on selected-agent routes", () => {
|
||||
const canToggle = canToggleFileExplorerShortcut({
|
||||
selectedAgentId: "server-1:agent-1",
|
||||
pathname: "/h/server-1/workspace/workspace-1/agent/agent-1",
|
||||
pathname: "/h/server-1/workspace/workspace-1/tab/agent_agent-1",
|
||||
toggleFileExplorer,
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("keyboard-shortcut-routing", () => {
|
||||
|
||||
it("allows the shortcut on workspace terminal routes", () => {
|
||||
const canToggle = canToggleFileExplorerShortcut({
|
||||
pathname: "/h/server-1/workspace/workspace-1/terminal/terminal-1",
|
||||
pathname: "/h/server-1/workspace/workspace-1/tab/terminal_terminal-1",
|
||||
toggleFileExplorer,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
parseHostAgentDraftRouteFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseHostWorkspaceAgentRouteFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
parseHostWorkspaceTabRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
@@ -28,9 +27,7 @@ export function resolveSelectedOrRouteAgentKey(input: {
|
||||
}
|
||||
}
|
||||
|
||||
const route =
|
||||
parseHostWorkspaceAgentRouteFromPathname(input.pathname) ??
|
||||
parseHostAgentRouteFromPathname(input.pathname);
|
||||
const route = parseHostAgentRouteFromPathname(input.pathname);
|
||||
if (!route) {
|
||||
const draftRoute = parseHostAgentDraftRouteFromPathname(input.pathname);
|
||||
if (!draftRoute) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { LayoutChangeEvent } from "react-native";
|
||||
import { useMemo } from "react";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import {
|
||||
computeWorkspaceTabLayout,
|
||||
type WorkspaceTabLayoutResult,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
type UseWorkspaceTabLayoutInput = {
|
||||
tabLabels: string[];
|
||||
metrics: {
|
||||
rowHorizontalInset: number;
|
||||
actionsReservedWidth: number;
|
||||
rowPaddingHorizontal: number;
|
||||
tabGap: number;
|
||||
maxTabWidth: number;
|
||||
@@ -20,36 +22,22 @@ type UseWorkspaceTabLayoutInput = {
|
||||
|
||||
type UseWorkspaceTabLayoutResult = {
|
||||
layout: WorkspaceTabLayoutResult;
|
||||
onContainerLayout: (event: LayoutChangeEvent) => void;
|
||||
onActionsLayout: (event: LayoutChangeEvent) => void;
|
||||
};
|
||||
|
||||
export function useWorkspaceTabLayout(input: UseWorkspaceTabLayoutInput): UseWorkspaceTabLayoutResult {
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [actionsWidth, setActionsWidth] = useState(0);
|
||||
|
||||
const onContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setContainerWidth(event.nativeEvent.layout.width);
|
||||
}, []);
|
||||
|
||||
const onActionsLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setActionsWidth(event.nativeEvent.layout.width);
|
||||
}, []);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
computeWorkspaceTabLayout({
|
||||
containerWidth,
|
||||
actionsWidth,
|
||||
viewportWidth,
|
||||
tabLabelLengths: input.tabLabels.map((label) => label.length),
|
||||
metrics: input.metrics,
|
||||
}),
|
||||
[actionsWidth, containerWidth, input.metrics, input.tabLabels]
|
||||
[input.metrics, input.tabLabels, viewportWidth]
|
||||
);
|
||||
|
||||
return {
|
||||
layout,
|
||||
onContainerLayout,
|
||||
onActionsLayout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ export function WorkspaceDesktopTabsRow({
|
||||
|
||||
const layoutMetrics = useMemo(
|
||||
() => ({
|
||||
rowHorizontalInset: 0,
|
||||
actionsReservedWidth: 120,
|
||||
rowPaddingHorizontal: theme.spacing[2],
|
||||
tabGap: theme.spacing[1],
|
||||
maxTabWidth: 260,
|
||||
@@ -87,13 +89,13 @@ export function WorkspaceDesktopTabsRow({
|
||||
[theme.spacing]
|
||||
);
|
||||
|
||||
const { layout, onContainerLayout, onActionsLayout } = useWorkspaceTabLayout({
|
||||
const { layout } = useWorkspaceTabLayout({
|
||||
tabLabels: tabs.map((tab) => tab.label),
|
||||
metrics: layoutMetrics,
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.tabsContainer} testID="workspace-tabs-row" onLayout={onContainerLayout}>
|
||||
<View style={styles.tabsContainer} testID="workspace-tabs-row">
|
||||
<ScrollView
|
||||
horizontal
|
||||
scrollEnabled={layout.shouldScroll}
|
||||
@@ -304,7 +306,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
<View style={styles.tabsActions} onLayout={onActionsLayout}>
|
||||
<View style={styles.tabsActions}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
testID="workspace-new-agent-tab"
|
||||
|
||||
@@ -59,10 +59,7 @@ import {
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceFileRoute,
|
||||
buildHostWorkspaceTabRoute,
|
||||
buildHostWorkspaceTerminalRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
} from "@/utils/host-routes";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
@@ -98,12 +95,9 @@ const EMPTY_WORKSPACE_TABS: WorkspaceTab[] = [];
|
||||
|
||||
type TabAvailability = "available" | "invalid" | "unknown";
|
||||
|
||||
type RouteTabTarget = WorkspaceTabTarget | null;
|
||||
|
||||
type WorkspaceScreenProps = {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
routeTab: RouteTabTarget;
|
||||
routeTabId?: string | null;
|
||||
};
|
||||
|
||||
@@ -189,98 +183,6 @@ function formatProviderLabel(provider: Agent["provider"]): string {
|
||||
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
}
|
||||
|
||||
function normalizeWorkspaceTab(
|
||||
value: WorkspaceTabTarget | null | undefined
|
||||
): WorkspaceTabTarget | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (value.kind === "draft") {
|
||||
const draftId = trimNonEmpty(decodeSegment(value.draftId));
|
||||
if (!draftId) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "draft", draftId };
|
||||
}
|
||||
if (value.kind === "agent") {
|
||||
const agentId = trimNonEmpty(decodeSegment(value.agentId));
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "agent", agentId };
|
||||
}
|
||||
if (value.kind === "terminal") {
|
||||
const terminalId = trimNonEmpty(decodeSegment(value.terminalId));
|
||||
if (!terminalId) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "terminal", terminalId };
|
||||
}
|
||||
if (value.kind === "file") {
|
||||
const path = trimNonEmpty(value.path);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "file", path: path.replace(/\\/g, "/") };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tabEquals(left: WorkspaceTabTarget | null, right: WorkspaceTabTarget | null): boolean {
|
||||
if (!left || !right) {
|
||||
return left === right;
|
||||
}
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
if (left.kind === "draft" && right.kind === "draft") {
|
||||
return left.draftId === right.draftId;
|
||||
}
|
||||
if (left.kind === "agent" && right.kind === "agent") {
|
||||
return left.agentId === right.agentId;
|
||||
}
|
||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||
return left.terminalId === right.terminalId;
|
||||
}
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildTabRoute(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tab: WorkspaceTabTarget;
|
||||
}): string {
|
||||
if (input.tab.kind === "draft") {
|
||||
return buildHostWorkspaceTabRoute(
|
||||
input.serverId,
|
||||
input.workspaceId,
|
||||
input.tab.draftId
|
||||
);
|
||||
}
|
||||
if (input.tab.kind === "agent") {
|
||||
return buildHostWorkspaceAgentRoute(
|
||||
input.serverId,
|
||||
input.workspaceId,
|
||||
input.tab.agentId
|
||||
);
|
||||
}
|
||||
if (input.tab.kind === "file") {
|
||||
return buildHostWorkspaceFileRoute(
|
||||
input.serverId,
|
||||
input.workspaceId,
|
||||
input.tab.path
|
||||
);
|
||||
}
|
||||
return buildHostWorkspaceTerminalRoute(
|
||||
input.serverId,
|
||||
input.workspaceId,
|
||||
input.tab.terminalId
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTabAvailability(input: {
|
||||
tab: WorkspaceTabTarget;
|
||||
agentsHydrated: boolean;
|
||||
@@ -317,25 +219,9 @@ function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] {
|
||||
});
|
||||
}
|
||||
|
||||
function toWorkspaceTabTarget(
|
||||
tab: WorkspaceTabDescriptor
|
||||
): WorkspaceTabTarget {
|
||||
if (tab.kind === "draft") {
|
||||
return { kind: "draft", draftId: tab.draftId };
|
||||
}
|
||||
if (tab.kind === "agent") {
|
||||
return { kind: "agent", agentId: tab.agentId };
|
||||
}
|
||||
if (tab.kind === "file") {
|
||||
return { kind: "file", path: tab.filePath };
|
||||
}
|
||||
return { kind: "terminal", terminalId: tab.terminalId };
|
||||
}
|
||||
|
||||
export function WorkspaceScreen({
|
||||
serverId,
|
||||
workspaceId,
|
||||
routeTab,
|
||||
routeTabId,
|
||||
}: WorkspaceScreenProps) {
|
||||
return (
|
||||
@@ -343,7 +229,6 @@ export function WorkspaceScreen({
|
||||
<WorkspaceScreenContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
routeTab={routeTab}
|
||||
routeTabId={routeTabId}
|
||||
/>
|
||||
</ExplorerSidebarAnimationProvider>
|
||||
@@ -353,7 +238,6 @@ export function WorkspaceScreen({
|
||||
function WorkspaceScreenContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
routeTab,
|
||||
routeTabId,
|
||||
}: WorkspaceScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -632,11 +516,6 @@ function WorkspaceScreenContent({
|
||||
return set;
|
||||
}, [terminals]);
|
||||
|
||||
const requestedTab = useMemo(
|
||||
() => normalizeWorkspaceTab(routeTab),
|
||||
[routeTab]
|
||||
);
|
||||
|
||||
const persistenceKey = useMemo(
|
||||
() =>
|
||||
buildWorkspaceTabPersistenceKey({
|
||||
@@ -772,31 +651,6 @@ function WorkspaceScreenContent({
|
||||
routeTabId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedTab || !persistenceKey) {
|
||||
return;
|
||||
}
|
||||
const tabId = openOrFocusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
target: requestedTab,
|
||||
});
|
||||
if (tabId) {
|
||||
focusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
focusTab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
openOrFocusTab,
|
||||
persistenceKey,
|
||||
requestedTab,
|
||||
]);
|
||||
|
||||
const activeTabId = useMemo(() => {
|
||||
const fromRoute =
|
||||
typeof routeTabId === "string" ? trimNonEmpty(routeTabId.trim()) : null;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import { computeWorkspaceTabLayout } from "@/screens/workspace/workspace-tab-layout";
|
||||
|
||||
const metrics = {
|
||||
rowHorizontalInset: 0,
|
||||
actionsReservedWidth: 120,
|
||||
rowPaddingHorizontal: 8,
|
||||
tabGap: 4,
|
||||
maxTabWidth: 260,
|
||||
@@ -14,8 +16,7 @@ const metrics = {
|
||||
describe("computeWorkspaceTabLayout", () => {
|
||||
it("keeps full width tabs when space is available", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
containerWidth: 1200,
|
||||
actionsWidth: 100,
|
||||
viewportWidth: 1200,
|
||||
tabLabelLengths: [8, 10, 7],
|
||||
metrics,
|
||||
});
|
||||
@@ -28,8 +29,7 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
|
||||
it("shrinks proportionally in compact mode before icon-only", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
containerWidth: 500,
|
||||
actionsWidth: 110,
|
||||
viewportWidth: 500,
|
||||
tabLabelLengths: [24, 12, 8],
|
||||
metrics,
|
||||
});
|
||||
@@ -43,8 +43,7 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
|
||||
it("falls back to icon mode when compact labels still cannot fit", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
containerWidth: 300,
|
||||
actionsWidth: 120,
|
||||
viewportWidth: 300,
|
||||
tabLabelLengths: [14, 14, 14, 14],
|
||||
metrics,
|
||||
});
|
||||
@@ -57,8 +56,7 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
|
||||
it("keeps icon mode without scroll when icons can fit", () => {
|
||||
const result = computeWorkspaceTabLayout({
|
||||
containerWidth: 380,
|
||||
actionsWidth: 120,
|
||||
viewportWidth: 380,
|
||||
tabLabelLengths: [20, 20, 20, 20],
|
||||
metrics,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export type WorkspaceTabLayoutMode = "full" | "compact" | "icon";
|
||||
|
||||
export type WorkspaceTabLayoutInput = {
|
||||
containerWidth: number;
|
||||
actionsWidth: number;
|
||||
viewportWidth: number;
|
||||
tabLabelLengths: number[];
|
||||
metrics: {
|
||||
rowHorizontalInset: number;
|
||||
actionsReservedWidth: number;
|
||||
rowPaddingHorizontal: number;
|
||||
tabGap: number;
|
||||
maxTabWidth: number;
|
||||
@@ -70,7 +71,10 @@ export function computeWorkspaceTabLayout(
|
||||
};
|
||||
}
|
||||
|
||||
const availableWidth = Math.max(0, input.containerWidth - input.actionsWidth);
|
||||
const availableWidth = Math.max(
|
||||
0,
|
||||
input.viewportWidth - input.metrics.rowHorizontalInset * 2 - input.metrics.actionsReservedWidth
|
||||
);
|
||||
const minLabelWidth =
|
||||
input.metrics.tabBaseWidthWithClose + input.metrics.charWidth * input.metrics.minLabelChars;
|
||||
|
||||
|
||||
@@ -128,7 +128,10 @@ function createControllerHarness(input?: {
|
||||
client,
|
||||
getPreferredSize: () => ({ rows: 24, cols: 80 }),
|
||||
onChunk: (chunk) => {
|
||||
chunks.push(chunk);
|
||||
chunks.push({
|
||||
terminalId: chunk.terminalId,
|
||||
text: chunk.text,
|
||||
});
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
statuses.push(status);
|
||||
@@ -212,6 +215,7 @@ describe("terminal-stream-controller", () => {
|
||||
|
||||
expect(harness.client.attachCalls.length).toBe(2);
|
||||
expect(harness.client.attachCalls[1]?.options).toEqual({
|
||||
resumeOffset: 0,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
@@ -268,6 +272,38 @@ describe("terminal-stream-controller", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers when stream exits before initial attach completes", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 1,
|
||||
currentOffset: 1200,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 2,
|
||||
currentOffset: 1200,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-attach-exit" });
|
||||
harness.controller.handleStreamExit({
|
||||
terminalId: "term-attach-exit",
|
||||
streamId: 1,
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.client.attachCalls).toHaveLength(2);
|
||||
expect(harness.client.attachCalls[1]?.options).toEqual({
|
||||
resumeOffset: 1200,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
expect(harness.client.detachCalls).toContain(1);
|
||||
expect(harness.controller.getActiveStreamId()).toBe(2);
|
||||
});
|
||||
|
||||
it("emits reset callback when attach indicates output reset", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
|
||||
@@ -85,6 +85,7 @@ const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…"
|
||||
|
||||
export class TerminalStreamController {
|
||||
private readonly resumeOffsetByTerminalId = new Map<string, number>();
|
||||
private readonly exitedStreamsWhileAttaching = new Set<number>();
|
||||
private selectedTerminalId: string | null = null;
|
||||
private activeStream: TerminalStreamControllerActiveStream | null = null;
|
||||
private attachGeneration = 0;
|
||||
@@ -167,31 +168,36 @@ export class TerminalStreamController {
|
||||
},
|
||||
});
|
||||
|
||||
const activeStream = this.activeStream;
|
||||
if (!activeStream) {
|
||||
return;
|
||||
}
|
||||
if (activeStream.terminalId !== input.terminalId || activeStream.streamId !== input.streamId) {
|
||||
return;
|
||||
}
|
||||
if (this.selectedTerminalId !== input.terminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.attachGeneration += 1;
|
||||
const generation = this.attachGeneration;
|
||||
void this.detachActiveStream({ shouldDetach: false });
|
||||
this.updateStatus({
|
||||
terminalId: input.terminalId,
|
||||
streamId: null,
|
||||
isAttaching: true,
|
||||
error:
|
||||
this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
|
||||
});
|
||||
void this.attachTerminal({
|
||||
terminalId: input.terminalId,
|
||||
generation,
|
||||
});
|
||||
const activeStream = this.activeStream;
|
||||
const activeMatches =
|
||||
Boolean(activeStream) &&
|
||||
activeStream!.terminalId === input.terminalId &&
|
||||
activeStream!.streamId === input.streamId;
|
||||
if (activeMatches) {
|
||||
this.attachGeneration += 1;
|
||||
const generation = this.attachGeneration;
|
||||
void this.detachActiveStream({ shouldDetach: false });
|
||||
this.updateStatus({
|
||||
terminalId: input.terminalId,
|
||||
streamId: null,
|
||||
isAttaching: true,
|
||||
error:
|
||||
this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
|
||||
});
|
||||
void this.attachTerminal({
|
||||
terminalId: input.terminalId,
|
||||
generation,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.status.isAttaching && this.status.terminalId === input.terminalId) {
|
||||
this.exitedStreamsWhileAttaching.add(input.streamId);
|
||||
}
|
||||
}
|
||||
|
||||
pruneResumeOffsets(input: { terminalIds: string[] }): void {
|
||||
@@ -267,6 +273,12 @@ export class TerminalStreamController {
|
||||
timeoutMessage: "Timed out attaching terminal stream",
|
||||
});
|
||||
|
||||
updateTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
offset: attachPayload.currentOffset,
|
||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||
});
|
||||
|
||||
if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) {
|
||||
if (typeof attachPayload.streamId === "number") {
|
||||
void this.options.client.detachTerminalStream(attachPayload.streamId).catch(() => {});
|
||||
@@ -300,17 +312,22 @@ export class TerminalStreamController {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.exitedStreamsWhileAttaching.has(attachPayload.streamId)) {
|
||||
this.exitedStreamsWhileAttaching.delete(attachPayload.streamId);
|
||||
void this.options.client.detachTerminalStream(attachPayload.streamId).catch(() => {});
|
||||
this.attachGeneration += 1;
|
||||
void this.attachTerminal({
|
||||
terminalId: input.terminalId,
|
||||
generation: this.attachGeneration,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachPayload.reset) {
|
||||
this.resumeOffsetByTerminalId.delete(input.terminalId);
|
||||
this.options.onReset?.({ terminalId: input.terminalId });
|
||||
}
|
||||
|
||||
updateTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
offset: attachPayload.currentOffset,
|
||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||
});
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const streamId = attachPayload.streamId;
|
||||
const replayedFromOffset =
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHostWorkspaceAgentTabRoute,
|
||||
buildHostWorkspaceFileTabRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildHostWorkspaceFileRoute,
|
||||
buildHostWorkspaceTerminalTabRoute,
|
||||
decodeFilePathFromPathSegment,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
encodeFilePathForPathSegment,
|
||||
@@ -9,10 +11,7 @@ import {
|
||||
parseHostAgentDraftRouteFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseHostDraftRouteFromPathname,
|
||||
parseHostWorkspaceAgentRouteFromPathname,
|
||||
parseHostWorkspaceFileRouteFromPathname,
|
||||
parseHostWorkspaceTabRouteFromPathname,
|
||||
parseHostWorkspaceTerminalRouteFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "./host-routes";
|
||||
|
||||
@@ -24,9 +23,7 @@ describe("parseHostAgentDraftRouteFromPathname", () => {
|
||||
});
|
||||
|
||||
it("parses encoded server id", () => {
|
||||
expect(
|
||||
parseHostAgentDraftRouteFromPathname("/h/team%20host/new")
|
||||
).toEqual({
|
||||
expect(parseHostAgentDraftRouteFromPathname("/h/team%20host/new")).toEqual({
|
||||
serverId: "team host",
|
||||
});
|
||||
});
|
||||
@@ -66,9 +63,7 @@ describe("workspace route parsing", () => {
|
||||
});
|
||||
|
||||
it("parses workspace route", () => {
|
||||
expect(
|
||||
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv")
|
||||
).toEqual({
|
||||
expect(parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv")).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
});
|
||||
@@ -83,48 +78,9 @@ describe("workspace route parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace file route", () => {
|
||||
const encodedPath = encodeFilePathForPathSegment("src/index.ts");
|
||||
expect(
|
||||
parseHostWorkspaceFileRouteFromPathname(
|
||||
`/h/local/workspace/L3RtcC9yZXBv/file/${encodedPath}`
|
||||
)
|
||||
).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
filePath: "src/index.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace agent route", () => {
|
||||
expect(
|
||||
parseHostWorkspaceAgentRouteFromPathname(
|
||||
"/h/local/workspace/L3RtcC9yZXBv/agent/agent-1"
|
||||
)
|
||||
).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace terminal route", () => {
|
||||
expect(
|
||||
parseHostWorkspaceTerminalRouteFromPathname(
|
||||
"/h/local/workspace/L3RtcC9yZXBv/terminal/term-1"
|
||||
)
|
||||
).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
terminalId: "term-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace tab route", () => {
|
||||
expect(
|
||||
parseHostWorkspaceTabRouteFromPathname(
|
||||
"/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123"
|
||||
)
|
||||
parseHostWorkspaceTabRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123")
|
||||
).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
@@ -132,27 +88,25 @@ describe("workspace route parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("still parses legacy percent-encoded workspace routes", () => {
|
||||
expect(
|
||||
parseHostWorkspaceAgentRouteFromPathname(
|
||||
"/h/local/workspace/%2Ftmp%2Frepo/agent/agent-1"
|
||||
)
|
||||
).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
it("builds base64url workspace routes", () => {
|
||||
expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe("/h/local/workspace/L3RtcC9yZXBv");
|
||||
});
|
||||
|
||||
it("builds base64url workspace routes", () => {
|
||||
expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv"
|
||||
it("builds workspace agent tab routes", () => {
|
||||
expect(buildHostWorkspaceAgentTabRoute("local", "/tmp/repo", "agent-1")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv/tab/agent_agent-1"
|
||||
);
|
||||
});
|
||||
|
||||
it("builds base64url workspace file routes", () => {
|
||||
expect(buildHostWorkspaceFileRoute("local", "/tmp/repo", "src/index.ts")).toBe(
|
||||
`/h/local/workspace/L3RtcC9yZXBv/file/${encodeFilePathForPathSegment("src/index.ts")}`
|
||||
it("builds workspace terminal tab routes", () => {
|
||||
expect(buildHostWorkspaceTerminalTabRoute("local", "/tmp/repo", "term-1")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv/tab/terminal_term-1"
|
||||
);
|
||||
});
|
||||
|
||||
it("builds workspace file tab routes", () => {
|
||||
expect(buildHostWorkspaceFileTabRoute("local", "/tmp/repo", "src/index.ts")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv/tab/file_src%2Findex.ts"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,21 @@ function normalizeWorkspaceId(value: string): string {
|
||||
return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function buildWorkspaceAgentTabId(agentId: string): string | null {
|
||||
const normalized = trimNonEmpty(agentId);
|
||||
return normalized ? `agent_${normalized}` : null;
|
||||
}
|
||||
|
||||
function buildWorkspaceTerminalTabId(terminalId: string): string | null {
|
||||
const normalized = trimNonEmpty(terminalId);
|
||||
return normalized ? `terminal_${normalized}` : null;
|
||||
}
|
||||
|
||||
function buildWorkspaceFileTabId(filePath: string): string | null {
|
||||
const normalized = trimNonEmpty(filePath);
|
||||
return normalized ? `file_${normalized.replace(/\\/g, "/")}` : null;
|
||||
}
|
||||
|
||||
export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
|
||||
const normalized = trimNonEmpty(workspaceId);
|
||||
if (!normalized) {
|
||||
@@ -222,22 +237,9 @@ export function parseHostWorkspaceRouteFromPathname(
|
||||
|
||||
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
||||
let workspaceIdEnd = pathname.length;
|
||||
|
||||
const agentIdx = pathname.lastIndexOf("/agent/");
|
||||
if (agentIdx >= 0 && agentIdx > workspaceIdStart) {
|
||||
workspaceIdEnd = Math.min(workspaceIdEnd, agentIdx);
|
||||
}
|
||||
const terminalIdx = pathname.lastIndexOf("/terminal/");
|
||||
if (terminalIdx >= 0 && terminalIdx > workspaceIdStart) {
|
||||
workspaceIdEnd = Math.min(workspaceIdEnd, terminalIdx);
|
||||
}
|
||||
const tabIdx = pathname.lastIndexOf("/tab/");
|
||||
if (tabIdx >= 0 && tabIdx > workspaceIdStart) {
|
||||
workspaceIdEnd = Math.min(workspaceIdEnd, tabIdx);
|
||||
}
|
||||
const fileIdx = pathname.lastIndexOf("/file/");
|
||||
if (fileIdx >= 0 && fileIdx > workspaceIdStart) {
|
||||
workspaceIdEnd = Math.min(workspaceIdEnd, fileIdx);
|
||||
workspaceIdEnd = tabIdx;
|
||||
}
|
||||
|
||||
const rawWorkspaceId = pathname.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, "");
|
||||
@@ -248,106 +250,6 @@ export function parseHostWorkspaceRouteFromPathname(
|
||||
return { serverId, workspaceId };
|
||||
}
|
||||
|
||||
export function parseHostWorkspaceAgentRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; workspaceId: string; agentId: string } | null {
|
||||
const prefix = "/h/";
|
||||
if (!pathname.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverIdStart = prefix.length;
|
||||
const serverIdEnd = pathname.indexOf("/", serverIdStart);
|
||||
if (serverIdEnd < 0) {
|
||||
return null;
|
||||
}
|
||||
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
|
||||
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspacePrefix = "/workspace/";
|
||||
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
||||
const agentMarker = "/agent/";
|
||||
const agentIdx = pathname.lastIndexOf(agentMarker);
|
||||
if (agentIdx < 0 || agentIdx <= workspaceIdStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawWorkspaceId = pathname.slice(workspaceIdStart, agentIdx).replace(/\/+$/, "");
|
||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const agentIdStart = agentIdx + agentMarker.length;
|
||||
const agentIdEnd = pathname.indexOf("/", agentIdStart);
|
||||
const rawAgentId =
|
||||
agentIdEnd < 0 ? pathname.slice(agentIdStart) : pathname.slice(agentIdStart, agentIdEnd);
|
||||
const agentId = trimNonEmpty(decodeSegment(rawAgentId));
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, workspaceId, agentId };
|
||||
}
|
||||
|
||||
export function parseHostWorkspaceTerminalRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; workspaceId: string; terminalId: string } | null {
|
||||
const prefix = "/h/";
|
||||
if (!pathname.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverIdStart = prefix.length;
|
||||
const serverIdEnd = pathname.indexOf("/", serverIdStart);
|
||||
if (serverIdEnd < 0) {
|
||||
return null;
|
||||
}
|
||||
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
|
||||
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspacePrefix = "/workspace/";
|
||||
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
||||
const terminalMarker = "/terminal/";
|
||||
const terminalIdx = pathname.lastIndexOf(terminalMarker);
|
||||
if (terminalIdx < 0 || terminalIdx <= workspaceIdStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawWorkspaceId = pathname.slice(workspaceIdStart, terminalIdx).replace(/\/+$/, "");
|
||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const terminalIdStart = terminalIdx + terminalMarker.length;
|
||||
const terminalIdEnd = pathname.indexOf("/", terminalIdStart);
|
||||
const rawTerminalId =
|
||||
terminalIdEnd < 0
|
||||
? pathname.slice(terminalIdStart)
|
||||
: pathname.slice(terminalIdStart, terminalIdEnd);
|
||||
const terminalId = trimNonEmpty(decodeSegment(rawTerminalId));
|
||||
if (!terminalId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, workspaceId, terminalId };
|
||||
}
|
||||
|
||||
export function parseHostWorkspaceTabRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; workspaceId: string; tabId: string } | null {
|
||||
@@ -397,55 +299,6 @@ export function parseHostWorkspaceTabRouteFromPathname(
|
||||
return { serverId, workspaceId, tabId };
|
||||
}
|
||||
|
||||
export function parseHostWorkspaceFileRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; workspaceId: string; filePath: string } | null {
|
||||
const prefix = "/h/";
|
||||
if (!pathname.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverIdStart = prefix.length;
|
||||
const serverIdEnd = pathname.indexOf("/", serverIdStart);
|
||||
if (serverIdEnd < 0) {
|
||||
return null;
|
||||
}
|
||||
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
|
||||
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspacePrefix = "/workspace/";
|
||||
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
||||
const fileMarker = "/file/";
|
||||
const fileIdx = pathname.lastIndexOf(fileMarker);
|
||||
if (fileIdx < 0 || fileIdx <= workspaceIdStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawWorkspaceId = pathname.slice(workspaceIdStart, fileIdx).replace(/\/+$/, "");
|
||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePathStart = fileIdx + fileMarker.length;
|
||||
const filePathEnd = pathname.indexOf("/", filePathStart);
|
||||
const rawFilePath =
|
||||
filePathEnd < 0 ? pathname.slice(filePathStart) : pathname.slice(filePathStart, filePathEnd);
|
||||
const filePath = decodeFilePathFromPathSegment(rawFilePath);
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, workspaceId, filePath };
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceRoute(
|
||||
serverId: string,
|
||||
workspaceId: string
|
||||
@@ -462,30 +315,28 @@ export function buildHostWorkspaceRoute(
|
||||
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceAgentRoute(
|
||||
export function buildHostWorkspaceAgentTabRoute(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
agentId: string
|
||||
): string {
|
||||
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
if (base === "/" || !normalizedAgentId) {
|
||||
const tabId = buildWorkspaceAgentTabId(agentId);
|
||||
if (!tabId) {
|
||||
return "/";
|
||||
}
|
||||
return `${base}/agent/${encodeSegment(normalizedAgentId)}`;
|
||||
return buildHostWorkspaceTabRoute(serverId, workspaceId, tabId);
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceTerminalRoute(
|
||||
export function buildHostWorkspaceTerminalTabRoute(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
terminalId: string
|
||||
): string {
|
||||
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
const normalizedTerminalId = trimNonEmpty(terminalId);
|
||||
if (base === "/" || !normalizedTerminalId) {
|
||||
const tabId = buildWorkspaceTerminalTabId(terminalId);
|
||||
if (!tabId) {
|
||||
return "/";
|
||||
}
|
||||
return `${base}/terminal/${encodeSegment(normalizedTerminalId)}`;
|
||||
return buildHostWorkspaceTabRoute(serverId, workspaceId, tabId);
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceTabRoute(
|
||||
@@ -501,17 +352,16 @@ export function buildHostWorkspaceTabRoute(
|
||||
return `${base}/tab/${encodeSegment(normalizedTabId)}`;
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceFileRoute(
|
||||
export function buildHostWorkspaceFileTabRoute(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
filePath: string
|
||||
): string {
|
||||
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
const encodedFilePath = encodeFilePathForPathSegment(filePath);
|
||||
if (base === "/" || !encodedFilePath) {
|
||||
const tabId = buildWorkspaceFileTabId(filePath);
|
||||
if (!tabId) {
|
||||
return "/";
|
||||
}
|
||||
return `${base}/file/${encodeSegment(encodedFilePath)}`;
|
||||
return buildHostWorkspaceTabRoute(serverId, workspaceId, tabId);
|
||||
}
|
||||
|
||||
export function buildHostAgentDetailRoute(
|
||||
@@ -521,7 +371,7 @@ export function buildHostAgentDetailRoute(
|
||||
): string {
|
||||
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
|
||||
if (normalizedWorkspaceId) {
|
||||
return buildHostWorkspaceAgentRoute(
|
||||
return buildHostWorkspaceAgentTabRoute(
|
||||
serverId,
|
||||
normalizedWorkspaceId,
|
||||
agentId
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("resolveSelectedAgentForNewAgent", () => {
|
||||
it("prefers the agent in the current route", () => {
|
||||
expect(
|
||||
resolveSelectedAgentForNewAgent({
|
||||
pathname: "/h/srv-1/workspace/%2Frepo/agent/agent-2",
|
||||
pathname: "/h/srv-1/workspace/%2Frepo/tab/agent_agent-2",
|
||||
selectedAgentId: "srv-9:agent-9",
|
||||
})
|
||||
).toEqual({
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||
import {
|
||||
buildHostAgentDraftRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseHostWorkspaceAgentRouteFromPathname,
|
||||
parseHostWorkspaceTabRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export function parseAgentKey(
|
||||
@@ -27,9 +27,12 @@ export function resolveSelectedAgentForNewAgent(input: {
|
||||
pathname: string;
|
||||
selectedAgentId?: string;
|
||||
}): { serverId: string; agentId: string } | null {
|
||||
const workspaceRoute = parseHostWorkspaceAgentRouteFromPathname(input.pathname);
|
||||
if (workspaceRoute) {
|
||||
return { serverId: workspaceRoute.serverId, agentId: workspaceRoute.agentId };
|
||||
const workspaceTabRoute = parseHostWorkspaceTabRouteFromPathname(input.pathname);
|
||||
if (workspaceTabRoute?.tabId?.startsWith("agent_")) {
|
||||
const agentId = workspaceTabRoute.tabId.slice("agent_".length).trim();
|
||||
if (agentId) {
|
||||
return { serverId: workspaceTabRoute.serverId, agentId };
|
||||
}
|
||||
}
|
||||
return (
|
||||
parseHostAgentRouteFromPathname(input.pathname) ??
|
||||
|
||||
@@ -2802,6 +2802,23 @@ export class DaemonClient {
|
||||
const startedAtMs = getNowMs()
|
||||
const rawData =
|
||||
data && typeof data === 'object' && 'data' in data ? (data as { data: unknown }).data : data
|
||||
|
||||
if (
|
||||
typeof Blob !== 'undefined' &&
|
||||
rawData instanceof Blob &&
|
||||
typeof rawData.arrayBuffer === 'function'
|
||||
) {
|
||||
void rawData
|
||||
.arrayBuffer()
|
||||
.then((buffer) => {
|
||||
this.handleTransportMessage(buffer)
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore failed blob decoding and allow reconnect logic to recover.
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const rawBytes = asUint8Array(rawData)
|
||||
if (rawBytes) {
|
||||
const frame = decodeBinaryMuxFrame(rawBytes)
|
||||
|
||||
@@ -6755,7 +6755,11 @@ export class Session {
|
||||
}
|
||||
|
||||
const streamId = this.allocateTerminalStreamId()
|
||||
const initialOffset = Math.max(0, Math.floor(msg.resumeOffset ?? 0))
|
||||
const requestedResumeOffset =
|
||||
typeof msg.resumeOffset === 'number'
|
||||
? msg.resumeOffset
|
||||
: session.getOutputOffset()
|
||||
const initialOffset = Math.max(0, Math.floor(requestedResumeOffset))
|
||||
const binding: {
|
||||
terminalId: string
|
||||
unsubscribe: () => void
|
||||
@@ -6789,7 +6793,7 @@ export class Session {
|
||||
replay: chunk.replay,
|
||||
})
|
||||
},
|
||||
{ fromOffset: msg.resumeOffset ?? 0 }
|
||||
{ fromOffset: requestedResumeOffset }
|
||||
)
|
||||
} catch (error) {
|
||||
this.terminalStreams.delete(streamId)
|
||||
|
||||
Reference in New Issue
Block a user