Merge branch 'keyword-nav-plan'

This commit is contained in:
Mohamed Boudra
2026-02-04 13:15:04 +07:00
38 changed files with 1513 additions and 679 deletions
@@ -30,6 +30,7 @@ import { CommandAutocomplete } from "./command-autocomplete";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { focusWithRetries } from "@/utils/web-focus";
type QueuedMessage = {
id: string;
@@ -363,9 +364,8 @@ export function AgentInputArea({
if (!isScreenFocused) return;
if (!focusChatInputRequest) return;
const target = focusChatInputRequest.agentKey;
const currentKey = `${serverId}:${agentId}`;
if (target !== null && target !== currentKey) {
if (focusChatInputRequest.agentKey !== currentKey) {
return;
}
@@ -374,12 +374,17 @@ export function AgentInputArea({
}
lastHandledFocusRequestIdRef.current = focusChatInputRequest.id;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
messageInputRef.current?.focus();
});
return focusWithRetries({
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,
clearFocusChatInputRequest,
@@ -172,6 +172,8 @@ export function AgentList({
queryKey,
queryFn: async () => await client.getCheckoutStatus(agent.cwd),
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 { useSessionStore } from "@/stores/session-store";
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 { ToolCallDetailsContent } from "./tool-call-details";
import { ToolCallSheetProvider } from "./tool-call-sheet";
@@ -796,7 +796,7 @@ function PermissionRequestCard({
client,
}: {
permission: PendingPermission;
client: DaemonClientV2 | null;
client: DaemonClient | null;
}) {
const { theme } = useUnistyles();
const isMobile =
+18 -151
View File
@@ -1,4 +1,3 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
@@ -9,160 +8,28 @@ import {
Platform,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { router, usePathname } from "expo-router";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { useSessionStore } from "@/stores/session-store";
import { AgentStatusDot } from "@/components/agent-status-dot";
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();
}
export function CommandCenter() {
const { theme } = useUnistyles();
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 takeFocusRestoreElement = useKeyboardNavStore((s) => s.takeFocusRestoreElement);
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]);
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]);
const {
open,
inputRef,
query,
setQuery,
activeIndex,
results,
handleClose,
handleSelect,
} = useCommandCenter();
if (Platform.OS !== "web") return null;
@@ -221,12 +88,12 @@ export function CommandCenter() {
<View style={styles.rowContent}>
<View style={styles.rowTitle}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
</View>
<Text
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]}
@@ -302,6 +302,8 @@ export function GroupedAgentList({
queryKey,
queryFn: async () => await client.getCheckoutStatus(agent.cwd),
staleTime: CHECKOUT_STATUS_STALE_TIME,
}).catch((error) => {
console.warn("[checkout_status] prefetch failed", error);
});
}
}, [agents, queryClient]);
@@ -325,7 +327,9 @@ export function GroupedAgentList({
const session = useSessionStore.getState().sessions[agent.serverId];
const client = session?.client ?? null;
if (client) {
void client.archiveAgent(agent.id);
void client.archiveAgent(agent.id).catch((error) => {
console.warn("[archive_agent] failed", error);
});
}
},
[]
+18 -2
View File
@@ -26,7 +26,7 @@ import Animated, {
} from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation";
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 { useVoiceOptional } from "@/contexts/voice-context";
@@ -51,7 +51,7 @@ export interface MessageInputProps {
images?: ImageAttachment[];
onPickImages?: () => void;
onRemoveImage?: (index: number) => void;
client: DaemonClientV2 | null;
client: DaemonClient | null;
placeholder?: string;
autoFocus?: boolean;
disabled?: boolean;
@@ -72,6 +72,11 @@ export interface MessageInputProps {
export interface MessageInputRef {
focus: () => 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;
@@ -135,6 +140,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
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 baselineInputHeightRef = useRef<number | null>(null);
@@ -20,7 +20,7 @@ import type {
} from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
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 { useDaemonConnections } from "./daemon-connections-context";
import {
@@ -1,8 +1,8 @@
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 = {
client: DaemonClientV2 | null;
client: DaemonClient | null;
format: 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.
*/
export class DictationStreamSender {
private client: DaemonClientV2 | null;
private client: DaemonClient | null;
private readonly format: string;
private readonly createDictationId: () => string;
@@ -40,7 +40,7 @@ export class DictationStreamSender {
this.createDictationId = params.createDictationId ?? generateMessageId;
}
setClient(client: DaemonClientV2 | null): void {
setClient(client: DaemonClient | null): void {
this.client = client;
}
@@ -1,12 +1,12 @@
import { useEffect, useRef, useCallback } from "react";
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 ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
interface ClientActivityOptions {
client: DaemonClientV2;
client: DaemonClient;
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,
};
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react";
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";
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 tauriTransportFactory = createTauriWebSocketTransportFactory();
return new DaemonClientV2({
return new DaemonClient({
url,
suppressSendErrors: true,
...(tauriTransportFactory
@@ -1,7 +1,7 @@
export type DictationStatus = "idle" | "recording" | "uploading" | "failed";
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;
onPartialTranscript?: (text: string, meta: { requestId: string }) => void;
onError?: (error: Error) => void;
@@ -4,6 +4,7 @@ import { usePathname, useRouter } from "expo-router";
import { getIsTauri } from "@/constants/layout";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
export function useGlobalKeyboardNav({
enabled,
@@ -34,6 +35,17 @@ export function useGlobalKeyboardNav({
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 code = event.code ?? "";
if (code.startsWith("Digit")) {
@@ -77,14 +89,6 @@ export function useGlobalKeyboardNav({
const key = event.key ?? "";
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") {
useKeyboardNavStore.getState().setAltDown(true);
}
@@ -97,9 +101,24 @@ export function useGlobalKeyboardNav({
(event.metaKey || event.ctrlKey) &&
(event.code === "KeyB" || lowerKey === "b")
) {
// When focus is in the chat input, MessageInput handles Cmd+B (and prevents default)
// via TextInput onKeyPress. If we also toggle here, it flips twice and appears "broken".
if (isEditableTarget) {
// The MessageInput already handles Cmd+B inside editable fields. If we also
// handle it globally, it can double-toggle and look like it "doesn't work".
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;
}
event.preventDefault();
@@ -115,7 +134,25 @@ export function useGlobalKeyboardNav({
(event.code === "KeyE" || lowerKey === "e")
) {
// 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;
}
event.preventDefault();
@@ -128,18 +165,18 @@ export function useGlobalKeyboardNav({
event.preventDefault();
const s = useKeyboardNavStore.getState();
if (!s.commandCenterOpen) {
const target = event.target as unknown;
const el =
typeof HTMLElement !== "undefined" && target instanceof HTMLElement
? target
: (typeof document !== "undefined"
? (document.activeElement as HTMLElement | null)
: null);
s.setFocusRestoreElement(el);
s.setCommandCenterOpen(true);
} else {
s.setCommandCenterOpen(false);
const target =
event.target instanceof Element ? (event.target as Element) : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement(
(targetEl as HTMLElement | null) ?? activeEl ?? null
);
}
s.setCommandCenterOpen(!s.commandCenterOpen);
return;
}
@@ -3,7 +3,7 @@ import { Platform } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications";
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:";
@@ -26,7 +26,7 @@ async function ensurePushPermission(): Promise<boolean> {
}
export function usePushTokenRegistration(params: {
client: DaemonClientV2;
client: DaemonClient;
serverId: string;
}): void {
const { client, serverId } = params;
@@ -1,7 +1,6 @@
import { useEffect, useMemo } from "react";
import { useQueries, type UseQueryOptions } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
CHECKOUT_STATUS_STALE_TIME,
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
@@ -23,32 +22,32 @@ export interface SidebarSectionData {
}
export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSectionData[] {
const checkoutCacheQueries = useQueries({
queries: agents.map(
(agent): UseQueryOptions<CheckoutStatusPayload> => ({
queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd),
enabled: false,
staleTime: CHECKOUT_STATUS_STALE_TIME,
queryFn: async (): Promise<CheckoutStatusPayload> => {
throw new Error("checkout status cache-only query should not run");
},
})
),
});
const queryClient = useQueryClient();
const [checkoutCacheBump, setCheckoutCacheBump] = useState(0);
// Re-render when checkout status cache updates so grouping can switch from cwd→remote.
useEffect(() => {
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
const queryKey = event?.query?.queryKey;
if (!Array.isArray(queryKey) || queryKey[0] !== "checkoutStatus") {
return;
}
setCheckoutCacheBump((v) => v + 1);
});
return unsubscribe;
}, [queryClient]);
const remoteUrlByAgentKey = useMemo(() => {
const result = new Map<string, string | null>();
for (let i = 0; i < agents.length; i++) {
const agent = agents[i];
if (!agent) {
continue;
}
const checkout = checkoutCacheQueries[i]?.data ?? null;
const remoteUrl = checkout?.remoteUrl ?? null;
result.set(`${agent.serverId}:${agent.id}`, remoteUrl);
for (const agent of agents) {
const checkout =
queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(agent.serverId, agent.cwd)
) ?? null;
result.set(`${agent.serverId}:${agent.id}`, checkout?.remoteUrl ?? null);
}
return result;
}, [agents, checkoutCacheQueries]);
}, [agents, checkoutCacheBump, queryClient]);
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
@@ -100,4 +99,3 @@ export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSecti
return sections;
}
+19 -32
View File
@@ -1,60 +1,47 @@
import { create } from "zustand";
type FocusChatInputRequest = {
id: number;
agentKey: string;
};
interface KeyboardNavState {
commandCenterOpen: boolean;
altDown: boolean;
cmdOrCtrlDown: boolean;
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[];
/** Captured focus target when opening the command center (web only). */
focusRestoreElement: HTMLElement | null;
focusChatInputRequest: { id: number; agentKey: string | null } | null;
/** Web-only request to focus the MessageInput for the selected agent. */
focusChatInputRequest: FocusChatInputRequest | null;
requestFocusChatInput: (agentKey: string) => void;
clearFocusChatInputRequest: () => void;
setCommandCenterOpen: (open: boolean) => void;
setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutAgentKeys: (keys: string[]) => void;
setFocusRestoreElement: (el: HTMLElement | null) => void;
takeFocusRestoreElement: () => HTMLElement | null;
requestFocusChatInput: (agentKey: string | null) => void;
clearFocusChatInputRequest: (id: number) => void;
resetModifiers: () => void;
}
export const useKeyboardNavStore = create<KeyboardNavState>((set) => ({
export const useKeyboardNavStore = create<KeyboardNavState>((set, get) => ({
commandCenterOpen: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [],
focusRestoreElement: 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 }),
setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
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 }),
}));
+6 -6
View File
@@ -1,6 +1,6 @@
import { create } from "zustand";
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 { AgentDirectoryEntry } from "@/types/agent-directory";
import type { StreamItem } from "@/types/stream";
@@ -151,7 +151,7 @@ export interface SessionState {
serverId: string;
// Daemon client (immutable reference)
client: DaemonClientV2 | null;
client: DaemonClient | null;
// Connection snapshot (mutable)
connection: DaemonConnectionSnapshot;
@@ -206,10 +206,10 @@ interface SessionStoreState {
// Action types
interface SessionStoreActions {
// 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;
getSession: (serverId: string) => SessionState | undefined;
updateSessionClient: (serverId: string, client: DaemonClientV2) => void;
updateSessionClient: (serverId: string, client: DaemonClient) => void;
updateSessionConnection: (serverId: string, connection: DaemonConnectionSnapshot) => void;
// Audio state
@@ -281,7 +281,7 @@ function logSessionStoreUpdate(
}
function createDefaultConnectionSnapshot(client?: DaemonClientV2 | null): DaemonConnectionSnapshot {
function createDefaultConnectionSnapshot(client?: DaemonClient | null): DaemonConnectionSnapshot {
if (!client) {
return { isConnected: false, isConnecting: false, lastError: null };
}
@@ -294,7 +294,7 @@ function createDefaultConnectionSnapshot(client?: DaemonClientV2 | null): Daemon
}
// 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 {
serverId,
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: "Text"; data: string }
@@ -1,5 +1,5 @@
import { DaemonClientV2 } from "@server/client/daemon-client-v2";
import type { ConnectionState } from "@server/client/daemon-client-v2";
import { DaemonClient } from "@server/client/daemon-client";
import type { ConnectionState } from "@server/client/daemon-client";
import { buildDaemonWebSocketUrl } from "./daemon-endpoints";
function normalizeNonEmptyString(value: unknown): string | null {
@@ -45,7 +45,7 @@ export async function testDaemonEndpointConnection(
const timeoutMs = options?.timeoutMs ?? 6000;
const url = buildDaemonWebSocketUrl(endpoint);
const client = new DaemonClientV2({
const client = new DaemonClient({
url,
suppressSendErrors: true,
});
+51
View File
@@ -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;
};
}