Merge branch 'keyword-nav-plan'
This commit is contained in:
@@ -30,6 +30,7 @@ import { CommandAutocomplete } from "./command-autocomplete";
|
|||||||
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||||
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -363,9 +364,8 @@ export function AgentInputArea({
|
|||||||
if (!isScreenFocused) return;
|
if (!isScreenFocused) return;
|
||||||
if (!focusChatInputRequest) return;
|
if (!focusChatInputRequest) return;
|
||||||
|
|
||||||
const target = focusChatInputRequest.agentKey;
|
|
||||||
const currentKey = `${serverId}:${agentId}`;
|
const currentKey = `${serverId}:${agentId}`;
|
||||||
if (target !== null && target !== currentKey) {
|
if (focusChatInputRequest.agentKey !== currentKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,12 +374,17 @@ export function AgentInputArea({
|
|||||||
}
|
}
|
||||||
lastHandledFocusRequestIdRef.current = focusChatInputRequest.id;
|
lastHandledFocusRequestIdRef.current = focusChatInputRequest.id;
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
return focusWithRetries({
|
||||||
requestAnimationFrame(() => {
|
focus: () => messageInputRef.current?.focus(),
|
||||||
messageInputRef.current?.focus();
|
isFocused: () => {
|
||||||
|
const el = messageInputRef.current?.getNativeElement?.() ?? null;
|
||||||
|
const active =
|
||||||
|
typeof document !== "undefined" ? document.activeElement : null;
|
||||||
|
return Boolean(el) && active === el;
|
||||||
|
},
|
||||||
|
onSuccess: () => clearFocusChatInputRequest(),
|
||||||
|
onTimeout: () => clearFocusChatInputRequest(),
|
||||||
});
|
});
|
||||||
});
|
|
||||||
clearFocusChatInputRequest(focusChatInputRequest.id);
|
|
||||||
}, [
|
}, [
|
||||||
agentId,
|
agentId,
|
||||||
clearFocusChatInputRequest,
|
clearFocusChatInputRequest,
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ export function AgentList({
|
|||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => await client.getCheckoutStatus(agent.cwd),
|
queryFn: async () => await client.getCheckoutStatus(agent.cwd),
|
||||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn("[checkout_status] prefetch failed", error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-typ
|
|||||||
import type { Agent } from "@/contexts/session-context";
|
import type { Agent } from "@/contexts/session-context";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
|
import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||||
@@ -796,7 +796,7 @@ function PermissionRequestCard({
|
|||||||
client,
|
client,
|
||||||
}: {
|
}: {
|
||||||
permission: PendingPermission;
|
permission: PendingPermission;
|
||||||
client: DaemonClientV2 | null;
|
client: DaemonClient | null;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -9,160 +8,28 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { router, usePathname } from "expo-router";
|
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||||
|
|
||||||
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||||
return `${agent.serverId}:${agent.id}`;
|
return `${agent.serverId}:${agent.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMatch(agent: AggregatedAgent, query: string): boolean {
|
|
||||||
if (!query) return true;
|
|
||||||
const q = query.toLowerCase();
|
|
||||||
const title = (agent.title ?? "New agent").toLowerCase();
|
|
||||||
const cwd = agent.cwd.toLowerCase();
|
|
||||||
const host = agent.serverLabel.toLowerCase();
|
|
||||||
return title.includes(q) || cwd.includes(q) || host.includes(q);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
|
||||||
const leftAttention = left.requiresAttention ? 1 : 0;
|
|
||||||
const rightAttention = right.requiresAttention ? 1 : 0;
|
|
||||||
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
|
|
||||||
|
|
||||||
const leftRunning = left.status === "running" ? 1 : 0;
|
|
||||||
const rightRunning = right.status === "running" ? 1 : 0;
|
|
||||||
if (leftRunning !== rightRunning) return rightRunning - leftRunning;
|
|
||||||
|
|
||||||
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommandCenter() {
|
export function CommandCenter() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const pathname = usePathname();
|
const {
|
||||||
const { agents } = useAggregatedAgents();
|
open,
|
||||||
const open = useKeyboardNavStore((s) => s.commandCenterOpen);
|
inputRef,
|
||||||
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen);
|
query,
|
||||||
const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput);
|
setQuery,
|
||||||
const takeFocusRestoreElement = useKeyboardNavStore((s) => s.takeFocusRestoreElement);
|
activeIndex,
|
||||||
const inputRef = useRef<TextInput>(null);
|
results,
|
||||||
const didNavigateRef = useRef(false);
|
handleClose,
|
||||||
const prevOpenRef = useRef(open);
|
handleSelect,
|
||||||
const [query, setQuery] = useState("");
|
} = useCommandCenter();
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
|
|
||||||
const results = useMemo(() => {
|
|
||||||
const filtered = agents.filter((agent) => isMatch(agent, query));
|
|
||||||
filtered.sort(sortAgents);
|
|
||||||
return filtered;
|
|
||||||
}, [agents, query]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const prevOpen = prevOpenRef.current;
|
|
||||||
prevOpenRef.current = open;
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
setQuery("");
|
|
||||||
setActiveIndex(0);
|
|
||||||
|
|
||||||
if (prevOpen && !didNavigateRef.current) {
|
|
||||||
const el = takeFocusRestoreElement();
|
|
||||||
if (el && el.isConnected) {
|
|
||||||
// Modal unmount can steal focus; restore on next tick.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
try {
|
|
||||||
el.focus();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
didNavigateRef.current = false;
|
|
||||||
|
|
||||||
const id = setTimeout(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, 0);
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
if (activeIndex >= results.length) {
|
|
||||||
setActiveIndex(results.length > 0 ? results.length - 1 : 0);
|
|
||||||
}
|
|
||||||
}, [activeIndex, open, results.length]);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
setOpen(false);
|
|
||||||
}, [setOpen]);
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
(agent: AggregatedAgent) => {
|
|
||||||
didNavigateRef.current = true;
|
|
||||||
const session = useSessionStore.getState().sessions[agent.serverId];
|
|
||||||
session?.client?.clearAgentAttention(agent.id);
|
|
||||||
|
|
||||||
const shouldReplace = pathname.startsWith("/agent/");
|
|
||||||
const navigate = shouldReplace ? router.replace : router.push;
|
|
||||||
|
|
||||||
requestFocusChatInput(`${agent.serverId}:${agent.id}`);
|
|
||||||
// Don't restore focus back to the prior element after we navigate.
|
|
||||||
takeFocusRestoreElement();
|
|
||||||
setOpen(false);
|
|
||||||
navigate(`/agent/${agent.serverId}/${agent.id}` as any);
|
|
||||||
},
|
|
||||||
[pathname, requestFocusChatInput, setOpen, takeFocusRestoreElement]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
const handler = (event: KeyboardEvent) => {
|
|
||||||
const key = event.key;
|
|
||||||
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
handleClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === "Enter") {
|
|
||||||
if (results.length === 0) return;
|
|
||||||
event.preventDefault();
|
|
||||||
const index = Math.max(0, Math.min(activeIndex, results.length - 1));
|
|
||||||
handleSelect(results[index]!);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
|
||||||
if (results.length === 0) return;
|
|
||||||
event.preventDefault();
|
|
||||||
setActiveIndex((current) => {
|
|
||||||
const delta = key === "ArrowDown" ? 1 : -1;
|
|
||||||
const next = current + delta;
|
|
||||||
if (next < 0) return results.length - 1;
|
|
||||||
if (next >= results.length) return 0;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// react-native-web can stop propagation on key events, so listen in capture phase.
|
|
||||||
window.addEventListener("keydown", handler, true);
|
|
||||||
return () => window.removeEventListener("keydown", handler, true);
|
|
||||||
}, [activeIndex, handleClose, handleSelect, open, results]);
|
|
||||||
|
|
||||||
if (Platform.OS !== "web") return null;
|
if (Platform.OS !== "web") return null;
|
||||||
|
|
||||||
|
|||||||
@@ -302,6 +302,8 @@ export function GroupedAgentList({
|
|||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => await client.getCheckoutStatus(agent.cwd),
|
queryFn: async () => await client.getCheckoutStatus(agent.cwd),
|
||||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn("[checkout_status] prefetch failed", error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [agents, queryClient]);
|
}, [agents, queryClient]);
|
||||||
@@ -325,7 +327,9 @@ export function GroupedAgentList({
|
|||||||
const session = useSessionStore.getState().sessions[agent.serverId];
|
const session = useSessionStore.getState().sessions[agent.serverId];
|
||||||
const client = session?.client ?? null;
|
const client = session?.client ?? null;
|
||||||
if (client) {
|
if (client) {
|
||||||
void client.archiveAgent(agent.id);
|
void client.archiveAgent(agent.id).catch((error) => {
|
||||||
|
console.warn("[archive_agent] failed", error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useDictation } from "@/hooks/use-dictation";
|
import { useDictation } from "@/hooks/use-dictation";
|
||||||
import { DictationOverlay } from "./dictation-controls";
|
import { DictationOverlay } from "./dictation-controls";
|
||||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ export interface MessageInputProps {
|
|||||||
images?: ImageAttachment[];
|
images?: ImageAttachment[];
|
||||||
onPickImages?: () => void;
|
onPickImages?: () => void;
|
||||||
onRemoveImage?: (index: number) => void;
|
onRemoveImage?: (index: number) => void;
|
||||||
client: DaemonClientV2 | null;
|
client: DaemonClient | null;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -72,6 +72,11 @@ export interface MessageInputProps {
|
|||||||
export interface MessageInputRef {
|
export interface MessageInputRef {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
blur: () => void;
|
blur: () => void;
|
||||||
|
/**
|
||||||
|
* Web-only: return the underlying DOM element for focus assertions/retries.
|
||||||
|
* May return null if not mounted or on native.
|
||||||
|
*/
|
||||||
|
getNativeElement?: () => HTMLElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_INPUT_HEIGHT = 30;
|
const MIN_INPUT_HEIGHT = 30;
|
||||||
@@ -135,6 +140,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
blur: () => {
|
blur: () => {
|
||||||
textInputRef.current?.blur?.();
|
textInputRef.current?.blur?.();
|
||||||
},
|
},
|
||||||
|
getNativeElement: () => {
|
||||||
|
if (!IS_WEB) return null;
|
||||||
|
const current = textInputRef.current as
|
||||||
|
| (TextInput & { getNativeRef?: () => unknown })
|
||||||
|
| null;
|
||||||
|
const native =
|
||||||
|
typeof current?.getNativeRef === "function"
|
||||||
|
? current.getNativeRef()
|
||||||
|
: current;
|
||||||
|
return native instanceof HTMLElement ? native : null;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
||||||
const baselineInputHeightRef = useRef<number | null>(null);
|
const baselineInputHeightRef = useRef<number | null>(null);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type {
|
|||||||
} from "@server/shared/messages";
|
} from "@server/shared/messages";
|
||||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||||
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
|
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { DaemonClientV2, ConnectionState } from "@server/client/daemon-client-v2";
|
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
|
||||||
import { File } from "expo-file-system";
|
import { File } from "expo-file-system";
|
||||||
import { useDaemonConnections } from "./daemon-connections-context";
|
import { useDaemonConnections } from "./daemon-connections-context";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { generateMessageId } from "@/types/stream";
|
import { generateMessageId } from "@/types/stream";
|
||||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
|
||||||
export type DictationStreamSenderParams = {
|
export type DictationStreamSenderParams = {
|
||||||
client: DaemonClientV2 | null;
|
client: DaemonClient | null;
|
||||||
format: string;
|
format: string;
|
||||||
createDictationId?: () => string;
|
createDictationId?: () => string;
|
||||||
};
|
};
|
||||||
@@ -22,7 +22,7 @@ type DictationFinishResult = { dictationId: string; text: string };
|
|||||||
* so enqueues can't "miss" a flush due to in-flight await/coalescing bugs.
|
* so enqueues can't "miss" a flush due to in-flight await/coalescing bugs.
|
||||||
*/
|
*/
|
||||||
export class DictationStreamSender {
|
export class DictationStreamSender {
|
||||||
private client: DaemonClientV2 | null;
|
private client: DaemonClient | null;
|
||||||
private readonly format: string;
|
private readonly format: string;
|
||||||
private readonly createDictationId: () => string;
|
private readonly createDictationId: () => string;
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export class DictationStreamSender {
|
|||||||
this.createDictationId = params.createDictationId ?? generateMessageId;
|
this.createDictationId = params.createDictationId ?? generateMessageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
setClient(client: DaemonClientV2 | null): void {
|
setClient(client: DaemonClient | null): void {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import { AppState, Platform } from "react-native";
|
import { AppState, Platform } from "react-native";
|
||||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
|
||||||
const HEARTBEAT_INTERVAL_MS = 15_000;
|
const HEARTBEAT_INTERVAL_MS = 15_000;
|
||||||
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
|
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
|
||||||
|
|
||||||
interface ClientActivityOptions {
|
interface ClientActivityOptions {
|
||||||
client: DaemonClientV2;
|
client: DaemonClient;
|
||||||
focusedAgentId: string | null;
|
focusedAgentId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { TextInput } from "react-native";
|
||||||
|
import { router, usePathname } from "expo-router";
|
||||||
|
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||||
|
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import {
|
||||||
|
clearCommandCenterFocusRestoreElement,
|
||||||
|
takeCommandCenterFocusRestoreElement,
|
||||||
|
} from "@/utils/command-center-focus-restore";
|
||||||
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
|
|
||||||
|
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||||
|
return `${agent.serverId}:${agent.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMatch(agent: AggregatedAgent, query: string): boolean {
|
||||||
|
if (!query) return true;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const title = (agent.title ?? "New agent").toLowerCase();
|
||||||
|
const cwd = agent.cwd.toLowerCase();
|
||||||
|
const host = agent.serverLabel.toLowerCase();
|
||||||
|
return title.includes(q) || cwd.includes(q) || host.includes(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||||
|
const leftAttention = left.requiresAttention ? 1 : 0;
|
||||||
|
const rightAttention = right.requiresAttention ? 1 : 0;
|
||||||
|
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
|
||||||
|
|
||||||
|
const leftRunning = left.status === "running" ? 1 : 0;
|
||||||
|
const rightRunning = right.status === "running" ? 1 : 0;
|
||||||
|
if (leftRunning !== rightRunning) return rightRunning - leftRunning;
|
||||||
|
|
||||||
|
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAgentKeyFromPathname(pathname: string): string | null {
|
||||||
|
const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/);
|
||||||
|
if (!match) return null;
|
||||||
|
return `${match[1]}:${match[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommandCenter() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { agents } = useAggregatedAgents();
|
||||||
|
const open = useKeyboardNavStore((s) => s.commandCenterOpen);
|
||||||
|
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen);
|
||||||
|
const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput);
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
const didNavigateRef = useRef(false);
|
||||||
|
const prevOpenRef = useRef(open);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
const filtered = agents.filter((agent) => isMatch(agent, query));
|
||||||
|
filtered.sort(sortAgents);
|
||||||
|
return filtered;
|
||||||
|
}, [agents, query]);
|
||||||
|
|
||||||
|
const agentKeyFromPathname = useMemo(
|
||||||
|
() => parseAgentKeyFromPathname(pathname),
|
||||||
|
[pathname]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [setOpen]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(agent: AggregatedAgent) => {
|
||||||
|
didNavigateRef.current = true;
|
||||||
|
const session = useSessionStore.getState().sessions[agent.serverId];
|
||||||
|
session?.client?.clearAgentAttention(agent.id);
|
||||||
|
|
||||||
|
const shouldReplace = pathname.startsWith("/agent/");
|
||||||
|
const navigate = shouldReplace ? router.replace : router.push;
|
||||||
|
|
||||||
|
requestFocusChatInput(agentKey(agent));
|
||||||
|
// Don't restore focus back to the prior element after we navigate.
|
||||||
|
clearCommandCenterFocusRestoreElement();
|
||||||
|
setOpen(false);
|
||||||
|
navigate(`/agent/${agent.serverId}/${agent.id}` as any);
|
||||||
|
},
|
||||||
|
[pathname, requestFocusChatInput, setOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prevOpen = prevOpenRef.current;
|
||||||
|
prevOpenRef.current = open;
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setQuery("");
|
||||||
|
setActiveIndex(0);
|
||||||
|
|
||||||
|
if (prevOpen && !didNavigateRef.current) {
|
||||||
|
const el = takeCommandCenterFocusRestoreElement();
|
||||||
|
const isFocused = () =>
|
||||||
|
Boolean(el) &&
|
||||||
|
typeof document !== "undefined" &&
|
||||||
|
document.activeElement === el;
|
||||||
|
|
||||||
|
const cancel = focusWithRetries({
|
||||||
|
focus: () => el?.focus(),
|
||||||
|
isFocused,
|
||||||
|
onTimeout: () => {
|
||||||
|
if (agentKeyFromPathname) {
|
||||||
|
requestFocusChatInput(agentKeyFromPathname);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return cancel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
didNavigateRef.current = false;
|
||||||
|
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [agentKeyFromPathname, open, requestFocusChatInput]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (activeIndex >= results.length) {
|
||||||
|
setActiveIndex(results.length > 0 ? results.length - 1 : 0);
|
||||||
|
}
|
||||||
|
}, [activeIndex, open, results.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
const key = event.key;
|
||||||
|
if (
|
||||||
|
key !== "ArrowDown" &&
|
||||||
|
key !== "ArrowUp" &&
|
||||||
|
key !== "Enter" &&
|
||||||
|
key !== "Escape"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "Enter") {
|
||||||
|
if (results.length === 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const index = Math.max(0, Math.min(activeIndex, results.length - 1));
|
||||||
|
handleSelect(results[index]!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||||
|
if (results.length === 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((current) => {
|
||||||
|
const delta = key === "ArrowDown" ? 1 : -1;
|
||||||
|
const next = current + delta;
|
||||||
|
if (next < 0) return results.length - 1;
|
||||||
|
if (next >= results.length) return 0;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// react-native-web can stop propagation on key events, so listen in capture phase.
|
||||||
|
window.addEventListener("keydown", handler, true);
|
||||||
|
return () => window.removeEventListener("keydown", handler, true);
|
||||||
|
}, [activeIndex, handleClose, handleSelect, open, results]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
inputRef,
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
activeIndex,
|
||||||
|
setActiveIndex,
|
||||||
|
results,
|
||||||
|
handleClose,
|
||||||
|
handleSelect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { AppState } from "react-native";
|
import { AppState } from "react-native";
|
||||||
import { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
import { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
|
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
|
||||||
|
|
||||||
function runDaemonRequest(label: string, promise: Promise<unknown>): void {
|
function runDaemonRequest(label: string, promise: Promise<unknown>): void {
|
||||||
@@ -9,11 +9,11 @@ function runDaemonRequest(label: string, promise: Promise<unknown>): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDaemonClient(url: string): DaemonClientV2 {
|
export function useDaemonClient(url: string): DaemonClient {
|
||||||
const client = useMemo(
|
const client = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||||
return new DaemonClientV2({
|
return new DaemonClient({
|
||||||
url,
|
url,
|
||||||
suppressSendErrors: true,
|
suppressSendErrors: true,
|
||||||
...(tauriTransportFactory
|
...(tauriTransportFactory
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export type DictationStatus = "idle" | "recording" | "uploading" | "failed";
|
export type DictationStatus = "idle" | "recording" | "uploading" | "failed";
|
||||||
|
|
||||||
export type UseDictationOptions = {
|
export type UseDictationOptions = {
|
||||||
client: import("@server/client/daemon-client-v2").DaemonClientV2 | null;
|
client: import("@server/client/daemon-client").DaemonClient | null;
|
||||||
onTranscript: (text: string, meta: { requestId: string }) => void;
|
onTranscript: (text: string, meta: { requestId: string }) => void;
|
||||||
onPartialTranscript?: (text: string, meta: { requestId: string }) => void;
|
onPartialTranscript?: (text: string, meta: { requestId: string }) => void;
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { usePathname, useRouter } from "expo-router";
|
|||||||
import { getIsTauri } from "@/constants/layout";
|
import { getIsTauri } from "@/constants/layout";
|
||||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||||
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
|
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
|
||||||
|
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||||
|
|
||||||
export function useGlobalKeyboardNav({
|
export function useGlobalKeyboardNav({
|
||||||
enabled,
|
enabled,
|
||||||
@@ -34,6 +35,17 @@ export function useGlobalKeyboardNav({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isEditableTarget = (event: KeyboardEvent): boolean => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Element)) return false;
|
||||||
|
|
||||||
|
if ((target as HTMLElement).isContentEditable) return true;
|
||||||
|
const tag = target.tagName.toLowerCase();
|
||||||
|
if (tag === "input" || tag === "textarea") return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const parseShortcutDigit = (event: KeyboardEvent): number | null => {
|
const parseShortcutDigit = (event: KeyboardEvent): number | null => {
|
||||||
const code = event.code ?? "";
|
const code = event.code ?? "";
|
||||||
if (code.startsWith("Digit")) {
|
if (code.startsWith("Digit")) {
|
||||||
@@ -77,14 +89,6 @@ export function useGlobalKeyboardNav({
|
|||||||
const key = event.key ?? "";
|
const key = event.key ?? "";
|
||||||
const lowerKey = key.toLowerCase();
|
const lowerKey = key.toLowerCase();
|
||||||
|
|
||||||
const target = event.target as unknown;
|
|
||||||
const isEditableTarget =
|
|
||||||
typeof HTMLElement !== "undefined" &&
|
|
||||||
target instanceof HTMLElement &&
|
|
||||||
(target.tagName === "TEXTAREA" ||
|
|
||||||
target.tagName === "INPUT" ||
|
|
||||||
target.isContentEditable);
|
|
||||||
|
|
||||||
if (key === "Alt") {
|
if (key === "Alt") {
|
||||||
useKeyboardNavStore.getState().setAltDown(true);
|
useKeyboardNavStore.getState().setAltDown(true);
|
||||||
}
|
}
|
||||||
@@ -97,9 +101,24 @@ export function useGlobalKeyboardNav({
|
|||||||
(event.metaKey || event.ctrlKey) &&
|
(event.metaKey || event.ctrlKey) &&
|
||||||
(event.code === "KeyB" || lowerKey === "b")
|
(event.code === "KeyB" || lowerKey === "b")
|
||||||
) {
|
) {
|
||||||
// When focus is in the chat input, MessageInput handles Cmd+B (and prevents default)
|
// The MessageInput already handles Cmd+B inside editable fields. If we also
|
||||||
// via TextInput onKeyPress. If we also toggle here, it flips twice and appears "broken".
|
// handle it globally, it can double-toggle and look like it "doesn't work".
|
||||||
if (isEditableTarget) {
|
if (isEditableTarget(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
toggleAgentList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd+.: toggle sidebar (VS Code quick-fix muscle memory)
|
||||||
|
// Note: intentionally works even when focus is inside an input/textarea.
|
||||||
|
if (
|
||||||
|
(event.metaKey || event.ctrlKey) &&
|
||||||
|
(event.code === "Period" || key === ".")
|
||||||
|
) {
|
||||||
|
// Ignore while command center is open.
|
||||||
|
if (useKeyboardNavStore.getState().commandCenterOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -115,7 +134,25 @@ export function useGlobalKeyboardNav({
|
|||||||
(event.code === "KeyE" || lowerKey === "e")
|
(event.code === "KeyE" || lowerKey === "e")
|
||||||
) {
|
) {
|
||||||
// Same double-toggle issue as Cmd+B when focus is inside a text input.
|
// Same double-toggle issue as Cmd+B when focus is inside a text input.
|
||||||
if (isEditableTarget) {
|
if (isEditableTarget(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
toggleFileExplorer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+`: toggle explorer sidebar (VS Code muscle memory)
|
||||||
|
// Note: intentionally works even when focus is inside an input/textarea.
|
||||||
|
if (
|
||||||
|
selectedAgentId &&
|
||||||
|
toggleFileExplorer &&
|
||||||
|
event.ctrlKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
(event.code === "Backquote" || key === "`")
|
||||||
|
) {
|
||||||
|
// Ignore while command center is open.
|
||||||
|
if (useKeyboardNavStore.getState().commandCenterOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -128,18 +165,18 @@ export function useGlobalKeyboardNav({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const s = useKeyboardNavStore.getState();
|
const s = useKeyboardNavStore.getState();
|
||||||
if (!s.commandCenterOpen) {
|
if (!s.commandCenterOpen) {
|
||||||
const target = event.target as unknown;
|
const target =
|
||||||
const el =
|
event.target instanceof Element ? (event.target as Element) : null;
|
||||||
typeof HTMLElement !== "undefined" && target instanceof HTMLElement
|
const targetEl =
|
||||||
? target
|
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||||
: (typeof document !== "undefined"
|
(target instanceof HTMLElement ? target : null);
|
||||||
? (document.activeElement as HTMLElement | null)
|
const active = document.activeElement;
|
||||||
: null);
|
const activeEl = active instanceof HTMLElement ? active : null;
|
||||||
s.setFocusRestoreElement(el);
|
setCommandCenterFocusRestoreElement(
|
||||||
s.setCommandCenterOpen(true);
|
(targetEl as HTMLElement | null) ?? activeEl ?? null
|
||||||
} else {
|
);
|
||||||
s.setCommandCenterOpen(false);
|
|
||||||
}
|
}
|
||||||
|
s.setCommandCenterOpen(!s.commandCenterOpen);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Platform } from "react-native";
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
import Constants from "expo-constants";
|
import Constants from "expo-constants";
|
||||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
|
||||||
const STORAGE_PREFIX = "@paseo:expo-push-token:";
|
const STORAGE_PREFIX = "@paseo:expo-push-token:";
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ async function ensurePushPermission(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePushTokenRegistration(params: {
|
export function usePushTokenRegistration(params: {
|
||||||
client: DaemonClientV2;
|
client: DaemonClient;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
}): void {
|
}): void {
|
||||||
const { client, serverId } = params;
|
const { client, serverId } = params;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQueries, type UseQueryOptions } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
CHECKOUT_STATUS_STALE_TIME,
|
|
||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
type CheckoutStatusPayload,
|
type CheckoutStatusPayload,
|
||||||
} from "@/hooks/use-checkout-status-query";
|
} from "@/hooks/use-checkout-status-query";
|
||||||
@@ -23,32 +22,32 @@ export interface SidebarSectionData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSectionData[] {
|
export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSectionData[] {
|
||||||
const checkoutCacheQueries = useQueries({
|
const queryClient = useQueryClient();
|
||||||
queries: agents.map(
|
const [checkoutCacheBump, setCheckoutCacheBump] = useState(0);
|
||||||
(agent): UseQueryOptions<CheckoutStatusPayload> => ({
|
|
||||||
queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd),
|
// Re-render when checkout status cache updates so grouping can switch from cwd→remote.
|
||||||
enabled: false,
|
useEffect(() => {
|
||||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
|
||||||
queryFn: async (): Promise<CheckoutStatusPayload> => {
|
const queryKey = event?.query?.queryKey;
|
||||||
throw new Error("checkout status cache-only query should not run");
|
if (!Array.isArray(queryKey) || queryKey[0] !== "checkoutStatus") {
|
||||||
},
|
return;
|
||||||
})
|
}
|
||||||
),
|
setCheckoutCacheBump((v) => v + 1);
|
||||||
});
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
const remoteUrlByAgentKey = useMemo(() => {
|
const remoteUrlByAgentKey = useMemo(() => {
|
||||||
const result = new Map<string, string | null>();
|
const result = new Map<string, string | null>();
|
||||||
for (let i = 0; i < agents.length; i++) {
|
for (const agent of agents) {
|
||||||
const agent = agents[i];
|
const checkout =
|
||||||
if (!agent) {
|
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||||
continue;
|
checkoutStatusQueryKey(agent.serverId, agent.cwd)
|
||||||
}
|
) ?? null;
|
||||||
const checkout = checkoutCacheQueries[i]?.data ?? null;
|
result.set(`${agent.serverId}:${agent.id}`, checkout?.remoteUrl ?? null);
|
||||||
const remoteUrl = checkout?.remoteUrl ?? null;
|
|
||||||
result.set(`${agent.serverId}:${agent.id}`, remoteUrl);
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [agents, checkoutCacheQueries]);
|
}, [agents, checkoutCacheBump, queryClient]);
|
||||||
|
|
||||||
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
|
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
|
||||||
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
|
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
|
||||||
@@ -100,4 +99,3 @@ export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSecti
|
|||||||
|
|
||||||
return sections;
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +1,47 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
type FocusChatInputRequest = {
|
||||||
|
id: number;
|
||||||
|
agentKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface KeyboardNavState {
|
interface KeyboardNavState {
|
||||||
commandCenterOpen: boolean;
|
commandCenterOpen: boolean;
|
||||||
altDown: boolean;
|
altDown: boolean;
|
||||||
cmdOrCtrlDown: boolean;
|
cmdOrCtrlDown: boolean;
|
||||||
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
|
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
|
||||||
sidebarShortcutAgentKeys: string[];
|
sidebarShortcutAgentKeys: string[];
|
||||||
/** Captured focus target when opening the command center (web only). */
|
|
||||||
focusRestoreElement: HTMLElement | null;
|
/** Web-only request to focus the MessageInput for the selected agent. */
|
||||||
focusChatInputRequest: { id: number; agentKey: string | null } | null;
|
focusChatInputRequest: FocusChatInputRequest | null;
|
||||||
|
requestFocusChatInput: (agentKey: string) => void;
|
||||||
|
clearFocusChatInputRequest: () => void;
|
||||||
|
|
||||||
setCommandCenterOpen: (open: boolean) => void;
|
setCommandCenterOpen: (open: boolean) => void;
|
||||||
setAltDown: (down: boolean) => void;
|
setAltDown: (down: boolean) => void;
|
||||||
setCmdOrCtrlDown: (down: boolean) => void;
|
setCmdOrCtrlDown: (down: boolean) => void;
|
||||||
setSidebarShortcutAgentKeys: (keys: string[]) => void;
|
setSidebarShortcutAgentKeys: (keys: string[]) => void;
|
||||||
setFocusRestoreElement: (el: HTMLElement | null) => void;
|
|
||||||
takeFocusRestoreElement: () => HTMLElement | null;
|
|
||||||
requestFocusChatInput: (agentKey: string | null) => void;
|
|
||||||
clearFocusChatInputRequest: (id: number) => void;
|
|
||||||
resetModifiers: () => void;
|
resetModifiers: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useKeyboardNavStore = create<KeyboardNavState>((set) => ({
|
export const useKeyboardNavStore = create<KeyboardNavState>((set, get) => ({
|
||||||
commandCenterOpen: false,
|
commandCenterOpen: false,
|
||||||
altDown: false,
|
altDown: false,
|
||||||
cmdOrCtrlDown: false,
|
cmdOrCtrlDown: false,
|
||||||
sidebarShortcutAgentKeys: [],
|
sidebarShortcutAgentKeys: [],
|
||||||
focusRestoreElement: null,
|
|
||||||
focusChatInputRequest: null,
|
focusChatInputRequest: null,
|
||||||
|
requestFocusChatInput: (agentKey) => {
|
||||||
|
const prev = get().focusChatInputRequest;
|
||||||
|
const id = (prev?.id ?? 0) + 1;
|
||||||
|
set({ focusChatInputRequest: { id, agentKey } });
|
||||||
|
},
|
||||||
|
clearFocusChatInputRequest: () => set({ focusChatInputRequest: null }),
|
||||||
|
|
||||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||||
setAltDown: (down) => set({ altDown: down }),
|
setAltDown: (down) => set({ altDown: down }),
|
||||||
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
||||||
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
|
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
|
||||||
setFocusRestoreElement: (el) => set({ focusRestoreElement: el }),
|
|
||||||
takeFocusRestoreElement: () => {
|
|
||||||
let value: HTMLElement | null = null;
|
|
||||||
set((state) => {
|
|
||||||
value = state.focusRestoreElement;
|
|
||||||
return { focusRestoreElement: null };
|
|
||||||
});
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
requestFocusChatInput: (agentKey) =>
|
|
||||||
set((state) => ({
|
|
||||||
focusChatInputRequest: {
|
|
||||||
id: (state.focusChatInputRequest?.id ?? 0) + 1,
|
|
||||||
agentKey,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
clearFocusChatInputRequest: (id) =>
|
|
||||||
set((state) => {
|
|
||||||
if (state.focusChatInputRequest?.id !== id) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return { focusChatInputRequest: null };
|
|
||||||
}),
|
|
||||||
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { subscribeWithSelector } from "zustand/middleware";
|
import { subscribeWithSelector } from "zustand/middleware";
|
||||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import type { useAudioPlayer } from "@/hooks/use-audio-player";
|
import type { useAudioPlayer } from "@/hooks/use-audio-player";
|
||||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
@@ -151,7 +151,7 @@ export interface SessionState {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
|
|
||||||
// Daemon client (immutable reference)
|
// Daemon client (immutable reference)
|
||||||
client: DaemonClientV2 | null;
|
client: DaemonClient | null;
|
||||||
|
|
||||||
// Connection snapshot (mutable)
|
// Connection snapshot (mutable)
|
||||||
connection: DaemonConnectionSnapshot;
|
connection: DaemonConnectionSnapshot;
|
||||||
@@ -206,10 +206,10 @@ interface SessionStoreState {
|
|||||||
// Action types
|
// Action types
|
||||||
interface SessionStoreActions {
|
interface SessionStoreActions {
|
||||||
// Session management
|
// Session management
|
||||||
initializeSession: (serverId: string, client: DaemonClientV2, audioPlayer: ReturnType<typeof useAudioPlayer>) => void;
|
initializeSession: (serverId: string, client: DaemonClient, audioPlayer: ReturnType<typeof useAudioPlayer>) => void;
|
||||||
clearSession: (serverId: string) => void;
|
clearSession: (serverId: string) => void;
|
||||||
getSession: (serverId: string) => SessionState | undefined;
|
getSession: (serverId: string) => SessionState | undefined;
|
||||||
updateSessionClient: (serverId: string, client: DaemonClientV2) => void;
|
updateSessionClient: (serverId: string, client: DaemonClient) => void;
|
||||||
updateSessionConnection: (serverId: string, connection: DaemonConnectionSnapshot) => void;
|
updateSessionConnection: (serverId: string, connection: DaemonConnectionSnapshot) => void;
|
||||||
|
|
||||||
// Audio state
|
// Audio state
|
||||||
@@ -281,7 +281,7 @@ function logSessionStoreUpdate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function createDefaultConnectionSnapshot(client?: DaemonClientV2 | null): DaemonConnectionSnapshot {
|
function createDefaultConnectionSnapshot(client?: DaemonClient | null): DaemonConnectionSnapshot {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return { isConnected: false, isConnecting: false, lastError: null };
|
return { isConnected: false, isConnecting: false, lastError: null };
|
||||||
}
|
}
|
||||||
@@ -294,7 +294,7 @@ function createDefaultConnectionSnapshot(client?: DaemonClientV2 | null): Daemon
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create initial session state
|
// Helper to create initial session state
|
||||||
function createInitialSessionState(serverId: string, client: DaemonClientV2, audioPlayer: ReturnType<typeof useAudioPlayer>): SessionState {
|
function createInitialSessionState(serverId: string, client: DaemonClient, audioPlayer: ReturnType<typeof useAudioPlayer>): SessionState {
|
||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
client,
|
client,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
let focusRestoreElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
export function setCommandCenterFocusRestoreElement(el: HTMLElement | null): void {
|
||||||
|
focusRestoreElement = el;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function takeCommandCenterFocusRestoreElement(): HTMLElement | null {
|
||||||
|
const el = focusRestoreElement;
|
||||||
|
focusRestoreElement = null;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCommandCenterFocusRestoreElement(): void {
|
||||||
|
focusRestoreElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client-v2";
|
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
|
||||||
|
|
||||||
type TauriWebSocketMessage =
|
type TauriWebSocketMessage =
|
||||||
| { type: "Text"; data: string }
|
| { type: "Text"; data: string }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
import { DaemonClient } from "@server/client/daemon-client";
|
||||||
import type { ConnectionState } from "@server/client/daemon-client-v2";
|
import type { ConnectionState } from "@server/client/daemon-client";
|
||||||
import { buildDaemonWebSocketUrl } from "./daemon-endpoints";
|
import { buildDaemonWebSocketUrl } from "./daemon-endpoints";
|
||||||
|
|
||||||
function normalizeNonEmptyString(value: unknown): string | null {
|
function normalizeNonEmptyString(value: unknown): string | null {
|
||||||
@@ -45,7 +45,7 @@ export async function testDaemonEndpointConnection(
|
|||||||
const timeoutMs = options?.timeoutMs ?? 6000;
|
const timeoutMs = options?.timeoutMs ?? 6000;
|
||||||
const url = buildDaemonWebSocketUrl(endpoint);
|
const url = buildDaemonWebSocketUrl(endpoint);
|
||||||
|
|
||||||
const client = new DaemonClientV2({
|
const client = new DaemonClient({
|
||||||
url,
|
url,
|
||||||
suppressSendErrors: true,
|
suppressSendErrors: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
type FocusWithRetriesOptions = {
|
||||||
|
focus: () => void;
|
||||||
|
isFocused: () => boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onTimeout?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function focusWithRetries({
|
||||||
|
focus,
|
||||||
|
isFocused,
|
||||||
|
timeoutMs = 1500,
|
||||||
|
onSuccess,
|
||||||
|
onTimeout,
|
||||||
|
}: FocusWithRetriesOptions): () => void {
|
||||||
|
let cancelled = false;
|
||||||
|
const deadlineMs = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
focus();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFocused()) {
|
||||||
|
onSuccess?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() >= deadlineMs) {
|
||||||
|
onTimeout?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
- `loadConfig`, `resolvePaseoHome`
|
- `loadConfig`, `resolvePaseoHome`
|
||||||
- `createRootLogger`, `LogLevel`, `LogFormat`
|
- `createRootLogger`, `LogLevel`, `LogFormat`
|
||||||
- `loadPersistedConfig`, `PersistedConfig`
|
- `loadPersistedConfig`, `PersistedConfig`
|
||||||
- `DaemonClientV2`, `DaemonClientV2Config`, `ConnectionState`, `DaemonEvent`
|
- `DaemonClient`, `DaemonClientConfig`, `ConnectionState`, `DaemonEvent`
|
||||||
|
|
||||||
No agent snapshot/timeline/permission/message types are exported.
|
No agent snapshot/timeline/permission/message types are exported.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Command } from 'commander'
|
import type { Command } from 'commander'
|
||||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||||
import type {
|
import type {
|
||||||
DaemonClientV2,
|
DaemonClient,
|
||||||
AgentStreamMessage,
|
AgentStreamMessage,
|
||||||
AgentStreamSnapshotMessage,
|
AgentStreamSnapshotMessage,
|
||||||
AgentStreamEventPayload,
|
AgentStreamEventPayload,
|
||||||
@@ -107,7 +107,7 @@ export async function runAttachCommand(
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
let client: DaemonClientV2
|
let client: DaemonClient
|
||||||
try {
|
try {
|
||||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Command } from 'commander'
|
|||||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||||
import type { CommandOptions } from '../../output/index.js'
|
import type { CommandOptions } from '../../output/index.js'
|
||||||
import type {
|
import type {
|
||||||
DaemonClientV2,
|
DaemonClient,
|
||||||
AgentStreamMessage,
|
AgentStreamMessage,
|
||||||
AgentStreamSnapshotMessage,
|
AgentStreamSnapshotMessage,
|
||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
@@ -80,7 +80,7 @@ export async function runLogsCommand(
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
let client: DaemonClientV2
|
let client: DaemonClient
|
||||||
try {
|
try {
|
||||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -172,7 +172,7 @@ export async function runLogsCommand(
|
|||||||
* Follow mode: stream logs in real-time until interrupted
|
* Follow mode: stream logs in real-time until interrupted
|
||||||
*/
|
*/
|
||||||
async function runFollowMode(
|
async function runFollowMode(
|
||||||
client: DaemonClientV2,
|
client: DaemonClient,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
options: AgentLogsOptions
|
options: AgentLogsOptions
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DaemonClientV2 } from '@paseo/server'
|
import { DaemonClient } from '@paseo/server'
|
||||||
import WebSocket from 'ws'
|
import WebSocket from 'ws'
|
||||||
|
|
||||||
export interface ConnectOptions {
|
export interface ConnectOptions {
|
||||||
@@ -35,12 +35,12 @@ function createNodeWebSocketFactory() {
|
|||||||
* Create and connect a daemon client
|
* Create and connect a daemon client
|
||||||
* Returns the connected client or throws if connection fails
|
* Returns the connected client or throws if connection fails
|
||||||
*/
|
*/
|
||||||
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2> {
|
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
|
||||||
const host = getDaemonHost(options)
|
const host = getDaemonHost(options)
|
||||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
|
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
|
||||||
const url = `ws://${host}/ws`
|
const url = `ws://${host}/ws`
|
||||||
|
|
||||||
const client = new DaemonClientV2({
|
const client = new DaemonClient({
|
||||||
url,
|
url,
|
||||||
webSocketFactory: createNodeWebSocketFactory(),
|
webSocketFactory: createNodeWebSocketFactory(),
|
||||||
reconnect: { enabled: false },
|
reconnect: { enabled: false },
|
||||||
@@ -76,7 +76,7 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
|
|||||||
/**
|
/**
|
||||||
* Try to connect to the daemon, returns null if connection fails
|
* Try to connect to the daemon, returns null if connection fails
|
||||||
*/
|
*/
|
||||||
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClientV2 | null> {
|
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
|
||||||
try {
|
try {
|
||||||
return await connectToDaemon(options)
|
return await connectToDaemon(options)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+38
-4
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { DaemonClientV2, type DaemonTransport } from "./daemon-client-v2";
|
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
||||||
|
|
||||||
function createMockLogger() {
|
function createMockLogger() {
|
||||||
return {
|
return {
|
||||||
@@ -49,8 +49,8 @@ function createMockTransport() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("DaemonClientV2", () => {
|
describe("DaemonClient", () => {
|
||||||
const clients: DaemonClientV2[] = [];
|
const clients: DaemonClient[] = [];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
@@ -63,7 +63,7 @@ describe("DaemonClientV2", () => {
|
|||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|
||||||
const client = new DaemonClientV2({
|
const client = new DaemonClient({
|
||||||
url: "ws://test",
|
url: "ws://test",
|
||||||
logger,
|
logger,
|
||||||
reconnect: { enabled: false },
|
reconnect: { enabled: false },
|
||||||
@@ -137,4 +137,38 @@ describe("DaemonClientV2", () => {
|
|||||||
isGit: false,
|
isGit: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("cancels waiters when send fails (no leaked timeouts)", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const transportFactory = () => ({
|
||||||
|
...mock.transport,
|
||||||
|
send: () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const promise = client.getCheckoutStatus("/tmp/project");
|
||||||
|
await expect(promise).rejects.toThrow("boom");
|
||||||
|
|
||||||
|
// Ensure we didn't leave a waiter behind that will reject later.
|
||||||
|
expect((client as any).waiters.size).toBe(0);
|
||||||
|
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+453
-312
File diff suppressed because it is too large
Load Diff
@@ -326,7 +326,7 @@ describe("Codex app-server provider (integration)", () => {
|
|||||||
"*** Add File: patch.txt",
|
"*** Add File: patch.txt",
|
||||||
"+patched",
|
"+patched",
|
||||||
"*** End Patch",
|
"*** End Patch",
|
||||||
].join("\\n");
|
].join("\n");
|
||||||
const patchEvents = session.stream(
|
const patchEvents = session.stream(
|
||||||
[
|
[
|
||||||
"Use the apply_patch tool and nothing else.",
|
"Use the apply_patch tool and nothing else.",
|
||||||
@@ -335,7 +335,7 @@ describe("Codex app-server provider (integration)", () => {
|
|||||||
"Apply the following patch exactly:",
|
"Apply the following patch exactly:",
|
||||||
patch,
|
patch,
|
||||||
"After it completes, reply PATCH_DONE.",
|
"After it completes, reply PATCH_DONE.",
|
||||||
].join("\\n")
|
].join("\n")
|
||||||
);
|
);
|
||||||
|
|
||||||
for await (const event of patchEvents) {
|
for await (const event of patchEvents) {
|
||||||
@@ -612,7 +612,7 @@ describe("Codex app-server provider (integration)", () => {
|
|||||||
"*** Add File: approval-test.txt",
|
"*** Add File: approval-test.txt",
|
||||||
"+ok",
|
"+ok",
|
||||||
"*** End Patch",
|
"*** End Patch",
|
||||||
].join("\\n");
|
].join("\n");
|
||||||
const events = session.stream(
|
const events = session.stream(
|
||||||
[
|
[
|
||||||
"Use the apply_patch tool and nothing else.",
|
"Use the apply_patch tool and nothing else.",
|
||||||
@@ -621,7 +621,7 @@ describe("Codex app-server provider (integration)", () => {
|
|||||||
"Apply the following patch exactly:",
|
"Apply the following patch exactly:",
|
||||||
patch,
|
patch,
|
||||||
"After approval, reply FILE_DONE.",
|
"After approval, reply FILE_DONE.",
|
||||||
].join("\\n")
|
].join("\n")
|
||||||
);
|
);
|
||||||
|
|
||||||
let failure: string | null = null;
|
let failure: string | null = null;
|
||||||
|
|||||||
+2
-2
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "./test-utils/dictation-e2e.js";
|
} from "./test-utils/dictation-e2e.js";
|
||||||
|
|
||||||
function tmpCwd(): string {
|
function tmpCwd(): string {
|
||||||
return mkdtempSync(path.join(tmpdir(), "daemon-client-v2-"));
|
return mkdtempSync(path.join(tmpdir(), "daemon-client-"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitForSignal<T>(
|
function waitForSignal<T>(
|
||||||
@@ -52,7 +52,7 @@ function waitForSignal<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("daemon client v2 E2E", () => {
|
describe("daemon client E2E", () => {
|
||||||
let ctx: DaemonTestContext;
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { DaemonClientV2 } from "../../client/daemon-client-v2.js";
|
import { DaemonClient } from "../../client/daemon-client.js";
|
||||||
|
|
||||||
// Patch WebSocket to log all messages
|
// Patch WebSocket to log all messages
|
||||||
const OriginalWebSocket = WebSocket;
|
const OriginalWebSocket = WebSocket;
|
||||||
@@ -36,7 +36,7 @@ async function testMultiAgentSequence() {
|
|||||||
console.log("\n=== Testing multi-agent checkout sequence ===");
|
console.log("\n=== Testing multi-agent checkout sequence ===");
|
||||||
console.log(`Daemon URL: ${DAEMON_URL}`);
|
console.log(`Daemon URL: ${DAEMON_URL}`);
|
||||||
|
|
||||||
const client = new DaemonClientV2({
|
const client = new DaemonClient({
|
||||||
url: DAEMON_URL,
|
url: DAEMON_URL,
|
||||||
webSocketFactory: (url) => new LoggingWebSocket(url) as any,
|
webSocketFactory: (url) => new LoggingWebSocket(url) as any,
|
||||||
reconnect: { enabled: false },
|
reconnect: { enabled: false },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export { loadConfig, type CliConfigOverrides } from "./config.js";
|
|||||||
export { resolvePaseoHome } from "./paseo-home.js";
|
export { resolvePaseoHome } from "./paseo-home.js";
|
||||||
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
|
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
|
||||||
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
||||||
export { DaemonClientV2, type DaemonClientV2Config, type ConnectionState, type DaemonEvent } from "../client/daemon-client-v2.js";
|
export { DaemonClient, type DaemonClientConfig, type ConnectionState, type DaemonEvent } from "../client/daemon-client.js";
|
||||||
|
|
||||||
// Agent SDK types for CLI commands
|
// Agent SDK types for CLI commands
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -1023,17 +1023,36 @@ export class Session {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
this.sessionLogger.error(
|
this.sessionLogger.error(
|
||||||
{ err: error },
|
{ err },
|
||||||
"Error handling message"
|
"Error handling message"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const requestId = (msg as { requestId?: unknown }).requestId;
|
||||||
|
if (typeof requestId === "string") {
|
||||||
|
try {
|
||||||
|
this.emit({
|
||||||
|
type: "rpc_error",
|
||||||
|
payload: {
|
||||||
|
requestId,
|
||||||
|
requestType: msg.type,
|
||||||
|
error: "Request failed",
|
||||||
|
code: "handler_error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (emitError) {
|
||||||
|
this.sessionLogger.error({ err: emitError }, "Failed to emit rpc_error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "activity_log",
|
type: "activity_log",
|
||||||
payload: {
|
payload: {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
type: "error",
|
type: "error",
|
||||||
content: `Error: ${error.message}`,
|
content: `Error: ${err.message}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1976,7 +1995,11 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async generateCommitMessage(cwd: string): Promise<string> {
|
private async generateCommitMessage(cwd: string): Promise<string> {
|
||||||
const diff = await getCheckoutDiff(cwd, { mode: "uncommitted" }, { paseoHome: this.paseoHome });
|
const diff = await getCheckoutDiff(
|
||||||
|
cwd,
|
||||||
|
{ mode: "uncommitted", includeStructured: true },
|
||||||
|
{ paseoHome: this.paseoHome }
|
||||||
|
);
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
message: z
|
message: z
|
||||||
.string()
|
.string()
|
||||||
@@ -1984,11 +2007,29 @@ export class Session {
|
|||||||
.max(72)
|
.max(72)
|
||||||
.describe("Concise git commit message, imperative mood, no trailing period."),
|
.describe("Concise git commit message, imperative mood, no trailing period."),
|
||||||
});
|
});
|
||||||
|
const fileList =
|
||||||
|
diff.structured && diff.structured.length > 0
|
||||||
|
? [
|
||||||
|
"Files changed:",
|
||||||
|
...diff.structured.map((file) => {
|
||||||
|
const changeType = file.isNew ? "A" : file.isDeleted ? "D" : "M";
|
||||||
|
const status = file.status && file.status !== "ok" ? ` [${file.status}]` : "";
|
||||||
|
return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`;
|
||||||
|
}),
|
||||||
|
].join("\n")
|
||||||
|
: "Files changed: (unknown)";
|
||||||
|
const maxPatchChars = 120_000;
|
||||||
|
const patch =
|
||||||
|
diff.diff.length > maxPatchChars
|
||||||
|
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
|
||||||
|
: diff.diff;
|
||||||
const prompt = [
|
const prompt = [
|
||||||
"Write a concise git commit message for the changes below.",
|
"Write a concise git commit message for the changes below.",
|
||||||
"Return JSON only with a single field 'message'.",
|
"Return JSON only with a single field 'message'.",
|
||||||
"",
|
"",
|
||||||
diff.diff.length > 0 ? diff.diff : "(No diff available)",
|
fileList,
|
||||||
|
"",
|
||||||
|
patch.length > 0 ? patch : "(No diff available)",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
try {
|
try {
|
||||||
const result = await generateStructuredAgentResponse({
|
const result = await generateStructuredAgentResponse({
|
||||||
@@ -2023,6 +2064,7 @@ export class Session {
|
|||||||
{
|
{
|
||||||
mode: "base",
|
mode: "base",
|
||||||
baseRef,
|
baseRef,
|
||||||
|
includeStructured: true,
|
||||||
},
|
},
|
||||||
{ paseoHome: this.paseoHome }
|
{ paseoHome: this.paseoHome }
|
||||||
);
|
);
|
||||||
@@ -2030,11 +2072,29 @@ export class Session {
|
|||||||
title: z.string().min(1).max(72),
|
title: z.string().min(1).max(72),
|
||||||
body: z.string().min(1),
|
body: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
const fileList =
|
||||||
|
diff.structured && diff.structured.length > 0
|
||||||
|
? [
|
||||||
|
"Files changed:",
|
||||||
|
...diff.structured.map((file) => {
|
||||||
|
const changeType = file.isNew ? "A" : file.isDeleted ? "D" : "M";
|
||||||
|
const status = file.status && file.status !== "ok" ? ` [${file.status}]` : "";
|
||||||
|
return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`;
|
||||||
|
}),
|
||||||
|
].join("\n")
|
||||||
|
: "Files changed: (unknown)";
|
||||||
|
const maxPatchChars = 200_000;
|
||||||
|
const patch =
|
||||||
|
diff.diff.length > maxPatchChars
|
||||||
|
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
|
||||||
|
: diff.diff;
|
||||||
const prompt = [
|
const prompt = [
|
||||||
"Write a pull request title and body for the changes below.",
|
"Write a pull request title and body for the changes below.",
|
||||||
"Return JSON only with fields 'title' and 'body'.",
|
"Return JSON only with fields 'title' and 'body'.",
|
||||||
"",
|
"",
|
||||||
diff.diff.length > 0 ? diff.diff : "(No diff available)",
|
fileList,
|
||||||
|
"",
|
||||||
|
patch.length > 0 ? patch : "(No diff available)",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
try {
|
try {
|
||||||
return await generateStructuredAgentResponse({
|
return await generateStructuredAgentResponse({
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import {
|
import {
|
||||||
DaemonClientV2 as SharedDaemonClient,
|
DaemonClient as SharedDaemonClient,
|
||||||
type DaemonClientV2Config as SharedDaemonClientConfig,
|
type DaemonClientConfig as SharedDaemonClientConfig,
|
||||||
type CreateAgentRequestOptions,
|
type CreateAgentRequestOptions,
|
||||||
type DaemonEvent,
|
type DaemonEvent,
|
||||||
type DaemonEventHandler,
|
type DaemonEventHandler,
|
||||||
type SendMessageOptions,
|
type SendMessageOptions,
|
||||||
type WebSocketLike,
|
type WebSocketLike,
|
||||||
} from "../../client/daemon-client-v2.js";
|
} from "../../client/daemon-client.js";
|
||||||
|
|
||||||
export type DaemonClientConfig = Omit<
|
export type DaemonClientConfig = Omit<
|
||||||
SharedDaemonClientConfig,
|
SharedDaemonClientConfig,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { DaemonClientV2 } from "../../client/daemon-client-v2.js";
|
import type { DaemonClient } from "../../client/daemon-client.js";
|
||||||
import type { SessionOutboundMessage } from "../../shared/messages.js";
|
import type { SessionOutboundMessage } from "../../shared/messages.js";
|
||||||
|
|
||||||
export interface MessageCollector {
|
export interface MessageCollector {
|
||||||
@@ -7,7 +7,7 @@ export interface MessageCollector {
|
|||||||
unsubscribe: () => void;
|
unsubscribe: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMessageCollector(client: DaemonClientV2): MessageCollector {
|
export function createMessageCollector(client: DaemonClient): MessageCollector {
|
||||||
const messages: SessionOutboundMessage[] = [];
|
const messages: SessionOutboundMessage[] = [];
|
||||||
const unsubscribe = client.subscribeRawMessages((message) => {
|
const unsubscribe = client.subscribeRawMessages((message) => {
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
@@ -20,4 +20,3 @@ export function createMessageCollector(client: DaemonClientV2): MessageCollector
|
|||||||
unsubscribe,
|
unsubscribe,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,56 @@ export class WebSocketSessionBridge {
|
|||||||
private async handleRawMessage(ws: WebSocket, data: Buffer | ArrayBuffer | Buffer[]): Promise<void> {
|
private async handleRawMessage(ws: WebSocket, data: Buffer | ArrayBuffer | Buffer[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data.toString());
|
const parsed = JSON.parse(data.toString());
|
||||||
const message = WSInboundMessageSchema.parse(parsed);
|
const parsedMessage = WSInboundMessageSchema.safeParse(parsed);
|
||||||
|
if (!parsedMessage.success) {
|
||||||
|
const requestInfo = extractRequestInfoFromUnknownWsInbound(parsed);
|
||||||
|
const isUnknownSchema =
|
||||||
|
requestInfo?.requestId != null &&
|
||||||
|
typeof parsed === "object" &&
|
||||||
|
parsed != null &&
|
||||||
|
"type" in parsed &&
|
||||||
|
(parsed as { type?: unknown }).type === "session";
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
{
|
||||||
|
requestId: requestInfo?.requestId,
|
||||||
|
requestType: requestInfo?.requestType,
|
||||||
|
error: parsedMessage.error.message,
|
||||||
|
},
|
||||||
|
"WS inbound message validation failed"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requestInfo) {
|
||||||
|
this.sendToClient(
|
||||||
|
ws,
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "rpc_error",
|
||||||
|
payload: {
|
||||||
|
requestId: requestInfo.requestId,
|
||||||
|
requestType: requestInfo.requestType,
|
||||||
|
error: isUnknownSchema ? "Unknown request schema" : "Invalid message",
|
||||||
|
code: isUnknownSchema ? "unknown_schema" : "invalid_message",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = `Invalid message: ${parsedMessage.error.message}`;
|
||||||
|
this.sendToClient(
|
||||||
|
ws,
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "status",
|
||||||
|
payload: {
|
||||||
|
status: "error",
|
||||||
|
message: errorMessage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = parsedMessage.data;
|
||||||
|
|
||||||
const messageSummary = {
|
const messageSummary = {
|
||||||
type: message.type,
|
type: message.type,
|
||||||
@@ -234,6 +283,23 @@ export class WebSocketSessionBridge {
|
|||||||
"Failed to parse/handle message"
|
"Failed to parse/handle message"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const requestInfo = extractRequestInfoFromUnknownWsInbound(parsedPayload);
|
||||||
|
if (requestInfo) {
|
||||||
|
this.sendToClient(
|
||||||
|
ws,
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "rpc_error",
|
||||||
|
payload: {
|
||||||
|
requestId: requestInfo.requestId,
|
||||||
|
requestType: requestInfo.requestType,
|
||||||
|
error: "Invalid message",
|
||||||
|
code: "invalid_message",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.sendToClient(
|
this.sendToClient(
|
||||||
ws,
|
ws,
|
||||||
wrapSessionMessage({
|
wrapSessionMessage({
|
||||||
@@ -466,3 +532,38 @@ export class WebSocketSessionBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractRequestInfoFromUnknownWsInbound(
|
||||||
|
payload: unknown
|
||||||
|
): { requestId: string; requestType?: string } | null {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = payload as {
|
||||||
|
type?: unknown;
|
||||||
|
requestId?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Session-wrapped messages
|
||||||
|
if (record.type === "session" && record.message && typeof record.message === "object") {
|
||||||
|
const msg = record.message as { requestId?: unknown; type?: unknown };
|
||||||
|
if (typeof msg.requestId === "string") {
|
||||||
|
return {
|
||||||
|
requestId: msg.requestId,
|
||||||
|
...(typeof msg.type === "string" ? { requestType: msg.type } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-session messages (future-proof)
|
||||||
|
if (typeof record.requestId === "string") {
|
||||||
|
return {
|
||||||
|
requestId: record.requestId,
|
||||||
|
...(typeof record.type === "string" ? { requestType: record.type } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1024,6 +1024,16 @@ export const StatusMessageSchema = z.object({
|
|||||||
.passthrough(), // Allow additional fields
|
.passthrough(), // Allow additional fields
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const RpcErrorMessageSchema = z.object({
|
||||||
|
type: z.literal("rpc_error"),
|
||||||
|
payload: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
requestType: z.string().optional(),
|
||||||
|
error: z.string(),
|
||||||
|
code: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const AgentStatusWithRequestSchema = z.object({
|
const AgentStatusWithRequestSchema = z.object({
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
@@ -1607,6 +1617,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
DictationStreamFinalMessageSchema,
|
DictationStreamFinalMessageSchema,
|
||||||
DictationStreamErrorMessageSchema,
|
DictationStreamErrorMessageSchema,
|
||||||
StatusMessageSchema,
|
StatusMessageSchema,
|
||||||
|
RpcErrorMessageSchema,
|
||||||
InitializeAgentResponseMessageSchema,
|
InitializeAgentResponseMessageSchema,
|
||||||
ArtifactMessageSchema,
|
ArtifactMessageSchema,
|
||||||
VoiceConversationLoadedMessageSchema,
|
VoiceConversationLoadedMessageSchema,
|
||||||
@@ -1663,6 +1674,7 @@ export type AssistantChunkMessage = z.infer<typeof AssistantChunkMessageSchema>;
|
|||||||
export type AudioOutputMessage = z.infer<typeof AudioOutputMessageSchema>;
|
export type AudioOutputMessage = z.infer<typeof AudioOutputMessageSchema>;
|
||||||
export type TranscriptionResultMessage = z.infer<typeof TranscriptionResultMessageSchema>;
|
export type TranscriptionResultMessage = z.infer<typeof TranscriptionResultMessageSchema>;
|
||||||
export type StatusMessage = z.infer<typeof StatusMessageSchema>;
|
export type StatusMessage = z.infer<typeof StatusMessageSchema>;
|
||||||
|
export type RpcErrorMessage = z.infer<typeof RpcErrorMessageSchema>;
|
||||||
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
|
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
|
||||||
export type VoiceConversationLoadedMessage = z.infer<
|
export type VoiceConversationLoadedMessage = z.infer<
|
||||||
typeof VoiceConversationLoadedMessageSchema
|
typeof VoiceConversationLoadedMessageSchema
|
||||||
|
|||||||
@@ -102,6 +102,36 @@ describe("checkout git utilities", () => {
|
|||||||
expect(logMessage).toBe(message);
|
expect(logMessage).toBe(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("diffs base mode against merge-base (no base-only deletions)", async () => {
|
||||||
|
execSync("git checkout -b feature", { cwd: repoDir });
|
||||||
|
|
||||||
|
// Advance base branch after feature splits off.
|
||||||
|
execSync("git checkout main", { cwd: repoDir });
|
||||||
|
writeFileSync(join(repoDir, "base-only.txt"), "base\n");
|
||||||
|
execSync("git add base-only.txt", { cwd: repoDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'base only'", { cwd: repoDir });
|
||||||
|
|
||||||
|
// Make a feature change.
|
||||||
|
execSync("git checkout feature", { cwd: repoDir });
|
||||||
|
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
|
||||||
|
execSync("git add feature.txt", { cwd: repoDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir });
|
||||||
|
|
||||||
|
const diff = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main" });
|
||||||
|
expect(diff.diff).toContain("feature.txt");
|
||||||
|
expect(diff.diff).not.toContain("base-only.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw on large diffs (marks file as too_large)", async () => {
|
||||||
|
const large = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join("\n") + "\n";
|
||||||
|
writeFileSync(join(repoDir, "file.txt"), large);
|
||||||
|
|
||||||
|
const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true });
|
||||||
|
expect(diff.structured?.some((f) => f.path === "file.txt" && f.status === "too_large")).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("handles status/diff/commit in a .paseo worktree", async () => {
|
it("handles status/diff/commit in a .paseo worktree", async () => {
|
||||||
const result = await createWorktree({
|
const result = await createWorktree({
|
||||||
branchName: "main",
|
branchName: "main",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { exec, execFile } from "child_process";
|
import { exec, execFile, spawn } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { resolve, dirname, basename } from "path";
|
import { resolve, dirname, basename } from "path";
|
||||||
import { realpathSync } from "fs";
|
import { realpathSync } from "fs";
|
||||||
@@ -14,6 +14,203 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
|||||||
GIT_OPTIONAL_LOCKS: "0",
|
GIT_OPTIONAL_LOCKS: "0",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SMALL_OUTPUT_MAX_BUFFER = 20 * 1024 * 1024; // 20MB
|
||||||
|
|
||||||
|
async function execGit(command: string, options: { cwd: string; env?: NodeJS.ProcessEnv }): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return execAsync(command, { ...options, maxBuffer: SMALL_OUTPUT_MAX_BUFFER });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execGitFile(
|
||||||
|
args: string[],
|
||||||
|
options: { cwd: string; env?: NodeJS.ProcessEnv }
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return execFileAsync("git", args, { ...options, maxBuffer: SMALL_OUTPUT_MAX_BUFFER });
|
||||||
|
}
|
||||||
|
|
||||||
|
type LimitedTextResult = {
|
||||||
|
text: string;
|
||||||
|
truncated: boolean;
|
||||||
|
exitCode: number | null;
|
||||||
|
signal: NodeJS.Signals | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function spawnLimitedText(params: {
|
||||||
|
cmd: string;
|
||||||
|
args: string[];
|
||||||
|
cwd: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
maxBytes: number;
|
||||||
|
acceptExitCodes?: number[];
|
||||||
|
}): Promise<LimitedTextResult> {
|
||||||
|
const accept = new Set(params.acceptExitCodes ?? [0]);
|
||||||
|
|
||||||
|
return new Promise((resolvePromise, rejectPromise) => {
|
||||||
|
const child = spawn(params.cmd, params.args, {
|
||||||
|
cwd: params.cwd,
|
||||||
|
env: params.env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stdoutChunks: Buffer[] = [];
|
||||||
|
let stdoutBytes = 0;
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (child.killed) return;
|
||||||
|
try {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
if (truncated) return;
|
||||||
|
stdoutBytes += chunk.length;
|
||||||
|
if (stdoutBytes > params.maxBytes) {
|
||||||
|
truncated = true;
|
||||||
|
stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stdoutChunks.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
// We don't buffer stderr (it can be large too). Keep it minimal for debugging.
|
||||||
|
let stderrPreview = "";
|
||||||
|
child.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
if (stderrPreview.length > 2048) return;
|
||||||
|
stderrPreview += chunk.toString("utf8");
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
rejectPromise(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code, signal) => {
|
||||||
|
if (code !== null && !accept.has(code) && !truncated) {
|
||||||
|
rejectPromise(new Error(`Command failed: ${params.cmd} ${params.args.join(" ")} (code ${code})\n${stderrPreview}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolvePromise({
|
||||||
|
text: Buffer.concat(stdoutChunks).toString("utf8"),
|
||||||
|
truncated,
|
||||||
|
exitCode: code,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckoutFileChange = {
|
||||||
|
path: string;
|
||||||
|
oldPath?: string;
|
||||||
|
status: string;
|
||||||
|
isNew: boolean;
|
||||||
|
isDeleted: boolean;
|
||||||
|
isUntracked?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function listCheckoutFileChanges(cwd: string, ref: string): Promise<CheckoutFileChange[]> {
|
||||||
|
const changes: CheckoutFileChange[] = [];
|
||||||
|
|
||||||
|
const { stdout: nameStatusOut } = await execGit(`git diff --name-status ${ref}`, {
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
});
|
||||||
|
for (const line of nameStatusOut.split("\n").map((l) => l.trim()).filter(Boolean)) {
|
||||||
|
// `--name-status` uses TAB separators, which preserves filenames with spaces.
|
||||||
|
const tabParts = line.split("\t");
|
||||||
|
const rawStatus = (tabParts[0] ?? "").trim();
|
||||||
|
if (!rawStatus) continue;
|
||||||
|
|
||||||
|
if (rawStatus.startsWith("R") || rawStatus.startsWith("C")) {
|
||||||
|
const oldPath = tabParts[1];
|
||||||
|
const newPath = tabParts[2];
|
||||||
|
if (newPath) {
|
||||||
|
changes.push({
|
||||||
|
path: newPath,
|
||||||
|
...(oldPath ? { oldPath } : {}),
|
||||||
|
status: rawStatus,
|
||||||
|
isNew: false,
|
||||||
|
isDeleted: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = tabParts[1];
|
||||||
|
if (!path) continue;
|
||||||
|
const code = rawStatus[0];
|
||||||
|
changes.push({
|
||||||
|
path,
|
||||||
|
status: rawStatus,
|
||||||
|
isNew: code === "A",
|
||||||
|
isDeleted: code === "D",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout: untrackedOut } = await execGit("git ls-files --others --exclude-standard", {
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
});
|
||||||
|
for (const file of untrackedOut.split("\n").map((l) => l.trim()).filter(Boolean)) {
|
||||||
|
changes.push({
|
||||||
|
path: file,
|
||||||
|
status: "U",
|
||||||
|
isNew: true,
|
||||||
|
isDeleted: false,
|
||||||
|
isUntracked: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate by path (prefer tracked status over untracked marker if both appear).
|
||||||
|
const byPath = new Map<string, CheckoutFileChange>();
|
||||||
|
for (const change of changes) {
|
||||||
|
const existing = byPath.get(change.path);
|
||||||
|
if (!existing) {
|
||||||
|
byPath.set(change.path, change);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (existing.isUntracked && !change.isUntracked) {
|
||||||
|
byPath.set(change.path, change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(byPath.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execGit(`git merge-base ${baseRef} HEAD`, { cwd, env: READ_ONLY_GIT_ENV });
|
||||||
|
const sha = stdout.trim();
|
||||||
|
return sha.length > 0 ? sha : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
|
||||||
|
|
||||||
|
async function tryGetNumstat(cwd: string, args: string[]): Promise<FileStat> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execGitFile(args, { cwd, env: READ_ONLY_GIT_ENV });
|
||||||
|
const line = stdout.trim().split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
|
||||||
|
if (!line) return null;
|
||||||
|
const [aRaw, dRaw] = line.split(/\s+/);
|
||||||
|
if (!aRaw || !dRaw) return null;
|
||||||
|
if (aRaw === "-" || dRaw === "-") {
|
||||||
|
return { additions: 0, deletions: 0, isBinary: true };
|
||||||
|
}
|
||||||
|
const additions = Number.parseInt(aRaw, 10);
|
||||||
|
const deletions = Number.parseInt(dRaw, 10);
|
||||||
|
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { additions, deletions, isBinary: false };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class NotGitRepoError extends Error {
|
export class NotGitRepoError extends Error {
|
||||||
readonly cwd: string;
|
readonly cwd: string;
|
||||||
readonly code = "NOT_GIT_REPO";
|
readonly code = "NOT_GIT_REPO";
|
||||||
@@ -435,32 +632,58 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise<num
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUntrackedDiff(cwd: string): Promise<string> {
|
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||||
let untrackedDiff = "";
|
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||||
try {
|
|
||||||
const { stdout: untrackedFiles } = await execAsync(
|
|
||||||
"git ls-files --others --exclude-standard",
|
|
||||||
{ cwd, env: READ_ONLY_GIT_ENV }
|
|
||||||
);
|
|
||||||
const newFiles = untrackedFiles.trim().split("\n").filter(Boolean);
|
|
||||||
|
|
||||||
for (const file of newFiles) {
|
function buildPlaceholderParsedDiffFile(
|
||||||
try {
|
change: CheckoutFileChange,
|
||||||
const { stdout: fileDiff } = await execAsync(
|
options: { status: "too_large" | "binary"; stat?: FileStat }
|
||||||
`git diff --no-index /dev/null "${file}" || true`,
|
): ParsedDiffFile {
|
||||||
{ cwd, env: READ_ONLY_GIT_ENV }
|
return {
|
||||||
);
|
path: change.path,
|
||||||
if (fileDiff) {
|
isNew: change.isNew,
|
||||||
untrackedDiff += fileDiff;
|
isDeleted: change.isDeleted,
|
||||||
|
additions: options.stat?.additions ?? 0,
|
||||||
|
deletions: options.stat?.deletions ?? 0,
|
||||||
|
hunks: [],
|
||||||
|
status: options.status,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Ignore errors for individual files
|
async function getPerFileDiffText(
|
||||||
|
cwd: string,
|
||||||
|
ref: string,
|
||||||
|
change: CheckoutFileChange
|
||||||
|
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
|
||||||
|
const stat: FileStat =
|
||||||
|
change.isUntracked
|
||||||
|
? null
|
||||||
|
: await tryGetNumstat(cwd, ["diff", "--numstat", ref, "--", change.path]);
|
||||||
|
|
||||||
|
if (stat?.isBinary) {
|
||||||
|
return { text: "", truncated: false, stat };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (change.isUntracked) {
|
||||||
|
const result = await spawnLimitedText({
|
||||||
|
cmd: "git",
|
||||||
|
args: ["diff", "--no-index", "/dev/null", "--", change.path],
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||||
|
acceptExitCodes: [0, 1],
|
||||||
|
});
|
||||||
|
return { text: result.text, truncated: result.truncated, stat };
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Ignore errors getting untracked files
|
const result = await spawnLimitedText({
|
||||||
}
|
cmd: "git",
|
||||||
return untrackedDiff;
|
args: ["diff", ref, "--", change.path],
|
||||||
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||||
|
});
|
||||||
|
return { text: result.text, truncated: result.truncated, stat };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCheckoutStatus(
|
export async function getCheckoutStatus(
|
||||||
@@ -530,43 +753,102 @@ export async function getCheckoutDiff(
|
|||||||
): Promise<CheckoutDiffResult> {
|
): Promise<CheckoutDiffResult> {
|
||||||
await requireGitRepo(cwd);
|
await requireGitRepo(cwd);
|
||||||
|
|
||||||
let diff = "";
|
let refForDiff: string;
|
||||||
|
|
||||||
if (compare.mode === "uncommitted") {
|
if (compare.mode === "uncommitted") {
|
||||||
const { stdout: trackedDiff } = await execAsync("git diff HEAD", {
|
refForDiff = "HEAD";
|
||||||
cwd,
|
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
});
|
|
||||||
const untrackedDiff = await getUntrackedDiff(cwd);
|
|
||||||
diff = trackedDiff + untrackedDiff;
|
|
||||||
} else {
|
} else {
|
||||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||||
const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd));
|
const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd));
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
diff = "";
|
return { diff: "" };
|
||||||
} else if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) {
|
|
||||||
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`);
|
|
||||||
} else {
|
|
||||||
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
|
||||||
// Find the merge-base (common ancestor) to diff only changes on this branch
|
|
||||||
const { stdout: mergeBaseOut } = await execAsync(
|
|
||||||
`git merge-base ${normalizedBaseRef} HEAD`,
|
|
||||||
{ cwd, env: READ_ONLY_GIT_ENV }
|
|
||||||
);
|
|
||||||
const mergeBase = mergeBaseOut.trim();
|
|
||||||
// Diff from merge-base to working tree (includes uncommitted changes)
|
|
||||||
const { stdout: trackedDiff } = await execAsync(`git diff ${mergeBase}`, {
|
|
||||||
cwd,
|
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
});
|
|
||||||
const untrackedDiff = await getUntrackedDiff(cwd);
|
|
||||||
diff = trackedDiff + untrackedDiff;
|
|
||||||
}
|
}
|
||||||
|
if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) {
|
||||||
|
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
||||||
|
const bestBaseRef = await resolveBestBaseRefForMerge(cwd, normalizedBaseRef);
|
||||||
|
refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = await listCheckoutFileChanges(cwd, refForDiff);
|
||||||
|
changes.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
|
||||||
|
const structured: ParsedDiffFile[] = [];
|
||||||
|
let diffText = "";
|
||||||
|
let diffBytes = 0;
|
||||||
|
const appendDiff = (text: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) return;
|
||||||
|
const buf = Buffer.from(text, "utf8");
|
||||||
|
if (diffBytes + buf.length <= TOTAL_DIFF_MAX_BYTES) {
|
||||||
|
diffText += text;
|
||||||
|
diffBytes += buf.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const remaining = TOTAL_DIFF_MAX_BYTES - diffBytes;
|
||||||
|
if (remaining > 0) {
|
||||||
|
diffText += buf.subarray(0, remaining).toString("utf8");
|
||||||
|
diffBytes = TOTAL_DIFF_MAX_BYTES;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
const { text, truncated, stat } = await getPerFileDiffText(cwd, refForDiff, change);
|
||||||
|
|
||||||
|
if (!compare.includeStructured) {
|
||||||
|
if (stat?.isBinary) {
|
||||||
|
appendDiff(`# ${change.path}: binary diff omitted\n`);
|
||||||
|
} else if (truncated) {
|
||||||
|
appendDiff(`# ${change.path}: diff too large omitted\n`);
|
||||||
|
} else {
|
||||||
|
appendDiff(text);
|
||||||
|
}
|
||||||
|
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stat?.isBinary) {
|
||||||
|
structured.push(buildPlaceholderParsedDiffFile(change, { status: "binary", stat }));
|
||||||
|
appendDiff(`# ${change.path}: binary diff omitted\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (truncated) {
|
||||||
|
structured.push(buildPlaceholderParsedDiffFile(change, { status: "too_large", stat }));
|
||||||
|
appendDiff(`# ${change.path}: diff too large omitted\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendDiff(text);
|
||||||
|
const parsed = await parseAndHighlightDiff(text, cwd);
|
||||||
|
const parsedFile =
|
||||||
|
parsed[0] ??
|
||||||
|
({
|
||||||
|
path: change.path,
|
||||||
|
isNew: change.isNew,
|
||||||
|
isDeleted: change.isDeleted,
|
||||||
|
additions: stat?.additions ?? 0,
|
||||||
|
deletions: stat?.deletions ?? 0,
|
||||||
|
hunks: [],
|
||||||
|
} satisfies ParsedDiffFile);
|
||||||
|
|
||||||
|
structured.push({
|
||||||
|
...parsedFile,
|
||||||
|
path: change.path,
|
||||||
|
isNew: change.isNew,
|
||||||
|
isDeleted: change.isDeleted,
|
||||||
|
status: "ok",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (compare.includeStructured) {
|
if (compare.includeStructured) {
|
||||||
return { diff, structured: await parseAndHighlightDiff(diff, cwd) };
|
return { diff: diffText, structured };
|
||||||
}
|
}
|
||||||
return { diff };
|
return { diff: diffText };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function commitChanges(
|
export async function commitChanges(
|
||||||
|
|||||||
Reference in New Issue
Block a user