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";
|
} from "@/utils/os-notifications";
|
||||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||||
import {
|
import {
|
||||||
buildHostDraftRoute,
|
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostWorkspaceTabRouteFromPathname,
|
parseHostWorkspaceTabRouteFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
@@ -351,7 +350,7 @@ function OfferLinkListener({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const serverId = (profile as any)?.serverId;
|
const serverId = (profile as any)?.serverId;
|
||||||
if (typeof serverId !== "string" || !serverId) return;
|
if (typeof serverId !== "string" || !serverId) return;
|
||||||
router.replace(buildHostDraftRoute(serverId) as any);
|
router.replace(`/h/${encodeURIComponent(serverId)}` as any);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -472,7 +471,6 @@ export default function RootLayout() {
|
|||||||
>
|
>
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
<Stack.Screen name="settings" />
|
<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]/index" />
|
||||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/tab/[tabId]" />
|
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/tab/[tabId]" />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -480,7 +478,6 @@ export default function RootLayout() {
|
|||||||
options={{ gestureEnabled: false }}
|
options={{ gestureEnabled: false }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="h/[serverId]/index" />
|
<Stack.Screen name="h/[serverId]/index" />
|
||||||
<Stack.Screen name="h/[serverId]/agent/index" />
|
|
||||||
<Stack.Screen name="h/[serverId]/agents" />
|
<Stack.Screen name="h/[serverId]/agents" />
|
||||||
<Stack.Screen name="h/[serverId]/settings" />
|
<Stack.Screen name="h/[serverId]/settings" />
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useLocalSearchParams, useRouter } from "expo-router";
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||||
import {
|
import {
|
||||||
buildHostDraftRoute,
|
|
||||||
buildHostWorkspaceAgentTabRoute,
|
buildHostWorkspaceAgentTabRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ export default function HostAgentReadyRoute() {
|
|||||||
}
|
}
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostDraftRoute(serverId) as any);
|
router.replace(`/h/${encodeURIComponent(serverId)}` as any);
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
||||||
|
|
||||||
@@ -80,14 +79,14 @@ export default function HostAgentReadyRoute() {
|
|||||||
router.replace(buildHostWorkspaceAgentTabRoute(serverId, cwd, agentId) as any);
|
router.replace(buildHostWorkspaceAgentTabRoute(serverId, cwd, agentId) as any);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostDraftRoute(serverId) as any);
|
router.replace(`/h/${encodeURIComponent(serverId)}` as any);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled || redirectedRef.current) {
|
if (cancelled || redirectedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostDraftRoute(serverId) as any);
|
router.replace(`/h/${encodeURIComponent(serverId)}` as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
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 { useEffect } from "react";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import {
|
import {
|
||||||
buildHostDraftRoute,
|
buildHostWorkspaceRoute,
|
||||||
buildHostWorkspaceAgentTabRoute,
|
buildHostWorkspaceAgentTabRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
@@ -10,11 +11,15 @@ export default function HostIndexRoute() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
|
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||||
const sessionAgents = useSessionStore(
|
const sessionAgents = useSessionStore(
|
||||||
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
|
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (preferencesLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -40,8 +45,11 @@ export default function HostIndexRoute() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.replace(buildHostDraftRoute(serverId) as any);
|
const preferredWorkingDir =
|
||||||
}, [router, serverId, sessionAgents]);
|
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;
|
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 { probeConnection } from "@/utils/test-daemon-connection";
|
||||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||||
import {
|
import {
|
||||||
buildHostAgentDraftRoute,
|
|
||||||
buildHostSettingsRoute,
|
buildHostSettingsRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
@@ -170,7 +169,7 @@ export default function PairScanScreen() {
|
|||||||
const returnToSource = useCallback(
|
const returnToSource = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
if (source === "onboarding") {
|
if (source === "onboarding") {
|
||||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
router.replace(`/h/${encodeURIComponent(serverId)}` as any);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (source === "editHost" && targetServerId) {
|
if (source === "editHost" && targetServerId) {
|
||||||
|
|||||||
@@ -634,7 +634,12 @@ function WorkspaceRowWithMenu({
|
|||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
menuController={contextMenu}
|
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
|
<ContextMenuItem
|
||||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
||||||
status={archiveStatus}
|
status={archiveStatus}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import "@xterm/xterm/css/xterm.css";
|
|||||||
import type { ITheme } from "@xterm/xterm";
|
import type { ITheme } from "@xterm/xterm";
|
||||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||||
|
import {
|
||||||
|
summarizeTerminalText,
|
||||||
|
terminalDebugLog,
|
||||||
|
} from "../terminal/runtime/terminal-debug";
|
||||||
|
|
||||||
function buildXtermThemeKey(theme: ITheme): string {
|
function buildXtermThemeKey(theme: ITheme): string {
|
||||||
const values: Array<string> = [
|
const values: Array<string> = [
|
||||||
@@ -40,6 +44,7 @@ interface TerminalEmulatorProps {
|
|||||||
dom?: DOMProps;
|
dom?: DOMProps;
|
||||||
streamKey: string;
|
streamKey: string;
|
||||||
initialOutputText: string;
|
initialOutputText: string;
|
||||||
|
initialOutputChunkSequence?: number;
|
||||||
outputChunkText: string;
|
outputChunkText: string;
|
||||||
outputChunkSequence: number;
|
outputChunkSequence: number;
|
||||||
outputChunkReplay?: boolean;
|
outputChunkReplay?: boolean;
|
||||||
@@ -71,6 +76,7 @@ declare global {
|
|||||||
export default function TerminalEmulator({
|
export default function TerminalEmulator({
|
||||||
streamKey,
|
streamKey,
|
||||||
initialOutputText,
|
initialOutputText,
|
||||||
|
initialOutputChunkSequence = 0,
|
||||||
outputChunkText,
|
outputChunkText,
|
||||||
outputChunkSequence,
|
outputChunkSequence,
|
||||||
outputChunkReplay = false,
|
outputChunkReplay = false,
|
||||||
@@ -95,7 +101,7 @@ export default function TerminalEmulator({
|
|||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||||
const appliedInitialOutputRef = useRef<string | null>(null);
|
const appliedChunkSequenceRef = useRef(0);
|
||||||
const mountedThemeRef = useRef<ITheme>(xtermTheme);
|
const mountedThemeRef = useRef<ITheme>(xtermTheme);
|
||||||
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
||||||
|
|
||||||
@@ -244,34 +250,20 @@ export default function TerminalEmulator({
|
|||||||
initialOutputText,
|
initialOutputText,
|
||||||
theme: mountedThemeRef.current,
|
theme: mountedThemeRef.current,
|
||||||
});
|
});
|
||||||
appliedInitialOutputRef.current = initialOutputText;
|
appliedChunkSequenceRef.current = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(initialOutputChunkSequence)
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
runtime.unmount();
|
runtime.unmount();
|
||||||
if (runtimeRef.current === runtime) {
|
if (runtimeRef.current === runtime) {
|
||||||
runtimeRef.current = null;
|
runtimeRef.current = null;
|
||||||
}
|
}
|
||||||
appliedInitialOutputRef.current = null;
|
appliedChunkSequenceRef.current = 0;
|
||||||
};
|
};
|
||||||
}, [streamKey]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
runtimeRef.current?.setCallbacks({
|
runtimeRef.current?.setCallbacks({
|
||||||
callbacks: {
|
callbacks: {
|
||||||
@@ -293,12 +285,34 @@ export default function TerminalEmulator({
|
|||||||
return;
|
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) {
|
if (!runtime) {
|
||||||
onOutputChunkConsumed?.(outputChunkSequence);
|
onOutputChunkConsumed?.(outputChunkSequence);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appliedChunkSequenceRef.current = outputChunkSequence;
|
||||||
|
|
||||||
if (outputChunkText.length === 0) {
|
if (outputChunkText.length === 0) {
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "emulator-component",
|
||||||
|
event: "output:chunk:clear",
|
||||||
|
details: {
|
||||||
|
sequence: outputChunkSequence,
|
||||||
|
},
|
||||||
|
});
|
||||||
runtime.clear({
|
runtime.clear({
|
||||||
onCommitted: () => {
|
onCommitted: () => {
|
||||||
onOutputChunkConsumed?.(outputChunkSequence);
|
onOutputChunkConsumed?.(outputChunkSequence);
|
||||||
@@ -306,6 +320,16 @@ export default function TerminalEmulator({
|
|||||||
});
|
});
|
||||||
return;
|
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({
|
runtime.write({
|
||||||
text: outputChunkText,
|
text: outputChunkText,
|
||||||
suppressInput: outputChunkReplay,
|
suppressInput: outputChunkReplay,
|
||||||
@@ -346,6 +370,10 @@ export default function TerminalEmulator({
|
|||||||
touchAction: "pan-y",
|
touchAction: "pan-y",
|
||||||
}}
|
}}
|
||||||
onPointerDown={() => {
|
onPointerDown={() => {
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "emulator-component",
|
||||||
|
event: "surface:pointer-down-focus",
|
||||||
|
});
|
||||||
runtimeRef.current?.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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -29,10 +29,10 @@ import {
|
|||||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import {
|
import {
|
||||||
TerminalOutputPump,
|
getTerminalOutputSession,
|
||||||
type TerminalOutputChunk,
|
releaseTerminalOutputSession,
|
||||||
} from "@/terminal/runtime/terminal-output-pump";
|
retainTerminalOutputSession,
|
||||||
import { TerminalOutputDeliveryQueue } from "@/terminal/runtime/terminal-output-delivery-queue";
|
} from "@/terminal/runtime/terminal-output-session";
|
||||||
import {
|
import {
|
||||||
TerminalStreamController,
|
TerminalStreamController,
|
||||||
type TerminalStreamControllerStatus,
|
type TerminalStreamControllerStatus,
|
||||||
@@ -82,12 +82,6 @@ type ModifierState = {
|
|||||||
alt: boolean;
|
alt: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TerminalOutputChunkState = {
|
|
||||||
sequence: number;
|
|
||||||
text: string;
|
|
||||||
replay: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PendingTerminalInput =
|
type PendingTerminalInput =
|
||||||
| {
|
| {
|
||||||
type: "data";
|
type: "data";
|
||||||
@@ -147,6 +141,7 @@ export function TerminalPane({
|
|||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
manageTerminalDirectorySubscription = true,
|
manageTerminalDirectorySubscription = true,
|
||||||
}: TerminalPaneProps) {
|
}: TerminalPaneProps) {
|
||||||
|
const isScreenFocused = useIsFocused();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
||||||
const isMobile =
|
const isMobile =
|
||||||
@@ -167,14 +162,33 @@ export function TerminalPane({
|
|||||||
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
|
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
|
||||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||||
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
|
const outputSession = useMemo(
|
||||||
const outputDeliveryQueueRef = useRef<TerminalOutputDeliveryQueue | null>(null);
|
() =>
|
||||||
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
getTerminalOutputSession({
|
||||||
sequence: 0,
|
scopeKey,
|
||||||
text: "",
|
maxOutputChars: MAX_OUTPUT_CHARS,
|
||||||
replay: false,
|
}),
|
||||||
});
|
[scopeKey]
|
||||||
const [selectedOutputSnapshot, setSelectedOutputSnapshot] = useState("");
|
);
|
||||||
|
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<{
|
const [activeStream, setActiveStream] = useState<{
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
streamId: number;
|
streamId: number;
|
||||||
@@ -204,39 +218,6 @@ export function TerminalPane({
|
|||||||
selectedTerminalIdRef.current = selectedTerminalId;
|
selectedTerminalIdRef.current = selectedTerminalId;
|
||||||
}, [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(() => {
|
const clearHoverOutTimeout = useCallback(() => {
|
||||||
if (!hoverOutTimeoutRef.current) {
|
if (!hoverOutTimeoutRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -467,7 +448,7 @@ export function TerminalPane({
|
|||||||
},
|
},
|
||||||
onSuccess: (_, terminalId) => {
|
onSuccess: (_, terminalId) => {
|
||||||
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||||
outputPumpRef.current?.clearTerminal({ terminalId });
|
outputSession.clearTerminal({ terminalId });
|
||||||
if (selectedTerminalIdRef.current === terminalId) {
|
if (selectedTerminalIdRef.current === terminalId) {
|
||||||
updateSelectedTerminalId(null);
|
updateSelectedTerminalId(null);
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
@@ -488,9 +469,9 @@ export function TerminalPane({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const terminalIds = terminals.map((terminal) => terminal.id);
|
const terminalIds = terminals.map((terminal) => terminal.id);
|
||||||
outputPumpRef.current?.prune({ terminalIds });
|
outputSession.prune({ terminalIds });
|
||||||
streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
|
streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
|
||||||
}, [terminals]);
|
}, [outputSession, terminals]);
|
||||||
|
|
||||||
const handleStreamControllerStatus = useCallback(
|
const handleStreamControllerStatus = useCallback(
|
||||||
(status: TerminalStreamControllerStatus) => {
|
(status: TerminalStreamControllerStatus) => {
|
||||||
@@ -519,28 +500,22 @@ export function TerminalPane({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputPump = outputPumpRef.current;
|
|
||||||
if (!outputPump) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new TerminalStreamController({
|
const controller = new TerminalStreamController({
|
||||||
client,
|
client,
|
||||||
getPreferredSize: () => lastReportedSizeRef.current,
|
getPreferredSize: () => lastReportedSizeRef.current,
|
||||||
onChunk: ({ terminalId, text, replay }) => {
|
onChunk: ({ terminalId, text, replay }) => {
|
||||||
outputPump.append({ terminalId, text, replay });
|
outputSession.append({ terminalId, text, replay });
|
||||||
},
|
},
|
||||||
onReset: ({ terminalId }) => {
|
onReset: ({ terminalId }) => {
|
||||||
outputPump.clearTerminal({ terminalId });
|
outputSession.clearTerminal({ terminalId });
|
||||||
if (selectedTerminalIdRef.current === terminalId) {
|
|
||||||
setSelectedOutputSnapshot("");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onStatusChange: handleStreamControllerStatus,
|
onStatusChange: handleStreamControllerStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
streamControllerRef.current = controller;
|
streamControllerRef.current = controller;
|
||||||
controller.setTerminal({ terminalId: selectedTerminalIdRef.current });
|
controller.setTerminal({
|
||||||
|
terminalId: isScreenFocused ? selectedTerminalIdRef.current : null,
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
@@ -548,24 +523,18 @@ export function TerminalPane({
|
|||||||
streamControllerRef.current = null;
|
streamControllerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [client, handleStreamControllerStatus, isConnected]);
|
}, [client, handleStreamControllerStatus, isConnected, isScreenFocused, outputSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
outputDeliveryQueueRef.current?.reset();
|
|
||||||
pendingTerminalInputRef.current = [];
|
pendingTerminalInputRef.current = [];
|
||||||
setSelectedOutputChunk({ sequence: 0, text: "", replay: false });
|
const nextSelectedTerminalId = isScreenFocused ? selectedTerminalId : null;
|
||||||
outputPumpRef.current?.setSelectedTerminal({
|
outputSession.setSelectedTerminal({
|
||||||
terminalId: selectedTerminalId,
|
terminalId: nextSelectedTerminalId,
|
||||||
});
|
});
|
||||||
streamControllerRef.current?.setTerminal({
|
streamControllerRef.current?.setTerminal({
|
||||||
terminalId: selectedTerminalId,
|
terminalId: nextSelectedTerminalId,
|
||||||
});
|
});
|
||||||
setSelectedOutputSnapshot(
|
}, [isScreenFocused, outputSession, selectedTerminalId]);
|
||||||
outputPumpRef.current?.readSnapshot({
|
|
||||||
terminalId: selectedTerminalId,
|
|
||||||
}) ?? ""
|
|
||||||
);
|
|
||||||
}, [selectedTerminalId]);
|
|
||||||
|
|
||||||
const activeStreamId =
|
const activeStreamId =
|
||||||
activeStream && activeStream.terminalId === selectedTerminalId
|
activeStream && activeStream.terminalId === selectedTerminalId
|
||||||
@@ -870,8 +839,15 @@ export function TerminalPane({
|
|||||||
}, [clearPendingModifiers]);
|
}, [clearPendingModifiers]);
|
||||||
|
|
||||||
const handleOutputChunkConsumed = useCallback((sequence: number) => {
|
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(
|
const toggleModifier = useCallback(
|
||||||
(modifier: keyof ModifierState) => {
|
(modifier: keyof ModifierState) => {
|
||||||
@@ -1046,10 +1022,11 @@ export function TerminalPane({
|
|||||||
contentInsetAdjustmentBehavior: "never",
|
contentInsetAdjustmentBehavior: "never",
|
||||||
}}
|
}}
|
||||||
streamKey={`${scopeKey}:${selectedTerminal.id}`}
|
streamKey={`${scopeKey}:${selectedTerminal.id}`}
|
||||||
initialOutputText={selectedOutputSnapshot}
|
initialOutputText={outputState.snapshotText}
|
||||||
outputChunkText={selectedOutputChunk.text}
|
initialOutputChunkSequence={outputState.snapshotSequence}
|
||||||
outputChunkSequence={selectedOutputChunk.sequence}
|
outputChunkText={outputState.chunkText}
|
||||||
outputChunkReplay={selectedOutputChunk.replay}
|
outputChunkSequence={outputState.chunkSequence}
|
||||||
|
outputChunkReplay={outputState.chunkReplay}
|
||||||
testId="terminal-surface"
|
testId="terminal-surface"
|
||||||
xtermTheme={xtermTheme}
|
xtermTheme={xtermTheme}
|
||||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
type ComponentProps,
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
@@ -24,14 +25,20 @@ import {
|
|||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
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 { Check, CheckCircle } from "lucide-react-native";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
|
||||||
// Keep parity with dropdown-menu action statuses.
|
// Keep parity with dropdown-menu action statuses.
|
||||||
export type ActionStatus = "idle" | "pending" | "success";
|
export type ActionStatus = "idle" | "pending" | "success";
|
||||||
|
|
||||||
type Placement = "top" | "bottom" | "left" | "right";
|
type Placement = "top" | "bottom" | "left" | "right";
|
||||||
type Alignment = "start" | "center" | "end";
|
type Alignment = "start" | "center" | "end";
|
||||||
|
type MobileMenuMode = "dropdown" | "sheet";
|
||||||
|
|
||||||
interface Rect {
|
interface Rect {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -308,6 +315,7 @@ export function ContextMenuContent({
|
|||||||
maxWidth,
|
maxWidth,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
horizontalPadding = 16,
|
horizontalPadding = 16,
|
||||||
|
mobileMode = "dropdown",
|
||||||
testID,
|
testID,
|
||||||
}: PropsWithChildren<{
|
}: PropsWithChildren<{
|
||||||
side?: Placement;
|
side?: Placement;
|
||||||
@@ -318,9 +326,15 @@ export function ContextMenuContent({
|
|||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
horizontalPadding?: number;
|
horizontalPadding?: number;
|
||||||
|
mobileMode?: MobileMenuMode;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}>): ReactElement | null {
|
}>): 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 [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
@@ -329,8 +343,45 @@ export function ContextMenuContent({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [setOpen]);
|
}, [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.
|
// Measure trigger when opening (fallback) and capture point anchors.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (useMobileSheet) {
|
||||||
|
setTriggerRect(null);
|
||||||
|
setContentSize(null);
|
||||||
|
setPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setTriggerRect(null);
|
setTriggerRect(null);
|
||||||
setContentSize(null);
|
setContentSize(null);
|
||||||
@@ -362,10 +413,11 @@ export function ContextMenuContent({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [anchorRect, open, triggerRef]);
|
}, [anchorRect, open, triggerRef, useMobileSheet]);
|
||||||
|
|
||||||
// Calculate position when we have both measurements
|
// Calculate position when we have both measurements
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (useMobileSheet) return;
|
||||||
if (!triggerRect || !contentSize) return;
|
if (!triggerRect || !contentSize) return;
|
||||||
|
|
||||||
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||||
@@ -387,7 +439,7 @@ export function ContextMenuContent({
|
|||||||
|
|
||||||
const x = fullWidth ? horizontalPadding : result.x;
|
const x = fullWidth ? horizontalPadding : result.x;
|
||||||
setPosition({ x, y: result.y });
|
setPosition({ x, y: result.y });
|
||||||
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]);
|
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding, useMobileSheet]);
|
||||||
|
|
||||||
const handleContentLayout = useCallback(
|
const handleContentLayout = useCallback(
|
||||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
(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;
|
if (!open) return null;
|
||||||
|
|
||||||
const { width: screenWidth } = Dimensions.get("window");
|
const { width: screenWidth } = Dimensions.get("window");
|
||||||
@@ -616,6 +697,20 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
elevation: 8,
|
elevation: 8,
|
||||||
overflow: "hidden",
|
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: {
|
labelContainer: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingTop: theme.spacing[2],
|
paddingTop: theme.spacing[2],
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useSessionStore } from "@/stores/session-store";
|
|||||||
import { AddHostModal } from "./add-host-modal";
|
import { AddHostModal } from "./add-host-modal";
|
||||||
import { PairLinkModal } from "./pair-link-modal";
|
import { PairLinkModal } from "./pair-link-modal";
|
||||||
import { NameHostModal } from "./name-host-modal";
|
import { NameHostModal } from "./name-host-modal";
|
||||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
|
||||||
import { resolveAppVersion } from "@/utils/app-version";
|
import { resolveAppVersion } from "@/utils/app-version";
|
||||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||||
|
|
||||||
@@ -106,7 +105,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
|
|
||||||
const finishOnboarding = useCallback(
|
const finishOnboarding = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
router.replace(buildHostAgentDraftRoute(serverId) as any);
|
router.replace(`/h/${encodeURIComponent(serverId)}` as any);
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ describe("keyboard-shortcut-routing", () => {
|
|||||||
expect(canToggle).toBe(true);
|
expect(canToggle).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows the shortcut on draft routes", () => {
|
it("allows the shortcut on workspace draft-tab routes", () => {
|
||||||
const canToggle = canToggleFileExplorerShortcut({
|
const canToggle = canToggleFileExplorerShortcut({
|
||||||
pathname: "/h/server-1/new",
|
pathname: "/h/server-1/workspace/workspace-1/tab/draft_123",
|
||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ describe("keyboard-shortcut-routing", () => {
|
|||||||
|
|
||||||
it("blocks the shortcut when no toggle handler exists", () => {
|
it("blocks the shortcut when no toggle handler exists", () => {
|
||||||
const canToggle = canToggleFileExplorerShortcut({
|
const canToggle = canToggleFileExplorerShortcut({
|
||||||
pathname: "/h/server-1/new",
|
pathname: "/h/server-1/workspace/workspace-1/tab/draft_123",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(canToggle).toBe(false);
|
expect(canToggle).toBe(false);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
parseHostAgentDraftRouteFromPathname,
|
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
parseHostWorkspaceTabRouteFromPathname,
|
parseHostWorkspaceTabRouteFromPathname,
|
||||||
@@ -29,11 +28,7 @@ export function resolveSelectedOrRouteAgentKey(input: {
|
|||||||
|
|
||||||
const route = parseHostAgentRouteFromPathname(input.pathname);
|
const route = parseHostAgentRouteFromPathname(input.pathname);
|
||||||
if (!route) {
|
if (!route) {
|
||||||
const draftRoute = parseHostAgentDraftRouteFromPathname(input.pathname);
|
return null;
|
||||||
if (!draftRoute) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return `${draftRoute.serverId}:${DRAFT_AGENT_ID}`;
|
|
||||||
}
|
}
|
||||||
return `${route.serverId}:${route.agentId}`;
|
return `${route.serverId}:${route.agentId}`;
|
||||||
}
|
}
|
||||||
@@ -54,9 +49,5 @@ export function canToggleFileExplorerShortcut(input: {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parseHostAgentDraftRouteFromPathname(input.pathname)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
|||||||
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
|
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
|
||||||
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
|
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||||
|
|
||||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||||
@@ -529,7 +528,7 @@ function AgentScreenContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasRedirectedArchivedAgentRef.current = true;
|
hasRedirectedArchivedAgentRef.current = true;
|
||||||
const route: Href = buildHostAgentDraftRoute(serverId) as Href;
|
const route: Href = `/h/${encodeURIComponent(serverId)}` as Href;
|
||||||
router.replace(route);
|
router.replace(route);
|
||||||
}, [agent?.archivedAt, resolvedAgentId, router, serverId]);
|
}, [agent?.archivedAt, resolvedAgentId, router, serverId]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { StyleSheet } from "react-native-unistyles";
|
|||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
import { AgentList } from "@/components/agent-list";
|
import { AgentList } from "@/components/agent-list";
|
||||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||||
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
|
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
|
|
||||||
export function AgentsScreen({ serverId }: { serverId: string }) {
|
export function AgentsScreen({ serverId }: { serverId: string }) {
|
||||||
@@ -44,7 +43,7 @@ export function AgentsScreen({ serverId }: { serverId: string }) {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<BackHeader
|
<BackHeader
|
||||||
title="All agents"
|
title="All agents"
|
||||||
onBack={() => router.replace(buildHostAgentDraftRoute(serverId) as any)}
|
onBack={() => router.replace(`/h/${encodeURIComponent(serverId)}` as any)}
|
||||||
/>
|
/>
|
||||||
<AgentList
|
<AgentList
|
||||||
agents={sortedAgents}
|
agents={sortedAgents}
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ function WorkspaceScreenContent({
|
|||||||
const workspaceHeader = workspaceDescriptor
|
const workspaceHeader = workspaceDescriptor
|
||||||
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
|
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
|
||||||
: null;
|
: null;
|
||||||
|
const isWorkspaceHeaderLoading = workspaceHeader === null;
|
||||||
|
|
||||||
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
@@ -417,21 +418,6 @@ function WorkspaceScreenContent({
|
|||||||
if (!normalized || !persistenceKey) {
|
if (!normalized || !persistenceKey) {
|
||||||
return;
|
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_")) {
|
if (normalized.startsWith("file_")) {
|
||||||
const path = normalized.slice("file_".length).trim();
|
const path = normalized.slice("file_".length).trim();
|
||||||
if (!path) {
|
if (!path) {
|
||||||
@@ -529,6 +515,38 @@ function WorkspaceScreenContent({
|
|||||||
[normalizedServerId, normalizedWorkspaceId, router]
|
[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(
|
const handleOpenFileFromExplorer = useCallback(
|
||||||
(filePath: string) => {
|
(filePath: string) => {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@@ -1025,12 +1043,21 @@ function WorkspaceScreenContent({
|
|||||||
<>
|
<>
|
||||||
<SidebarMenuToggle />
|
<SidebarMenuToggle />
|
||||||
<View style={styles.headerTitleContainer}>
|
<View style={styles.headerTitleContainer}>
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
{isWorkspaceHeaderLoading ? (
|
||||||
{workspaceHeader ? workspaceHeader.title : "Loading workspace…"}
|
<>
|
||||||
</Text>
|
<View style={styles.headerTitleSkeleton} />
|
||||||
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
<View style={styles.headerProjectTitleSkeleton} />
|
||||||
{workspaceHeader ? workspaceHeader.subtitle : "Awaiting workspace descriptor"}
|
</>
|
||||||
</Text>
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
|
{workspaceHeader.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
||||||
|
{workspaceHeader.subtitle}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -1326,6 +1353,22 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
flexShrink: 1,
|
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: {
|
headerRight: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ export class TerminalOutputDeliveryQueue {
|
|||||||
|
|
||||||
consume(input: { sequence: number }): void {
|
consume(input: { sequence: number }): void {
|
||||||
if (this.inFlightChunk?.sequence !== input.sequence) {
|
if (this.inFlightChunk?.sequence !== input.sequence) {
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "output-delivery-queue",
|
||||||
|
event: "consume:ignored",
|
||||||
|
details: {
|
||||||
|
sequence: input.sequence,
|
||||||
|
inFlightSequence: this.inFlightChunk?.sequence ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
return;
|
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,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
encodeFilePathForPathSegment,
|
encodeFilePathForPathSegment,
|
||||||
encodeWorkspaceIdForPathSegment,
|
encodeWorkspaceIdForPathSegment,
|
||||||
parseHostAgentDraftRouteFromPathname,
|
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostDraftRouteFromPathname,
|
|
||||||
parseHostWorkspaceTabRouteFromPathname,
|
parseHostWorkspaceTabRouteFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
} from "./host-routes";
|
} 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", () => {
|
describe("parseHostAgentRouteFromPathname", () => {
|
||||||
it("continues parsing detail routes", () => {
|
it("continues parsing detail routes", () => {
|
||||||
expect(parseHostAgentRouteFromPathname("/h/local/agent/abc123")).toEqual({
|
expect(parseHostAgentRouteFromPathname("/h/local/agent/abc123")).toEqual({
|
||||||
|
|||||||
@@ -188,54 +188,6 @@ export function parseHostAgentRouteFromPathname(
|
|||||||
return { serverId, agentId };
|
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(
|
export function parseHostWorkspaceRouteFromPathname(
|
||||||
pathname: string
|
pathname: string
|
||||||
): { serverId: string; workspaceId: string } | null {
|
): { serverId: string; workspaceId: string } | null {
|
||||||
@@ -445,9 +397,6 @@ export function mapPathnameToServer(
|
|||||||
if (suffix.startsWith("agents")) {
|
if (suffix.startsWith("agents")) {
|
||||||
return `${base}/agents`;
|
return `${base}/agents`;
|
||||||
}
|
}
|
||||||
if (suffix.startsWith("new")) {
|
|
||||||
return `${base}/new`;
|
|
||||||
}
|
|
||||||
if (suffix.startsWith("workspace/")) {
|
if (suffix.startsWith("workspace/")) {
|
||||||
return `${base}/${suffix}`;
|
return `${base}/${suffix}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ import {
|
|||||||
} from "./new-agent-routing";
|
} from "./new-agent-routing";
|
||||||
|
|
||||||
describe("buildNewAgentRoute", () => {
|
describe("buildNewAgentRoute", () => {
|
||||||
it("falls back to host-scoped draft route when no working directory is provided", () => {
|
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/new");
|
expect(buildNewAgentRoute("srv-1", undefined)).toBe(
|
||||||
expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/new");
|
"/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(
|
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 type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||||
import {
|
import {
|
||||||
buildHostAgentDraftRoute,
|
buildHostWorkspaceRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostWorkspaceTabRouteFromPathname,
|
parseHostWorkspaceTabRouteFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
@@ -74,13 +74,6 @@ export function buildNewAgentRoute(
|
|||||||
serverId: string,
|
serverId: string,
|
||||||
workingDir?: string | null
|
workingDir?: string | null
|
||||||
): string {
|
): string {
|
||||||
const baseRoute = buildHostAgentDraftRoute(serverId);
|
|
||||||
if (baseRoute === "/") {
|
|
||||||
return baseRoute;
|
|
||||||
}
|
|
||||||
const trimmedWorkingDir = workingDir?.trim();
|
const trimmedWorkingDir = workingDir?.trim();
|
||||||
if (!trimmedWorkingDir) {
|
return buildHostWorkspaceRoute(serverId, trimmedWorkingDir || ".");
|
||||||
return baseRoute;
|
|
||||||
}
|
|
||||||
return `${baseRoute}?workingDir=${encodeURIComponent(trimmedWorkingDir)}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ describe("buildNotificationRoute", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to host-scoped draft route when only serverId is present", () => {
|
it("falls back to host root when only serverId is present", () => {
|
||||||
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only/new");
|
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to root when no server id is present", () => {
|
it("falls back to root when no server id is present", () => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
buildHostDraftRoute,
|
|
||||||
buildHostAgentDetailRoute,
|
buildHostAgentDetailRoute,
|
||||||
buildHostWorkspaceAgentTabRoute,
|
buildHostWorkspaceAgentTabRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
@@ -41,7 +40,7 @@ export function buildNotificationRoute(data: NotificationData): string {
|
|||||||
return buildHostAgentDetailRoute(serverId, agentId);
|
return buildHostAgentDetailRoute(serverId, agentId);
|
||||||
}
|
}
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
return buildHostDraftRoute(serverId);
|
return `/h/${encodeURIComponent(serverId)}`;
|
||||||
}
|
}
|
||||||
return "/";
|
return "/";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user