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 "/";
}