feat: unify workspace routing through tab-based URLs and remove legacy per-type routes
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user