refactor: replace store-based keyboard action requests with direct dispatcher and simplify agent attention clearing
This commit is contained in:
@@ -2,11 +2,28 @@ import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-agent-initialization";
|
||||
|
||||
describe("useAgentInitialization timeline request policy", () => {
|
||||
it("uses canonical tail bootstrap when history has not synced yet", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
},
|
||||
hasAuthoritativeHistory: false,
|
||||
initialTimelineLimit: 200,
|
||||
})
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical tail bootstrap when cursor is missing", () => {
|
||||
expect(
|
||||
__private__.buildInitialTimelineRequest({
|
||||
cursor: undefined,
|
||||
hasLocalTail: false,
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: null,
|
||||
hasAuthoritativeHistory: true,
|
||||
initialTimelineLimit: 200,
|
||||
})
|
||||
).toEqual({
|
||||
@@ -16,31 +33,14 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical tail bootstrap when cursor exists but local tail is empty", () => {
|
||||
it("uses canonical catch-up after the current cursor once history is synced", () => {
|
||||
expect(
|
||||
__private__.buildInitialTimelineRequest({
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
endSeq: 42,
|
||||
seq: 42,
|
||||
},
|
||||
hasLocalTail: false,
|
||||
initialTimelineLimit: 200,
|
||||
})
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical catch-up after the current cursor when local tail exists", () => {
|
||||
expect(
|
||||
__private__.buildInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
endSeq: 42,
|
||||
},
|
||||
hasLocalTail: true,
|
||||
hasAuthoritativeHistory: true,
|
||||
initialTimelineLimit: 200,
|
||||
})
|
||||
).toEqual({
|
||||
@@ -53,9 +53,9 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
|
||||
it("supports unbounded tail bootstrap policy", () => {
|
||||
expect(
|
||||
__private__.buildInitialTimelineRequest({
|
||||
cursor: undefined,
|
||||
hasLocalTail: false,
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: null,
|
||||
hasAuthoritativeHistory: false,
|
||||
initialTimelineLimit: 0,
|
||||
})
|
||||
).toEqual({
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Platform } from "react-native";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchAgentTimelineOptions,
|
||||
} from "@server/client/daemon-client";
|
||||
import {
|
||||
attachInitTimeout,
|
||||
@@ -12,43 +11,12 @@ import {
|
||||
getInitKey,
|
||||
rejectInitDeferred,
|
||||
} from "@/utils/agent-initialization";
|
||||
import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy";
|
||||
|
||||
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
|
||||
const UNBOUNDED_TIMELINE_LIMIT = 0;
|
||||
|
||||
type TimelineCursorState = {
|
||||
epoch: string;
|
||||
endSeq: number;
|
||||
};
|
||||
|
||||
type BuildInitialTimelineRequestInput = {
|
||||
cursor: TimelineCursorState | undefined;
|
||||
hasLocalTail: boolean;
|
||||
initialTimelineLimit: number;
|
||||
};
|
||||
|
||||
function buildInitialTimelineRequest(
|
||||
input: BuildInitialTimelineRequestInput
|
||||
): FetchAgentTimelineOptions {
|
||||
const { cursor, hasLocalTail, initialTimelineLimit } = input;
|
||||
if (!cursor || !hasLocalTail) {
|
||||
return {
|
||||
direction: "tail",
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
// Catch up all missing canonical rows by default.
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInitialTimelineLimit(): number {
|
||||
return Platform.OS === "web"
|
||||
? UNBOUNDED_TIMELINE_LIMIT
|
||||
@@ -56,7 +24,7 @@ function resolveInitialTimelineLimit(): number {
|
||||
}
|
||||
|
||||
export const __private__ = {
|
||||
buildInitialTimelineRequest,
|
||||
deriveInitialTimelineRequest,
|
||||
resolveInitialTimelineLimit,
|
||||
};
|
||||
|
||||
@@ -92,11 +60,14 @@ export function useAgentInitialization({
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const cursor = session?.agentTimelineCursor.get(agentId);
|
||||
const hasLocalTail = (session?.agentStreamTail.get(agentId)?.length ?? 0) > 0;
|
||||
const initialTimelineLimit = resolveInitialTimelineLimit();
|
||||
const timelineRequest = buildInitialTimelineRequest({
|
||||
cursor,
|
||||
hasLocalTail,
|
||||
const hasAuthoritativeHistory =
|
||||
(session?.agentHistorySyncGeneration.get(agentId) ?? -1) >= 0;
|
||||
const timelineRequest = deriveInitialTimelineRequest({
|
||||
cursor: cursor
|
||||
? { epoch: cursor.epoch, seq: cursor.endSeq }
|
||||
: null,
|
||||
hasAuthoritativeHistory,
|
||||
initialTimelineLimit,
|
||||
});
|
||||
const initRequestDirection =
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TextInput } from "react-native";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
@@ -17,10 +18,6 @@ import {
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
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();
|
||||
@@ -46,14 +43,6 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||
}
|
||||
|
||||
function parseAgentKeyFromPathname(pathname: string): string | null {
|
||||
const match = parseHostAgentRouteFromPathname(pathname);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return `${match.serverId}:${match.agentId}`;
|
||||
}
|
||||
|
||||
type CommandCenterActionDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -117,9 +106,6 @@ export function useCommandCenter() {
|
||||
const { agents } = useAggregatedAgents();
|
||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||
const requestMessageInputAction = useKeyboardShortcutsStore(
|
||||
(s) => s.requestMessageInputAction
|
||||
);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const didNavigateRef = useRef(false);
|
||||
const prevOpenRef = useRef(open);
|
||||
@@ -134,11 +120,6 @@ export function useCommandCenter() {
|
||||
|
||||
const fallbackServerId = agents[0]?.serverId ?? null;
|
||||
|
||||
const agentKeyFromPathname = useMemo(
|
||||
() => parseAgentKeyFromPathname(pathname),
|
||||
[pathname]
|
||||
);
|
||||
|
||||
const newAgentRoute = useMemo<Href>(() => {
|
||||
const serverIdFromPath =
|
||||
parseServerIdFromPathname(pathname) ?? fallbackServerId;
|
||||
@@ -191,10 +172,6 @@ export function useCommandCenter() {
|
||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
|
||||
requestMessageInputAction({
|
||||
agentKey: agentKey(agent),
|
||||
kind: "focus",
|
||||
});
|
||||
// Don't restore focus back to the prior element after we navigate.
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
@@ -205,7 +182,7 @@ export function useCommandCenter() {
|
||||
) as Href;
|
||||
navigate(route);
|
||||
},
|
||||
[pathname, requestMessageInputAction, setOpen]
|
||||
[pathname, setOpen]
|
||||
);
|
||||
|
||||
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
|
||||
@@ -245,12 +222,10 @@ export function useCommandCenter() {
|
||||
focus: () => el?.focus(),
|
||||
isFocused,
|
||||
onTimeout: () => {
|
||||
if (agentKeyFromPathname) {
|
||||
requestMessageInputAction({
|
||||
agentKey: agentKeyFromPathname,
|
||||
kind: "focus",
|
||||
});
|
||||
}
|
||||
keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.focus",
|
||||
scope: "message-input",
|
||||
});
|
||||
},
|
||||
});
|
||||
return cancel;
|
||||
@@ -265,7 +240,7 @@ export function useCommandCenter() {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
return () => clearTimeout(id);
|
||||
}, [agentKeyFromPathname, open, requestMessageInputAction]);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import {
|
||||
keyboardActionDispatcher,
|
||||
type KeyboardActionDefinition,
|
||||
type KeyboardActionHandler,
|
||||
type KeyboardActionId,
|
||||
} from "@/keyboard/keyboard-action-dispatcher";
|
||||
|
||||
type UseKeyboardActionHandlerInput = {
|
||||
handlerId: string;
|
||||
actions: readonly KeyboardActionId[];
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
isActive?: () => boolean;
|
||||
handle: (action: KeyboardActionDefinition) => boolean;
|
||||
};
|
||||
|
||||
export function useKeyboardActionHandler(input: UseKeyboardActionHandlerInput) {
|
||||
useEffect(() => {
|
||||
return keyboardActionDispatcher.registerHandler({
|
||||
handlerId: input.handlerId,
|
||||
actions: input.actions,
|
||||
enabled: input.enabled,
|
||||
priority: input.priority,
|
||||
isActive: input.isActive,
|
||||
handle: input.handle,
|
||||
});
|
||||
}, [
|
||||
input.actions,
|
||||
input.enabled,
|
||||
input.handle,
|
||||
input.handlerId,
|
||||
input.isActive,
|
||||
input.priority,
|
||||
]);
|
||||
}
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
type MessageInputKeyboardActionKind,
|
||||
type KeyboardShortcutPayload,
|
||||
} from "@/keyboard/actions";
|
||||
import {
|
||||
canToggleFileExplorerShortcut,
|
||||
resolveSelectedOrRouteAgentKey,
|
||||
} from "@/keyboard/keyboard-shortcut-routing";
|
||||
import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
@@ -121,18 +119,38 @@ export function useKeyboardShortcuts({
|
||||
return true;
|
||||
};
|
||||
|
||||
const requestMessageInputAction = (
|
||||
const dispatchMessageInputAction = (
|
||||
kind: MessageInputKeyboardActionKind
|
||||
): boolean => {
|
||||
const agentKey = resolveSelectedOrRouteAgentKey({ selectedAgentId, pathname });
|
||||
if (!agentKey) {
|
||||
return false;
|
||||
switch (kind) {
|
||||
case "focus":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.focus",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "dictation-toggle":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.dictation-toggle",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "dictation-cancel":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.dictation-cancel",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "voice-toggle":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.voice-toggle",
|
||||
scope: "message-input",
|
||||
});
|
||||
case "voice-mute-toggle":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "message-input.voice-mute-toggle",
|
||||
scope: "message-input",
|
||||
});
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
useKeyboardShortcutsStore.getState().requestMessageInputAction({
|
||||
agentKey,
|
||||
kind,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
const requestWorkspaceTabAction = (input:
|
||||
| { kind: "new" | "close-current" }
|
||||
@@ -232,7 +250,7 @@ export function useKeyboardShortcuts({
|
||||
if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) {
|
||||
return false;
|
||||
}
|
||||
return requestMessageInputAction(input.payload.kind);
|
||||
return dispatchMessageInputAction(input.payload.kind);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user