refactor: remove draft agent routes and consolidate terminal output into session model
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 "/";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user