refactor: remove draft agent routes and consolidate terminal output into session model

This commit is contained in:
Mohamed Boudra
2026-03-04 14:17:39 +07:00
parent b21764fd40
commit 92991ffdb8
25 changed files with 634 additions and 317 deletions
+1 -4
View File
@@ -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() {
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/new" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/index" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/tab/[tabId]" />
<Stack.Screen
@@ -480,7 +478,6 @@ export default function RootLayout() {
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/agent/index" />
<Stack.Screen name="h/[serverId]/agents" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
@@ -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 () => {
@@ -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;
}
+11 -3
View File
@@ -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;
}
@@ -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 <DraftAgentScreen forcedServerId={serverId} />;
}
+1 -2
View File
@@ -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) {
@@ -634,7 +634,12 @@ function WorkspaceRowWithMenu({
dragHandleProps={dragHandleProps}
menuController={contextMenu}
/>
<ContextMenuContent align="start" width={220} testID={`sidebar-workspace-context-${workspace.workspaceKey}`}>
<ContextMenuContent
align="start"
width={220}
mobileMode="sheet"
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
>
<ContextMenuItem
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
status={archiveStatus}
@@ -6,6 +6,10 @@ import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import {
summarizeTerminalText,
terminalDebugLog,
} from "../terminal/runtime/terminal-debug";
function buildXtermThemeKey(theme: ITheme): string {
const values: Array<string> = [
@@ -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<HTMLDivElement | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
const appliedInitialOutputRef = useRef<string | null>(null);
const appliedChunkSequenceRef = useRef(0);
const mountedThemeRef = useRef<ITheme>(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();
}}
>
+62 -85
View File
@@ -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<TerminalStreamController | null>(null);
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
const outputDeliveryQueueRef = useRef<TerminalOutputDeliveryQueue | null>(null);
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
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}
@@ -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<BottomSheetModal>(null);
const sheetSnapPoints = useMemo(() => ["30%", "55%"], []);
const [triggerRect, setTriggerRect] = useState<Rect | null>(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<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
opacity={0.45}
/>
),
[]
);
// 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 (
<ContextMenuContext.Provider value={context}>
<BottomSheetModal
ref={bottomSheetRef}
index={0}
snapPoints={sheetSnapPoints}
enableDynamicSizing={false}
onChange={handleSheetChange}
backdropComponent={renderSheetBackdrop}
enablePanDownToClose
backgroundStyle={styles.sheetBackground}
handleIndicatorStyle={styles.sheetHandle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
<BottomSheetScrollView
contentContainerStyle={styles.sheetScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
testID={testID ? `${testID}-content` : undefined}
>
{children}
</BottomSheetScrollView>
</BottomSheetModal>
</ContextMenuContext.Provider>
);
}
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],
@@ -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]
);
@@ -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);
@@ -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;
}
@@ -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]);
+1 -2
View File
@@ -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 }) {
<View style={styles.container}>
<BackHeader
title="All agents"
onBack={() => router.replace(buildHostAgentDraftRoute(serverId) as any)}
onBack={() => router.replace(`/h/${encodeURIComponent(serverId)}` as any)}
/>
<AgentList
agents={sortedAgents}
@@ -306,6 +306,7 @@ function WorkspaceScreenContent({
const workspaceHeader = workspaceDescriptor
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
: null;
const isWorkspaceHeaderLoading = workspaceHeader === null;
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
const mobileView = usePanelStore((state) => 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<string | null>(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({
<>
<SidebarMenuToggle />
<View style={styles.headerTitleContainer}>
<Text style={styles.headerTitle} numberOfLines={1}>
{workspaceHeader ? workspaceHeader.title : "Loading workspace…"}
</Text>
<Text style={styles.headerProjectTitle} numberOfLines={1}>
{workspaceHeader ? workspaceHeader.subtitle : "Awaiting workspace descriptor"}
</Text>
{isWorkspaceHeaderLoading ? (
<>
<View style={styles.headerTitleSkeleton} />
<View style={styles.headerProjectTitleSkeleton} />
</>
) : (
<>
<Text style={styles.headerTitle} numberOfLines={1}>
{workspaceHeader.title}
</Text>
<Text style={styles.headerProjectTitle} numberOfLines={1}>
{workspaceHeader.subtitle}
</Text>
</>
)}
</View>
</>
}
@@ -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",
@@ -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;
}
@@ -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<void> {
await Promise.resolve();
await new Promise<void>((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("");
});
});
@@ -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<string, TerminalOutputSession>();
const sessionRefCountByScopeKey = new Map<string, number>();
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);
}
@@ -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({
-51
View File
@@ -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}`;
}
@@ -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"
);
});
});
+2 -9
View File
@@ -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 || ".");
}
@@ -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", () => {
@@ -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 "/";
}