From 6de775695210823e64aef081f528b6ed325cf5b6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Mar 2026 10:37:43 +0700 Subject: [PATCH] feat: add workspace-aware notification routing and clean up agent screen header --- packages/app/src/components/agent-list.tsx | 11 +- .../src/components/sidebar-workspace-list.tsx | 40 +- packages/app/src/hooks/use-command-center.ts | 29 +- .../src/screens/agent/agent-ready-screen.tsx | 373 +----------------- .../src/screens/agent/draft-agent-screen.tsx | 11 +- .../screens/workspace/workspace-screen.tsx | 3 +- .../src/utils/notification-routing.test.ts | 13 + .../app/src/utils/notification-routing.ts | 10 +- 8 files changed, 73 insertions(+), 417 deletions(-) diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 15d919a17..a8e779d75 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -10,7 +10,7 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useCallback, useMemo, useState, type ReactElement } from "react"; -import { router, usePathname } from "expo-router"; +import { router, usePathname, type Href } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useQueryClient } from "@tanstack/react-query"; import { formatTimeAgo } from "@/utils/time"; @@ -33,7 +33,7 @@ import { startNavigationTiming, } from "@/utils/navigation-timing"; import { - buildHostAgentDetailRoute, + buildHostWorkspaceAgentTabRoute, } from "@/utils/host-routes"; interface AgentListProps { @@ -189,7 +189,12 @@ export function AgentList({ onAgentSelect?.(); - navigate(buildHostAgentDetailRoute(serverId, agentId, agent.cwd) as any); + const route: Href = buildHostWorkspaceAgentTabRoute( + serverId, + agent.cwd, + agentId + ) as Href; + navigate(route); }, [isActionSheetVisible, pathname, onAgentSelect] ); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index ee0dd4acc..1691f8625 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -8,7 +8,6 @@ import { Alert, StatusBar, ScrollView, - RefreshControl, type GestureResponderEvent, } from 'react-native' import { useQueries } from '@tanstack/react-query' @@ -55,7 +54,6 @@ import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store' import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts' import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reorder' import { - decideLongPressMove, shouldOpenContextMenuOnPressOut, } from '@/utils/sidebar-gesture-arbitration' @@ -276,10 +274,9 @@ function useLongPressDragInteraction(input: { } touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY } }) - .onTouchesMove((event, stateManager) => { + .onTouchesMove((event) => { const touch = event.changedTouches[0] if (!touch || event.numberOfTouches !== 1) { - stateManager.fail() return } @@ -289,28 +286,27 @@ function useLongPressDragInteraction(input: { return } - const decision = decideLongPressMove({ - longPressArmed: longPressArmedRef.current, - didStartDrag: didStartDragRef.current, - startPoint: start, - currentPoint: { x: touch.absoluteX, y: touch.absoluteY }, - cancelSlopPx: CANCEL_SLOP_PX, - dragSlopPx: DRAG_SLOP_PX, - }) - - if (decision === 'cancel_long_press') { - longPressCancelledRef.current = true - stateManager.fail() + if (didStartDragRef.current) { return } - if (decision === 'start_drag') { + const dx = touch.absoluteX - start.x + const dy = touch.absoluteY - start.y + const distance = Math.sqrt(dx * dx + dy * dy) + + if (!longPressArmedRef.current) { + if (distance > CANCEL_SLOP_PX) { + longPressCancelledRef.current = true + } + return + } + + if (distance > DRAG_SLOP_PX) { didStartDragRef.current = true input.drag() - stateManager.fail() } }) - }, [input]) + }, [input.drag]) return { didLongPressRef, @@ -1038,10 +1034,6 @@ export function SidebarWorkspaceList({ ] ) - const refreshControl = onRefresh ? ( - - ) : undefined - const content = ( <> {projects.length === 0 ? ( @@ -1070,7 +1062,6 @@ export function SidebarWorkspaceList({ style={styles.list} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} - refreshControl={refreshControl} testID="sidebar-project-workspace-list-scroll" > {content} @@ -1080,7 +1071,6 @@ export function SidebarWorkspaceList({ style={styles.list} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} - refreshControl={refreshControl} testID="sidebar-project-workspace-list-scroll" > {content} diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 47a0394b7..8c80a80f6 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TextInput } from "react-native"; -import { router, usePathname } from "expo-router"; +import { router, usePathname, type Href } from "expo-router"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; @@ -75,7 +75,7 @@ type CommandCenterActionDefinition = { icon?: "plus" | "settings"; shortcutKeys?: ShortcutKey[]; keywords: string[]; - buildRoute: (params: { newAgentRoute: string; settingsRoute: string }) => string; + buildRoute: (params: { newAgentRoute: Href; settingsRoute: Href }) => Href; }; const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ @@ -113,7 +113,7 @@ export type CommandCenterActionItem = { id: string; title: string; icon?: "plus" | "settings"; - route: string; + route: Href; shortcutKeys?: ShortcutKey[]; }; @@ -154,19 +154,19 @@ export function useCommandCenter() { [pathname] ); - const newAgentRoute = useMemo(() => { + const newAgentRoute = useMemo(() => { const serverIdFromPath = parseServerIdFromPathname(pathname) ?? fallbackServerId; const routeAgent = parseAgentRouteFromPathname(pathname); if (!routeAgent) { - return serverIdFromPath ? buildNewAgentRoute(serverIdFromPath) : "/"; + return serverIdFromPath ? (buildNewAgentRoute(serverIdFromPath) as Href) : "/"; } const { serverId, agentId } = routeAgent; const currentAgent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId); const cwd = currentAgent?.cwd?.trim(); if (!cwd) { - return buildNewAgentRoute(serverId); + return buildNewAgentRoute(serverId) as Href; } const checkout = @@ -174,13 +174,13 @@ export function useCommandCenter() { checkoutStatusQueryKey(serverId, cwd) ) ?? null; const workingDir = resolveNewAgentWorkingDir(cwd, checkout); - return buildNewAgentRoute(serverId, workingDir); + return buildNewAgentRoute(serverId, workingDir) as Href; }, [fallbackServerId, pathname]); - const settingsRoute = useMemo(() => { + const settingsRoute = useMemo(() => { const serverIdFromPath = parseServerIdFromPathname(pathname) ?? fallbackServerId; - return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/"; + return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/"; }, [fallbackServerId, pathname]); const actionItems = useMemo(() => { @@ -230,9 +230,12 @@ export function useCommandCenter() { // Don't restore focus back to the prior element after we navigate. clearCommandCenterFocusRestoreElement(); setOpen(false); - navigate( - buildHostWorkspaceAgentTabRoute(agent.serverId, agent.cwd, agent.id) as any - ); + const route: Href = buildHostWorkspaceAgentTabRoute( + agent.serverId, + agent.cwd, + agent.id + ) as Href; + navigate(route); }, [pathname, requestMessageInputAction, setOpen] ); @@ -241,7 +244,7 @@ export function useCommandCenter() { didNavigateRef.current = true; clearCommandCenterFocusRestoreElement(); setOpen(false); - router.push(action.route as any); + router.push(action.route); }, [setOpen]); const handleSelectItem = useCallback( diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 463477999..363ecacfd 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -3,30 +3,16 @@ import { View, Text, ActivityIndicator, - Pressable, - ScrollView, Platform, BackHandler, } from "react-native"; -import { useRouter } from "expo-router"; -import * as Clipboard from "expo-clipboard"; +import { useRouter, type Href } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useQueryClient } from "@tanstack/react-query"; import ReanimatedAnimated from "react-native-reanimated"; import { GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { - MoreVertical, - GitBranch, - Folder, - RotateCcw, - PanelRight, - CheckCircle2, -} from "lucide-react-native"; -import { MenuHeader } from "@/components/headers/menu-header"; -import { BackHeader } from "@/components/headers/back-header"; -import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; import { AgentInputArea } from "@/components/agent-input-area"; import { ExplorerSidebar } from "@/components/explorer-sidebar"; @@ -48,13 +34,8 @@ import type { StreamItem } from "@/types/stream"; import { buildAgentNavigationKey, endNavigationTiming, - HOME_NAVIGATION_KEY, - startNavigationTiming, } from "@/utils/navigation-timing"; -import { extractAgentModel } from "@/utils/extract-agent-model"; import { startPerfMonitor } from "@/utils/perf-monitor"; -import { shortenPath } from "@/utils/shorten-path"; -import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info"; import { checkoutStatusQueryKey, type CheckoutStatusPayload, @@ -78,17 +59,9 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention"; import type { DaemonClient } from "@server/client/daemon-client"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { buildHostAgentDraftRoute } from "@/utils/host-routes"; import type { ExplorerCheckoutContext } from "@/stores/panel-store"; -const DROPDOWN_WIDTH = 220; const EMPTY_STREAM_ITEMS: StreamItem[] = []; const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); @@ -102,17 +75,14 @@ function logAgentExplorer(event: string, details: Record): void export function AgentReadyScreen({ serverId, agentId, - showHeader = true, showExplorerSidebar = true, wrapWithExplorerSidebarProvider = true, }: { serverId: string; agentId: string; - showHeader?: boolean; showExplorerSidebar?: boolean; wrapWithExplorerSidebarProvider?: boolean; }) { - const router = useRouter(); const resolvedAgentId = agentId?.trim() || undefined; const resolvedServerId = serverId?.trim() || undefined; const { daemons } = useDaemonRegistry(); @@ -136,33 +106,6 @@ export function AgentReadyScreen({ const lastConnectionError = runtimeSnapshot?.lastError ?? null; const isRuntimeSessionAvailable = Boolean(resolvedServerId && runtimeClient); - const handleBackToHome = useCallback(() => { - const targetServerId = resolvedServerId; - const targetAgentId = resolvedAgentId ?? null; - if (targetServerId && targetAgentId) { - startNavigationTiming(HOME_NAVIGATION_KEY, { - from: "agent", - to: "home", - targetMs: 300, - params: { - serverId: targetServerId, - agentId: targetAgentId, - }, - }); - } else { - startNavigationTiming(HOME_NAVIGATION_KEY, { - from: "agent", - to: "home", - targetMs: 300, - }); - } - if (targetServerId) { - router.replace(buildHostAgentDraftRoute(targetServerId) as any); - return; - } - router.replace("/" as any); - }, [resolvedAgentId, resolvedServerId, router]); - const focusServerId = resolvedServerId; const navigationStatus = isRuntimeSessionAvailable ? "ready" @@ -187,7 +130,6 @@ export function AgentReadyScreen({ if (!resolvedServerId || !runtimeClient) { return ( ); @@ -221,7 +162,6 @@ type AgentScreenContentProps = { client: DaemonClient; isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; - showHeader: boolean; showExplorerSidebar: boolean; }; @@ -242,7 +182,6 @@ function AgentScreenContent({ client, isConnected, connectionStatus, - showHeader, showExplorerSidebar, }: AgentScreenContentProps) { const { theme } = useUnistyles(); @@ -269,7 +208,6 @@ function AgentScreenContent({ const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); - const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); const closeToAgent = usePanelStore((state) => state.closeToAgent); const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout); @@ -348,19 +286,6 @@ function AgentScreenContent({ } openFileExplorer(); }, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]); - const handleToggleExplorer = useCallback(() => { - logAgentExplorer("handleToggleExplorer", { - isExplorerOpen, - mobileView, - isMobile, - }); - if (isExplorerOpen) { - toggleFileExplorer(); - return; - } - openExplorerForActiveCheckout(); - }, [isExplorerOpen, isMobile, mobileView, openExplorerForActiveCheckout, toggleFileExplorer]); - useEffect(() => { if (Platform.OS !== "web") { return; @@ -501,7 +426,7 @@ function AgentScreenContent({ const focusedAgentId = useSessionStore( (state) => state.sessions[serverId]?.focusedAgentId ?? null ); - const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization({ + const { ensureAgentIsInitialized } = useAgentInitialization({ serverId, client: hasSession ? client : null, }); @@ -604,7 +529,8 @@ function AgentScreenContent({ return; } hasRedirectedArchivedAgentRef.current = true; - router.replace(buildHostAgentDraftRoute(serverId) as any); + const route: Href = buildHostAgentDraftRoute(serverId) as Href; + router.replace(route); }, [agent?.archivedAt, resolvedAgentId, router, serverId]); useEffect(() => { @@ -730,19 +656,6 @@ function AgentScreenContent({ }); const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null; - const agentModel = extractAgentModel(effectiveAgent ?? agent); - const modelDisplayValue = agentModel ?? "Unknown"; - const providerLabel = (effectiveAgent?.provider ?? "Provider").replace( - /^\w/, - (m) => m.toUpperCase() - ); - const providerSessionId = - effectiveAgent?.runtimeInfo?.sessionId ?? effectiveAgent?.persistence?.sessionId ?? null; - - // Header subtitle: project path + branch (matching agent list row format) - const headerProjectPath = effectiveAgent - ? deriveProjectPath(effectiveAgent.cwd, checkout) - : null; useEffect(() => { if (!isPendingCreateForRoute || !pendingCreate) { return; @@ -952,33 +865,6 @@ function AgentScreenContent({ resolvedAgentId, ]); - const handleRefreshAgent = useCallback(() => { - if (!resolvedAgentId || !hasSession) { - return; - } - void refreshAgent(resolvedAgentId).catch((error) => { - console.warn("[AgentScreen] refreshAgent failed", { agentId: resolvedAgentId, error }); - }); - }, [hasSession, refreshAgent, resolvedAgentId]); - - const handleCopyMeta = useCallback( - async (label: string, value: string | null | undefined) => { - if (!value) { - return; - } - try { - await Clipboard.setStringAsync(value); - toast.show(`Copied ${label}`, { - variant: "success", - icon: , - }); - } catch { - toast.error("Copy failed"); - } - }, - [theme.colors.primary, toast] - ); - const isHistoryRefreshCatchingUp = viewState.tag === "ready" && viewState.sync.status === "catching_up" && @@ -1003,7 +889,6 @@ function AgentScreenContent({ if (viewState.tag === "not_found") { return ( - {showHeader ? : null} Agent not found @@ -1014,7 +899,6 @@ function AgentScreenContent({ if (viewState.tag === "error") { return ( - {showHeader ? : null} Failed to load agent {viewState.message} @@ -1026,7 +910,6 @@ function AgentScreenContent({ if (viewState.tag === "boot" || !effectiveAgent) { return ( - {showHeader ? : null} @@ -1041,210 +924,6 @@ function AgentScreenContent({ disabled={isArchivingCurrentAgent} > - {/* Header */} - {showHeader ? ( - - - {isMobile ? ( - checkout?.isGit ? ( - - ) : ( - - ) - ) : ( - - )} - - { - if (open && agent?.cwd) { - checkoutStatusQuery.refresh().catch(() => {}); - } - }} - > - - - - - - [ - styles.menuMetaRow, - (hovered || pressed) && styles.menuMetaRowActive, - ]} - onPress={() => { - void handleCopyMeta("Directory", effectiveAgent.cwd); - }} - > - - Directory - - - {shortenPath(effectiveAgent.cwd)} - - - - [ - styles.menuMetaRow, - (hovered || pressed) && styles.menuMetaRowActive, - ]} - onPress={() => { - void handleCopyMeta("Model", modelDisplayValue); - }} - > - - Model - - - {modelDisplayValue} - - - - {checkout?.isGit && checkout.currentBranch && checkout.currentBranch !== "HEAD" ? ( - [ - styles.menuMetaRow, - (hovered || pressed) && styles.menuMetaRowActive, - ]} - onPress={() => { - if (checkoutStatusQuery.isFetching) { - return; - } - void handleCopyMeta("Branch", checkout.currentBranch); - }} - > - - Branch - - - {checkoutStatusQuery.isFetching ? "Fetching…" : checkout.currentBranch} - - - ) : null} - - [ - styles.menuMetaRow, - (hovered || pressed) && styles.menuMetaRowActive, - ]} - onPress={() => { - void handleCopyMeta("Paseo ID", effectiveAgent.id); - }} - > - - Paseo ID - - - {effectiveAgent.id} - - - - [ - styles.menuMetaRow, - providerSessionId && (hovered || pressed) && styles.menuMetaRowActive, - ]} - disabled={!providerSessionId} - onPress={() => { - void handleCopyMeta(`${providerLabel} ID`, providerSessionId); - }} - > - - {providerLabel} ID - - - {providerSessionId ?? "Not available"} - - - - - - - } - disabled={isInitializing} - trailing={ - isInitializing ? ( - - ) : null - } - onSelect={handleRefreshAgent} - > - {isInitializing ? "Refreshing..." : "Refresh"} - - - - - } - /> - ) : null} - {/* Content Area with Keyboard Animation */} void; serverLabel: string; connectionStatus: HostRuntimeConnectionStatus; lastError: string | null; @@ -1347,7 +1024,6 @@ function AgentSessionUnavailableState({ if (isUnknownDaemon) { return ( - Cannot open this agent because {serverLabel} is not configured on @@ -1367,7 +1043,6 @@ function AgentSessionUnavailableState({ return ( - {isConnecting || isPreparingSession ? ( <> @@ -1411,10 +1086,6 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, backgroundColor: theme.colors.surface0, }, - headerRightContent: { - flexDirection: "row", - alignItems: "center", - }, contentContainer: { flex: 1, overflow: "hidden", @@ -1506,40 +1177,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, textAlign: "center", }, - menuButton: { - padding: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - }, - menuMetaContainer: { - paddingVertical: theme.spacing[1], - }, - menuMetaRow: { - minHeight: 32, - paddingHorizontal: theme.spacing[3], - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing[3], - }, - menuMetaRowActive: { - backgroundColor: theme.colors.surface2, - }, - menuMetaLabel: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - flexShrink: 0, - }, - menuMetaValue: { - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - flex: 1, - minWidth: 0, - textAlign: "right", - }, - menuMetaValueError: { - color: theme.colors.destructive, - }, - menuItemSpinner: { - marginLeft: "auto", - }, })); diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index a96415166..53bd8fa72 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState, useSyncE import { createNameId } from 'mnemonic-id' import type { ImageAttachment } from '@/components/message-input' import { View, Text, Pressable, ScrollView, Keyboard, Platform } from 'react-native' -import { useLocalSearchParams, useRouter } from 'expo-router' +import { useLocalSearchParams, useRouter, type Href } from 'expo-router' import { useIsFocused } from '@react-navigation/native' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -1037,9 +1037,12 @@ function DraftAgentScreenContent({ const configuredWorkingDir = config.cwd.trim() const workspaceId = createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir - router.replace( - buildHostWorkspaceAgentTabRoute(selectedServerId, workspaceId, agentId) as any - ) + const route: Href = buildHostWorkspaceAgentTabRoute( + selectedServerId, + workspaceId, + agentId + ) as Href + router.replace(route) return } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 76ed6b16b..b91dc9318 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1265,11 +1265,10 @@ function WorkspaceScreenContent({ } if (target.kind === "agent") { - return ( + return ( diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts index b4f36d6ee..d6fefc80e 100644 --- a/packages/app/src/utils/notification-routing.test.ts +++ b/packages/app/src/utils/notification-routing.test.ts @@ -15,6 +15,7 @@ describe("resolveNotificationTarget", () => { ).toEqual({ serverId: "server-123", agentId: "agent-456", + workspaceId: null, }); }); @@ -22,15 +23,27 @@ describe("resolveNotificationTarget", () => { expect(resolveNotificationTarget({ serverId: "", agentId: " " })).toEqual({ serverId: null, agentId: null, + workspaceId: null, }); expect(resolveNotificationTarget(undefined)).toEqual({ serverId: null, agentId: null, + workspaceId: null, }); }); }); describe("buildNotificationRoute", () => { + it("routes directly to workspace tab when workspace id is present", () => { + expect( + buildNotificationRoute({ + serverId: "srv-1", + agentId: "agent-1", + workspaceId: "/tmp/repo", + }) + ).toBe("/h/srv-1/workspace/L3RtcC9yZXBv/tab/agent_agent-1"); + }); + it("routes directly to server-scoped agent path when both ids are present", () => { expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe( "/h/srv-1/agent/agent-1" diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts index 055bb12ee..008a2c918 100644 --- a/packages/app/src/utils/notification-routing.ts +++ b/packages/app/src/utils/notification-routing.ts @@ -1,6 +1,7 @@ import { buildHostDraftRoute, buildHostAgentDetailRoute, + buildHostWorkspaceAgentTabRoute, } from "@/utils/host-routes"; type NotificationData = Record | null | undefined; @@ -20,16 +21,23 @@ function readNonEmptyString( export function resolveNotificationTarget(data: NotificationData): { serverId: string | null; agentId: string | null; + workspaceId: string | null; } { return { serverId: readNonEmptyString(data, "serverId"), agentId: readNonEmptyString(data, "agentId"), + workspaceId: + readNonEmptyString(data, "workspaceId") ?? + readNonEmptyString(data, "cwd"), }; } export function buildNotificationRoute(data: NotificationData): string { - const { serverId, agentId } = resolveNotificationTarget(data); + const { serverId, agentId, workspaceId } = resolveNotificationTarget(data); if (serverId && agentId) { + if (workspaceId) { + return buildHostWorkspaceAgentTabRoute(serverId, workspaceId, agentId); + } return buildHostAgentDetailRoute(serverId, agentId); } if (serverId) {