refactor: replace store-based keyboard action requests with direct dispatcher and simplify agent attention clearing
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user