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,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;