feat: unify workspace routing through tab-based URLs and remove legacy per-type routes

This commit is contained in:
Mohamed Boudra
2026-03-03 21:14:15 +07:00
parent 74ac17f910
commit f3796e2ba5
25 changed files with 204 additions and 569 deletions
+1 -11
View File
@@ -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);
+2 -2
View File
@@ -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
@@ -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 =
+20 -66
View File
@@ -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"
);
});
});
+29 -179
View File
@@ -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({
+7 -4
View File
@@ -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)
+6 -2
View File
@@ -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)