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
@@ -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]
);