diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index b6114f165..640d4cd32 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -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() {
-
-
();
- const agentId = typeof params.agentId === "string" ? params.agentId : "";
+ const tabId = typeof params.tabId === "string" ? params.tabId : "";
return (
);
}
diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/file/[fileId].tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/file/[fileId].tsx
deleted file mode 100644
index 0516169d7..000000000
--- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/file/[fileId].tsx
+++ /dev/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 (
-
- );
-}
-
diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx
index fe1ac5ca8..bb863288a 100644
--- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx
+++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx
@@ -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 (
-
- );
+ return null;
}
diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/tab/[tabId].tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/tab/[tabId].tsx
index 7b90cb9ec..85de1f2e1 100644
--- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/tab/[tabId].tsx
+++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/tab/[tabId].tsx
@@ -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 (
-
- );
+ return null;
}
-
diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/terminal/[terminalId].tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/terminal/[terminalId].tsx
deleted file mode 100644
index 6ce85e057..000000000
--- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/terminal/[terminalId].tsx
+++ /dev/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 (
-
- );
-}
diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx
index df7908374..58bc9b34f 100644
--- a/packages/app/src/components/agent-stream-view.tsx
+++ b/packages/app/src/components/agent-stream-view.tsx
@@ -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 {
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,
});
diff --git a/packages/app/src/keyboard/keyboard-shortcut-routing.ts b/packages/app/src/keyboard/keyboard-shortcut-routing.ts
index d393c32b2..058b87b2c 100644
--- a/packages/app/src/keyboard/keyboard-shortcut-routing.ts
+++ b/packages/app/src/keyboard/keyboard-shortcut-routing.ts
@@ -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) {
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 f76047efd..6df941d57 100644
--- a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts
+++ b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts
@@ -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,
};
}
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 49c1d3ada..848c8d07c 100644
--- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
+++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
@@ -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 (
-
+
-
+
@@ -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;
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 534808cca..208b740c5 100644
--- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts
+++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts
@@ -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,
});
diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts
index 7e9dd3385..400ee0cdc 100644
--- a/packages/app/src/screens/workspace/workspace-tab-layout.ts
+++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts
@@ -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;
diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts
index 0bcee67f0..96a450430 100644
--- a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts
+++ b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts
@@ -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({
diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts
index d768a2c6d..8b2f61536 100644
--- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts
+++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts
@@ -85,6 +85,7 @@ const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…"
export class TerminalStreamController {
private readonly resumeOffsetByTerminalId = new Map();
+ private readonly exitedStreamsWhileAttaching = new Set();
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 =
diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts
index 1db108c1a..882aac0a4 100644
--- a/packages/app/src/utils/host-routes.test.ts
+++ b/packages/app/src/utils/host-routes.test.ts
@@ -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"
);
});
});
diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts
index a47952015..76929309c 100644
--- a/packages/app/src/utils/host-routes.ts
+++ b/packages/app/src/utils/host-routes.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
diff --git a/packages/app/src/utils/new-agent-routing.test.ts b/packages/app/src/utils/new-agent-routing.test.ts
index bcc838beb..7e15b8426 100644
--- a/packages/app/src/utils/new-agent-routing.test.ts
+++ b/packages/app/src/utils/new-agent-routing.test.ts
@@ -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({
diff --git a/packages/app/src/utils/new-agent-routing.ts b/packages/app/src/utils/new-agent-routing.ts
index 6d57b1a19..7472e1977 100644
--- a/packages/app/src/utils/new-agent-routing.ts
+++ b/packages/app/src/utils/new-agent-routing.ts
@@ -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) ??
diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts
index f33a8c6f7..92acf32c7 100644
--- a/packages/server/src/client/daemon-client.ts
+++ b/packages/server/src/client/daemon-client.ts
@@ -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)
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index c18132166..148a2689e 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -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)