refactor: replace store-based keyboard action requests with direct dispatcher and simplify agent attention clearing

This commit is contained in:
Mohamed Boudra
2026-03-06 14:49:06 +07:00
parent 885b575d14
commit 5b270444d5
25 changed files with 617 additions and 1355 deletions
+120 -40
View File
@@ -1,9 +1,24 @@
import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import {
createAgent,
createAgentInRepo,
ensureHostSelected,
gotoHome,
openSettings,
setWorkingDirectory,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
import type { Page } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import {
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from '@/utils/host-routes';
import {
ensureWorkspaceAgentPaneVisible,
waitForWorkspaceTabsVisible,
} from './helpers/workspace-tabs';
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -110,6 +125,89 @@ async function addFakeMicrophone(page: Page) {
}, { base64Audio, mimeType });
}
async function expectComposerReady(page: Page) {
await expect(page.getByRole('textbox', { name: 'Message agent...' }).first()).toBeEditable();
}
async function expectDictationStarted(page: Page) {
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
}
async function expectDictationStopped(page: Page) {
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
}
test('dictation hotkey works on a workspace agent tab', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.');
}
await createAgentInRepo(page, {
directory: repo.path,
prompt: 'Respond with exactly: Hello',
});
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
await waitForWorkspaceTabsVisible(page);
await ensureWorkspaceAgentPaneVisible(page);
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
expect(calls).toBe(1);
await page.keyboard.press('Escape');
await expectDictationStopped(page);
} finally {
await repo.cleanup();
}
});
test('dictation hotkey works on a workspace draft tab', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.');
}
await createAgentInRepo(page, {
directory: repo.path,
prompt: 'Respond with exactly: Hello',
});
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
await waitForWorkspaceTabsVisible(page);
await ensureWorkspaceAgentPaneVisible(page);
await page.getByTestId('workspace-new-agent-tab').first().click();
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
expect(calls).toBe(1);
await page.keyboard.press('Escape');
await expectDictationStopped(page);
} finally {
await repo.cleanup();
}
});
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
await addFakeMicrophone(page);
@@ -119,23 +217,18 @@ test('dictation hotkeys do not trigger on background screens', async ({ page })
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await expect(page).toHaveURL(/\/agent\//);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
await openSettings(page);
await page.keyboard.press('Control+d');
await page.waitForTimeout(200);
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
const active = await page.evaluate(() => (window as any).__mic.active as number);
expect(calls).toBe(1);
expect(active).toBe(1);
await page.keyboard.press('Escape');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
expect(calls).toBe(0);
expect(active).toBe(0);
} finally {
await repo.cleanup();
}
@@ -150,23 +243,18 @@ test('dictation transcribes fixture via real STT', async ({ page }) => {
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent\//);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
await expectDictationStarted(page);
const initialCopyMessageCount = await page
.getByRole('button', { name: 'Copy message' })
.count();
await page.keyboard.press('Control+d');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
await expectDictationStopped(page);
await expect
.poll(
@@ -189,13 +277,11 @@ test('cancel stops mic even if recorder is already inactive', async ({ page }) =
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent\//);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
await expectDictationStarted(page);
await page.evaluate(() => {
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
@@ -205,9 +291,7 @@ test('cancel stops mic even if recorder is already inactive', async ({ page }) =
});
await page.keyboard.press('Escape');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
await expectDictationStopped(page);
} finally {
await repo.cleanup();
}
@@ -223,35 +307,31 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
await ensureHostSelected(page);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent($|\/)/);
const match = page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/);
await expect(page).toHaveURL(/\/workspace\//);
const match = page.url().match(/\/h\/([^/]+)\/workspace\/[^?]+(?:\?open=agent%3A|\\?open=agent:)([^/?#&]+)/);
if (!match) {
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
throw new Error(`Expected workspace agent URL, got ${page.url()}`);
}
const serverId = decodeURIComponent(match[1]!);
const agentId = decodeURIComponent(match[2]!);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
await expectComposerReady(page);
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
await page.keyboard.press('Control+d');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
await expectDictationStarted(page);
await page.keyboard.press('Control+d');
const newAgentButton = page.getByTestId('sidebar-new-agent');
await expect(newAgentButton).toBeVisible();
await newAgentButton.click();
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
await expect(page).toHaveURL(/\/h\/[^/]+\/new-agent(\?|$)/);
await page.waitForTimeout(10_000);
const agentEntry = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
await expect(agentEntry).toBeVisible();
await agentEntry.click();
await page.goto(buildHostWorkspaceAgentRoute(serverId, repo.path, agentId));
await expect(page).toHaveURL(
new RegExp(`/h/${escapeRegex(serverId)}/agent/${escapeRegex(agentId)}(?:\\?|$)`)
new RegExp(`/h/${escapeRegex(serverId)}/workspace/[^?]+\\?open=agent(?:%3A|:)${escapeRegex(agentId)}(?:$|&)`)
);
await expect(page.getByRole('button', { name: 'Copy message' })).toHaveCount(initialCopyMessageCount);
+3 -2
View File
@@ -371,8 +371,9 @@ export const createAgent = async (page: Page, message: string) => {
await input.fill(message);
await input.press('Enter');
// Router updates can be same-document transitions; assert URL state instead of waiting for a commit event.
await expect(page).toHaveURL(/\/(workspace|agent)\//, { timeout: 30000 });
// The composer may remain on the draft screen briefly while the initial run starts,
// so assert the user-visible result instead of forcing one route shape here.
await expect(page).toHaveURL(/\/(workspace|agent|new-agent)(\/|$|\?)/, { timeout: 30000 });
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
@@ -21,7 +21,6 @@ import {
import { Theme } from '@/styles/theme'
import type { DraftCommandConfig } from '@/hooks/use-agent-commands-query'
import { encodeImages } from '@/utils/encode-images'
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
import { focusWithRetries } from '@/utils/web-focus'
import { useVoiceOptional } from '@/contexts/voice-context'
import { useToast } from '@/contexts/toast-context'
@@ -38,6 +37,9 @@ import { resolveStatusControlMode } from '@/components/agent-input-area.status-c
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { useKeyboardActionHandler } from '@/hooks/use-keyboard-action-handler'
import type { KeyboardActionDefinition } from '@/keyboard/keyboard-action-dispatcher'
import { shouldClearAgentAttention } from '@/utils/agent-attention'
type QueuedMessage = {
id: string
@@ -89,10 +91,6 @@ export function AgentInputArea({
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const isScreenFocused = useIsFocused()
const messageInputActionRequest = useKeyboardShortcutsStore((s) => s.messageInputActionRequest)
const clearMessageInputActionRequest = useKeyboardShortcutsStore(
(s) => s.clearMessageInputActionRequest
)
const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
const toast = useToast()
@@ -134,10 +132,13 @@ export function AgentInputArea({
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([])
const [isCancellingAgent, setIsCancellingAgent] = useState(false)
const [sendError, setSendError] = useState<string | null>(null)
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null)
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false)
const messageInputRef = useRef<MessageInputRef>(null)
const draftGenerationRef = useRef(0)
const hydratedGenerationRef = useRef(0)
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`
)
const autocomplete = useAgentAutocomplete({
userInput,
@@ -240,8 +241,18 @@ export function AgentInputArea({
messageId: clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
})
if (
shouldClearAgentAttention({
agentId,
isConnected,
requiresAttention: agent?.requiresAttention,
attentionReason: agent?.attentionReason,
})
) {
client.clearAgentAttention(agentId)
}
}
}, [client, serverId, setAgentStreamTail, setAgentStreamHead])
}, [agent?.attentionReason, agent?.requiresAttention, client, isConnected, serverId, setAgentStreamTail, setAgentStreamHead])
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage
@@ -513,50 +524,58 @@ export function AgentInputArea({
userInput,
])
// Keyboard-dispatched message-input actions are routed through store requests.
useEffect(() => {
if (!isScreenFocused) return
if (!messageInputActionRequest) return
const currentKey = `${serverId}:${agentId}`
if (messageInputActionRequest.agentKey !== currentKey) {
return
const handleKeyboardAction = useCallback((action: KeyboardActionDefinition): boolean => {
if (!isScreenFocused) {
return false
}
if (lastHandledMessageInputActionRequestIdRef.current === messageInputActionRequest.id) {
return
}
lastHandledMessageInputActionRequestIdRef.current = messageInputActionRequest.id
switch (action.id) {
case 'message-input.focus':
if (Platform.OS !== 'web') {
messageInputRef.current?.focus()
return true
}
if (messageInputActionRequest.kind !== 'focus') {
messageInputRef.current?.runKeyboardAction(messageInputActionRequest.kind)
clearMessageInputActionRequest(messageInputActionRequest.id)
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
},
})
return true
case 'message-input.dictation-toggle':
messageInputRef.current?.runKeyboardAction('dictation-toggle')
return true
case 'message-input.dictation-cancel':
messageInputRef.current?.runKeyboardAction('dictation-cancel')
return true
case 'message-input.voice-toggle':
messageInputRef.current?.runKeyboardAction('voice-toggle')
return true
case 'message-input.voice-mute-toggle':
messageInputRef.current?.runKeyboardAction('voice-mute-toggle')
return true
default:
return false
}
}, [isScreenFocused])
if (Platform.OS !== 'web') {
messageInputRef.current?.focus()
clearMessageInputActionRequest(messageInputActionRequest.id)
return
}
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: () => clearMessageInputActionRequest(messageInputActionRequest.id),
onTimeout: () => clearMessageInputActionRequest(messageInputActionRequest.id),
})
}, [
agentId,
clearMessageInputActionRequest,
isScreenFocused,
messageInputActionRequest,
serverId,
])
useKeyboardActionHandler({
handlerId: keyboardHandlerIdRef.current,
actions: [
'message-input.focus',
'message-input.dictation-toggle',
'message-input.dictation-cancel',
'message-input.voice-toggle',
'message-input.voice-mute-toggle',
],
enabled: isScreenFocused,
priority: isMessageInputFocused ? 200 : 100,
isActive: () => isScreenFocused,
handle: handleKeyboardAction,
})
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
mode: 'translate',
@@ -803,6 +822,7 @@ export function AgentInputArea({
onSelectionChange={(selection) => {
setCursorIndex(selection.start)
}}
onFocusChange={setIsMessageInputFocused}
/>
</View>
</View>
@@ -83,6 +83,7 @@ export interface MessageInputProps {
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean
/** Reports cursor selection updates from the underlying input. */
onSelectionChange?: (selection: { start: number; end: number }) => void
onFocusChange?: (focused: boolean) => void
}
export interface MessageInputRef {
@@ -151,6 +152,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSubmitLoadingPress,
onKeyPress: onKeyPressCallback,
onSelectionChange: onSelectionChangeCallback,
onFocusChange,
},
ref
) {
@@ -234,6 +236,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
valueRef.current = value
}, [value])
useEffect(() => {
return () => {
onFocusChange?.(false)
}
}, [onFocusChange])
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
useEffect(() => {
if (!IS_WEB || !autoFocus) return
@@ -707,9 +715,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
placeholderTextColor={theme.colors.mutedForeground}
onFocus={() => {
isInputFocusedRef.current = true
onFocusChange?.(true)
}}
onBlur={() => {
isInputFocusedRef.current = false
onFocusChange?.(false)
}}
style={[
styles.textInput,
@@ -1181,7 +1181,7 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
workspaceListContainer: {
marginLeft: theme.spacing[2],
marginLeft: 0,
},
emptyText: {
color: theme.colors.foregroundMuted,
@@ -1192,7 +1192,7 @@ const styles = StyleSheet.create((theme) => ({
projectRow: {
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[1],
flexDirection: 'row',
@@ -1253,7 +1253,7 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: 'row',
alignItems: 'center',
@@ -1329,6 +1329,7 @@ const styles = StyleSheet.create((theme) => ({
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
opacity: 0.76,
flex: 1,
minWidth: 0,
},
@@ -1,10 +1,45 @@
type TimelineDirection = "tail" | "before" | "after";
type InitRequestDirection = "tail" | "after";
type TimelineCursor = {
type BootstrapTailCursor = {
seq: number;
} | null;
type InitialTimelineCursor = {
epoch: string;
seq: number;
} | null;
export function deriveInitialTimelineRequest({
cursor,
hasAuthoritativeHistory,
initialTimelineLimit,
}: {
cursor: InitialTimelineCursor;
hasAuthoritativeHistory: boolean;
initialTimelineLimit: number;
}): {
direction: "tail" | "after";
cursor?: { epoch: string; seq: number };
limit: number;
projection: "canonical";
} {
if (!hasAuthoritativeHistory || !cursor) {
return {
direction: "tail",
limit: initialTimelineLimit,
projection: "canonical",
};
}
return {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.seq },
limit: 0,
projection: "canonical",
};
}
export function deriveBootstrapTailTimelinePolicy({
direction,
reset,
@@ -16,7 +51,7 @@ export function deriveBootstrapTailTimelinePolicy({
direction: TimelineDirection;
reset: boolean;
epoch: string;
endCursor: TimelineCursor;
endCursor: BootstrapTailCursor;
isInitializing: boolean;
hasActiveInitDeferred: boolean;
}): {
@@ -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 =
+7 -32
View File
@@ -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;
}
@@ -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;
@@ -55,7 +55,7 @@ import {
} from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import { shouldClearAgentAttention } from "@/utils/agent-attention";
import type { DaemonClient } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
@@ -432,6 +432,8 @@ function AgentScreenContent({
});
const reconnectToastArmedRef = useRef(false);
const initAttemptTokenRef = useRef(0);
const attentionClientRef = useRef(client);
const attentionConnectedRef = useRef(isConnected);
const setFocusedAgentId = useCallback(
(agentId: string | null) => {
useSessionStore.getState().setFocusedAgentId(serverId, agentId);
@@ -439,6 +441,11 @@ function AgentScreenContent({
[serverId]
);
useEffect(() => {
attentionClientRef.current = client;
attentionConnectedRef.current = isConnected;
}, [client, isConnected]);
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
mode: "translate",
});
@@ -520,9 +527,25 @@ function AgentScreenContent({
setFocusedAgentId(resolvedAgentId);
return () => {
const latestClient = attentionClientRef.current;
const latestAgent = useSessionStore
.getState()
.sessions[serverId]
?.agents.get(resolvedAgentId);
if (
latestClient &&
shouldClearAgentAttention({
agentId: resolvedAgentId,
isConnected: attentionConnectedRef.current,
requiresAttention: latestAgent?.requiresAttention,
attentionReason: latestAgent?.attentionReason,
})
) {
latestClient.clearAgentAttention(resolvedAgentId);
}
setFocusedAgentId(null);
};
}, [resolvedAgentId, setFocusedAgentId]);
}, [resolvedAgentId, serverId, setFocusedAgentId]);
const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false;
const isHistorySyncing = useMemo(() => {
@@ -818,32 +841,6 @@ function AgentScreenContent({
document.title = title;
}, [agent?.title]);
// Clear attention as soon as the user is focused on this agent screen.
useEffect(() => {
const clearAgentId = resolvedAgentId?.trim();
if (!clearAgentId || !client) {
return;
}
if (
!shouldClearAgentAttentionOnView({
agentId: clearAgentId,
focusedAgentId,
isConnected,
requiresAttention: agent?.requiresAttention,
attentionReason: agent?.attentionReason,
})
) {
return;
}
client.clearAgentAttention(clearAgentId);
}, [
agent?.requiresAttention,
client,
focusedAgentId,
isConnected,
resolvedAgentId,
]);
const isHistoryRefreshCatchingUp =
viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
@@ -9,8 +9,6 @@ beforeEach(() => {
cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [],
visibleWorkspaceTargets: [],
messageInputActionRequest: null,
nextMessageInputActionRequestId: 1,
workspaceTabActionRequest: null,
nextWorkspaceTabActionRequestId: 1,
});
@@ -1,13 +1,6 @@
import { create } from "zustand";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
export type MessageInputActionRequest = {
id: number;
agentKey: string;
kind: MessageInputKeyboardActionKind;
};
export type WorkspaceTabActionRequest =
| {
id: number;
@@ -39,8 +32,6 @@ interface KeyboardShortcutsState {
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
/** All visible workspace targets in top-to-bottom visual order. */
visibleWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
messageInputActionRequest: MessageInputActionRequest | null;
nextMessageInputActionRequestId: number;
workspaceTabActionRequest: WorkspaceTabActionRequest | null;
nextWorkspaceTabActionRequestId: number;
@@ -52,12 +43,6 @@ interface KeyboardShortcutsState {
setVisibleWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
resetModifiers: () => void;
requestMessageInputAction: (input: {
agentKey: string;
kind: MessageInputKeyboardActionKind;
}) => void;
clearMessageInputActionRequest: (id: number) => void;
requestWorkspaceTabAction: (input:
| {
serverId: string;
@@ -87,8 +72,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [],
visibleWorkspaceTargets: [],
messageInputActionRequest: null,
nextMessageInputActionRequestId: 1,
workspaceTabActionRequest: null,
nextWorkspaceTabActionRequestId: 1,
@@ -101,21 +84,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
setVisibleWorkspaceTargets: (targets) => set({ visibleWorkspaceTargets: targets }),
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
requestMessageInputAction: ({ agentKey, kind }) => {
const id = get().nextMessageInputActionRequestId;
set({
messageInputActionRequest: { id, agentKey, kind },
nextMessageInputActionRequestId: id + 1,
});
},
clearMessageInputActionRequest: (id) => {
const current = get().messageInputActionRequest;
if (!current || current.id !== id) {
return;
}
set({ messageInputActionRequest: null });
},
requestWorkspaceTabAction: (input) => {
const id = get().nextWorkspaceTabActionRequestId;
set({
+8 -25
View File
@@ -1,13 +1,12 @@
import { describe, expect, it } from "vitest";
import { shouldClearAgentAttentionOnView } from "./agent-attention";
import { shouldClearAgentAttention } from "./agent-attention";
describe("shouldClearAgentAttentionOnView", () => {
it("returns true only when the viewed agent is focused, connected, and requires attention", () => {
describe("shouldClearAgentAttention", () => {
it("returns true only when the agent is connected and requires attention", () => {
expect(
shouldClearAgentAttentionOnView({
shouldClearAgentAttention({
agentId: "agent-1",
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
@@ -17,9 +16,8 @@ describe("shouldClearAgentAttentionOnView", () => {
it("returns false when the app is disconnected", () => {
expect(
shouldClearAgentAttentionOnView({
shouldClearAgentAttention({
agentId: "agent-1",
focusedAgentId: "agent-1",
isConnected: false,
requiresAttention: true,
attentionReason: "finished",
@@ -27,23 +25,10 @@ describe("shouldClearAgentAttentionOnView", () => {
).toBe(false);
});
it("returns false when the agent is not focused", () => {
expect(
shouldClearAgentAttentionOnView({
agentId: "agent-1",
focusedAgentId: "agent-2",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
})
).toBe(false);
});
it("returns false when attention is already clear", () => {
expect(
shouldClearAgentAttentionOnView({
shouldClearAgentAttention({
agentId: "agent-1",
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: false,
attentionReason: null,
@@ -53,9 +38,8 @@ describe("shouldClearAgentAttentionOnView", () => {
it("returns false for empty agent ids", () => {
expect(
shouldClearAgentAttentionOnView({
shouldClearAgentAttention({
agentId: "",
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
@@ -65,9 +49,8 @@ describe("shouldClearAgentAttentionOnView", () => {
it("returns false for permission-shaped attention", () => {
expect(
shouldClearAgentAttentionOnView({
shouldClearAgentAttention({
agentId: "agent-1",
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "permission",
+4 -5
View File
@@ -1,13 +1,12 @@
interface ShouldClearAgentAttentionOnViewInput {
interface ShouldClearAgentAttentionInput {
agentId: string | null | undefined;
focusedAgentId: string | null | undefined;
isConnected: boolean;
requiresAttention: boolean | null | undefined;
attentionReason?: "finished" | "error" | "permission" | null | undefined;
}
export function shouldClearAgentAttentionOnView(
input: ShouldClearAgentAttentionOnViewInput
export function shouldClearAgentAttention(
input: ShouldClearAgentAttentionInput
): boolean {
const agentId = input.agentId?.trim();
if (!agentId) {
@@ -22,5 +21,5 @@ export function shouldClearAgentAttentionOnView(
if (input.attentionReason === "permission") {
return false;
}
return input.focusedAgentId === agentId;
return true;
}