refactor: replace store-based keyboard action requests with direct dispatcher and simplify agent attention clearing
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createKeyboardActionDispatcher,
|
||||
type KeyboardActionDefinition,
|
||||
} from "./keyboard-action-dispatcher";
|
||||
|
||||
describe("keyboard-action-dispatcher", () => {
|
||||
let dispatcher: ReturnType<typeof createKeyboardActionDispatcher>;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatcher = createKeyboardActionDispatcher();
|
||||
});
|
||||
|
||||
it("dispatches to the highest-priority active handler", () => {
|
||||
const calls: string[] = [];
|
||||
const action: KeyboardActionDefinition = {
|
||||
id: "message-input.dictation-toggle",
|
||||
scope: "message-input",
|
||||
};
|
||||
|
||||
dispatcher.registerHandler({
|
||||
handlerId: "low",
|
||||
actions: [action.id],
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
handle: () => {
|
||||
calls.push("low");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
dispatcher.registerHandler({
|
||||
handlerId: "high",
|
||||
actions: [action.id],
|
||||
enabled: true,
|
||||
priority: 200,
|
||||
isActive: () => true,
|
||||
handle: () => {
|
||||
calls.push("high");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const handled = dispatcher.dispatch(action);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(calls).toEqual(["high"]);
|
||||
});
|
||||
|
||||
it("skips disabled and inactive handlers", () => {
|
||||
const handle = vi.fn(() => true);
|
||||
const action: KeyboardActionDefinition = {
|
||||
id: "message-input.dictation-toggle",
|
||||
scope: "message-input",
|
||||
};
|
||||
|
||||
dispatcher.registerHandler({
|
||||
handlerId: "disabled",
|
||||
actions: [action.id],
|
||||
enabled: false,
|
||||
priority: 300,
|
||||
isActive: () => true,
|
||||
handle,
|
||||
});
|
||||
|
||||
dispatcher.registerHandler({
|
||||
handlerId: "inactive",
|
||||
actions: [action.id],
|
||||
enabled: true,
|
||||
priority: 200,
|
||||
isActive: () => false,
|
||||
handle,
|
||||
});
|
||||
|
||||
dispatcher.registerHandler({
|
||||
handlerId: "active",
|
||||
actions: [action.id],
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
handle,
|
||||
});
|
||||
|
||||
const handled = dispatcher.dispatch(action);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(handle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tries lower-priority handlers when a higher one does not consume the action", () => {
|
||||
const calls: string[] = [];
|
||||
const action: KeyboardActionDefinition = {
|
||||
id: "message-input.focus",
|
||||
scope: "message-input",
|
||||
};
|
||||
|
||||
dispatcher.registerHandler({
|
||||
handlerId: "first",
|
||||
actions: [action.id],
|
||||
enabled: true,
|
||||
priority: 200,
|
||||
isActive: () => true,
|
||||
handle: () => {
|
||||
calls.push("first");
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
dispatcher.registerHandler({
|
||||
handlerId: "second",
|
||||
actions: [action.id],
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
handle: () => {
|
||||
calls.push("second");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const handled = dispatcher.dispatch(action);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(calls).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("does not dispatch after a handler is unregistered", () => {
|
||||
const handle = vi.fn(() => true);
|
||||
const action: KeyboardActionDefinition = {
|
||||
id: "message-input.dictation-cancel",
|
||||
scope: "message-input",
|
||||
};
|
||||
|
||||
const unregister = dispatcher.registerHandler({
|
||||
handlerId: "handler",
|
||||
actions: [action.id],
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
handle,
|
||||
});
|
||||
|
||||
unregister();
|
||||
|
||||
const handled = dispatcher.dispatch(action);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(handle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
export type KeyboardActionScope =
|
||||
| "global"
|
||||
| "message-input"
|
||||
| "sidebar"
|
||||
| "workspace";
|
||||
|
||||
export type KeyboardActionId =
|
||||
| "message-input.focus"
|
||||
| "message-input.dictation-toggle"
|
||||
| "message-input.dictation-cancel"
|
||||
| "message-input.voice-toggle"
|
||||
| "message-input.voice-mute-toggle";
|
||||
|
||||
export type KeyboardActionDefinition = {
|
||||
id: KeyboardActionId;
|
||||
scope: KeyboardActionScope;
|
||||
};
|
||||
|
||||
export type KeyboardActionHandler = {
|
||||
handlerId: string;
|
||||
actions: readonly KeyboardActionId[];
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
isActive?: () => boolean;
|
||||
handle: (action: KeyboardActionDefinition) => boolean;
|
||||
};
|
||||
|
||||
type KeyboardActionRegistryEntry = KeyboardActionHandler & {
|
||||
registeredAt: number;
|
||||
};
|
||||
|
||||
export function createKeyboardActionDispatcher() {
|
||||
let nextRegistrationOrder = 1;
|
||||
const handlers = new Map<string, KeyboardActionRegistryEntry>();
|
||||
|
||||
return {
|
||||
registerHandler(handler: KeyboardActionHandler) {
|
||||
handlers.set(handler.handlerId, {
|
||||
...handler,
|
||||
registeredAt: nextRegistrationOrder++,
|
||||
});
|
||||
|
||||
return () => {
|
||||
const current = handlers.get(handler.handlerId);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
handlers.delete(handler.handlerId);
|
||||
};
|
||||
},
|
||||
|
||||
dispatch(action: KeyboardActionDefinition): boolean {
|
||||
const candidates = Array.from(handlers.values())
|
||||
.filter((handler) => handler.actions.includes(action.id))
|
||||
.filter((handler) => handler.enabled)
|
||||
.filter((handler) => (handler.isActive ? handler.isActive() : true))
|
||||
.sort((left, right) => {
|
||||
if (left.priority !== right.priority) {
|
||||
return right.priority - left.priority;
|
||||
}
|
||||
return right.registeredAt - left.registeredAt;
|
||||
});
|
||||
|
||||
for (const handler of candidates) {
|
||||
if (handler.handle(action)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const keyboardActionDispatcher = createKeyboardActionDispatcher();
|
||||
@@ -1,30 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
canToggleFileExplorerShortcut,
|
||||
resolveSelectedOrRouteAgentKey,
|
||||
} from "./keyboard-shortcut-routing";
|
||||
import { canToggleFileExplorerShortcut } from "./keyboard-shortcut-routing";
|
||||
|
||||
describe("keyboard-shortcut-routing", () => {
|
||||
describe("resolveSelectedOrRouteAgentKey", () => {
|
||||
it("returns selected agent key when provided", () => {
|
||||
const key = resolveSelectedOrRouteAgentKey({
|
||||
selectedAgentId: "server-1:agent-1",
|
||||
pathname: "/h/server-1/workspace/workspace-1?open=draft%3Adraft_123",
|
||||
});
|
||||
|
||||
expect(key).toBe("server-1:agent-1");
|
||||
});
|
||||
|
||||
it("maps workspace draft tabs to the draft tab id key", () => {
|
||||
const key = resolveSelectedOrRouteAgentKey({
|
||||
pathname: "/h/server-1/workspace/workspace-1?open=draft%3Adraft_123",
|
||||
});
|
||||
|
||||
expect(key).toBe("server-1:draft_123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canToggleFileExplorerShortcut", () => {
|
||||
const toggleFileExplorer = () => undefined;
|
||||
|
||||
|
||||
@@ -1,37 +1,8 @@
|
||||
import {
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseHostWorkspaceOpenIntentFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export function resolveSelectedOrRouteAgentKey(input: {
|
||||
selectedAgentId?: string;
|
||||
pathname: string;
|
||||
}): string | null {
|
||||
if (input.selectedAgentId) {
|
||||
return input.selectedAgentId;
|
||||
}
|
||||
|
||||
const workspaceRoute = parseHostWorkspaceRouteFromPathname(input.pathname);
|
||||
const workspaceOpenIntent = parseHostWorkspaceOpenIntentFromPathname(input.pathname);
|
||||
if (workspaceRoute && workspaceOpenIntent) {
|
||||
if (workspaceOpenIntent.kind === "agent") {
|
||||
const agentId = workspaceOpenIntent.agentId.trim();
|
||||
return agentId ? `${workspaceRoute.serverId}:${agentId}` : null;
|
||||
}
|
||||
if (workspaceOpenIntent.kind === "draft") {
|
||||
const draftId = workspaceOpenIntent.draftId.trim();
|
||||
return draftId ? `${workspaceRoute.serverId}:${draftId}` : null;
|
||||
}
|
||||
}
|
||||
|
||||
const route = parseHostAgentRouteFromPathname(input.pathname);
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
return `${route.serverId}:${route.agentId}`;
|
||||
}
|
||||
|
||||
export function canToggleFileExplorerShortcut(input: {
|
||||
selectedAgentId?: string;
|
||||
pathname: string;
|
||||
|
||||
Reference in New Issue
Block a user