diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 640d4cd32..92e3561b3 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -45,7 +45,6 @@ import { } from "@/utils/os-notifications"; import { buildNotificationRoute } from "@/utils/notification-routing"; import { - buildHostDraftRoute, parseHostAgentRouteFromPathname, parseHostWorkspaceTabRouteFromPathname, } from "@/utils/host-routes"; @@ -351,7 +350,7 @@ function OfferLinkListener({ if (cancelled) return; const serverId = (profile as any)?.serverId; if (typeof serverId !== "string" || !serverId) return; - router.replace(buildHostDraftRoute(serverId) as any); + router.replace(`/h/${encodeURIComponent(serverId)}` as any); }) .catch((error) => { if (cancelled) return; @@ -472,7 +471,6 @@ export default function RootLayout() { > - - diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index c668b2e8c..277529c9f 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -3,7 +3,6 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { - buildHostDraftRoute, buildHostWorkspaceAgentTabRoute, } from "@/utils/host-routes"; @@ -55,7 +54,7 @@ export default function HostAgentReadyRoute() { } if (!client || !isConnected) { redirectedRef.current = true; - router.replace(buildHostDraftRoute(serverId) as any); + router.replace(`/h/${encodeURIComponent(serverId)}` as any); } }, [agentCwd, agentId, client, isConnected, router, serverId]); @@ -80,14 +79,14 @@ export default function HostAgentReadyRoute() { router.replace(buildHostWorkspaceAgentTabRoute(serverId, cwd, agentId) as any); return; } - router.replace(buildHostDraftRoute(serverId) as any); + router.replace(`/h/${encodeURIComponent(serverId)}` as any); }) .catch(() => { if (cancelled || redirectedRef.current) { return; } redirectedRef.current = true; - router.replace(buildHostDraftRoute(serverId) as any); + router.replace(`/h/${encodeURIComponent(serverId)}` as any); }); return () => { diff --git a/packages/app/src/app/h/[serverId]/agent/index.tsx b/packages/app/src/app/h/[serverId]/agent/index.tsx deleted file mode 100644 index 9f41cd645..000000000 --- a/packages/app/src/app/h/[serverId]/agent/index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { buildHostDraftRoute } from "@/utils/host-routes"; - -export default function HostDraftAgentRoute() { - const router = useRouter(); - const params = useLocalSearchParams() as Record< - string, - string | string[] | undefined - >; - const serverId = typeof params.serverId === "string" ? params.serverId : ""; - - const redirectRoute = useMemo(() => { - if (!serverId) { - return "/"; - } - - const next = buildHostDraftRoute(serverId); - const queryParts: string[] = []; - for (const key in params) { - const value = params[key]; - if (key === "serverId") { - continue; - } - if (typeof value === "string") { - queryParts.push( - `${encodeURIComponent(key)}=${encodeURIComponent(value)}` - ); - continue; - } - if (Array.isArray(value)) { - for (const entry of value) { - queryParts.push( - `${encodeURIComponent(key)}=${encodeURIComponent(entry)}` - ); - } - } - } - const queryString = queryParts.join("&"); - return queryString.length > 0 ? `${next}?${queryString}` : next; - }, [params, serverId]); - - useEffect(() => { - router.replace(redirectRoute as any); - }, [redirectRoute, router]); - - return null; -} diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 52b994602..e56e199a1 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -1,8 +1,9 @@ import { useEffect } from "react"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useSessionStore } from "@/stores/session-store"; +import { useFormPreferences } from "@/hooks/use-form-preferences"; import { - buildHostDraftRoute, + buildHostWorkspaceRoute, buildHostWorkspaceAgentTabRoute, } from "@/utils/host-routes"; @@ -10,11 +11,15 @@ export default function HostIndexRoute() { const router = useRouter(); const params = useLocalSearchParams<{ serverId?: string }>(); const serverId = typeof params.serverId === "string" ? params.serverId : ""; + const { preferences, isLoading: preferencesLoading } = useFormPreferences(); const sessionAgents = useSessionStore( (state) => (serverId ? state.sessions[serverId]?.agents : undefined) ); useEffect(() => { + if (preferencesLoading) { + return; + } if (!serverId) { return; } @@ -40,8 +45,11 @@ export default function HostIndexRoute() { return; } - router.replace(buildHostDraftRoute(serverId) as any); - }, [router, serverId, sessionAgents]); + const preferredWorkingDir = + preferences.serverId === serverId ? preferences.workingDir?.trim() : ""; + const workspaceId = preferredWorkingDir || "."; + router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any); + }, [preferences.serverId, preferences.workingDir, preferencesLoading, router, serverId, sessionAgents]); return null; } diff --git a/packages/app/src/app/h/[serverId]/new.tsx b/packages/app/src/app/h/[serverId]/new.tsx deleted file mode 100644 index 76fe78ef2..000000000 --- a/packages/app/src/app/h/[serverId]/new.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { useLocalSearchParams } from "expo-router"; -import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; - -export default function HostDraftRoute() { - const params = useLocalSearchParams<{ serverId?: string }>(); - const serverId = typeof params.serverId === "string" ? params.serverId : ""; - - return ; -} diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index ad4397fa5..ca15c11fd 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -12,7 +12,6 @@ import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-en import { probeConnection } from "@/utils/test-daemon-connection"; import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { - buildHostAgentDraftRoute, buildHostSettingsRoute, } from "@/utils/host-routes"; @@ -170,7 +169,7 @@ export default function PairScanScreen() { const returnToSource = useCallback( (serverId: string) => { if (source === "onboarding") { - router.replace(buildHostAgentDraftRoute(serverId) as any); + router.replace(`/h/${encodeURIComponent(serverId)}` as any); return; } if (source === "editHost" && targetServerId) { diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 90ababd88..2243472c2 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -634,7 +634,12 @@ function WorkspaceRowWithMenu({ dragHandleProps={dragHandleProps} menuController={contextMenu} /> - + = [ @@ -40,6 +44,7 @@ interface TerminalEmulatorProps { dom?: DOMProps; streamKey: string; initialOutputText: string; + initialOutputChunkSequence?: number; outputChunkText: string; outputChunkSequence: number; outputChunkReplay?: boolean; @@ -71,6 +76,7 @@ declare global { export default function TerminalEmulator({ streamKey, initialOutputText, + initialOutputChunkSequence = 0, outputChunkText, outputChunkSequence, outputChunkReplay = false, @@ -95,7 +101,7 @@ export default function TerminalEmulator({ const rootRef = useRef(null); const hostRef = useRef(null); const runtimeRef = useRef(null); - const appliedInitialOutputRef = useRef(null); + const appliedChunkSequenceRef = useRef(0); const mountedThemeRef = useRef(xtermTheme); const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]); @@ -244,34 +250,20 @@ export default function TerminalEmulator({ initialOutputText, theme: mountedThemeRef.current, }); - appliedInitialOutputRef.current = initialOutputText; + appliedChunkSequenceRef.current = Math.max( + 0, + Math.floor(initialOutputChunkSequence) + ); return () => { runtime.unmount(); if (runtimeRef.current === runtime) { runtimeRef.current = null; } - appliedInitialOutputRef.current = null; + appliedChunkSequenceRef.current = 0; }; }, [streamKey]); - useEffect(() => { - const runtime = runtimeRef.current; - if (!runtime) { - return; - } - - if (appliedInitialOutputRef.current === initialOutputText) { - return; - } - - appliedInitialOutputRef.current = initialOutputText; - runtime.clear(); - if (initialOutputText.length > 0) { - runtime.write({ text: initialOutputText }); - } - }, [initialOutputText]); - useEffect(() => { runtimeRef.current?.setCallbacks({ callbacks: { @@ -293,12 +285,34 @@ export default function TerminalEmulator({ return; } + if (outputChunkSequence <= appliedChunkSequenceRef.current) { + terminalDebugLog({ + scope: "emulator-component", + event: "output:chunk:skip-duplicate", + details: { + sequence: outputChunkSequence, + lastAppliedSequence: appliedChunkSequenceRef.current, + }, + }); + onOutputChunkConsumed?.(outputChunkSequence); + return; + } + if (!runtime) { onOutputChunkConsumed?.(outputChunkSequence); return; } + appliedChunkSequenceRef.current = outputChunkSequence; + if (outputChunkText.length === 0) { + terminalDebugLog({ + scope: "emulator-component", + event: "output:chunk:clear", + details: { + sequence: outputChunkSequence, + }, + }); runtime.clear({ onCommitted: () => { onOutputChunkConsumed?.(outputChunkSequence); @@ -306,6 +320,16 @@ export default function TerminalEmulator({ }); return; } + terminalDebugLog({ + scope: "emulator-component", + event: "output:chunk:write", + details: { + sequence: outputChunkSequence, + replay: outputChunkReplay, + length: outputChunkText.length, + preview: summarizeTerminalText({ text: outputChunkText, maxChars: 80 }), + }, + }); runtime.write({ text: outputChunkText, suppressInput: outputChunkReplay, @@ -346,6 +370,10 @@ export default function TerminalEmulator({ touchAction: "pan-y", }} onPointerDown={() => { + terminalDebugLog({ + scope: "emulator-component", + event: "surface:pointer-down-focus", + }); runtimeRef.current?.focus(); }} > diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index f6200542d..36e5fa9cf 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useFocusEffect } from "@react-navigation/native"; +import { useFocusEffect, useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, Pressable, @@ -29,10 +29,10 @@ import { import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { confirmDialog } from "@/utils/confirm-dialog"; import { - TerminalOutputPump, - type TerminalOutputChunk, -} from "@/terminal/runtime/terminal-output-pump"; -import { TerminalOutputDeliveryQueue } from "@/terminal/runtime/terminal-output-delivery-queue"; + getTerminalOutputSession, + releaseTerminalOutputSession, + retainTerminalOutputSession, +} from "@/terminal/runtime/terminal-output-session"; import { TerminalStreamController, type TerminalStreamControllerStatus, @@ -82,12 +82,6 @@ type ModifierState = { alt: boolean; }; -type TerminalOutputChunkState = { - sequence: number; - text: string; - replay: boolean; -}; - type PendingTerminalInput = | { type: "data"; @@ -147,6 +141,7 @@ export function TerminalPane({ hideHeader = false, manageTerminalDirectorySubscription = true, }: TerminalPaneProps) { + const isScreenFocused = useIsFocused(); const { theme } = useUnistyles(); const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]); const isMobile = @@ -167,14 +162,33 @@ export function TerminalPane({ const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]); const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null); const streamControllerRef = useRef(null); - const outputPumpRef = useRef(null); - const outputDeliveryQueueRef = useRef(null); - const [selectedOutputChunk, setSelectedOutputChunk] = useState({ - sequence: 0, - text: "", - replay: false, - }); - const [selectedOutputSnapshot, setSelectedOutputSnapshot] = useState(""); + const outputSession = useMemo( + () => + getTerminalOutputSession({ + scopeKey, + maxOutputChars: MAX_OUTPUT_CHARS, + }), + [scopeKey] + ); + const subscribeOutputSession = useCallback( + (listener: () => void) => outputSession.subscribe(listener), + [outputSession] + ); + const getOutputSessionState = useCallback( + () => outputSession.getState(), + [outputSession] + ); + useEffect(() => { + retainTerminalOutputSession({ scopeKey }); + return () => { + releaseTerminalOutputSession({ scopeKey }); + }; + }, [scopeKey]); + const outputState = useSyncExternalStore( + subscribeOutputSession, + getOutputSessionState, + getOutputSessionState + ); const [activeStream, setActiveStream] = useState<{ terminalId: string; streamId: number; @@ -204,39 +218,6 @@ export function TerminalPane({ selectedTerminalIdRef.current = selectedTerminalId; }, [selectedTerminalId]); - useEffect(() => { - const outputDeliveryQueue = new TerminalOutputDeliveryQueue({ - onDeliver: (chunk) => { - setSelectedOutputChunk(chunk); - }, - }); - outputDeliveryQueueRef.current = outputDeliveryQueue; - - return () => { - if (outputDeliveryQueueRef.current === outputDeliveryQueue) { - outputDeliveryQueueRef.current = null; - } - outputDeliveryQueue.reset(); - }; - }, []); - - useEffect(() => { - const outputPump = new TerminalOutputPump({ - maxOutputChars: MAX_OUTPUT_CHARS, - onSelectedOutputChunk: (chunk: TerminalOutputChunk) => { - outputDeliveryQueueRef.current?.enqueue(chunk); - }, - }); - outputPumpRef.current = outputPump; - - return () => { - if (outputPumpRef.current === outputPump) { - outputPumpRef.current = null; - } - outputPump.dispose(); - }; - }, []); - const clearHoverOutTimeout = useCallback(() => { if (!hoverOutTimeoutRef.current) { return; @@ -467,7 +448,7 @@ export function TerminalPane({ }, onSuccess: (_, terminalId) => { setHoveredTerminalId((current) => (current === terminalId ? null : current)); - outputPumpRef.current?.clearTerminal({ terminalId }); + outputSession.clearTerminal({ terminalId }); if (selectedTerminalIdRef.current === terminalId) { updateSelectedTerminalId(null); setModifiers({ ...EMPTY_MODIFIERS }); @@ -488,9 +469,9 @@ export function TerminalPane({ useEffect(() => { const terminalIds = terminals.map((terminal) => terminal.id); - outputPumpRef.current?.prune({ terminalIds }); + outputSession.prune({ terminalIds }); streamControllerRef.current?.pruneResumeOffsets({ terminalIds }); - }, [terminals]); + }, [outputSession, terminals]); const handleStreamControllerStatus = useCallback( (status: TerminalStreamControllerStatus) => { @@ -519,28 +500,22 @@ export function TerminalPane({ return; } - const outputPump = outputPumpRef.current; - if (!outputPump) { - return; - } - const controller = new TerminalStreamController({ client, getPreferredSize: () => lastReportedSizeRef.current, onChunk: ({ terminalId, text, replay }) => { - outputPump.append({ terminalId, text, replay }); + outputSession.append({ terminalId, text, replay }); }, onReset: ({ terminalId }) => { - outputPump.clearTerminal({ terminalId }); - if (selectedTerminalIdRef.current === terminalId) { - setSelectedOutputSnapshot(""); - } + outputSession.clearTerminal({ terminalId }); }, onStatusChange: handleStreamControllerStatus, }); streamControllerRef.current = controller; - controller.setTerminal({ terminalId: selectedTerminalIdRef.current }); + controller.setTerminal({ + terminalId: isScreenFocused ? selectedTerminalIdRef.current : null, + }); return () => { controller.dispose(); @@ -548,24 +523,18 @@ export function TerminalPane({ streamControllerRef.current = null; } }; - }, [client, handleStreamControllerStatus, isConnected]); + }, [client, handleStreamControllerStatus, isConnected, isScreenFocused, outputSession]); useEffect(() => { - outputDeliveryQueueRef.current?.reset(); pendingTerminalInputRef.current = []; - setSelectedOutputChunk({ sequence: 0, text: "", replay: false }); - outputPumpRef.current?.setSelectedTerminal({ - terminalId: selectedTerminalId, + const nextSelectedTerminalId = isScreenFocused ? selectedTerminalId : null; + outputSession.setSelectedTerminal({ + terminalId: nextSelectedTerminalId, }); streamControllerRef.current?.setTerminal({ - terminalId: selectedTerminalId, + terminalId: nextSelectedTerminalId, }); - setSelectedOutputSnapshot( - outputPumpRef.current?.readSnapshot({ - terminalId: selectedTerminalId, - }) ?? "" - ); - }, [selectedTerminalId]); + }, [isScreenFocused, outputSession, selectedTerminalId]); const activeStreamId = activeStream && activeStream.terminalId === selectedTerminalId @@ -870,8 +839,15 @@ export function TerminalPane({ }, [clearPendingModifiers]); const handleOutputChunkConsumed = useCallback((sequence: number) => { - outputDeliveryQueueRef.current?.consume({ sequence }); - }, []); + terminalDebugLog({ + scope: "terminal-pane", + event: "output:chunk:consumed", + details: { + sequence, + }, + }); + outputSession.consume({ sequence }); + }, [outputSession]); const toggleModifier = useCallback( (modifier: keyof ModifierState) => { @@ -1046,10 +1022,11 @@ export function TerminalPane({ contentInsetAdjustmentBehavior: "never", }} streamKey={`${scopeKey}:${selectedTerminal.id}`} - initialOutputText={selectedOutputSnapshot} - outputChunkText={selectedOutputChunk.text} - outputChunkSequence={selectedOutputChunk.sequence} - outputChunkReplay={selectedOutputChunk.replay} + initialOutputText={outputState.snapshotText} + initialOutputChunkSequence={outputState.snapshotSequence} + outputChunkText={outputState.chunkText} + outputChunkSequence={outputState.chunkSequence} + outputChunkReplay={outputState.chunkReplay} testId="terminal-surface" xtermTheme={xtermTheme} swipeGesturesEnabled={swipeGesturesEnabled} diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index ce5326396..df174b6b7 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -1,4 +1,5 @@ import { + type ComponentProps, createContext, useCallback, useContext, @@ -24,14 +25,20 @@ import { type ViewStyle, } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { Check, CheckCircle } from "lucide-react-native"; +import { + BottomSheetBackdrop, + BottomSheetModal, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; // Keep parity with dropdown-menu action statuses. export type ActionStatus = "idle" | "pending" | "success"; type Placement = "top" | "bottom" | "left" | "right"; type Alignment = "start" | "center" | "end"; +type MobileMenuMode = "dropdown" | "sheet"; interface Rect { x: number; @@ -308,6 +315,7 @@ export function ContextMenuContent({ maxWidth, fullWidth = false, horizontalPadding = 16, + mobileMode = "dropdown", testID, }: PropsWithChildren<{ side?: Placement; @@ -318,9 +326,15 @@ export function ContextMenuContent({ maxWidth?: number; fullWidth?: boolean; horizontalPadding?: number; + mobileMode?: MobileMenuMode; testID?: string; }>): ReactElement | null { - const { open, setOpen, triggerRef, anchorRect } = useContextMenuContext("ContextMenuContent"); + const context = useContextMenuContext("ContextMenuContent"); + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const useMobileSheet = isMobile && mobileMode === "sheet"; + const { open, setOpen, triggerRef, anchorRect } = context; + const bottomSheetRef = useRef(null); + const sheetSnapPoints = useMemo(() => ["30%", "55%"], []); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); @@ -329,8 +343,45 @@ export function ContextMenuContent({ setOpen(false); }, [setOpen]); + useEffect(() => { + if (!useMobileSheet) return; + if (open) { + bottomSheetRef.current?.present(); + return; + } + bottomSheetRef.current?.dismiss(); + }, [open, useMobileSheet]); + + const handleSheetChange = useCallback( + (index: number) => { + if (index === -1) { + handleClose(); + } + }, + [handleClose] + ); + + const renderSheetBackdrop = useCallback( + (props: ComponentProps) => ( + + ), + [] + ); + // Measure trigger when opening (fallback) and capture point anchors. useEffect(() => { + if (useMobileSheet) { + setTriggerRect(null); + setContentSize(null); + setPosition(null); + return; + } + if (!open) { setTriggerRect(null); setContentSize(null); @@ -362,10 +413,11 @@ export function ContextMenuContent({ return () => { cancelled = true; }; - }, [anchorRect, open, triggerRef]); + }, [anchorRect, open, triggerRef, useMobileSheet]); // Calculate position when we have both measurements useEffect(() => { + if (useMobileSheet) return; if (!triggerRect || !contentSize) return; const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); @@ -387,7 +439,7 @@ export function ContextMenuContent({ const x = fullWidth ? horizontalPadding : result.x; setPosition({ x, y: result.y }); - }, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]); + }, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding, useMobileSheet]); const handleContentLayout = useCallback( (event: { nativeEvent: { layout: { width: number; height: number } } }) => { @@ -397,6 +449,35 @@ export function ContextMenuContent({ [] ); + if (useMobileSheet) { + return ( + + + + {children} + + + + ); + } + if (!open) return null; const { width: screenWidth } = Dimensions.get("window"); @@ -616,6 +697,20 @@ const styles = StyleSheet.create((theme) => ({ elevation: 8, overflow: "hidden", }, + sheetBackground: { + backgroundColor: theme.colors.surface0, + borderTopLeftRadius: theme.borderRadius.xl, + borderTopRightRadius: theme.borderRadius.xl, + borderWidth: 1, + borderColor: theme.colors.border, + }, + sheetHandle: { + backgroundColor: theme.colors.surface2, + }, + sheetScrollContent: { + paddingTop: theme.spacing[1], + paddingBottom: theme.spacing[4], + }, labelContainer: { paddingHorizontal: theme.spacing[3], paddingTop: theme.spacing[2], diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 7c94120dc..607523e49 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -9,7 +9,6 @@ import { useSessionStore } from "@/stores/session-store"; import { AddHostModal } from "./add-host-modal"; import { PairLinkModal } from "./pair-link-modal"; import { NameHostModal } from "./name-host-modal"; -import { buildHostAgentDraftRoute } from "@/utils/host-routes"; import { resolveAppVersion } from "@/utils/app-version"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; @@ -106,7 +105,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const finishOnboarding = useCallback( (serverId: string) => { - router.replace(buildHostAgentDraftRoute(serverId) as any); + router.replace(`/h/${encodeURIComponent(serverId)}` as any); }, [router] ); diff --git a/packages/app/src/keyboard/keyboard-shortcut-routing.test.ts b/packages/app/src/keyboard/keyboard-shortcut-routing.test.ts index 7b4346610..eb5afc291 100644 --- a/packages/app/src/keyboard/keyboard-shortcut-routing.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcut-routing.test.ts @@ -34,9 +34,9 @@ describe("keyboard-shortcut-routing", () => { expect(canToggle).toBe(true); }); - it("allows the shortcut on draft routes", () => { + it("allows the shortcut on workspace draft-tab routes", () => { const canToggle = canToggleFileExplorerShortcut({ - pathname: "/h/server-1/new", + pathname: "/h/server-1/workspace/workspace-1/tab/draft_123", toggleFileExplorer, }); @@ -45,7 +45,7 @@ describe("keyboard-shortcut-routing", () => { it("blocks the shortcut when no toggle handler exists", () => { const canToggle = canToggleFileExplorerShortcut({ - pathname: "/h/server-1/new", + pathname: "/h/server-1/workspace/workspace-1/tab/draft_123", }); expect(canToggle).toBe(false); diff --git a/packages/app/src/keyboard/keyboard-shortcut-routing.ts b/packages/app/src/keyboard/keyboard-shortcut-routing.ts index 058b87b2c..0bd99183e 100644 --- a/packages/app/src/keyboard/keyboard-shortcut-routing.ts +++ b/packages/app/src/keyboard/keyboard-shortcut-routing.ts @@ -1,5 +1,4 @@ import { - parseHostAgentDraftRouteFromPathname, parseHostAgentRouteFromPathname, parseHostWorkspaceRouteFromPathname, parseHostWorkspaceTabRouteFromPathname, @@ -29,11 +28,7 @@ export function resolveSelectedOrRouteAgentKey(input: { const route = parseHostAgentRouteFromPathname(input.pathname); if (!route) { - const draftRoute = parseHostAgentDraftRouteFromPathname(input.pathname); - if (!draftRoute) { - return null; - } - return `${draftRoute.serverId}:${DRAFT_AGENT_ID}`; + return null; } return `${route.serverId}:${route.agentId}`; } @@ -54,9 +49,5 @@ export function canToggleFileExplorerShortcut(input: { return true; } - if (parseHostAgentDraftRouteFromPathname(input.pathname)) { - return true; - } - return false; } diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 363ecacfd..5a16ee9e6 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -59,7 +59,6 @@ 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 { buildHostAgentDraftRoute } from "@/utils/host-routes"; import type { ExplorerCheckoutContext } from "@/stores/panel-store"; const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -529,7 +528,7 @@ function AgentScreenContent({ return; } hasRedirectedArchivedAgentRef.current = true; - const route: Href = buildHostAgentDraftRoute(serverId) as Href; + const route: Href = `/h/${encodeURIComponent(serverId)}` as Href; router.replace(route); }, [agent?.archivedAt, resolvedAgentId, router, serverId]); diff --git a/packages/app/src/screens/agents-screen.tsx b/packages/app/src/screens/agents-screen.tsx index 39c97e73e..a63d1be5c 100644 --- a/packages/app/src/screens/agents-screen.tsx +++ b/packages/app/src/screens/agents-screen.tsx @@ -4,7 +4,6 @@ import { StyleSheet } from "react-native-unistyles"; import { BackHeader } from "@/components/headers/back-header"; import { AgentList } from "@/components/agent-list"; import { useAllAgentsList } from "@/hooks/use-all-agents-list"; -import { buildHostAgentDraftRoute } from "@/utils/host-routes"; import { router } from "expo-router"; export function AgentsScreen({ serverId }: { serverId: string }) { @@ -44,7 +43,7 @@ export function AgentsScreen({ serverId }: { serverId: string }) { router.replace(buildHostAgentDraftRoute(serverId) as any)} + onBack={() => router.replace(`/h/${encodeURIComponent(serverId)}` as any)} /> state.mobileView); @@ -417,21 +418,6 @@ function WorkspaceScreenContent({ if (!normalized || !persistenceKey) { return; } - if (normalized.startsWith("draft_")) { - const tabId = openDraftTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - draftId: normalized, - }); - if (tabId) { - focusTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId, - }); - } - return; - } if (normalized.startsWith("file_")) { const path = normalized.slice("file_".length).trim(); if (!path) { @@ -529,6 +515,38 @@ function WorkspaceScreenContent({ [normalizedServerId, normalizedWorkspaceId, router] ); + const emptyWorkspaceSeedRef = useRef(null); + useEffect(() => { + if (!persistenceKey) { + return; + } + if (tabs.length > 0) { + emptyWorkspaceSeedRef.current = null; + return; + } + const workspaceKey = `${normalizedServerId}:${normalizedWorkspaceId}`; + if (emptyWorkspaceSeedRef.current === workspaceKey) { + return; + } + emptyWorkspaceSeedRef.current = workspaceKey; + const draftId = generateDraftId(); + const tabId = openDraftTab({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + draftId, + }); + if (tabId) { + navigateToTabId(tabId); + } + }, [ + navigateToTabId, + normalizedServerId, + normalizedWorkspaceId, + openDraftTab, + persistenceKey, + tabs.length, + ]); + const handleOpenFileFromExplorer = useCallback( (filePath: string) => { if (isMobile) { @@ -1025,12 +1043,21 @@ function WorkspaceScreenContent({ <> - - {workspaceHeader ? workspaceHeader.title : "Loading workspace…"} - - - {workspaceHeader ? workspaceHeader.subtitle : "Awaiting workspace descriptor"} - + {isWorkspaceHeaderLoading ? ( + <> + + + + ) : ( + <> + + {workspaceHeader.title} + + + {workspaceHeader.subtitle} + + + )} } @@ -1326,6 +1353,22 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, flexShrink: 1, }, + headerTitleSkeleton: { + width: 190, + maxWidth: "45%", + height: 22, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface3, + opacity: 0.25, + }, + headerProjectTitleSkeleton: { + width: 300, + maxWidth: "45%", + height: 22, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface3, + opacity: 0.18, + }, headerRight: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts index a3d477973..140188934 100644 --- a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts +++ b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts @@ -74,6 +74,14 @@ export class TerminalOutputDeliveryQueue { consume(input: { sequence: number }): void { if (this.inFlightChunk?.sequence !== input.sequence) { + terminalDebugLog({ + scope: "output-delivery-queue", + event: "consume:ignored", + details: { + sequence: input.sequence, + inFlightSequence: this.inFlightChunk?.sequence ?? null, + }, + }); return; } diff --git a/packages/app/src/terminal/runtime/terminal-output-session.test.ts b/packages/app/src/terminal/runtime/terminal-output-session.test.ts new file mode 100644 index 000000000..6b8744a15 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-output-session.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { + getTerminalOutputSession, + releaseTerminalOutputSession, + retainTerminalOutputSession, +} from "./terminal-output-session"; + +describe("terminal-output-session", () => { + async function flushAsyncWork(): Promise { + await Promise.resolve(); + await new Promise((resolve) => { + setTimeout(() => resolve(), 0); + }); + await Promise.resolve(); + } + + it("returns the same session instance for the same scope key", () => { + const a = getTerminalOutputSession({ + scopeKey: "scope-a", + maxOutputChars: 1_000, + }); + const b = getTerminalOutputSession({ + scopeKey: "scope-a", + maxOutputChars: 50, + }); + + expect(a).toBe(b); + }); + + it("evicts a scope session when the retain count returns to zero", () => { + const scopeKey = "scope-retain-release"; + const first = getTerminalOutputSession({ + scopeKey, + maxOutputChars: 1_000, + }); + + retainTerminalOutputSession({ scopeKey }); + releaseTerminalOutputSession({ scopeKey }); + + const second = getTerminalOutputSession({ + scopeKey, + maxOutputChars: 1_000, + }); + + expect(second).not.toBe(first); + }); + + it("publishes selected terminal snapshot and chunk state via delivery/consume", async () => { + const session = getTerminalOutputSession({ + scopeKey: "scope-session-state", + maxOutputChars: 1_000, + }); + + session.setSelectedTerminal({ terminalId: null }); + session.setSelectedTerminal({ terminalId: "term-1" }); + + session.append({ + terminalId: "term-1", + text: "abc", + replay: false, + }); + await flushAsyncWork(); + + const stateAfterAppend = session.getState(); + expect(stateAfterAppend.selectedTerminalId).toBe("term-1"); + expect(stateAfterAppend.chunkSequence).toBeGreaterThan(0); + expect(stateAfterAppend.chunkText).toBe("abc"); + expect(stateAfterAppend.snapshotText).toBe("abc"); + expect(stateAfterAppend.snapshotSequence).toBe(stateAfterAppend.chunkSequence); + + session.consume({ sequence: stateAfterAppend.chunkSequence }); + + const stateAfterConsume = session.getState(); + expect(stateAfterConsume.snapshotText).toBe("abc"); + }); + + it("keeps chunks for non-selected terminals out of selected state", async () => { + const session = getTerminalOutputSession({ + scopeKey: "scope-other-terminal", + maxOutputChars: 1_000, + }); + + session.setSelectedTerminal({ terminalId: "term-1" }); + session.append({ + terminalId: "term-2", + text: "hidden", + replay: false, + }); + await flushAsyncWork(); + + const state = session.getState(); + expect(state.selectedTerminalId).toBe("term-1"); + expect(state.chunkText).toBe(""); + expect(state.snapshotText).toBe(""); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-output-session.ts b/packages/app/src/terminal/runtime/terminal-output-session.ts new file mode 100644 index 000000000..bb06ad0bd --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-output-session.ts @@ -0,0 +1,215 @@ +import { + TerminalOutputPump, + type TerminalOutputChunk, +} from "./terminal-output-pump"; +import { + TerminalOutputDeliveryQueue, + type TerminalOutputDeliveryChunk, +} from "./terminal-output-delivery-queue"; +import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug"; + +type TerminalOutputState = { + selectedTerminalId: string | null; + snapshotText: string; + snapshotSequence: number; + chunkText: string; + chunkSequence: number; + chunkReplay: boolean; +}; + +const EMPTY_STATE: TerminalOutputState = { + selectedTerminalId: null, + snapshotText: "", + snapshotSequence: 0, + chunkText: "", + chunkSequence: 0, + chunkReplay: false, +}; + +export type TerminalOutputSessionAppendInput = { + terminalId: string; + text: string; + replay: boolean; +}; + +export type TerminalOutputSessionSetSelectedInput = { + terminalId: string | null; +}; + +export type TerminalOutputSessionPruneInput = { + terminalIds: string[]; +}; + +export type TerminalOutputSessionConsumeInput = { + sequence: number; +}; + +export class TerminalOutputSession { + private readonly listeners = new Set<() => void>(); + private readonly outputPump: TerminalOutputPump; + private readonly deliveryQueue: TerminalOutputDeliveryQueue; + private state: TerminalOutputState = EMPTY_STATE; + + constructor(input: { maxOutputChars: number }) { + this.outputPump = new TerminalOutputPump({ + maxOutputChars: input.maxOutputChars, + onSelectedOutputChunk: (chunk: TerminalOutputChunk) => { + this.deliveryQueue.enqueue(chunk); + }, + }); + + this.deliveryQueue = new TerminalOutputDeliveryQueue({ + onDeliver: (chunk: TerminalOutputDeliveryChunk) => { + const snapshotText = this.outputPump.readSnapshot({ + terminalId: this.state.selectedTerminalId, + }); + this.state = { + ...this.state, + snapshotText, + snapshotSequence: chunk.sequence, + chunkText: chunk.text, + chunkSequence: chunk.sequence, + chunkReplay: chunk.replay, + }; + terminalDebugLog({ + scope: "output-session", + event: "deliver", + details: { + selectedTerminalId: this.state.selectedTerminalId, + sequence: chunk.sequence, + replay: chunk.replay, + chunkLength: chunk.text.length, + snapshotLength: snapshotText.length, + preview: summarizeTerminalText({ text: chunk.text, maxChars: 80 }), + }, + }); + this.emit(); + }, + }); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + getState(): TerminalOutputState { + return this.state; + } + + setSelectedTerminal(input: TerminalOutputSessionSetSelectedInput): void { + if (this.state.selectedTerminalId === input.terminalId) { + return; + } + + this.deliveryQueue.reset(); + this.outputPump.setSelectedTerminal({ terminalId: input.terminalId }); + const snapshotText = this.outputPump.readSnapshot({ + terminalId: input.terminalId, + }); + this.state = { + selectedTerminalId: input.terminalId, + snapshotText, + snapshotSequence: this.state.snapshotSequence, + chunkText: "", + chunkSequence: 0, + chunkReplay: false, + }; + terminalDebugLog({ + scope: "output-session", + event: "selected-terminal:set", + details: { + selectedTerminalId: input.terminalId, + snapshotLength: snapshotText.length, + }, + }); + this.emit(); + } + + append(input: TerminalOutputSessionAppendInput): void { + this.outputPump.append(input); + } + + clearTerminal(input: { terminalId: string }): void { + this.outputPump.clearTerminal(input); + if (this.state.selectedTerminalId !== input.terminalId) { + return; + } + + this.deliveryQueue.reset(); + this.state = { + ...this.state, + snapshotText: "", + chunkText: "", + chunkSequence: 0, + chunkReplay: false, + }; + terminalDebugLog({ + scope: "output-session", + event: "selected-terminal:clear", + details: { + terminalId: input.terminalId, + }, + }); + this.emit(); + } + + prune(input: TerminalOutputSessionPruneInput): void { + this.outputPump.prune(input); + } + + consume(input: TerminalOutputSessionConsumeInput): void { + this.deliveryQueue.consume(input); + } + + private emit(): void { + for (const listener of this.listeners) { + listener(); + } + } +} + +const sessionsByScopeKey = new Map(); +const sessionRefCountByScopeKey = new Map(); + +export function getTerminalOutputSession(input: { + scopeKey: string; + maxOutputChars: number; +}): TerminalOutputSession { + const existing = sessionsByScopeKey.get(input.scopeKey); + if (existing) { + return existing; + } + + const session = new TerminalOutputSession({ + maxOutputChars: input.maxOutputChars, + }); + sessionsByScopeKey.set(input.scopeKey, session); + return session; +} + +export function retainTerminalOutputSession(input: { scopeKey: string }): void { + const current = sessionRefCountByScopeKey.get(input.scopeKey) ?? 0; + sessionRefCountByScopeKey.set(input.scopeKey, current + 1); +} + +export function releaseTerminalOutputSession(input: { scopeKey: string }): void { + const current = sessionRefCountByScopeKey.get(input.scopeKey) ?? 0; + if (current <= 1) { + sessionRefCountByScopeKey.delete(input.scopeKey); + sessionsByScopeKey.delete(input.scopeKey); + terminalDebugLog({ + scope: "output-session", + event: "scope:release", + details: { + scopeKey: input.scopeKey, + released: true, + }, + }); + return; + } + + sessionRefCountByScopeKey.set(input.scopeKey, current - 1); +} diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 6f824cdc1..173489ba5 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -8,39 +8,11 @@ import { decodeWorkspaceIdFromPathSegment, encodeFilePathForPathSegment, encodeWorkspaceIdForPathSegment, - parseHostAgentDraftRouteFromPathname, parseHostAgentRouteFromPathname, - parseHostDraftRouteFromPathname, parseHostWorkspaceTabRouteFromPathname, parseHostWorkspaceRouteFromPathname, } from "./host-routes"; -describe("parseHostAgentDraftRouteFromPathname", () => { - it("parses draft route server id", () => { - expect(parseHostAgentDraftRouteFromPathname("/h/local/new")).toEqual({ - serverId: "local", - }); - }); - - it("parses encoded server id", () => { - expect(parseHostAgentDraftRouteFromPathname("/h/team%20host/new")).toEqual({ - serverId: "team host", - }); - }); - - it("does not match agent detail routes", () => { - expect(parseHostAgentDraftRouteFromPathname("/h/local/agent/abc123")).toBeNull(); - }); -}); - -describe("parseHostDraftRouteFromPathname", () => { - it("parses /new draft routes", () => { - expect(parseHostDraftRouteFromPathname("/h/local/new")).toEqual({ - serverId: "local", - }); - }); -}); - describe("parseHostAgentRouteFromPathname", () => { it("continues parsing detail routes", () => { expect(parseHostAgentRouteFromPathname("/h/local/agent/abc123")).toEqual({ diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index ba430dbd4..42bbef691 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -188,54 +188,6 @@ export function parseHostAgentRouteFromPathname( return { serverId, agentId }; } -export function parseHostAgentDraftRouteFromPathname( - pathname: string -): { serverId: string } | null { - const match = pathname.match(/^\/h\/([^/]+)\/(?:agent|new)\/?$/); - if (!match) { - return null; - } - const encodedServerId = match[1]; - if (!encodedServerId) { - return null; - } - const serverId = trimNonEmpty(decodeSegment(encodedServerId)); - if (!serverId) { - return null; - } - return { serverId }; -} - -export function buildHostAgentDraftRoute(serverId: string): string { - return buildHostDraftRoute(serverId); -} - -export function parseHostDraftRouteFromPathname( - pathname: string -): { serverId: string } | null { - const match = pathname.match(/^\/h\/([^/]+)\/new\/?$/); - if (!match) { - return null; - } - const encodedServerId = match[1]; - if (!encodedServerId) { - return null; - } - const serverId = trimNonEmpty(decodeSegment(encodedServerId)); - if (!serverId) { - return null; - } - return { serverId }; -} - -export function buildHostDraftRoute(serverId: string): string { - const normalized = trimNonEmpty(serverId); - if (!normalized) { - return "/"; - } - return `/h/${encodeSegment(normalized)}/new`; -} - export function parseHostWorkspaceRouteFromPathname( pathname: string ): { serverId: string; workspaceId: string } | null { @@ -445,9 +397,6 @@ export function mapPathnameToServer( if (suffix.startsWith("agents")) { return `${base}/agents`; } - if (suffix.startsWith("new")) { - return `${base}/new`; - } if (suffix.startsWith("workspace/")) { return `${base}/${suffix}`; } diff --git a/packages/app/src/utils/new-agent-routing.test.ts b/packages/app/src/utils/new-agent-routing.test.ts index 7e15b8426..90e90520c 100644 --- a/packages/app/src/utils/new-agent-routing.test.ts +++ b/packages/app/src/utils/new-agent-routing.test.ts @@ -9,14 +9,16 @@ import { } from "./new-agent-routing"; describe("buildNewAgentRoute", () => { - it("falls back to host-scoped draft route when no working directory is provided", () => { - expect(buildNewAgentRoute("srv-1", undefined)).toBe("/h/srv-1/new"); - expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/new"); + it("falls back to server workspace route with dot workspace when no working directory is provided", () => { + expect(buildNewAgentRoute("srv-1", undefined)).toBe( + "/h/srv-1/workspace/Lg" + ); + expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/workspace/Lg"); }); - it("encodes the working directory query parameter", () => { + it("encodes the working directory as a workspace path segment", () => { expect(buildNewAgentRoute("srv-1", "/Users/me/dev/paseo")).toBe( - "/h/srv-1/new?workingDir=%2FUsers%2Fme%2Fdev%2Fpaseo" + "/h/srv-1/workspace/L1VzZXJzL21lL2Rldi9wYXNlbw" ); }); }); diff --git a/packages/app/src/utils/new-agent-routing.ts b/packages/app/src/utils/new-agent-routing.ts index 7472e1977..6383a068b 100644 --- a/packages/app/src/utils/new-agent-routing.ts +++ b/packages/app/src/utils/new-agent-routing.ts @@ -1,6 +1,6 @@ import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query"; import { - buildHostAgentDraftRoute, + buildHostWorkspaceRoute, parseHostAgentRouteFromPathname, parseHostWorkspaceTabRouteFromPathname, } from "@/utils/host-routes"; @@ -74,13 +74,6 @@ export function buildNewAgentRoute( serverId: string, workingDir?: string | null ): string { - const baseRoute = buildHostAgentDraftRoute(serverId); - if (baseRoute === "/") { - return baseRoute; - } const trimmedWorkingDir = workingDir?.trim(); - if (!trimmedWorkingDir) { - return baseRoute; - } - return `${baseRoute}?workingDir=${encodeURIComponent(trimmedWorkingDir)}`; + return buildHostWorkspaceRoute(serverId, trimmedWorkingDir || "."); } diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts index d6fefc80e..ee85ab628 100644 --- a/packages/app/src/utils/notification-routing.test.ts +++ b/packages/app/src/utils/notification-routing.test.ts @@ -50,8 +50,8 @@ describe("buildNotificationRoute", () => { ); }); - it("falls back to host-scoped draft route when only serverId is present", () => { - expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only/new"); + it("falls back to host root when only serverId is present", () => { + expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only"); }); it("falls back to root when no server id is present", () => { diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts index 008a2c918..6f99fe257 100644 --- a/packages/app/src/utils/notification-routing.ts +++ b/packages/app/src/utils/notification-routing.ts @@ -1,5 +1,4 @@ import { - buildHostDraftRoute, buildHostAgentDetailRoute, buildHostWorkspaceAgentTabRoute, } from "@/utils/host-routes"; @@ -41,7 +40,7 @@ export function buildNotificationRoute(data: NotificationData): string { return buildHostAgentDetailRoute(serverId, agentId); } if (serverId) { - return buildHostDraftRoute(serverId); + return `/h/${encodeURIComponent(serverId)}`; } return "/"; }