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)