mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 03:04:03 +00:00
Add Assistant keyboard interrupt guard
This commit is contained in:
@@ -247,6 +247,18 @@ runtime cost control, and shared AI transport surfaces.
|
||||
those rows through the existing chat-runtime queue without aborting the
|
||||
active model stream.
|
||||
The referenced OpenCode source at fetched `origin/dev` commit
|
||||
`09d9cf01f93798939c1284fbe974b6e1f4d2759d` registers the
|
||||
`session.interrupt` command while a turn is non-idle in
|
||||
`packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`, and its
|
||||
direct-run footer implements the same two-press interrupt guard in
|
||||
`packages/opencode/src/cli/cmd/run/footer.ts` while rendering the armed
|
||||
state in `packages/opencode/src/cli/cmd/run/footer.view.tsx`. Pulse's
|
||||
Assistant drawer adapts that ergonomics model by letting Escape from the
|
||||
focused composer arm the visible Stop control first and letting the next
|
||||
Escape confirm the same governed `chat.stop()` path as the Stop button,
|
||||
including aborting the active stream, clearing queued follow-ups, preserving
|
||||
partial text, and returning focus to the composer.
|
||||
The referenced OpenCode source at fetched `origin/dev` commit
|
||||
`fa2b63f850fc0a23bec2bdff9e660450d3fe7913` keeps prompt/footer status visible
|
||||
only while the session is non-idle in
|
||||
`packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`, and maps
|
||||
|
||||
@@ -394,9 +394,14 @@ function App() {
|
||||
// Setup escape handling for the assistant drawer.
|
||||
onMount(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Escape to close
|
||||
// Escape closes the drawer only after mounted drawer controls have had
|
||||
// a chance to claim the key for local flows such as interrupt confirm.
|
||||
if (e.key === 'Escape' && aiChatStore.isOpen) {
|
||||
aiChatStore.close();
|
||||
window.setTimeout(() => {
|
||||
if (!e.defaultPrevented && aiChatStore.isOpen) {
|
||||
aiChatStore.close();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -173,6 +173,8 @@ describe('App architecture', () => {
|
||||
'if (dialogStackHasBlockingDialog() && aiChatStore.isOpenSignal()) {',
|
||||
);
|
||||
expect(appSource).toContain("if (e.key === 'Escape' && aiChatStore.isOpen) {");
|
||||
expect(appSource).toContain('window.setTimeout(() => {');
|
||||
expect(appSource).toContain('if (!e.defaultPrevented && aiChatStore.isOpen) {');
|
||||
expect(appSource).toContain('<AIChat onClose={() => aiChatStore.close()} />');
|
||||
expect(appSource).toContain('showOrgSwitcher={runtime.showOrgSwitcher}');
|
||||
expect(appSource).not.toContain('TrialBanner');
|
||||
|
||||
@@ -1476,6 +1476,22 @@ describe('AIChat', () => {
|
||||
expect(mockChat.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('arms keyboard interruption on first Escape and stops on second Escape', async () => {
|
||||
mockChat.isLoading.mockReturnValue(true);
|
||||
renderChat();
|
||||
const textarea = screen.getByPlaceholderText('Ask about your infrastructure...');
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Escape' });
|
||||
|
||||
expect(mockChat.stop).not.toHaveBeenCalled();
|
||||
expect(screen.getByTitle('Stop response armed')).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Escape' });
|
||||
|
||||
expect(mockChat.stop).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(document.activeElement).toBe(textarea));
|
||||
});
|
||||
|
||||
it('returns focus to the composer after stopping a response', async () => {
|
||||
mockChat.isLoading.mockReturnValue(true);
|
||||
renderChat();
|
||||
|
||||
@@ -457,6 +457,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
const [editingQueuedFollowUp, setEditingQueuedFollowUp] = createSignal<QueuedFollowUp | null>(
|
||||
null,
|
||||
);
|
||||
const [interruptArmed, setInterruptArmed] = createSignal(false);
|
||||
const [promptHistory, setPromptHistory] = createSignal<PromptHistoryEntry[]>([]);
|
||||
const [promptHistoryIndex, setPromptHistoryIndex] = createSignal(-1);
|
||||
const [savedPromptDraft, setSavedPromptDraft] = createSignal<PromptHistoryEntry | null>(null);
|
||||
@@ -497,6 +498,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
const [mentionResources, setMentionResources] = createSignal<MentionResource[]>([]);
|
||||
const [accumulatedMentions, setAccumulatedMentions] = createSignal<MentionResource[]>([]);
|
||||
let textareaRef: HTMLTextAreaElement | undefined;
|
||||
let interruptArmTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const focusComposer = () => {
|
||||
queueMicrotask(() => {
|
||||
@@ -504,6 +506,23 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const clearInterruptArm = () => {
|
||||
if (interruptArmTimeout) {
|
||||
clearTimeout(interruptArmTimeout);
|
||||
interruptArmTimeout = undefined;
|
||||
}
|
||||
setInterruptArmed(false);
|
||||
};
|
||||
|
||||
const armKeyboardInterrupt = () => {
|
||||
clearInterruptArm();
|
||||
setInterruptArmed(true);
|
||||
interruptArmTimeout = setTimeout(() => {
|
||||
interruptArmTimeout = undefined;
|
||||
setInterruptArmed(false);
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const resizeTextarea = () => {
|
||||
if (!textareaRef) return;
|
||||
textareaRef.style.height = 'auto';
|
||||
@@ -756,6 +775,12 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
onConversationChanged: refreshSessions,
|
||||
});
|
||||
|
||||
const stopActiveResponse = () => {
|
||||
clearInterruptArm();
|
||||
chat.stop();
|
||||
focusComposer();
|
||||
};
|
||||
|
||||
const queuedFollowUpPreview = (prompt: string) => {
|
||||
const firstLine = prompt
|
||||
.split(/\r?\n/)
|
||||
@@ -1183,6 +1208,12 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
void initializeWhenOpen();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!chat.isLoading() && interruptArmed()) {
|
||||
clearInterruptArm();
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const open = isOpen();
|
||||
const model = selectedChatModel().trim();
|
||||
@@ -1259,6 +1290,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
onCleanup(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
aiChatStore.registerInput?.(null);
|
||||
clearInterruptArm();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1714,6 +1746,18 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' && chat.isLoading()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (interruptArmed()) {
|
||||
stopActiveResponse();
|
||||
} else {
|
||||
armKeyboardInterrupt();
|
||||
focusComposer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
@@ -2516,13 +2560,14 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
<Show when={chat.isLoading()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
chat.stop();
|
||||
focusComposer();
|
||||
}}
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md border border-border bg-surface text-base-content shadow-sm transition-colors hover:bg-surface-hover"
|
||||
title="Stop"
|
||||
aria-label="Stop response"
|
||||
onClick={stopActiveResponse}
|
||||
class={`flex h-9 w-9 items-center justify-center rounded-md border bg-surface text-base-content shadow-sm transition-colors hover:bg-surface-hover ${
|
||||
interruptArmed()
|
||||
? 'border-blue-400 ring-2 ring-blue-500/30'
|
||||
: 'border-border'
|
||||
}`}
|
||||
title={interruptArmed() ? 'Stop response armed' : 'Stop'}
|
||||
aria-label={interruptArmed() ? 'Stop response armed' : 'Stop response'}
|
||||
>
|
||||
<SquareIcon class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user