Fix workspace tab focus from route open intent

This commit is contained in:
Mohamed Boudra
2026-03-06 16:18:19 +07:00
parent 885b575d14
commit 74c99a827d
10 changed files with 318 additions and 34 deletions
+6 -4
View File
@@ -1,6 +1,6 @@
import "@/styles/unistyles";
import { polyfillCrypto } from "@/polyfills/crypto";
import { Stack, usePathname, useRouter } from "expo-router";
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
@@ -47,7 +47,7 @@ import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
parseHostAgentRouteFromPathname,
parseHostWorkspaceOpenIntentFromPathname,
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
@@ -376,6 +376,7 @@ function OfferLinkListener({
function AppWithSidebar({ children }: { children: ReactNode }) {
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
useFaviconStatus();
// Parse selectedAgentKey directly from pathname
@@ -383,7 +384,8 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const selectedAgentKey = useMemo(() => {
const workspaceMatch = pathname.match(/^\/h\/([^/]+)\/workspace\/[^/]+(?:\/|$)/);
const workspaceServerId = workspaceMatch?.[1]?.trim() ?? "";
const openIntent = parseHostWorkspaceOpenIntentFromPathname(pathname);
const openValue = Array.isArray(params.open) ? params.open[0] : params.open;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (workspaceServerId && openIntent?.kind === "agent") {
const agentId = openIntent.agentId.trim();
return agentId ? `${workspaceServerId}:${agentId}` : undefined;
@@ -391,7 +393,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const match = parseHostAgentRouteFromPathname(pathname);
return match ? `${match.serverId}:${match.agentId}` : undefined;
}, [pathname]);
}, [params.open, pathname]);
return (
<AppContainer selectedAgentId={selectedAgentKey}>{children}</AppContainer>
+3 -11
View File
@@ -1,6 +1,5 @@
import { useEffect } from "react";
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
import { Platform } from "react-native";
import { useSessionStore } from "@/stores/session-store";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import {
@@ -13,11 +12,7 @@ const HOST_ROOT_REDIRECT_DELAY_MS = 300;
export default function HostIndexRoute() {
const router = useRouter();
const routerPathname = usePathname();
const pathname =
Platform.OS === "web" && typeof window !== "undefined"
? window.location.pathname
: routerPathname;
const pathname = usePathname();
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
@@ -37,11 +32,8 @@ export default function HostIndexRoute() {
return;
}
const timer = setTimeout(() => {
if (Platform.OS === "web" && typeof window !== "undefined") {
const currentPathname = window.location.pathname;
if (currentPathname !== rootRoute && currentPathname !== `${rootRoute}/`) {
return;
}
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
return;
}
const visibleAgents = sessionAgents
@@ -1,17 +1,18 @@
import { usePathname } from 'expo-router'
import { useGlobalSearchParams, usePathname } from 'expo-router'
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
import {
parseHostWorkspaceRouteFromPathname,
parseHostWorkspaceOpenIntentFromPathname,
parseWorkspaceOpenIntent,
} from '@/utils/host-routes'
export default function HostWorkspaceLayout() {
const expoPathname = usePathname()
const resolvedPathname = expoPathname
const activeRoute = parseHostWorkspaceRouteFromPathname(resolvedPathname)
const params = useGlobalSearchParams<{ open?: string | string[] }>()
const activeRoute = parseHostWorkspaceRouteFromPathname(expoPathname)
const serverId = activeRoute?.serverId ?? ''
const workspaceId = activeRoute?.workspaceId ?? ''
const openIntent = parseHostWorkspaceOpenIntentFromPathname(resolvedPathname)
const openValue = Array.isArray(params.open) ? params.open[0] : params.open
const openIntent = parseWorkspaceOpenIntent(openValue)
return (
<WorkspaceScreen
+2 -6
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react";
import { ActivityIndicator, Platform, View } from "react-native";
import { ActivityIndicator, View } from "react-native";
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
@@ -9,11 +9,7 @@ import { buildHostRootRoute } from "@/utils/host-routes";
export default function Index() {
const router = useRouter();
const routerPathname = usePathname();
const pathname =
Platform.OS === "web" && typeof window !== "undefined"
? window.location.pathname
: routerPathname;
const pathname = usePathname();
const params = useLocalSearchParams<{ serverId?: string }>();
const { theme } = useUnistyles();
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
@@ -38,11 +38,7 @@ export function useKeyboardShortcuts({
toggleFileExplorer?: () => void;
}) {
const router = useRouter();
const routerPathname = usePathname();
const pathname =
Platform.OS === "web" && typeof window !== "undefined"
? window.location.pathname
: routerPathname;
const pathname = usePathname();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
useEffect(() => {
@@ -327,7 +323,6 @@ export function useKeyboardShortcuts({
pathname,
resetModifiers,
router,
routerPathname,
selectedAgentId,
toggleAgentList,
toggleFileExplorer,
@@ -576,8 +576,18 @@ function WorkspaceScreenContent({
tabs: uiTabs,
tabOrder,
focusedTabId,
preferredTarget:
openIntent?.kind === "agent"
? { kind: "agent", agentId: openIntent.agentId }
: openIntent?.kind === "terminal"
? { kind: "terminal", terminalId: openIntent.terminalId }
: openIntent?.kind === "draft"
? { kind: "draft", draftId: openIntent.draftId }
: openIntent?.kind === "file"
? { kind: "file", path: openIntent.path }
: null,
}),
[focusedTabId, tabOrder, terminals, uiTabs, workspaceAgents]
[focusedTabId, openIntent, tabOrder, terminals, uiTabs, workspaceAgents]
);
const activeTabId = tabModel.activeTabId;
@@ -151,6 +151,23 @@ describe("deriveWorkspaceTabModel", () => {
).toBe("agent_agent-b");
});
it("prefers the route-selected target over stale focused tab state", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [],
tabs: [
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
],
tabOrder: ["agent_agent-a", "agent_agent-b"],
focusedTabId: "agent_agent-a",
preferredTarget: { kind: "agent", agentId: "agent-b" },
});
expect(model.activeTabId).toBe("agent_agent-b");
expect(model.activeTab?.target).toEqual({ kind: "agent", agentId: "agent-b" });
});
it("re-resolves active content for a new workspace when prior focused tab is not available", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })],
@@ -237,4 +254,24 @@ describe("deriveWorkspaceTabModel", () => {
expect(upgradedDescriptor.label).toBe("Ready title");
}
});
it("prefers a retargeted tab when the route selects its upgraded agent target", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })],
terminals: [],
tabs: [
{
tabId: "draft_abc",
target: { kind: "agent", agentId: "agent-1" },
createdAt: 1,
},
],
tabOrder: ["draft_abc"],
focusedTabId: "some_other_tab",
preferredTarget: { kind: "agent", agentId: "agent-1" },
});
expect(model.activeTabId).toBe("draft_abc");
expect(model.activeTab?.descriptor.tabId).toBe("draft_abc");
});
});
@@ -26,6 +26,25 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
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 formatProviderLabel(provider: Agent["provider"]): string {
if (provider === "claude") {
return "Claude";
@@ -130,6 +149,7 @@ export function deriveWorkspaceTabModel(input: {
tabs: WorkspaceTab[];
tabOrder: string[];
focusedTabId?: string | null;
preferredTarget?: WorkspaceTabTarget | null;
}): WorkspaceTabModel {
const tabsById = new Map<string, WorkspaceDerivedTab>();
const agentsById = new Map(input.workspaceAgents.map((agent) => [agent.id, agent]));
@@ -234,9 +254,20 @@ export function deriveWorkspaceTabModel(input: {
const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId));
const focusedTabId = trimNonEmpty(input.focusedTabId);
const preferredTarget = input.preferredTarget ?? null;
const preferredTabId = (() => {
if (!preferredTarget) {
return null;
}
const matchingTab =
tabs.find((tab) => tabTargetsEqual(tab.target, preferredTarget)) ?? null;
return matchingTab?.descriptor.tabId ?? buildWorkspaceTabId(preferredTarget);
})();
const activeTabId =
focusedTabId && openTabIds.has(focusedTabId)
preferredTabId && openTabIds.has(preferredTabId)
? preferredTabId
: focusedTabId && openTabIds.has(focusedTabId)
? focusedTabId
: tabs[0]?.descriptor.tabId ?? null;