Merge branch 'main' into dev

Surgical merge of 74 commits from main into dev. Key features ported:

- Provider visibility gating (appVersion filtering for Pi/Copilot)
- Pi agent provider + Copilot re-enabled
- Provider-declared features system (Codex fast mode)
- Workspace dedup by worktree root (adapted to integer IDs)
- Bulk close archiving fix for stored agents
- Agent creation timeout increase to 60s
- Audio/voice crash fixes (external buffer copies)
- Multi-host setup fix
- Reload agent tab action

Dev architecture preserved: SQLite storage, service proxy,
workspace setup dialog, hover cards, removed launcher tabs.
This commit is contained in:
Mohamed Boudra
2026-04-05 12:59:07 +07:00
173 changed files with 8873 additions and 2316 deletions
+145 -22
View File
@@ -27,6 +27,7 @@ import {
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useOpenProject } from "@/hooks/use-open-project";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -68,6 +69,7 @@ import {
type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { getDesktopHost } from "@/desktop/host";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
@@ -232,6 +234,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
useEffect(() => {
let cancelled = false;
let cancelAnyOnline: (() => void) | null = null;
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
@@ -242,28 +245,53 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
if (isDesktopManaged) {
setPhase("starting-daemon");
setError(null);
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
if (!cancelled) {
setPhase("error");
setError(bootstrapResult.error);
let raceSettled = false;
const anyOnline = store.waitForAnyConnectionOnline();
cancelAnyOnline = anyOnline.cancel;
const bootstrapPromise = (async (): Promise<
{ type: "online" } | { type: "error"; error: string }
> => {
try {
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
return { type: "error", error: bootstrapResult.error };
}
if (!cancelled && !raceSettled) {
setPhase("connecting");
}
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
return { type: "online" };
} catch (err) {
return {
type: "error",
error: err instanceof Error ? err.message : String(err),
};
}
return;
}
})();
if (cancelled) {
return;
}
const result = await Promise.race([
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
bootstrapPromise,
]);
raceSettled = true;
anyOnline.cancel();
setPhase("connecting");
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
if (!cancelled) {
setPhase("online");
setError(null);
if (result.type === "online") {
setPhase("online");
setError(null);
} else {
setPhase("error");
setError(result.error);
}
}
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
@@ -290,6 +318,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true;
cancelAnyOnline?.();
};
}, [retryToken]);
@@ -396,14 +425,28 @@ function MobileGestureWrapper({
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const horizontalScroll = useHorizontalScrollOptional();
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
useSidebarAnimation();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
} = useSidebarAnimation();
const touchStartX = useSharedValue(0);
const openGestureEnabled = chromeEnabled && mobileView === "agent";
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
openAgentList();
}, [openAgentList, gestureAnimatingRef]);
const openGesture = useMemo(
() =>
Gesture.Pan()
.withRef(openGestureRef)
.enabled(openGestureEnabled)
.manualActivation(true)
.failOffsetY([-10, 10])
@@ -446,7 +489,7 @@ function MobileGestureWrapper({
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
if (shouldOpen) {
animateToOpen();
runOnJS(openAgentList)();
runOnJS(handleGestureOpen)();
} else {
animateToClose();
}
@@ -461,8 +504,9 @@ function MobileGestureWrapper({
backdropOpacity,
animateToOpen,
animateToClose,
openAgentList,
handleGestureOpen,
isGesturing,
openGestureRef,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
],
@@ -558,6 +602,84 @@ function OfferLinkListener({
return null;
}
interface OpenProjectEventPayload {
path?: unknown;
}
function OpenProjectListener() {
const hosts = useHosts();
const serverId = hosts[0]?.serverId ?? null;
const client = useHostRuntimeClient(serverId ?? "");
const openProject = useOpenProject(serverId);
const pendingPathRef = useRef<string | null>(null);
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | null = null;
const maybeOpenProject = (inputPath: string) => {
const nextPath = inputPath.trim();
if (!nextPath) {
return;
}
pendingPathRef.current = nextPath;
if (!serverId || !client) {
return;
}
const pathToOpen = pendingPathRef.current;
pendingPathRef.current = null;
if (!pathToOpen) {
return;
}
void openProject(pathToOpen).catch(() => undefined);
};
// Pull any path that was passed on cold start (before the listener existed).
// Store in the ref even if this effect instance is disposed — the next
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
void getDesktopHost()
?.getPendingOpenProject?.()
?.then((pending) => {
if (pending) {
pendingPathRef.current = pending;
}
if (!disposed && pending) {
maybeOpenProject(pending);
}
})
.catch(() => undefined);
// Listen for hot-start paths relayed via the second-instance event.
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
if (disposed) {
return;
}
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
maybeOpenProject(nextPath);
})
.then((dispose) => {
if (disposed) {
dispose();
return;
}
unlisten = dispose;
})
.catch(() => undefined);
maybeOpenProject(pendingPathRef.current ?? "");
return () => {
disposed = true;
unlisten?.();
};
}, [client, openProject, serverId]);
return null;
}
function AppWithSidebar({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
@@ -692,6 +814,7 @@ export default function RootLayout() {
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<ToastProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint,
normalizeModelId,
resolveAgentModelSelection,
@@ -13,6 +15,31 @@ describe("getStatusSelectorHint", () => {
});
});
describe("feature metadata helpers", () => {
it("prefers explicit feature tooltip copy", () => {
expect(
getFeatureTooltip({
label: "Plan",
tooltip: "Toggle plan mode",
}),
).toBe("Toggle plan mode");
});
it("falls back to the feature label when no tooltip is provided", () => {
expect(
getFeatureTooltip({
label: "Custom",
}),
).toBe("Custom");
});
it("maps feature highlight colors by feature id", () => {
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
expect(getFeatureHighlightColor("other")).toBe("default");
});
});
describe("normalizeModelId", () => {
it("treats empty values as unset", () => {
expect(normalizeModelId("")).toBeNull();
@@ -1,6 +1,7 @@
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "yellow";
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
switch (selector) {
@@ -21,6 +22,21 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
return normalized;
}
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
return feature.tooltip ?? feature.label;
}
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
switch (featureId) {
case "fast_mode":
return "yellow";
case "plan_mode":
return "blue";
default:
return "default";
}
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
+53 -174
View File
@@ -9,7 +9,6 @@ import {
useState,
} from "react";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import Markdown from "react-native-markdown-display";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
@@ -36,6 +35,7 @@ import {
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "./message";
import { PlanCard } from "./plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
@@ -59,9 +59,7 @@ import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
@@ -258,10 +256,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return looseGap;
}
if (
(item.kind === "user_message" || item.kind === "assistant_message") &&
isToolSequenceItem(belowItem)
) {
if ((item.kind === "user_message" || item.kind === "assistant_message") && isToolSequenceItem(belowItem)) {
return tightGap;
}
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
@@ -275,44 +270,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[looseGap, tightGap],
);
// ---------------------------------------------------------------------------
// DEBUG: track when render callback deps change
// ---------------------------------------------------------------------------
const debugStreamPrevRef = useRef<Record<string, unknown>>({});
useEffect(() => {
const prev = debugStreamPrevRef.current;
const curr: Record<string, unknown> = {
// handleInlinePathPress deps (line 196-205)
"hip.agent.cwd": agent.cwd,
"hip.openFileExplorer": openFileExplorer,
"hip.requestDirectoryListing": requestDirectoryListing,
"hip.resolvedServerId": resolvedServerId,
"hip.router": router,
"hip.setExplorerTabForCheckout": setExplorerTabForCheckout,
"hip.onOpenWorkspaceFile": onOpenWorkspaceFile,
"hip.workspaceId": workspaceId,
// top-level deps
handleInlinePathPress,
"agent.status": agent.status,
streamRenderStrategy,
getGapBetween,
streamItems,
"streamItems.length": streamItems.length,
streamHead,
baseRenderModel,
};
const changed: string[] = [];
for (const key of Object.keys(curr)) {
if (!Object.is(prev[key], curr[key])) {
changed.push(key);
}
}
if (changed.length > 0 && Object.keys(prev).length > 0) {
console.log("[AgentStreamView] deps changed:", changed.join(", "));
}
debugStreamPrevRef.current = curr;
});
const renderStreamItemContent = useCallback(
(
item: StreamItem,
@@ -374,7 +331,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceRoot={workspaceRoot}
/>
);
case "thought": {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
@@ -761,90 +717,6 @@ function PermissionRequestCard({
return undefined;
}, [request]);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownRules = useMemo(() => {
return {
text: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: any,
children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
code_block: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
fence: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{node.content}
</Text>
),
bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: React.ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
</View>
);
},
};
}, []);
const permissionMutation = useMutation({
mutationFn: async (input: {
agentId: string;
@@ -897,50 +769,8 @@ function PermissionRequestCard({
);
}
return (
<View
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
{planMarkdown ? (
<View style={permissionStyles.section}>
{!isPlanRequest ? (
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.foregroundMuted }]}>
Proposed plan
</Text>
) : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{planMarkdown}
</Markdown>
</View>
) : null}
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}
const footer = (
<>
<Text
testID="permission-request-question"
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
@@ -1013,6 +843,55 @@ function PermissionRequestCard({
)}
</Pressable>
</View>
</>
);
if (isPlanRequest && planMarkdown) {
return (
<PlanCard
title={title}
description={description}
text={planMarkdown}
footer={footer}
disableOuterSpacing
/>
);
}
return (
<View
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
{planMarkdown ? <PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing /> : null}
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}
{footer}
</View>
);
}
@@ -1,5 +1,12 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import {
View,
Text,
Pressable,
Platform,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
@@ -81,6 +88,7 @@ export function ExplorerSidebar({
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
closeGestureRef,
} = useExplorerSidebarAnimation();
@@ -101,6 +109,11 @@ export function ExplorerSidebar({
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
);
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
closeToAgent();
}, [closeToAgent, gestureAnimatingRef]);
const enableSidebarCloseGesture = isMobile && isOpen;
const handleTabPress = useCallback(
@@ -175,7 +188,7 @@ export function ExplorerSidebar({
});
if (shouldClose) {
animateToClose();
runOnJS(handleClose)("swipe-close-gesture");
runOnJS(handleCloseFromGesture)();
} else {
animateToOpen();
}
@@ -190,7 +203,7 @@ export function ExplorerSidebar({
backdropOpacity,
animateToOpen,
animateToClose,
handleClose,
handleCloseFromGesture,
isGesturing,
closeGestureRef,
closeTouchStartX,
@@ -251,18 +264,13 @@ export function ExplorerSidebar({
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable
style={styles.backdropPressable}
onPress={() => handleClose("backdrop-press")}
/>
</Animated.View>
<Animated.View style={[explorerStaticStyles.backdrop, backdropAnimatedStyle]} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[
styles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top },
explorerStaticStyles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top, backgroundColor: theme.colors.surfaceSidebar },
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
]}
@@ -291,25 +299,27 @@ export function ExplorerSidebar({
}
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
<Animated.View style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={() => handleClose("desktop-close-button")}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={false}
onOpenFile={onOpenFile}
/>
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={() => handleClose("desktop-close-button")}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isMobile={false}
onOpenFile={onOpenFile}
/>
</View>
</Animated.View>
);
}
@@ -400,24 +410,28 @@ function SidebarContent({
);
}
const styles = StyleSheet.create((theme) => ({
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
const explorerStaticStyles = RNStyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
...RNStyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropPressable: {
flex: 1,
},
mobileSidebar: {
position: "absolute",
position: "absolute" as const,
top: 0,
right: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar,
overflow: "hidden",
overflow: "hidden" as const,
},
desktopSidebar: {
position: "relative",
position: "relative" as const,
},
});
const styles = StyleSheet.create((theme) => ({
desktopSidebarBorder: {
borderLeftWidth: 1,
borderLeftColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
@@ -0,0 +1,19 @@
import Svg, { Path } from "react-native-svg";
interface PiIconProps {
size?: number;
color?: string;
}
export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 800 800" fill={color}>
<Path
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
fill={color}
fillRule="evenodd"
/>
<Path d="M517.36 400 H634.72 V634.72 H517.36 Z" fill={color} />
</Svg>
);
}
+34 -21
View File
@@ -10,7 +10,14 @@ import {
type RefObject,
type SetStateAction,
} from "react";
import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native";
import {
View,
Pressable,
Text,
Platform,
useWindowDimensions,
StyleSheet as RNStyleSheet,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
@@ -370,14 +377,16 @@ function MobileSidebar({
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
closeGestureRef,
} = useSidebarAnimation();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const handleClose = useCallback(() => {
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
closeToAgent();
}, [closeToAgent]);
}, [closeToAgent, gestureAnimatingRef]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
@@ -452,7 +461,7 @@ function MobileSidebar({
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) {
animateToClose();
runOnJS(handleClose)();
runOnJS(handleCloseFromGesture)();
} else {
animateToOpen();
}
@@ -471,7 +480,7 @@ function MobileSidebar({
backdropOpacity,
animateToClose,
animateToOpen,
handleClose,
handleCloseFromGesture,
],
);
@@ -498,13 +507,11 @@ function MobileSidebar({
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} />
</Animated.View>
<Animated.View style={[staticStyles.backdrop, backdropAnimatedStyle]} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[styles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle]}
style={[staticStyles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
pointerEvents="auto"
>
<View style={styles.sidebarContent} pointerEvents="auto">
@@ -526,7 +533,7 @@ function MobileSidebar({
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
onWorkspacePress={() => closeToAgent()}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>
@@ -688,7 +695,8 @@ function DesktopSidebar({
}
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
<Animated.View style={[staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
@@ -796,32 +804,37 @@ function DesktopSidebar({
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create((theme) => ({
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
const staticStyles = RNStyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
...RNStyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropPressable: {
flex: 1,
},
mobileSidebar: {
position: "absolute",
position: "absolute" as const,
top: 0,
left: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar,
overflow: "hidden",
overflow: "hidden" as const,
},
desktopSidebar: {
position: "relative" as const,
},
});
const styles = StyleSheet.create((theme) => ({
sidebarContent: {
flex: 1,
minHeight: 0,
},
desktopSidebar: {
position: "relative",
desktopSidebarBorder: {
borderRightWidth: 1,
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
+7 -10
View File
@@ -71,6 +71,7 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
export type { InlinePathTarget } from "@/utils/inline-path";
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
@@ -716,13 +717,6 @@ export const AssistantMessage = memo(function AssistantMessage({
workspaceRoot,
disableOuterSpacing,
}: AssistantMessageProps) {
// DEBUG: log when AssistantMessage actually renders (inside memo boundary)
console.log("[AssistantMessage] render", {
messageLength: message?.length,
timestamp,
hasOnInlinePathPress: !!onInlinePathPress,
});
const { theme, rt } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
@@ -1764,9 +1758,6 @@ export const ToolCall = memo(function ToolCall({
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
}: ToolCallProps) {
// DEBUG: log when ToolCall actually renders (inside memo boundary)
console.log("[ToolCall] render", { toolName, status });
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
@@ -1884,6 +1875,12 @@ export const ToolCall = memo(function ToolCall({
);
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
if (effectiveDetail?.type === "plan") {
return (
<PlanCard title="Plan" text={effectiveDetail.text} disableOuterSpacing={disableOuterSpacing} />
);
}
return (
<ExpandableBadge
testID="tool-call-badge"
+154
View File
@@ -0,0 +1,154 @@
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import Markdown from "react-native-markdown-display";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { getMarkdownListMarker } from "@/utils/markdown-list";
function createPlanMarkdownRules() {
return {
text: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
code_block: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
fence: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{node.content}
</Text>
),
bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
</View>
);
},
};
}
export function PlanCard({
title = "Plan",
description,
text,
footer,
disableOuterSpacing = false,
}: {
title?: string;
description?: string;
text: string;
footer?: ReactNode;
disableOuterSpacing?: boolean;
}) {
const { theme } = useUnistyles();
const markdownStyles = createMarkdownStyles(theme);
const markdownRules = createPlanMarkdownRules();
return (
<View
style={[
styles.container,
disableOuterSpacing && styles.containerCompact,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[styles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[styles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{text}
</Markdown>
{footer ? <View style={styles.footer}>{footer}</View> : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
marginVertical: theme.spacing[3],
padding: theme.spacing[3],
borderRadius: theme.spacing[2],
borderWidth: 1,
gap: theme.spacing[2],
},
containerCompact: {
marginVertical: 0,
},
title: {
fontSize: theme.fontSize.base,
lineHeight: 22,
},
description: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
footer: {
gap: theme.spacing[2],
},
}));
@@ -6,16 +6,17 @@ import { CodexIcon } from "@/components/icons/codex-icon";
import { GeminiIcon } from "@/components/icons/gemini-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { PiIcon } from "@/components/icons/pi-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
"claude-acp": ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
gemini: GeminiIcon as unknown as typeof Bot,
amp: AmpIcon as unknown as typeof Bot,
aider: AiderIcon as unknown as typeof Bot,
copilot: CopilotIcon as unknown as typeof Bot,
opencode: OpenCodeIcon as unknown as typeof Bot,
pi: PiIcon as unknown as typeof Bot,
};
export function getProviderIcon(provider: string): typeof Bot {
@@ -84,6 +84,7 @@ interface SplitContainerProps {
onCloseTab: (tabId: string) => Promise<void> | void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
@@ -172,18 +173,6 @@ const MountedTabSlot = memo(function MountedTabSlot({
paneId,
buildPaneContentModel,
}: MountedTabSlotProps) {
useEffect(() => {
if (tabDescriptor.target.kind !== "terminal") {
return;
}
console.log("[terminal-tab-slot]", {
paneId,
tabId: tabDescriptor.tabId,
terminalId: tabDescriptor.target.terminalId,
isVisible,
isPaneFocused,
});
}, [isPaneFocused, isVisible, paneId, tabDescriptor]);
const content = useMemo(
() =>
@@ -261,6 +250,7 @@ export function SplitContainer({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -529,6 +519,7 @@ export function SplitContainer({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -650,6 +641,7 @@ function SplitNodeView({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -684,6 +676,7 @@ function SplitNodeView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -733,6 +726,7 @@ function SplitNodeView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -781,6 +775,7 @@ function SplitPaneView({
onCloseTab,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -891,6 +886,7 @@ function SplitPaneView({
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
@@ -30,6 +30,7 @@ import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
@@ -595,6 +596,7 @@ export function ContextMenuItem({
successLabel,
closeOnSelect = true,
testID,
tooltip,
}: PropsWithChildren<{
description?: string;
onSelect?: () => void;
@@ -612,6 +614,7 @@ export function ContextMenuItem({
successLabel?: string;
closeOnSelect?: boolean;
testID?: string;
tooltip?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useContextMenuContext("ContextMenuItem");
@@ -642,7 +645,7 @@ export function ContextMenuItem({
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
return (
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
@@ -704,6 +707,19 @@ export function ContextMenuItem({
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
</Pressable>
);
if (!tooltip) {
return content;
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right" align="center" offset={10}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
@@ -762,6 +778,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
item: {
flexDirection: "row",
alignItems: "center",
@@ -27,6 +27,7 @@ import {
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
@@ -471,6 +472,7 @@ export function DropdownMenuItem({
successLabel,
closeOnSelect = true,
testID,
tooltip,
}: PropsWithChildren<{
description?: string;
onSelect?: () => void;
@@ -491,6 +493,7 @@ export function DropdownMenuItem({
successLabel?: string;
closeOnSelect?: boolean;
testID?: string;
tooltip?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useDropdownMenuContext("DropdownMenuItem");
@@ -524,7 +527,7 @@ export function DropdownMenuItem({
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
return (
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
@@ -586,6 +589,19 @@ export function DropdownMenuItem({
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
</Pressable>
);
if (!tooltip) {
return content;
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right" align="center" offset={10}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
@@ -630,6 +646,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
item: {
flexDirection: "row",
alignItems: "center",
@@ -77,7 +77,6 @@ export function useWebElementScrollbar(
}
element.addEventListener("scroll", update, { passive: true });
element.addEventListener("input", update, { passive: true });
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(element);
@@ -90,7 +89,6 @@ export function useWebElementScrollbar(
return () => {
element.removeEventListener("scroll", update);
element.removeEventListener("input", update);
resizeObserver.disconnect();
element.removeAttribute("data-hide-scrollbar");
(element.style as any).scrollbarWidth = "";
+36 -1
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -20,6 +20,7 @@ import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url";
type WelcomeAction = {
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
@@ -118,6 +119,25 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
setupHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
marginBottom: theme.spacing[6],
lineHeight: theme.fontSize.sm * 1.5,
},
setupLink: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
marginBottom: theme.spacing[6],
},
setupLinkText: {
color: theme.colors.accent,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
versionLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -331,6 +351,21 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text>
{!showHostList && Platform.OS !== "web" && (
<>
<Text style={styles.setupHint}>
You need the Paseo desktop app or server running on your computer first.
</Text>
<Pressable
style={styles.setupLink}
onPress={() => openExternalUrl("https://paseo.sh")}
>
<Text style={styles.setupLinkText}>Get started at paseo.sh</Text>
<ExternalLink size={14} color={theme.colors.accent} />
</Pressable>
</>
)}
<View style={styles.actions}>
{actions.map((action) => {
const Icon = action.icon;
@@ -18,6 +18,8 @@ interface ExplorerSidebarAnimationContextValue {
animateToOpen: () => void;
animateToClose: () => void;
isGesturing: SharedValue<boolean>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
}
@@ -39,6 +41,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const gestureAnimatingRef = useRef(false);
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
@@ -61,6 +65,11 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
return;
}
if (gestureAnimatingRef.current) {
gestureAnimatingRef.current = false;
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
@@ -123,6 +132,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}}
>
@@ -27,6 +27,8 @@ interface SidebarAnimationContextValue {
animateToOpen: () => void;
animateToClose: () => void;
isGesturing: SharedValue<boolean>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
}
@@ -46,6 +48,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const gestureAnimatingRef = useRef(false);
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
@@ -68,6 +72,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
return;
}
// Gesture onEnd already started the animation on the UI thread — skip to avoid
// a second competing withTiming that can desync translateX and backdropOpacity
// after a provider remount (e.g. theme change).
if (gestureAnimatingRef.current) {
gestureAnimatingRef.current = false;
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
@@ -123,6 +135,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}),
[
@@ -132,6 +146,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
],
);
@@ -10,19 +10,19 @@ import {
Play,
Pause,
RotateCw,
Terminal,
Copy,
FileText,
Smartphone,
Activity,
} from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { useAppSettings } from "@/hooks/use-settings";
import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url";
import { getLocalDaemonVersion, isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliSymlinkInstructions,
getCliDaemonStatus,
getDesktopDaemonLogs,
getDesktopDaemonPairing,
getDesktopDaemonStatus,
@@ -30,7 +30,6 @@ import {
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
type CliSymlinkInstructions,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
type DesktopPairingOffer,
@@ -50,28 +49,26 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const [statusError, setStatusError] = useState<string | null>(null);
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
useState<CliSymlinkInstructions | null>(null);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
const loadDaemonData = useCallback(() => {
if (!showSection) {
return Promise.resolve();
}
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
.then(([status, logs, version]) => {
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
.then(([status, logs]) => {
setDaemonStatus(status);
setDaemonLogs(logs);
setDaemonVersion(version.version);
setDaemonVersion(status.version);
setStatusError(null);
})
.catch((error) => {
@@ -218,40 +215,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
updateSettings,
]);
const handleOpenCliSymlinkInstructions = useCallback(() => {
if (!showSection || isLoadingCliSymlinkInstructions) {
return;
}
setIsLoadingCliSymlinkInstructions(true);
setCliStatusMessage(null);
void getCliSymlinkInstructions()
.then((instructions) => {
setCliSymlinkInstructions(instructions);
setIsCliSymlinkModalOpen(true);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`);
})
.finally(() => {
setIsLoadingCliSymlinkInstructions(false);
});
}, [isLoadingCliSymlinkInstructions, showSection]);
const handleCopyCliSymlinkCommands = useCallback(() => {
if (!cliSymlinkInstructions?.commands) {
return;
}
void Clipboard.setStringAsync(cliSymlinkInstructions.commands)
.then(() => {
Alert.alert("Copied", "CLI symlink commands copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy CLI symlink commands", error);
Alert.alert("Error", "Unable to copy CLI symlink commands.");
});
}, [cliSymlinkInstructions?.commands]);
const handleCopyLogPath = useCallback(() => {
const logPath = daemonLogs?.logPath;
if (!logPath) {
@@ -315,20 +278,47 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
});
}, [pairingOffer?.url]);
const handleOpenCliStatus = useCallback(async () => {
setIsLoadingCliStatus(true);
try {
setCliStatusOutput(await getCliDaemonStatus());
setIsCliStatusModalOpen(true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setCliStatusOutput(`Failed to fetch daemon status: ${message}`);
setIsCliStatusModalOpen(true);
} finally {
setIsLoadingCliStatus(false);
}
}, []);
const handleCopyCliStatus = useCallback(() => {
if (!cliStatusOutput) {
return;
}
void Clipboard.setStringAsync(cliStatusOutput)
.then(() => {
Alert.alert("Copied", "Status copied to clipboard.");
})
.catch((error) => {
console.error("[Settings] Failed to copy daemon status", error);
});
}, [cliStatusOutput]);
if (!showSection) {
return null;
}
return (
<View style={settingsStyles.section}>
<View style={styles.sectionHeader}>
<Text style={settingsStyles.sectionTitle}>Built-in daemon</Text>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Built-in daemon</Text>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={styles.sectionLinkText}
style={styles.sectionLink}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
accessibilityLabel="Open advanced daemon settings"
>
@@ -336,10 +326,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</Button>
</View>
<View style={settingsStyles.card}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Status</Text>
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
@@ -348,10 +338,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View>
{showLifecycleControls ? (
<>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Daemon management</Text>
<Text style={styles.hintText}>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowHint}>
{isDaemonManagementPaused
? "Paused. The built-in daemon stays stopped until you start it again."
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
@@ -379,10 +369,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
: "Pause"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
<Text style={styles.hintText}>{daemonActionMessage}</Text>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
@@ -401,26 +391,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View>
</>
) : null}
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Command line (CLI)</Text>
<Text style={styles.hintText}>Shows the command to add `paseo` to your terminal.</Text>
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Terminal size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenCliSymlinkInstructions}
disabled={isLoadingCliSymlinkInstructions}
>
{isLoadingCliSymlinkInstructions ? "Loading..." : "Show instructions"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
@@ -444,10 +418,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</Button>
</View>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Pair device</Text>
<Text style={styles.hintText}>Connect your phone to this computer.</Text>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Pair device</Text>
<Text style={settingsStyles.rowHint}>Connect your phone to this computer.</Text>
</View>
<Button
variant="outline"
@@ -458,6 +432,23 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
Pair device
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output.
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void handleOpenCliStatus()}
disabled={isLoadingCliStatus}
>
{isLoadingCliStatus ? "Loading..." : "View status"}
</Button>
</View>
</View>
{daemonVersionMismatch ? (
@@ -469,33 +460,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View>
) : null}
<AdaptiveModalSheet
visible={isCliSymlinkModalOpen}
onClose={() => setIsCliSymlinkModalOpen(false)}
title="Add paseo to your shell"
testID="managed-daemon-cli-symlink-dialog"
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>
Paseo does not add the command for you. Run the command below in your terminal.
</Text>
{cliSymlinkInstructions?.detail ? (
<Text style={styles.hintText}>{cliSymlinkInstructions.detail}</Text>
) : null}
<Text style={styles.codeBlock} selectable>
{cliSymlinkInstructions?.commands ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setIsCliSymlinkModalOpen(false)}>
Close
</Button>
<Button size="sm" onPress={handleCopyCliSymlinkCommands}>
Copy commands
</Button>
</View>
</View>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isPairingModalOpen}
onClose={() => setIsPairingModalOpen(false)}
@@ -518,12 +482,34 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
snapPoints={["70%", "92%"]}
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
<Text style={styles.logOutput} selectable>
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
</Text>
</View>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isCliStatusModalOpen}
onClose={() => setIsCliStatusModalOpen(false)}
title="Daemon status"
testID="daemon-cli-status-dialog"
snapPoints={["60%", "85%"]}
>
<View style={styles.modalBody}>
<Text style={styles.logOutput} selectable>
{cliStatusOutput ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setIsCliStatusModalOpen(false)}>
Close
</Button>
<Button size="sm" onPress={handleCopyCliStatus}>
Copy
</Button>
</View>
</View>
</AdaptiveModalSheet>
</View>
);
}
@@ -581,7 +567,7 @@ function PairingOfferDialogContent(input: {
return (
<View style={styles.pairingState}>
<ActivityIndicator size="small" />
<Text style={styles.hintText}>Loading pairing offer…</Text>
<Text style={settingsStyles.rowHint}>Loading pairing offer…</Text>
</View>
);
}
@@ -589,7 +575,7 @@ function PairingOfferDialogContent(input: {
if (statusMessage) {
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>{statusMessage}</Text>
<Text style={settingsStyles.rowHint}>{statusMessage}</Text>
</View>
);
}
@@ -597,21 +583,21 @@ function PairingOfferDialogContent(input: {
if (!pairingOffer?.url) {
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
<Text style={settingsStyles.rowHint}>Pairing offer unavailable.</Text>
</View>
);
}
return (
<View style={styles.modalBody}>
<Text style={styles.hintText}>
<Text style={settingsStyles.rowHint}>
Scan this QR code in Paseo, or copy the pairing link below.
</Text>
<View style={styles.qrCard}>
{qrDataUrl ? (
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
) : qrError ? (
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
<Text style={settingsStyles.rowHint}>QR unavailable: {qrError}</Text>
) : (
<ActivityIndicator size="small" />
)}
@@ -632,37 +618,6 @@ function PairingOfferDialogContent(input: {
}
const styles = StyleSheet.create((theme) => ({
sectionHeader: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionLink: {
alignItems: "center",
flexDirection: "row",
gap: theme.spacing[1],
},
sectionLinkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
rowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
rowContent: {
flex: 1,
marginRight: theme.spacing[3],
},
actionGroup: {
flexDirection: "row",
gap: theme.spacing[2],
@@ -673,10 +628,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "flex-end",
gap: 2,
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
valueText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
@@ -686,11 +637,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
hintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
statusText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -0,0 +1,189 @@
import { useCallback, useState } from "react";
import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native";
import { settingsStyles } from "@/styles/settings";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
import {
shouldUseDesktopDaemon,
getCliInstallStatus,
installCli,
getSkillsInstallStatus,
installSkills,
type InstallStatus,
} from "@/desktop/daemon/desktop-daemon";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
export function IntegrationsSection() {
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const [cliStatus, setCliStatus] = useState<InstallStatus | null>(null);
const [skillsStatus, setSkillsStatus] = useState<InstallStatus | null>(null);
const [isInstallingCli, setIsInstallingCli] = useState(false);
const [isInstallingSkills, setIsInstallingSkills] = useState(false);
const loadStatus = useCallback(() => {
if (!showSection) return;
void getCliInstallStatus()
.then(setCliStatus)
.catch((error) => {
console.error("[Integrations] Failed to load CLI status", error);
});
void getSkillsInstallStatus()
.then(setSkillsStatus)
.catch((error) => {
console.error("[Integrations] Failed to load skills status", error);
});
}, [showSection]);
useFocusEffect(
useCallback(() => {
if (!showSection) return undefined;
loadStatus();
return undefined;
}, [loadStatus, showSection]),
);
const handleInstallCli = useCallback(() => {
if (isInstallingCli) return;
setIsInstallingCli(true);
void installCli()
.then(setCliStatus)
.catch((error) => {
console.error("[Integrations] Failed to install CLI", error);
})
.finally(() => {
setIsInstallingCli(false);
});
}, [isInstallingCli]);
const handleInstallSkills = useCallback(() => {
if (isInstallingSkills) return;
setIsInstallingSkills(true);
void installSkills()
.then(setSkillsStatus)
.catch((error) => {
console.error("[Integrations] Failed to install skills", error);
})
.finally(() => {
setIsInstallingSkills(false);
});
}, [isInstallingSkills]);
if (!showSection) {
return null;
}
return (
<View style={settingsStyles.section}>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Integrations</Text>
<View style={styles.headerLinks}>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
accessibilityLabel="Open CLI documentation"
>
CLI docs
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
accessibilityLabel="Open skills documentation"
>
Skills docs
</Button>
</View>
</View>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>Command line</Text>
</View>
<Text style={settingsStyles.rowHint}>
Control and script agents from your terminal.
</Text>
</View>
{cliStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
) : (
<Button
variant="outline"
size="sm"
onPress={handleInstallCli}
disabled={isInstallingCli}
>
{isInstallingCli ? "Installing..." : "Install"}
</Button>
)}
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
</View>
<Text style={settingsStyles.rowHint}>
Teach your agents to orchestrate through the CLI.
</Text>
</View>
{skillsStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
) : (
<Button
variant="outline"
size="sm"
onPress={handleInstallSkills}
disabled={isInstallingSkills}
>
{isInstallingSkills ? "Installing..." : "Install"}
</Button>
)}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
headerLinks: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[0],
},
rowTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
installedLabel: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
mutedText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
@@ -10,6 +10,8 @@ export type DesktopDaemonStatus = {
hostname: string | null;
pid: number | null;
home: string;
version: string | null;
desktopManaged: boolean;
error: string | null;
};
@@ -24,12 +26,6 @@ export type DesktopPairingOffer = {
qr: string | null;
};
export type CliSymlinkInstructions = {
title: string;
detail: string;
commands: string;
};
export type LocalTransportTarget = {
transportType: "socket" | "pipe";
transportPath: string;
@@ -86,6 +82,8 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? "",
version: toStringOrNull(raw.version),
desktopManaged: raw.desktopManaged === true,
error: toStringOrNull(raw.error),
};
}
@@ -111,17 +109,6 @@ function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
};
}
function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null {
if (!isRecord(raw)) {
return null;
}
return {
title: toStringOrNull(raw.title) ?? "",
detail: toStringOrNull(raw.detail) ?? "",
commands: toStringOrNull(raw.commands) ?? "",
};
}
export function shouldUseDesktopDaemon(): boolean {
return isElectronRuntime();
}
@@ -150,16 +137,12 @@ export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
}
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
const instructions = parseCliSymlinkInstructionsInternal(raw);
if (!instructions) {
throw new Error("Unexpected CLI symlink instructions response.");
export async function getCliDaemonStatus(): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
if (typeof raw !== "string") {
throw new Error("Unexpected CLI daemon status response.");
}
return instructions;
}
export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstructions> {
return parseCliSymlinkInstructions(await invokeDesktopCommand("cli_symlink_instructions"));
return raw;
}
export type LocalTransportEventUnlisten = () => void;
@@ -212,3 +195,34 @@ export async function sendLocalTransportMessage(input: {
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
}
// ---------------------------------------------------------------------------
// Integrations
// ---------------------------------------------------------------------------
export interface InstallStatus {
installed: boolean;
}
function parseInstallStatus(raw: unknown): InstallStatus {
if (!isRecord(raw)) {
throw new Error("Unexpected install status response.");
}
return { installed: raw.installed === true };
}
export async function getCliInstallStatus(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("get_cli_install_status"));
}
export async function installCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
}
export async function installSkills(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
}
+1
View File
@@ -79,6 +79,7 @@ export interface DesktopInvokeBridge {
export interface DesktopHostBridge {
platform?: string;
invoke?: DesktopInvokeBridge["invoke"];
getPendingOpenProject?: () => Promise<string | null>;
events?: DesktopEventsBridge;
window?: DesktopWindowModuleBridge;
dialog?: DesktopDialogBridge;
@@ -62,12 +62,14 @@ function normalizeDraftCommandConfig(
const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
const featureValues = draftConfig.featureValues;
return {
provider: draftConfig.provider,
cwd,
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}),
};
}
@@ -16,6 +16,7 @@ export interface DraftCommandConfig {
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
}
function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) {
@@ -28,6 +29,7 @@ function commandsQueryKey(serverId: string, agentId: string, draftConfig?: Draft
draftConfig?.modeId ?? null,
draftConfig?.model ?? null,
draftConfig?.thinkingOptionId ?? null,
draftConfig?.featureValues ?? null,
] as const;
}
@@ -9,7 +9,6 @@ import {
type ReadyState = Extract<AgentScreenViewState, { tag: "ready" }>;
type CatchingUpSyncState = Extract<ReadyState["sync"], { status: "catching_up" }>;
type SyncErrorSyncState = Extract<ReadyState["sync"], { status: "sync_error" }>;
function createAgent(id: string): Agent {
const now = new Date("2026-02-19T00:00:00.000Z");
@@ -74,7 +73,6 @@ function createBaseMemory(
return {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
...overrides,
};
@@ -96,12 +94,8 @@ function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState {
return state.sync;
}
function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState {
function expectSyncErrorSync(state: ReadyState): void {
expect(state.sync.status).toBe("sync_error");
if (state.sync.status !== "sync_error") {
throw new Error("expected sync_error sync state");
}
return state.sync;
}
describe("deriveAgentScreenViewState", () => {
@@ -165,10 +159,9 @@ describe("deriveAgentScreenViewState", () => {
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("overlay");
expect(sync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("uses toast catching-up state for already-hydrated agents", () => {
it("uses silent catching-up state for already-hydrated agents", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
@@ -183,8 +176,7 @@ describe("deriveAgentScreenViewState", () => {
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(sync.shouldEmitHistoryRefreshToast).toBe(true);
expect(sync.ui).toBe("silent");
});
it("keeps sync errors non-blocking once the screen was ready", () => {
@@ -200,9 +192,7 @@ describe("deriveAgentScreenViewState", () => {
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expect(sync.shouldEmitSyncErrorToast).toBe(true);
expectSyncErrorSync(ready);
});
it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => {
@@ -221,9 +211,7 @@ describe("deriveAgentScreenViewState", () => {
memory: initialMemory,
});
const errorReady = expectReadyState(errorResult.state);
const errorSync = expectSyncErrorSync(errorReady);
expect(errorSync.shouldEmitSyncErrorToast).toBe(true);
expectSyncErrorSync(errorReady);
expect(errorResult.memory.hadInitialSyncFailure).toBe(true);
const retryInput: AgentScreenMachineInput = {
@@ -239,7 +227,6 @@ describe("deriveAgentScreenViewState", () => {
const retrySync = expectCatchingUpSync(retryReady);
expect(retrySync.ui).toBe("silent");
expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false);
expect(retryResult.memory.hadInitialSyncFailure).toBe(true);
});
@@ -255,35 +242,10 @@ describe("deriveAgentScreenViewState", () => {
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectSyncErrorSync(ready);
expectSyncErrorSync(ready);
expect(ready.source).toBe("stale");
expect(ready.agent.id).toBe("agent-1");
expect(sync.shouldEmitSyncErrorToast).toBe(true);
});
it("emits sync error toast only on transition into sync_error", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
missingAgentState: { kind: "error", message: "network timeout" },
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectSyncErrorSync(firstReady);
expect(firstSync.shouldEmitSyncErrorToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectSyncErrorSync(secondReady);
expect(secondSync.shouldEmitSyncErrorToast).toBe(false);
});
it("returns blocking error before first paint when refresh fails", () => {
@@ -484,71 +446,6 @@ describe("deriveAgentScreenViewState", () => {
expect(ready.agent.status).toBe("closed");
});
it("emits history refresh toast only on transition into toast catch-up state", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
needsAuthoritativeSync: true,
hasHydratedHistoryBefore: true,
};
const first = deriveAgentScreenViewState({ input, memory });
const firstReady = expectReadyState(first.state);
const firstSync = expectCatchingUpSync(firstReady);
expect(firstSync.ui).toBe("toast");
expect(firstSync.shouldEmitHistoryRefreshToast).toBe(true);
const second = deriveAgentScreenViewState({
input,
memory: first.memory,
});
const secondReady = expectReadyState(second.state);
const secondSync = expectCatchingUpSync(secondReady);
expect(secondSync.ui).toBe("toast");
expect(secondSync.shouldEmitHistoryRefreshToast).toBe(false);
});
it("re-arms history refresh toast after leaving and re-entering catch-up", () => {
const baseInput: AgentScreenMachineInput = {
...createBaseInput(),
hasHydratedHistoryBefore: true,
};
const initialMemory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const firstCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: initialMemory,
});
const firstCatchingUpReady = expectReadyState(firstCatchingUp.state);
const firstCatchingUpSync = expectCatchingUpSync(firstCatchingUpReady);
expect(firstCatchingUpSync.ui).toBe("toast");
expect(firstCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
const idle = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: false },
memory: firstCatchingUp.memory,
});
const idleReady = expectReadyState(idle.state);
expect(idleReady.sync.status).toBe("idle");
const secondCatchingUp = deriveAgentScreenViewState({
input: { ...baseInput, needsAuthoritativeSync: true },
memory: idle.memory,
});
const secondCatchingUpReady = expectReadyState(secondCatchingUp.state);
const secondCatchingUpSync = expectCatchingUpSync(secondCatchingUpReady);
expect(secondCatchingUpSync.ui).toBe("toast");
expect(secondCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
});
it("clears initial sync failure memory after history is hydrated", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
@@ -565,7 +462,7 @@ describe("deriveAgentScreenViewState", () => {
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("toast");
expect(sync.ui).toBe("silent");
expect(result.memory.hadInitialSyncFailure).toBe(false);
});
});
@@ -40,12 +40,9 @@ function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInpu
);
}
export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
export interface AgentScreenMachineMemory {
hasRenderedReady: boolean;
lastReadyAgent: AgentScreenAgent | null;
activeToastLatch: AgentScreenToastLatch;
hadInitialSyncFailure: boolean;
}
@@ -55,17 +52,8 @@ export type AgentScreenReadySyncState =
| {
status: "catching_up";
ui: "overlay" | "silent";
shouldEmitHistoryRefreshToast: false;
}
| {
status: "catching_up";
ui: "toast";
shouldEmitHistoryRefreshToast: boolean;
}
| {
status: "sync_error";
shouldEmitSyncErrorToast: boolean;
};
| { status: "sync_error" };
export type AgentScreenViewState =
| {
@@ -99,7 +87,6 @@ export function deriveAgentScreenViewState({
const nextMemory: AgentScreenMachineMemory = {
hasRenderedReady: memory.hasRenderedReady,
lastReadyAgent: memory.lastReadyAgent,
activeToastLatch: memory.activeToastLatch,
hadInitialSyncFailure: memory.hadInitialSyncFailure,
};
@@ -181,45 +168,23 @@ export function deriveAgentScreenViewState({
let sync: AgentScreenReadySyncState;
if (!input.isConnected) {
nextMemory.activeToastLatch = "none";
sync = { status: "reconnecting" };
} else if (input.missingAgentState.kind === "error") {
const shouldEmitSyncErrorToast = memory.activeToastLatch !== "sync_error";
nextMemory.activeToastLatch = "sync_error";
sync = {
status: "sync_error",
shouldEmitSyncErrorToast,
};
sync = { status: "sync_error" };
} else if (input.needsAuthoritativeSync || input.isHistorySyncing) {
let ui: "overlay" | "toast" | "silent";
let ui: "overlay" | "silent";
if (input.shouldUseOptimisticStream) {
ui = "silent";
} else if (input.hasHydratedHistoryBefore) {
ui = "toast";
ui = "silent";
} else if (nextMemory.hadInitialSyncFailure) {
ui = "silent";
} else {
ui = "overlay";
}
if (ui === "toast") {
const shouldEmitHistoryRefreshToast = memory.activeToastLatch !== "history_refresh";
nextMemory.activeToastLatch = "history_refresh";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast,
};
} else {
nextMemory.activeToastLatch = "none";
sync = {
status: "catching_up",
ui,
shouldEmitHistoryRefreshToast: false,
};
}
sync = { status: "catching_up", ui };
} else {
nextMemory.activeToastLatch = "none";
sync = { status: "idle" };
}
@@ -246,7 +211,6 @@ export function useAgentScreenStateMachine({
const memoryRef = useRef<AgentScreenMachineMemory>({
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
});
@@ -255,7 +219,6 @@ export function useAgentScreenStateMachine({
memoryRef.current = {
hasRenderedReady: false,
lastReadyAgent: null,
activeToastLatch: "none",
hadInitialSyncFailure: false,
};
}
@@ -1,70 +0,0 @@
import { useEffect, useRef, type ReactNode } from "react";
import { ActivityIndicator } from "react-native";
import type { ToastShowOptions } from "@/components/toast-host";
const HISTORY_REFRESH_TOAST_DELAY_MS = 1000;
const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
interface UseDelayedHistoryRefreshToastParams {
isCatchingUp: boolean;
indicatorColor: string;
showToast: (content: ReactNode, options?: ToastShowOptions) => void;
}
export function useDelayedHistoryRefreshToast({
isCatchingUp,
indicatorColor,
showToast,
}: UseDelayedHistoryRefreshToastParams): void {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wasCatchingUpRef = useRef(false);
const isCatchingUpRef = useRef(false);
const showToastRef = useRef(showToast);
const indicatorColorRef = useRef(indicatorColor);
useEffect(() => {
showToastRef.current = showToast;
}, [showToast]);
useEffect(() => {
indicatorColorRef.current = indicatorColor;
}, [indicatorColor]);
useEffect(() => {
isCatchingUpRef.current = isCatchingUp;
const enteredCatchUp = !wasCatchingUpRef.current && isCatchingUp;
const exitedCatchUp = wasCatchingUpRef.current && !isCatchingUp;
if (enteredCatchUp) {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
timerRef.current = null;
if (!isCatchingUpRef.current) {
return;
}
showToastRef.current("Refreshing", {
icon: <ActivityIndicator size="small" color={indicatorColorRef.current} />,
durationMs: HISTORY_REFRESH_TOAST_DURATION_MS,
testID: "agent-history-refresh-toast",
});
}, HISTORY_REFRESH_TOAST_DELAY_MS);
} else if (exitedCatchUp && timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
wasCatchingUpRef.current = isCatchingUp;
}, [isCatchingUp]);
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, []);
}
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type {
AgentFeature,
AgentProvider,
AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
function pruneFeatureValues(
featureValues: Record<string, unknown>,
features: AgentFeature[],
): Record<string, unknown> {
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
let changed = false;
const next: Record<string, unknown> = {};
for (const [featureId, value] of Object.entries(featureValues)) {
if (!allowedFeatureIds.has(featureId)) {
changed = true;
continue;
}
next[featureId] = value;
}
return changed ? next : featureValues;
}
function applyFeatureValues(
features: AgentFeature[],
featureValues: Record<string, unknown>,
): AgentFeature[] {
if (Object.keys(featureValues).length === 0) {
return features;
}
return features.map((feature) => {
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
return feature;
}
return {
...feature,
value: featureValues[feature.id],
} as AgentFeature;
});
}
type DraftFeatureConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
>;
export function useDraftAgentFeatures(input: {
serverId: string | null | undefined;
provider: AgentProvider;
cwd: string | null | undefined;
modeId: string | null | undefined;
modelId: string | null | undefined;
thinkingOptionId: string | null | undefined;
}) {
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
const [featureValues, setFeatureValues] = useState<Record<string, unknown>>({});
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const normalizedCwd = cwd?.trim() || "";
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
if (!normalizedCwd) {
return null;
}
return {
provider,
cwd: normalizedCwd,
...(modeId ? { modeId } : {}),
...(modelId ? { model: modelId } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
};
}, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]);
const featuresQuery = useQuery({
queryKey: [
"providerFeatures",
serverId ?? null,
provider,
normalizedCwd || null,
modeId ?? null,
modelId ?? null,
thinkingOptionId ?? null,
],
enabled: Boolean(serverId && client && isConnected && draftConfig),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !draftConfig) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderFeatures(draftConfig);
if (payload.error) {
throw new Error(payload.error);
}
return payload.features ?? [];
},
});
const features = useMemo(() => {
return applyFeatureValues(featuresQuery.data ?? [], featureValues);
}, [featureValues, featuresQuery.data]);
useEffect(() => {
const next = pruneFeatureValues(featureValues, features);
if (next !== featureValues) {
setFeatureValues(next);
}
}, [featureValues, features]);
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
const setFeatureValue = useCallback((featureId: string, value: unknown) => {
setFeatureValues((current) => {
if (Object.is(current[featureId], value)) {
return current;
}
return { ...current, [featureId]: value };
});
}, []);
return {
features,
featureValues: effectiveFeatureValues,
isLoading: featuresQuery.isLoading,
setFeatureValue,
};
}
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { Gesture } from "react-native-gesture-handler";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
@@ -9,14 +9,28 @@ interface UseExplorerOpenGestureParams {
}
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
useExplorerSidebarAnimation();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
gestureAnimatingRef,
openGestureRef,
} = useExplorerSidebarAnimation();
const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
onOpen();
}, [onOpen, gestureAnimatingRef]);
return useMemo(
() =>
Gesture.Pan()
.withRef(openGestureRef)
.enabled(enabled)
.manualActivation(true)
.onTouchesDown((event) => {
@@ -78,7 +92,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
if (shouldOpen) {
animateToOpen();
runOnJS(onOpen)();
runOnJS(handleGestureOpen)();
} else {
animateToClose();
}
@@ -94,7 +108,8 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
animateToOpen,
animateToClose,
isGesturing,
onOpen,
openGestureRef,
handleGestureOpen,
touchStartX,
touchStartY,
],
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveKeyboardFocusScope } from "./focus-scope";
class FakeNode {
parentElement: FakeElement | null = null;
}
class FakeElement extends FakeNode {
tagName: string;
isContentEditable = false;
private selectors: Set<string>;
constructor(input?: { tagName?: string; selectors?: string[]; isContentEditable?: boolean }) {
super();
this.tagName = (input?.tagName ?? "div").toUpperCase();
this.selectors = new Set(input?.selectors ?? []);
if (input?.isContentEditable) {
this.isContentEditable = true;
}
}
closest(selector: string): FakeElement | null {
if (this.selectors.has(selector)) {
return this;
}
return this.parentElement?.closest(selector) ?? null;
}
}
describe("resolveKeyboardFocusScope", () => {
const globalRef = globalThis as {
Element?: unknown;
Node?: unknown;
document?: { activeElement?: unknown };
};
const originalElement = globalRef.Element;
const originalNode = globalRef.Node;
const originalDocument = globalRef.document;
beforeEach(() => {
globalRef.Element = FakeElement;
globalRef.Node = FakeNode;
globalRef.document = { activeElement: null };
});
afterEach(() => {
globalRef.Element = originalElement;
globalRef.Node = originalNode;
globalRef.document = originalDocument;
});
it("resolves terminal scope from the direct keyboard event target", () => {
const target = new FakeElement({ selectors: [".xterm"] });
const scope = resolveKeyboardFocusScope({
target: target as unknown as EventTarget,
commandCenterOpen: false,
});
expect(scope).toBe("terminal");
});
it("falls back to activeElement when target is not an Element", () => {
const activeElement = new FakeElement({ selectors: [".xterm"] });
globalRef.document = { activeElement };
const scope = resolveKeyboardFocusScope({
target: null,
commandCenterOpen: false,
});
expect(scope).toBe("terminal");
});
it("detects editable scope from activeElement fallback", () => {
const activeElement = new FakeElement({ tagName: "input" });
globalRef.document = { activeElement };
const scope = resolveKeyboardFocusScope({
target: null,
commandCenterOpen: false,
});
expect(scope).toBe("editable");
});
});
+52 -12
View File
@@ -1,37 +1,77 @@
import type { KeyboardFocusScope } from "@/keyboard/actions";
function isElement(value: unknown): value is Element {
return typeof Element !== "undefined" && value instanceof Element;
}
function getFocusCandidateElements(target: EventTarget | null): Element[] {
const candidates: Element[] = [];
const pushUnique = (element: Element | null) => {
if (!element || candidates.includes(element)) {
return;
}
candidates.push(element);
};
if (isElement(target)) {
pushUnique(target);
}
if (typeof Node !== "undefined" && target instanceof Node) {
pushUnique(isElement(target.parentElement) ? target.parentElement : null);
}
if (typeof document !== "undefined" && isElement(document.activeElement)) {
pushUnique(document.activeElement);
}
return candidates;
}
export function resolveKeyboardFocusScope(input: {
target: EventTarget | null;
commandCenterOpen: boolean;
}): KeyboardFocusScope {
const { target, commandCenterOpen } = input;
if (!(target instanceof Element)) {
const candidates = getFocusCandidateElements(target);
if (candidates.length === 0) {
return commandCenterOpen ? "command-center" : "other";
}
if (target.closest("[data-testid='terminal-surface']") || target.closest(".xterm")) {
if (
candidates.some((element) =>
Boolean(element.closest("[data-testid='terminal-surface']") || element.closest(".xterm")),
)
) {
return "terminal";
}
if (
commandCenterOpen &&
(target.closest("[data-testid='command-center-panel']") ||
target.closest("[data-testid='command-center-input']"))
candidates.some((element) =>
Boolean(
element.closest("[data-testid='command-center-panel']") ||
element.closest("[data-testid='command-center-input']"),
),
)
) {
return "command-center";
}
if (target.closest("[data-testid='message-input-root']")) {
if (candidates.some((element) => Boolean(element.closest("[data-testid='message-input-root']")))) {
return "message-input";
}
const editable = target as HTMLElement;
if (editable.isContentEditable) {
return commandCenterOpen ? "command-center" : "editable";
}
const tag = target.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") {
if (
candidates.some((element) => {
const editable = element as HTMLElement;
if (editable.isContentEditable) {
return true;
}
const tag = element.tagName.toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select";
})
) {
return commandCenterOpen ? "command-center" : "editable";
}
@@ -157,6 +157,7 @@ function makeFetchAgentsEntry(input: {
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
+37
View File
@@ -14,6 +14,7 @@ import {
type HostProfile,
} from "@/types/host-connection";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { resolveAppVersion } from "@/utils/app-version";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import {
shouldUseDesktopDaemon,
@@ -422,6 +423,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
suppressSendErrors: true,
clientId,
clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined,
runtimeGeneration,
};
if (connection.type === "directSocket" || connection.type === "directPipe") {
@@ -1681,6 +1683,41 @@ export class HostRuntimeStore {
};
}
waitForAnyConnectionOnline(): { promise: Promise<void>; cancel: () => void } {
let unsubscribe: (() => void) | null = null;
const isAnyOnline = (): boolean => {
for (const host of this.hosts) {
const snapshot = this.getSnapshot(host.serverId);
if (snapshot?.connectionStatus === "online") return true;
}
return false;
};
const promise = new Promise<void>((resolve) => {
if (isAnyOnline()) {
resolve();
return;
}
unsubscribe = this.subscribeAll(() => {
if (isAnyOnline()) {
unsubscribe?.();
unsubscribe = null;
resolve();
}
});
});
return {
promise,
cancel: () => {
unsubscribe?.();
unsubscribe = null;
},
};
}
ensureConnectedAll(): void {
for (const controller of this.controllers.values()) {
controller.ensureConnected();
@@ -8,7 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { isCompactFormFactor } from "@/constants/layout";
import { isCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
@@ -59,11 +59,15 @@ const styles = StyleSheet.create((theme) => ({
},
content: {
position: "relative",
flexGrow: 1,
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: 0,
padding: theme.spacing[6],
paddingBottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
md: HEADER_INNER_HEIGHT + theme.spacing[6],
},
},
logo: {
marginBottom: theme.spacing[8],
+45 -86
View File
@@ -20,6 +20,7 @@ import {
Stethoscope,
Info,
Shield,
Puzzle,
} from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import type { HostProfile, HostConnection } from "@/types/host-connection";
@@ -50,6 +51,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { isElectronRuntime } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
@@ -69,6 +71,7 @@ type SettingsSectionId =
| "hosts"
| "appearance"
| "shortcuts"
| "integrations"
| "diagnostics"
| "about"
| "permissions"
@@ -85,17 +88,21 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
{ id: "hosts", label: "Hosts", icon: Server },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
{ id: "about", label: "About", icon: Info },
{ id: "permissions", label: "Permissions", icon: Shield },
];
if (context.isDesktopApp) {
sections.push(
{ id: "permissions", label: "Permissions", icon: Shield },
{ id: "integrations", label: "Integrations", icon: Puzzle },
{ id: "daemon", label: "Daemon", icon: Settings },
);
}
sections.push(
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
{ id: "about", label: "About", icon: Info },
);
return sections;
}
@@ -177,13 +184,10 @@ interface HostsSectionProps {
routeServerId: string;
theme: ReturnType<typeof useUnistyles>["theme"];
handleEditDaemon: (profile: HostProfile) => void;
setAddConnectionTargetServerId: (id: string | null) => void;
setPendingEditReopenServerId: (id: string | null) => void;
setIsAddHostMethodVisible: (visible: boolean) => void;
isAddHostMethodVisible: boolean;
isDirectHostVisible: boolean;
isPasteLinkVisible: boolean;
addConnectionTargetServerId: string | null;
closeAddConnectionFlow: () => void;
goBackToAddConnectionMethods: () => void;
setIsDirectHostVisible: (visible: boolean) => void;
@@ -203,7 +207,6 @@ interface HostsSectionProps {
handleSaveEditDaemon: (label: string) => Promise<void>;
handleRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
handleRemoveDaemon: (profile: HostProfile) => void;
handleAddConnectionFromModal: () => void;
restartConfirmationMessage: string;
waitForCondition: (
predicate: () => boolean,
@@ -243,8 +246,6 @@ function HostsSection(props: HostsSectionProps) {
style={styles.addButton}
textStyle={styles.addButtonText}
onPress={() => {
props.setAddConnectionTargetServerId(null);
props.setPendingEditReopenServerId(null);
props.setIsAddHostMethodVisible(true);
}}
>
@@ -264,22 +265,17 @@ function HostsSection(props: HostsSectionProps) {
props.setIsPasteLinkVisible(true);
}}
onScanQr={() => {
const targetServerId = props.addConnectionTargetServerId;
const source = targetServerId ? "editHost" : "settings";
const sourceServerId = props.routeServerId || targetServerId || undefined;
const sourceServerId = props.routeServerId || undefined;
props.closeAddConnectionFlow();
router.push({
pathname: "/pair-scan",
params: targetServerId
? { source, targetServerId, sourceServerId }
: { source, sourceServerId },
params: { source: "settings", sourceServerId },
});
}}
/>
<AddHostModal
visible={props.isDirectHostVisible}
targetServerId={props.addConnectionTargetServerId ?? undefined}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
@@ -291,7 +287,6 @@ function HostsSection(props: HostsSectionProps) {
<PairLinkModal
visible={props.isPasteLinkVisible}
targetServerId={props.addConnectionTargetServerId ?? undefined}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
@@ -371,7 +366,6 @@ function HostsSection(props: HostsSectionProps) {
onSave={(label) => void props.handleSaveEditDaemon(label)}
onRemoveConnection={props.handleRemoveConnection}
onRemoveHost={props.handleRemoveDaemon}
onAddConnection={props.handleAddConnectionFromModal}
restartConfirmationMessage={props.restartConfirmationMessage}
waitForCondition={props.waitForCondition}
isScreenMountedRef={props.isMountedRef}
@@ -520,6 +514,8 @@ function SettingsSectionContent({
return <DiagnosticsSection {...diagnosticsProps} />;
case "about":
return <AboutSection {...aboutProps} />;
case "integrations":
return isDesktopApp ? <IntegrationsSection /> : null;
case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null;
case "daemon":
@@ -570,31 +566,35 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
{sections.map((section) => {
const isSelected = section.id === selectedSectionId;
const IconComponent = section.icon;
const showSeparator =
section.id === "integrations" || section.id === "diagnostics";
return (
<Pressable
key={section.id}
style={[
desktopStyles.sidebarItem,
isSelected && { backgroundColor: theme.colors.surface2 },
]}
onPress={() => setSelectedSectionId(section.id)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
>
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
<View key={section.id}>
{showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
<Pressable
style={[
desktopStyles.sidebarLabel,
isSelected && { color: theme.colors.foreground },
desktopStyles.sidebarItem,
isSelected && { backgroundColor: theme.colors.surface2 },
]}
numberOfLines={1}
onPress={() => setSelectedSectionId(section.id)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
>
{section.label}
</Text>
</Pressable>
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
desktopStyles.sidebarLabel,
isSelected && { color: theme.colors.foreground },
]}
numberOfLines={1}
>
{section.label}
</Text>
</Pressable>
</View>
);
})}
</View>
@@ -728,10 +728,6 @@ export default function SettingsScreen() {
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState<string | null>(
null,
);
const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
@@ -810,7 +806,6 @@ export default function SettingsScreen() {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(false);
setIsPasteLinkVisible(false);
setAddConnectionTargetServerId(null);
}, []);
const goBackToAddConnectionMethods = useCallback(() => {
@@ -829,24 +824,6 @@ export default function SettingsScreen() {
handleEditDaemon(profile);
}, [daemons, handleEditDaemon, params.editHost]);
useEffect(() => {
if (!pendingEditReopenServerId) return;
if (isAddHostMethodVisible || isDirectHostVisible || isPasteLinkVisible) return;
const profile = daemons.find((daemon) => daemon.serverId === pendingEditReopenServerId) ?? null;
setPendingEditReopenServerId(null);
setAddConnectionTargetServerId(null);
if (profile) {
handleEditDaemon(profile);
}
}, [
daemons,
handleEditDaemon,
isAddHostMethodVisible,
isDirectHostVisible,
isPasteLinkVisible,
pendingEditReopenServerId,
]);
const handleSaveEditDaemon = useCallback(
async (nextLabelRaw: string) => {
if (!editingServerId) return;
@@ -884,15 +861,6 @@ export default function SettingsScreen() {
setPendingRemoveHost(profile);
}, []);
const handleAddConnectionFromModal = useCallback(() => {
if (!editingServerId) return;
const serverId = editingServerId;
setEditingDaemon(null);
setAddConnectionTargetServerId(serverId);
setPendingEditReopenServerId(serverId);
setIsAddHostMethodVisible(true);
}, [editingServerId]);
const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => {
void updateSettings({ theme: newTheme });
@@ -938,13 +906,10 @@ export default function SettingsScreen() {
routeServerId,
theme,
handleEditDaemon,
setAddConnectionTargetServerId,
setPendingEditReopenServerId,
setIsAddHostMethodVisible,
isAddHostMethodVisible,
isDirectHostVisible,
isPasteLinkVisible,
addConnectionTargetServerId,
closeAddConnectionFlow,
goBackToAddConnectionMethods,
setIsDirectHostVisible,
@@ -964,7 +929,6 @@ export default function SettingsScreen() {
handleSaveEditDaemon,
handleRemoveConnection,
handleRemoveDaemon,
handleAddConnectionFromModal,
restartConfirmationMessage: "This will restart the daemon. The app will reconnect automatically.",
waitForCondition,
isMountedRef,
@@ -1029,7 +993,6 @@ interface HostDetailModalProps {
onSave: (label: string) => void;
onRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
onRemoveHost: (host: HostProfile) => void;
onAddConnection: () => void;
restartConfirmationMessage: string;
waitForCondition: (
predicate: () => boolean,
@@ -1047,7 +1010,6 @@ function HostDetailModal({
onSave,
onRemoveConnection,
onRemoveHost,
onAddConnection,
restartConfirmationMessage,
waitForCondition,
isScreenMountedRef,
@@ -1278,15 +1240,6 @@ function HostDetailModal({
/>
);
})}
<Button
variant="outline"
size="md"
style={styles.addButton}
textStyle={styles.addButtonText}
onPress={onAddConnection}
>
+ Add connection
</Button>
</View>
</View>
) : null}
@@ -1893,6 +1846,12 @@ const desktopStyles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
sidebarSeparator: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
marginVertical: theme.spacing[2],
marginHorizontal: theme.spacing[3],
},
contentPane: {
flex: 1,
},
@@ -192,8 +192,8 @@ export function KeyboardShortcutsSection() {
return (
<View style={settingsStyles.section}>
<View style={styles.sectionHeader}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Shortcuts</Text>
{hasOverrides && (
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
Reset all
@@ -246,11 +246,6 @@ export function KeyboardShortcutsSection() {
}
const styles = StyleSheet.create((theme) => ({
sectionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
subsectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -15,6 +15,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
it("uses desktop tab ordering labels for desktop menus", () => {
const onCopyResumeCommand = vi.fn();
const onCopyAgentId = vi.fn();
const onReloadAgent = vi.fn();
const onCloseTab = vi.fn();
const onCloseTabsBefore = vi.fn();
const onCloseTabsAfter = vi.fn();
@@ -28,6 +29,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-context-agent_123",
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTab,
onCloseTabsBefore,
onCloseTabsAfter,
@@ -37,6 +39,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
"Copy resume command",
"Copy agent id",
"Reload agent",
"Close to the left",
"Close to the right",
"Close other tabs",
@@ -53,6 +56,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-menu-agent_123",
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
@@ -62,6 +66,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
"Copy resume command",
"Copy agent id",
"Reload agent",
"Close tabs above",
"Close tabs below",
"Close other tabs",
@@ -83,6 +88,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
menuTestIDBase: "workspace-tab-menu-draft_123",
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
@@ -92,6 +98,34 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Copy agent id")).toBe(
false,
);
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Reload agent")).toBe(
false,
);
expect(entries.some((entry) => entry.kind === "separator")).toBe(false);
});
it("adds reload tooltip copy for agent tabs", () => {
const entries = buildWorkspaceTabMenuEntries({
surface: "desktop",
tab: createAgentTab(),
index: 0,
tabCount: 1,
menuTestIDBase: "workspace-tab-context-agent_123",
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onReloadAgent: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsBefore: vi.fn(),
onCloseTabsAfter: vi.fn(),
onCloseOtherTabs: vi.fn(),
});
expect(entries).toContainEqual(
expect.objectContaining({
kind: "item",
key: "reload-agent",
tooltip: "Reload agent to update skills, MCPs or login status.",
}),
);
});
});
@@ -8,8 +8,15 @@ export type WorkspaceTabMenuEntry =
kind: "item";
key: string;
label: string;
icon?: "copy" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x";
icon?:
| "copy"
| "rotate-cw"
| "arrow-left-to-line"
| "arrow-right-to-line"
| "copy-x"
| "x";
hint?: string;
tooltip?: string;
disabled?: boolean;
destructive?: boolean;
testID: string;
@@ -28,6 +35,7 @@ interface BuildWorkspaceTabMenuEntriesInput {
menuTestIDBase: string;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsBefore: (tabId: string) => Promise<void> | void;
onCloseTabsAfter: (tabId: string) => Promise<void> | void;
@@ -40,6 +48,7 @@ interface BuildWorkspaceDesktopTabActionsInput {
tabCount: number;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
@@ -95,6 +104,7 @@ export function buildWorkspaceTabMenuEntries(
menuTestIDBase,
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onCloseTab,
onCloseTabsBefore,
onCloseTabsAfter,
@@ -128,6 +138,17 @@ export function buildWorkspaceTabMenuEntries(
void onCopyAgentId(agentId);
},
});
entries.push({
kind: "item",
key: "reload-agent",
label: "Reload agent",
icon: "rotate-cw",
tooltip: "Reload agent to update skills, MCPs or login status.",
testID: `${menuTestIDBase}-reload-agent`,
onSelect: () => {
void onReloadAgent(agentId);
},
});
entries.push({
kind: "separator",
key: "copy-separator",
@@ -195,6 +216,7 @@ export function buildWorkspaceDesktopTabActions(
menuTestIDBase: contextMenuTestId,
onCopyResumeCommand: input.onCopyResumeCommand,
onCopyAgentId: input.onCopyAgentId,
onReloadAgent: input.onReloadAgent,
onCloseTab: input.onCloseTab,
onCloseTabsBefore: input.onCloseTabsToLeft,
onCloseTabsAfter: input.onCloseTabsToRight,
+2
View File
@@ -10,6 +10,7 @@ import type {
AgentPermissionResponse,
AgentPermissionRequest,
AgentSessionConfig,
AgentFeature,
AgentProvider,
AgentMode,
AgentCapabilityFlags,
@@ -100,6 +101,7 @@ export interface Agent {
title: string | null;
cwd: string;
model: string | null;
features?: AgentFeature[];
thinkingOptionId?: string | null;
requiresAttention?: boolean;
attentionReason?: "finished" | "error" | "permission" | null;
+45
View File
@@ -4,6 +4,13 @@ export const settingsStyles = StyleSheet.create((theme) => ({
section: {
marginBottom: theme.spacing[6],
},
sectionHeader: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -11,6 +18,20 @@ export const settingsStyles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionHeaderTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
sectionHeaderLink: {
alignItems: "center",
flexDirection: "row",
gap: theme.spacing[1],
},
sectionHeaderLinkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
card: {
backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.lg,
@@ -18,4 +39,28 @@ export const settingsStyles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
overflow: "hidden",
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
rowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
rowContent: {
flex: 1,
marginRight: theme.spacing[3],
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
rowHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
}));
@@ -286,6 +286,25 @@ export class TerminalEmulatorRuntime {
}
if (!isMac && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
const key = event.key.toLowerCase();
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
if (key === "c" && terminal.hasSelection()) {
void navigator.clipboard.writeText(terminal.getSelection());
return false;
}
// Ctrl+V: paste from clipboard into terminal
if (key === "v") {
event.preventDefault();
void navigator.clipboard.readText().then((text) => {
if (text) {
terminal.paste(text);
}
});
return false;
}
return true;
}
@@ -47,6 +47,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
title: snapshot.title ?? null,
cwd: snapshot.cwd,
model: snapshot.model ?? null,
features: snapshot.features,
thinkingOptionId: snapshot.thinkingOptionId ?? null,
requiresAttention: snapshot.requiresAttention ?? false,
attentionReason: snapshot.attentionReason ?? null,
@@ -39,8 +39,9 @@ describe("resolveNewAgentWorkingDir", () => {
it("returns the main repo root for paseo-owned worktrees", () => {
const checkout = {
isPaseoOwnedWorktree: true,
worktreeRoot: "/repo/.paseo/worktrees/feature",
mainRepoRoot: "/repo/main",
} as CheckoutStatusPayload;
} as unknown as CheckoutStatusPayload;
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", checkout)).toBe(
"/repo/main",
@@ -28,6 +28,7 @@ describe("project-placement", () => {
isGit: true as const,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
worktreeRoot: "/Users/test/repo",
isPaseoOwnedWorktree: false as const,
mainRepoRoot: null,
},
@@ -18,6 +18,7 @@ export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayl
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
@@ -2,6 +2,7 @@ import { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { HostConnection } from "@/types/host-connection";
import { getOrCreateClientId } from "./client-id";
import { resolveAppVersion } from "./app-version";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import {
buildLocalDaemonTransportUrl,
@@ -53,6 +54,7 @@ export async function buildClientConfig(
const base = {
clientId,
clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined,
suppressSendErrors: true,
reconnect: { enabled: false },
...(connection.type === "directSocket" || connection.type === "directPipe"
@@ -51,6 +51,8 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
);
case "plain_text":
return Boolean(detail.label || detail.text);
case "plan":
return detail.text.trim().length > 0;
case "unknown":
return hasMeaningfulUnknownValue(detail.input) || hasMeaningfulUnknownValue(detail.output);
}
+1
View File
@@ -24,6 +24,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
worktree_setup: SquareTerminal,
sub_agent: Bot,
plain_text: Wrench,
plan: Brain,
unknown: Wrench,
};
+2 -116
View File
@@ -14,20 +14,6 @@ interface AudioEngineTraceOptions {
traceLabel?: string;
}
let nextAudioEngineInstanceId = 1;
interface BridgeStats {
windowStartedAtMs: number;
captureEvents: number;
captureBytes: number;
volumeEvents: number;
volumeMax: number;
playbackEvents: number;
playbackInputBytes: number;
playbackResampledBytes: number;
playbackDurationMs: number;
}
function parsePcmSampleRate(mimeType: string): number | null {
const match = /rate=(\d+)/i.exec(mimeType);
if (!match) {
@@ -85,48 +71,6 @@ export function createAudioEngine(
_options?: AudioEngineTraceOptions,
): AudioEngine {
const native = require("@getpaseo/expo-two-way-audio");
const instanceId = nextAudioEngineInstanceId++;
const bridgeStats: BridgeStats = {
windowStartedAtMs: Date.now(),
captureEvents: 0,
captureBytes: 0,
volumeEvents: 0,
volumeMax: 0,
playbackEvents: 0,
playbackInputBytes: 0,
playbackResampledBytes: 0,
playbackDurationMs: 0,
};
const toHexPreview = (bytes: Uint8Array, count = 12): string =>
Array.from(bytes.slice(0, count))
.map((value) => value.toString(16).padStart(2, "0"))
.join(" ");
const maybeFlushBridgeStats = (reason: string): void => {
const now = Date.now();
const elapsedMs = now - bridgeStats.windowStartedAtMs;
if (elapsedMs < 1000) {
return;
}
console.log(
`[AudioEngine.native#${instanceId}][bridge] ${reason} ` +
`capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` +
`volume=${bridgeStats.volumeEvents}ev max=${bridgeStats.volumeMax.toFixed(3)} ` +
`play=${bridgeStats.playbackEvents}ev/${bridgeStats.playbackInputBytes}B->${bridgeStats.playbackResampledBytes}B ` +
`playMs=${bridgeStats.playbackDurationMs.toFixed(1)} ` +
`windowMs=${elapsedMs}`,
);
bridgeStats.windowStartedAtMs = now;
bridgeStats.captureEvents = 0;
bridgeStats.captureBytes = 0;
bridgeStats.volumeEvents = 0;
bridgeStats.volumeMax = 0;
bridgeStats.playbackEvents = 0;
bridgeStats.playbackInputBytes = 0;
bridgeStats.playbackResampledBytes = 0;
bridgeStats.playbackDurationMs = 0;
};
const refs: {
initialized: boolean;
@@ -140,8 +84,6 @@ export function createAudioEngine(
reject: (error: Error) => void;
settled: boolean;
} | null;
sawFirstMicChunk: boolean;
sawFirstVolumeEvent: boolean;
destroyed: boolean;
} = {
initialized: false,
@@ -151,8 +93,6 @@ export function createAudioEngine(
processingQueue: false,
playbackTimeout: null,
activePlayback: null,
sawFirstMicChunk: false,
sawFirstVolumeEvent: false,
destroyed: false,
};
@@ -163,15 +103,6 @@ export function createAudioEngine(
return;
}
const pcm = event.data as Uint8Array;
if (!refs.sawFirstMicChunk) {
refs.sawFirstMicChunk = true;
console.log(
`[AudioEngine.native#${instanceId}] firstMicChunk bytes=${pcm.byteLength} head=${toHexPreview(pcm)}`,
);
}
bridgeStats.captureEvents += 1;
bridgeStats.captureBytes += pcm.byteLength;
maybeFlushBridgeStats("capture");
callbacks.onCaptureData(pcm);
},
);
@@ -182,26 +113,10 @@ export function createAudioEngine(
return;
}
const level = refs.muted ? 0 : event.data;
bridgeStats.volumeEvents += 1;
bridgeStats.volumeMax = Math.max(bridgeStats.volumeMax, level);
if (!refs.sawFirstVolumeEvent) {
refs.sawFirstVolumeEvent = true;
console.log(
`[AudioEngine.native#${instanceId}] firstInputVolume level=${level.toFixed(3)} muted=${refs.muted}`,
);
}
maybeFlushBridgeStats("volume");
callbacks.onVolumeLevel(level);
},
);
const outputVolumeSubscription = native.addExpoTwoWayAudioEventListener(
"onOutputVolumeLevelData",
(event: any) => {
console.log(`[AudioEngine.native#${instanceId}] outputVolume=${event.data}`);
},
);
async function ensureInitialized(): Promise<void> {
if (refs.initialized) {
return;
@@ -210,20 +125,13 @@ export function createAudioEngine(
if (!success) {
throw new Error("expo-two-way-audio: native initialize() returned false");
}
console.log(`[AudioEngine.native#${instanceId}] initialized successfully`);
refs.initialized = true;
}
async function ensureMicrophonePermission(): Promise<void> {
let permission = await native.getMicrophonePermissionsAsync().catch(() => null);
console.log(
`[AudioEngine.native#${instanceId}] microphonePermission initial=${permission?.status ?? "unknown"} granted=${String(permission?.granted ?? false)}`,
);
if (!permission?.granted) {
permission = await native.requestMicrophonePermissionsAsync().catch(() => null);
console.log(
`[AudioEngine.native#${instanceId}] microphonePermission requested=${permission?.status ?? "unknown"} granted=${String(permission?.granted ?? false)}`,
);
}
if (!permission?.granted) {
throw new Error(
@@ -253,17 +161,6 @@ export function createAudioEngine(
// Native AudioEngine expects 16kHz PCM16
const pcm16k = resamplePcm16(pcm, inputRate, 16000);
const durationSec = pcm16k.length / 2 / 16000;
bridgeStats.playbackEvents += 1;
bridgeStats.playbackInputBytes += pcm.length;
bridgeStats.playbackResampledBytes += pcm16k.length;
bridgeStats.playbackDurationMs += durationSec * 1000;
console.log(
`[AudioEngine.native#${instanceId}] playPCMData: inputRate=${inputRate} inputBytes=${pcm.length} ` +
`resampled=${pcm16k.length} durationSec=${durationSec.toFixed(3)} ` +
`pcmHead=${toHexPreview(pcm)} resampledHead=${toHexPreview(pcm16k)}`,
);
maybeFlushBridgeStats("play");
native.resumePlayback();
native.playPCMData(pcm16k);
@@ -334,26 +231,18 @@ export function createAudioEngine(
}
microphoneSubscription.remove();
volumeSubscription.remove();
outputVolumeSubscription.remove();
},
async startCapture() {
if (refs.captureActive) {
console.log(`[AudioEngine.native#${instanceId}] startCapture skipped: already active`);
return;
}
try {
console.log(`[AudioEngine.native#${instanceId}] startCapture begin`);
await ensureMicrophonePermission();
await ensureInitialized();
refs.sawFirstMicChunk = false;
refs.sawFirstVolumeEvent = false;
const isRecording = native.toggleRecording(true);
native.toggleRecording(true);
refs.captureActive = true;
console.log(
`[AudioEngine.native#${instanceId}] startCapture toggleRecording(true) => ${String(isRecording)}`,
);
} catch (error) {
const wrapped = error instanceof Error ? error : new Error(String(error));
callbacks.onError?.(wrapped);
@@ -363,10 +252,7 @@ export function createAudioEngine(
async stopCapture() {
if (refs.captureActive) {
const isRecording = native.toggleRecording(false);
console.log(
`[AudioEngine.native#${instanceId}] stopCapture toggleRecording(false) => ${String(isRecording)}`,
);
native.toggleRecording(false);
}
refs.captureActive = false;
refs.muted = false;
+1 -164
View File
@@ -3,7 +3,6 @@ import type { AgentStreamEventPayload, SessionOutboundMessage } from "@server/sh
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
import type { DaemonServerInfo } from "@/stores/session-store";
import type { AudioEngine } from "@/voice/audio-engine-types";
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
import {
THINKING_TONE_NATIVE_PCM_BASE64,
THINKING_TONE_NATIVE_PCM_DURATION_MS,
@@ -82,8 +81,6 @@ interface RuntimeState {
segmentDurationTimer: ReturnType<typeof setInterval> | null;
lastDisplayVolumePublishMs: number;
serverSpeechStartedAt: number | null;
lastNoServerSpeechLogMs: number;
localAboveThresholdActive: boolean;
}
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
@@ -120,19 +117,6 @@ interface CueState {
playing: boolean;
}
interface RealtimeBridgeStats {
windowStartedAtMs: number;
captureEvents: number;
captureBytes: number;
uplinkEvents: number;
uplinkRawBytes: number;
uplinkBase64Chars: number;
outputEvents: number;
outputBytes: number;
outputGroups: number;
jsLagMaxMs: number;
}
const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = {
phase: "disabled",
isVoiceMode: false,
@@ -210,8 +194,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
segmentDurationTimer: null,
lastDisplayVolumePublishMs: 0,
serverSpeechStartedAt: null,
lastNoServerSpeechLogMs: 0,
localAboveThresholdActive: false,
};
const playback: RuntimePlaybackState = {
groups: new Map(),
@@ -220,18 +202,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
processing: false,
generation: 0,
};
const bridgeStats: RealtimeBridgeStats = {
windowStartedAtMs: Date.now(),
captureEvents: 0,
captureBytes: 0,
uplinkEvents: 0,
uplinkRawBytes: 0,
uplinkBase64Chars: 0,
outputEvents: 0,
outputBytes: 0,
outputGroups: 0,
jsLagMaxMs: 0,
};
const cue: CueState = {
active: false,
token: 0,
@@ -246,41 +216,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
return cuePcm16.buffer.slice(cuePcm16.byteOffset, cuePcm16.byteOffset + cuePcm16.byteLength);
},
};
let lagProbeLastMs = Date.now();
const lagProbe = setInterval(() => {
const now = Date.now();
const lagMs = Math.max(0, now - lagProbeLastMs - 100);
lagProbeLastMs = now;
if (lagMs > bridgeStats.jsLagMaxMs) {
bridgeStats.jsLagMaxMs = lagMs;
}
}, 100);
function flushBridgeStats(reason: string): void {
const now = Date.now();
const elapsedMs = now - bridgeStats.windowStartedAtMs;
if (elapsedMs < 1000) {
return;
}
console.log(
`[VoiceRuntime#${instanceId}][bridge] ${reason} ` +
`capture=${bridgeStats.captureEvents}ev/${bridgeStats.captureBytes}B ` +
`uplink=${bridgeStats.uplinkEvents}ev/${bridgeStats.uplinkRawBytes}B/${bridgeStats.uplinkBase64Chars}c ` +
`output=${bridgeStats.outputEvents}ev/${bridgeStats.outputBytes}B groups=${bridgeStats.outputGroups} ` +
`jsLagMaxMs=${bridgeStats.jsLagMaxMs} windowMs=${elapsedMs}`,
);
bridgeStats.windowStartedAtMs = now;
bridgeStats.captureEvents = 0;
bridgeStats.captureBytes = 0;
bridgeStats.uplinkEvents = 0;
bridgeStats.uplinkRawBytes = 0;
bridgeStats.uplinkBase64Chars = 0;
bridgeStats.outputEvents = 0;
bridgeStats.outputBytes = 0;
bridgeStats.outputGroups = 0;
bridgeStats.jsLagMaxMs = 0;
}
function emit(): void {
for (const listener of listeners) {
listener();
@@ -354,17 +289,11 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
}
function resetPlaybackState(): void {
const hadGroups = playback.groups.size;
playback.generation += 1;
playback.groups.clear();
playback.orderedGroupIds = [];
playback.activeGroupId = null;
playback.processing = false;
if (hadGroups > 0) {
console.log(
`[VoiceRuntime] resetPlaybackState: cleared ${hadGroups} groups, new gen=${playback.generation}`,
);
}
}
function activateNextPlaybackGroup(): void {
@@ -394,15 +323,9 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
playback.processing = true;
const generation = playback.generation;
console.log(
`[VoiceRuntime] processPlaybackQueue start gen=${generation} activeGroup=${playback.activeGroupId}`,
);
try {
while (playback.activeGroupId) {
if (generation !== playback.generation) {
console.log(
`[VoiceRuntime] processPlaybackQueue abort: generation changed ${generation} -> ${playback.generation}`,
);
return;
}
@@ -415,9 +338,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
const nextChunk = group.chunks.get(group.nextChunkToPlay);
if (!nextChunk) {
if (group.finalChunkIndex !== null && group.nextChunkToPlay > group.finalChunkIndex) {
console.log(
`[VoiceRuntime] group=${group.groupId} complete, played=${group.started} chunks=${group.nextChunkToPlay}`,
);
playback.groups.delete(group.groupId);
if (playback.orderedGroupIds[0] === group.groupId) {
playback.orderedGroupIds.shift();
@@ -432,9 +352,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
activateNextPlaybackGroup();
continue;
}
console.log(
`[VoiceRuntime] group=${group.groupId} waiting for chunk=${group.nextChunkToPlay} (finalChunkIndex=${group.finalChunkIndex})`,
);
return;
}
@@ -442,39 +359,21 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
if (group.shouldPlay && !group.started && group.isVoiceMode) {
group.started = true;
console.log(
`[VoiceRuntime] group=${group.groupId} first play starting at chunk=${group.nextChunkToPlay}`,
);
api.onAssistantAudioStarted(serverId);
}
const playStart = Date.now();
try {
if (group.shouldPlay) {
await deps.engine.play(nextChunk.source);
console.log(
`[VoiceRuntime] played chunk=${group.nextChunkToPlay} id=${nextChunk.id} took=${Date.now() - playStart}ms`,
);
} else {
console.log(
`[VoiceRuntime] SKIPPED chunk=${group.nextChunkToPlay} id=${nextChunk.id} shouldPlay=false`,
);
}
} catch (error) {
if (generation !== playback.generation) {
console.log(`[VoiceRuntime] play error + generation changed, aborting`);
return;
}
console.error(
`[VoiceRuntime] play error chunk=${group.nextChunkToPlay} took=${Date.now() - playStart}ms:`,
error,
);
console.error(`[VoiceRuntime] play error chunk=${group.nextChunkToPlay}:`, error);
}
if (generation !== playback.generation) {
console.log(
`[VoiceRuntime] post-play generation changed ${generation} -> ${playback.generation}`,
);
return;
}
@@ -491,9 +390,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
if (generation === playback.generation) {
playback.processing = false;
}
console.log(
`[VoiceRuntime] processPlaybackQueue exit gen=${generation} currentGen=${playback.generation}`,
);
}
}
@@ -607,12 +503,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
}
const base64 = Buffer.from(chunk).toString("base64");
bridgeStats.captureEvents += 1;
bridgeStats.captureBytes += chunk.byteLength;
bridgeStats.uplinkEvents += 1;
bridgeStats.uplinkRawBytes += chunk.byteLength;
bridgeStats.uplinkBase64Chars += base64.length;
flushBridgeStats("uplink");
void activeSession.adapter.sendVoiceAudioChunk(base64, PCM_MIME_TYPE).catch((error) => {
console.error(`[VoiceRuntime#${instanceId}] Failed to send audio chunk:`, error);
@@ -625,8 +515,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
state.turnInProgress = false;
state.serverSpeechDetected = false;
state.lastDisplayVolumePublishMs = 0;
state.lastNoServerSpeechLogMs = 0;
state.localAboveThresholdActive = false;
uploader.reset();
resetCaptureTelemetry();
patchSnapshot({ ...INITIAL_SNAPSHOT });
@@ -747,11 +635,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
return;
}
if (bridgeStats.captureEvents === 0) {
console.log(
`[VoiceRuntime#${instanceId}] firstCapturePcm bytes=${chunk.byteLength} phase=${state.snapshot.phase} transportReady=${state.transportReady}`,
);
}
uploader.pushPcmChunk(chunk);
},
@@ -768,30 +651,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
return;
}
const isActive = level > REALTIME_VOICE_VAD_CONFIG.volumeThreshold;
if (isActive && !state.localAboveThresholdActive) {
state.localAboveThresholdActive = true;
console.log(
`[VoiceRuntime#${instanceId}] localSpeechActive level=${level.toFixed(3)} threshold=${REALTIME_VOICE_VAD_CONFIG.volumeThreshold.toFixed(3)} phase=${state.snapshot.phase} transportReady=${state.transportReady}`,
);
}
if (!isActive && state.localAboveThresholdActive) {
state.localAboveThresholdActive = false;
console.log(
`[VoiceRuntime#${instanceId}] localSpeechInactive phase=${state.snapshot.phase} serverSpeaking=${state.serverSpeechDetected}`,
);
}
if (
isActive &&
!state.serverSpeechDetected &&
nowMs - state.lastNoServerSpeechLogMs >= 1500
) {
state.lastNoServerSpeechLogMs = nowMs;
console.log(
`[VoiceRuntime#${instanceId}] localSpeechWithoutServerSpeech level=${level.toFixed(3)} phase=${state.snapshot.phase} turnInProgress=${state.turnInProgress} transportReady=${state.transportReady}`,
);
}
patchTelemetry((prev) => ({
...prev,
isSpeaking: state.serverSpeechDetected,
@@ -806,33 +665,15 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
!state.snapshot.isVoiceMode ||
!payload.isVoiceMode
) {
console.log(
`[VoiceRuntime#${instanceId}] audio_output DROPPED: activeServer=${state.snapshot.activeServerId} serverId=${serverId} isVoiceMode=${state.snapshot.isVoiceMode} payloadVoice=${payload.isVoiceMode}`,
);
return;
}
const groupId = payload.groupId ?? payload.id;
const chunkIndex = payload.chunkIndex ?? 0;
const decoded = decodeAudioChunk(payload.audio);
bridgeStats.outputEvents += 1;
bridgeStats.outputBytes += decoded.byteLength;
bridgeStats.outputGroups += playback.groups.has(groupId) ? 0 : 1;
console.log(
`[VoiceRuntime#${instanceId}] audio_output groupId=${groupId} chunk=${chunkIndex} isLast=${payload.isLastChunk} ` +
`base64Chars=${payload.audio.length} decodedBytes=${decoded.byteLength} format=${payload.format} ` +
`head=${Array.from(decoded.slice(0, 12))
.map((value) => value.toString(16).padStart(2, "0"))
.join(" ")}`,
);
flushBridgeStats("audio_output");
let group = playback.groups.get(groupId);
if (!group) {
const shouldPlay = api.shouldPlayVoiceAudio(serverId);
console.log(
`[VoiceRuntime] new group=${groupId} shouldPlay=${shouldPlay} phase=${state.snapshot.phase} isSpeaking=${state.telemetry.isSpeaking}`,
);
group = {
groupId,
isVoiceMode: payload.isVoiceMode,
@@ -966,7 +807,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
async destroy() {
await this.stopVoice().catch(() => undefined);
clearInterval(lagProbe);
await deps.engine.destroy();
listeners.clear();
telemetryListeners.clear();
@@ -1057,9 +897,6 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
return;
}
console.log(
`[VoiceRuntime#${instanceId}] onServerSpeechStateChanged isSpeaking=${isSpeaking} phase=${state.snapshot.phase} volume=${state.telemetry.volume}`,
);
state.serverSpeechDetected = isSpeaking;
state.serverSpeechStartedAt = isSpeaking ? (state.serverSpeechStartedAt ?? Date.now()) : null;
if (isSpeaking) {