mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Expose Assistant queued follow-ups in commands
This commit is contained in:
parent
43974e7760
commit
7745a20eea
7 changed files with 219 additions and 8 deletions
|
|
@ -37,6 +37,8 @@ export const AssistantSlashCommandIcon = (props: { action: AssistantSlashCommand
|
|||
return <PlusIcon class="h-4 w-4" aria-hidden="true" />;
|
||||
case 'sessions':
|
||||
return <ClockIcon class="h-4 w-4" aria-hidden="true" />;
|
||||
case 'queue':
|
||||
return <ClockIcon class="h-4 w-4" aria-hidden="true" />;
|
||||
case 'compact':
|
||||
return <Minimize2Icon class="h-4 w-4" aria-hidden="true" />;
|
||||
case 'models':
|
||||
|
|
|
|||
|
|
@ -2817,6 +2817,84 @@ describe('AIChat', () => {
|
|||
expect(mockChat.clearQueuedFollowUps).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('focuses the first queued follow-up from the queue slash command', async () => {
|
||||
mockChat.queuedFollowUpCount.mockReturnValue(2);
|
||||
mockChat.queuedFollowUps.mockReturnValue([
|
||||
{
|
||||
id: 'queued-1',
|
||||
messageId: 'msg-queued-1',
|
||||
prompt: 'first queued prompt',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'queued-2',
|
||||
messageId: 'msg-queued-2',
|
||||
prompt: 'second queued prompt',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
renderChat();
|
||||
|
||||
const textarea = screen.getByPlaceholderText(
|
||||
'Ask about your infrastructure...',
|
||||
) as HTMLTextAreaElement;
|
||||
fireEvent.input(textarea, { target: { value: '/queue' } });
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
|
||||
const queuedRow = screen.getByRole('listitem', {
|
||||
name: 'Queued follow-up: first queued prompt. Press Enter to edit or Delete to remove.',
|
||||
});
|
||||
|
||||
await waitFor(() => expect(document.activeElement).toBe(queuedRow));
|
||||
expect(queuedRow).toHaveAttribute(
|
||||
'data-assistant-queue-command-target',
|
||||
'true',
|
||||
);
|
||||
expect(textarea.value).toBe('');
|
||||
});
|
||||
|
||||
it('runs the queue slash command from the composer send button', async () => {
|
||||
mockChat.queuedFollowUpCount.mockReturnValue(1);
|
||||
mockChat.queuedFollowUps.mockReturnValue([
|
||||
{
|
||||
id: 'queued-click',
|
||||
messageId: 'msg-queued-click',
|
||||
prompt: 'clicked queued prompt',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
renderChat();
|
||||
|
||||
const textarea = screen.getByPlaceholderText(
|
||||
'Ask about your infrastructure...',
|
||||
) as HTMLTextAreaElement;
|
||||
fireEvent.input(textarea, { target: { value: '/queue' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
|
||||
|
||||
const queuedRow = screen.getByRole('listitem', {
|
||||
name: 'Queued follow-up: clicked queued prompt. Press Enter to edit or Delete to remove.',
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(queuedRow).toHaveAttribute('data-assistant-queue-command-target', 'true'),
|
||||
);
|
||||
expect(textarea.value).toBe('');
|
||||
});
|
||||
|
||||
it('explains the queue slash command when no follow-ups are queued', async () => {
|
||||
renderChat();
|
||||
|
||||
const textarea = screen.getByPlaceholderText(
|
||||
'Ask about your infrastructure...',
|
||||
) as HTMLTextAreaElement;
|
||||
fireEvent.input(textarea, { target: { value: '/queue' } });
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
|
||||
expect(mockNotificationStore.info).toHaveBeenCalledWith('No queued follow-ups.', 2000);
|
||||
await waitFor(() => expect(document.activeElement).toBe(textarea));
|
||||
expect(textarea.value).toBe('');
|
||||
});
|
||||
|
||||
it('shows paused queued follow-ups and lets the first one resume', () => {
|
||||
mockChat.queuedFollowUpCount.mockReturnValue(1);
|
||||
mockChat.queuedFollowUpsPaused.mockReturnValue(true);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,19 @@ describe('AssistantCommandHelpDialog', () => {
|
|||
expect(screen.getByRole('option', { name: /\/fixture/ })).toHaveTextContent('/fixture');
|
||||
});
|
||||
|
||||
it('finds queued follow-up management from command help search', () => {
|
||||
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={vi.fn()} />);
|
||||
|
||||
fireEvent.input(screen.getByLabelText('Search Assistant commands'), {
|
||||
target: { value: 'queued' },
|
||||
});
|
||||
|
||||
expect(screen.getByRole('option', { name: /\/queue/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /\/queue/ })).toHaveTextContent(
|
||||
'Manage queued follow-ups',
|
||||
);
|
||||
});
|
||||
|
||||
it('moves command selection with arrow keys before running', () => {
|
||||
const onRunCommand = vi.fn();
|
||||
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={onRunCommand} />);
|
||||
|
|
@ -66,7 +79,7 @@ describe('AssistantCommandHelpDialog', () => {
|
|||
expect(screen.getByText('Model')).toBeInTheDocument();
|
||||
expect(screen.getByText('Transcript')).toBeInTheDocument();
|
||||
expect(screen.getByText('Help')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('option')).toHaveLength(13);
|
||||
expect(screen.getAllByRole('option')).toHaveLength(14);
|
||||
});
|
||||
|
||||
it('hides command group headers once help search is filtered', () => {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,9 @@ describe('SlashCommandAutocomplete', () => {
|
|||
expect(options.map((option) => option.getAttribute('aria-label'))).toContain(
|
||||
'Run /compact: Summarize older turns and keep this session moving',
|
||||
);
|
||||
expect(options.map((option) => option.getAttribute('aria-label'))).toContain(
|
||||
'Run /queue: Manage queued follow-ups',
|
||||
);
|
||||
expect(container.querySelectorAll('[role="option"] svg')).toHaveLength(activeCommands.length);
|
||||
expect(screen.getByText('Session')).toBeInTheDocument();
|
||||
expect(screen.getByText('Model')).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ describe('assistantSlashCommands', () => {
|
|||
expect(parseAssistantSlashCommand('/sessions')).toBe('sessions');
|
||||
expect(parseAssistantSlashCommand('/resume')).toBe('sessions');
|
||||
expect(parseAssistantSlashCommand('/continue')).toBe('sessions');
|
||||
expect(parseAssistantSlashCommand('/queue')).toBe('queue');
|
||||
expect(parseAssistantSlashCommand('/queued')).toBe('queue');
|
||||
expect(parseAssistantSlashCommand('/prompts')).toBe('queue');
|
||||
expect(parseAssistantSlashCommand('/compact')).toBe('compact');
|
||||
expect(parseAssistantSlashCommand('/summarize')).toBe('compact');
|
||||
expect(parseAssistantSlashCommand('/models')).toBe('models');
|
||||
|
|
@ -101,6 +104,7 @@ describe('assistantSlashCommands', () => {
|
|||
expect(filterAssistantSlashCommands('').map((command) => command.name)).toEqual([
|
||||
'new',
|
||||
'sessions',
|
||||
'queue',
|
||||
'compact',
|
||||
'fork',
|
||||
'undo',
|
||||
|
|
@ -116,6 +120,10 @@ describe('assistantSlashCommands', () => {
|
|||
expect(filterAssistantSlashCommands('resume').map((command) => command.name)).toEqual([
|
||||
'sessions',
|
||||
]);
|
||||
expect(filterAssistantSlashCommands('queued').map((command) => command.name)).toEqual([
|
||||
'queue',
|
||||
'fixture',
|
||||
]);
|
||||
expect(filterAssistantSlashCommands('summarize').map((command) => command.name)).toEqual([
|
||||
'compact',
|
||||
]);
|
||||
|
|
@ -149,13 +157,13 @@ describe('assistantSlashCommands', () => {
|
|||
'Developer',
|
||||
]);
|
||||
expect(groups.map((group) => group.items.map((item) => item.command.name))).toEqual([
|
||||
['new', 'sessions', 'compact', 'fork', 'undo', 'redo'],
|
||||
['new', 'sessions', 'queue', 'compact', 'fork', 'undo', 'redo'],
|
||||
['models', 'providers', 'status'],
|
||||
['copy', 'export'],
|
||||
['help'],
|
||||
['fixture'],
|
||||
]);
|
||||
expect(groups[1].items[0].index).toBe(6);
|
||||
expect(groups[1].items[0].index).toBe(7);
|
||||
});
|
||||
|
||||
it('keeps dev fixture commands out of production command surfaces', () => {
|
||||
|
|
@ -215,6 +223,8 @@ describe('assistantSlashCommands', () => {
|
|||
expect(getAssistantSlashCommandTokens(help)).toEqual(['help', 'commands']);
|
||||
const sessions = filterAssistantSlashCommands('resume')[0];
|
||||
expect(getAssistantSlashCommandTokens(sessions)).toEqual(['sessions', 'resume', 'continue']);
|
||||
const queue = filterAssistantSlashCommands('queued')[0];
|
||||
expect(getAssistantSlashCommandTokens(queue)).toEqual(['queue', 'queued', 'prompts']);
|
||||
const compact = filterAssistantSlashCommands('summarize')[0];
|
||||
expect(getAssistantSlashCommandTokens(compact)).toEqual(['compact', 'summarize']);
|
||||
const models = filterAssistantSlashCommands('mo')[0];
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export type AssistantSlashCommandAction =
|
|||
| 'models'
|
||||
| 'new'
|
||||
| 'providers'
|
||||
| 'queue'
|
||||
| 'redo'
|
||||
| 'status'
|
||||
| 'undo'
|
||||
|
|
@ -107,6 +108,14 @@ export const ASSISTANT_SLASH_COMMANDS: AssistantSlashCommand[] = [
|
|||
category: 'Session',
|
||||
description: 'Search or resume Assistant sessions (/sessions backup)',
|
||||
},
|
||||
{
|
||||
name: 'queue',
|
||||
aliases: ['queued', 'prompts'],
|
||||
action: 'queue',
|
||||
category: 'Session',
|
||||
description: 'Manage queued follow-ups',
|
||||
keywords: ['follow-up', 'follow-ups', 'queued', 'prompt', 'prompts'],
|
||||
},
|
||||
{
|
||||
name: 'compact',
|
||||
aliases: ['summarize'],
|
||||
|
|
|
|||
|
|
@ -629,6 +629,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
const [editingQueuedFollowUp, setEditingQueuedFollowUp] = createSignal<QueuedFollowUp | null>(
|
||||
null,
|
||||
);
|
||||
const [queuedFollowUpCommandTargetId, setQueuedFollowUpCommandTargetId] = createSignal<
|
||||
string | null
|
||||
>(null);
|
||||
const [restoredPromptDraft, setRestoredPromptDraft] = createSignal<RestoredPromptDraft | null>(
|
||||
null,
|
||||
);
|
||||
|
|
@ -700,8 +703,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
let textareaRef: HTMLTextAreaElement | undefined;
|
||||
let transcriptFallbackTextareaRef: HTMLTextAreaElement | undefined;
|
||||
let interruptArmTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let queuedFollowUpCommandTargetTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let composerSubmitDispatchLocked = false;
|
||||
let handledAssistantCommandRequestId = 0;
|
||||
const queuedFollowUpRowRefs = new Map<string, HTMLDivElement>();
|
||||
|
||||
const focusComposer = () => {
|
||||
queueMicrotask(() => {
|
||||
|
|
@ -717,6 +722,19 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
setInterruptArmed(false);
|
||||
};
|
||||
|
||||
const markQueuedFollowUpCommandTarget = (id: string | null) => {
|
||||
if (queuedFollowUpCommandTargetTimeout) {
|
||||
clearTimeout(queuedFollowUpCommandTargetTimeout);
|
||||
queuedFollowUpCommandTargetTimeout = undefined;
|
||||
}
|
||||
setQueuedFollowUpCommandTargetId(id);
|
||||
if (!id) return;
|
||||
queuedFollowUpCommandTargetTimeout = setTimeout(() => {
|
||||
queuedFollowUpCommandTargetTimeout = undefined;
|
||||
setQueuedFollowUpCommandTargetId((current) => (current === id ? null : current));
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
const armKeyboardInterrupt = () => {
|
||||
clearInterruptArm();
|
||||
setInterruptArmed(true);
|
||||
|
|
@ -1557,6 +1575,45 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
});
|
||||
};
|
||||
|
||||
const focusQueuedFollowUps = () => {
|
||||
const firstQueued = chat.queuedFollowUps()[0];
|
||||
const findQueuedRow = () =>
|
||||
(firstQueued ? queuedFollowUpRowRefs.get(firstQueued.id) : undefined) ||
|
||||
document.querySelector<HTMLElement>('[data-testid="assistant-queued-follow-up-row"]');
|
||||
const queuedRow = findQueuedRow();
|
||||
const targetQueuedId =
|
||||
firstQueued?.id ?? queuedRow?.dataset.assistantQueuedFollowUpId ?? null;
|
||||
|
||||
if (!firstQueued && !queuedRow) {
|
||||
markQueuedFollowUpCommandTarget(null);
|
||||
notificationStore.info('No queued follow-ups.', 2000);
|
||||
focusComposer();
|
||||
return;
|
||||
}
|
||||
|
||||
setShowSessions(false);
|
||||
setShowCommandHelp(false);
|
||||
setSessionRefreshLoading(false);
|
||||
resetSessionSearch();
|
||||
markQueuedFollowUpCommandTarget(targetQueuedId);
|
||||
const focusRow = () => {
|
||||
const row = findQueuedRow();
|
||||
if (!row) return;
|
||||
row.scrollIntoView({ block: 'nearest' });
|
||||
row.focus({ preventScroll: true });
|
||||
if (document.activeElement !== row) {
|
||||
row.querySelector<HTMLElement>('button:not(:disabled)')?.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
queueMicrotask(focusRow);
|
||||
window.requestAnimationFrame(() => {
|
||||
focusRow();
|
||||
});
|
||||
window.setTimeout(focusRow, 0);
|
||||
window.setTimeout(focusRow, 50);
|
||||
window.setTimeout(focusRow, 150);
|
||||
};
|
||||
|
||||
const handleQueuedFollowUpRowKeyDown = (
|
||||
event: KeyboardEvent & { currentTarget: HTMLDivElement },
|
||||
id: string,
|
||||
|
|
@ -1729,6 +1786,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
disabled: !canRedoLastTurn(),
|
||||
reason: 'No undone Assistant turn is available.',
|
||||
},
|
||||
queue: {
|
||||
disabled: chat.queuedFollowUpCount() === 0,
|
||||
reason: chat.queuedFollowUpCount() === 0 ? 'No queued follow-ups.' : undefined,
|
||||
},
|
||||
status: {
|
||||
disabled: providerReadiness().status === 'checking',
|
||||
reason: 'Route health check already running.',
|
||||
|
|
@ -1745,6 +1806,19 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
return state?.disabled ? state.reason || 'Assistant command is unavailable right now.' : '';
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const validQueuedIds = new Set(chat.queuedFollowUps().map((queued) => queued.id));
|
||||
for (const queuedId of queuedFollowUpRowRefs.keys()) {
|
||||
if (!validQueuedIds.has(queuedId)) {
|
||||
queuedFollowUpRowRefs.delete(queuedId);
|
||||
}
|
||||
}
|
||||
const targetQueuedId = queuedFollowUpCommandTargetId();
|
||||
if (targetQueuedId && !validQueuedIds.has(targetQueuedId)) {
|
||||
markQueuedFollowUpCommandTarget(null);
|
||||
}
|
||||
});
|
||||
|
||||
const buildCurrentTranscript = (generatedAt = new Date()) => {
|
||||
const sessionId = chat.sessionId().trim();
|
||||
const knownSession = sessionId ? findKnownSession(sessionId) : undefined;
|
||||
|
|
@ -2604,6 +2678,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
(Boolean(node.closest('[data-mention-autocomplete]')) ||
|
||||
Boolean(node.closest('[data-slash-command-autocomplete]'))),
|
||||
);
|
||||
const isInsideComposer = Boolean(target.closest('[data-assistant-composer]'));
|
||||
// Only close if click is outside dropdown containers
|
||||
if (!isInsideDropdown && !isInsideComposerPopup) {
|
||||
setShowSessions(false);
|
||||
|
|
@ -2612,10 +2687,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
setShowControlMenu(false);
|
||||
}
|
||||
// Close mention autocomplete when clicking outside
|
||||
if (!target.closest('[data-mention-autocomplete]') && !target.closest('textarea')) {
|
||||
if (!target.closest('[data-mention-autocomplete]') && !isInsideComposer) {
|
||||
setMentionActive(false);
|
||||
}
|
||||
if (!target.closest('[data-slash-command-autocomplete]') && !target.closest('textarea')) {
|
||||
if (!target.closest('[data-slash-command-autocomplete]') && !isInsideComposer) {
|
||||
closeSlashCommandAutocomplete({ clearTransientDraft: true });
|
||||
}
|
||||
};
|
||||
|
|
@ -2625,6 +2700,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
document.removeEventListener('click', handleClickOutside);
|
||||
aiChatStore.registerInput?.(null);
|
||||
clearInterruptArm();
|
||||
if (queuedFollowUpCommandTargetTimeout) {
|
||||
clearTimeout(queuedFollowUpCommandTargetTimeout);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2923,7 +3001,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
return input();
|
||||
};
|
||||
|
||||
const clearLocalComposerCommand = () => {
|
||||
const clearLocalComposerCommand = (options: { refocusComposer?: boolean } = {}) => {
|
||||
const shouldRefocusComposer = options.refocusComposer !== false;
|
||||
stashedComposerDraft = null;
|
||||
setEditingQueuedFollowUp(null);
|
||||
setRestoredPromptDraft(null);
|
||||
|
|
@ -2935,7 +3014,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
resetPromptHistoryNavigation();
|
||||
queueMicrotask(() => {
|
||||
resizeTextarea();
|
||||
focusComposer();
|
||||
if (shouldRefocusComposer) {
|
||||
focusComposer();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -3018,7 +3099,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
return runAssistantFixtureSlashCommand(commandArgs);
|
||||
}
|
||||
|
||||
clearLocalComposerCommand();
|
||||
clearLocalComposerCommand({ refocusComposer: command !== 'queue' });
|
||||
|
||||
switch (command) {
|
||||
case 'new':
|
||||
|
|
@ -3028,6 +3109,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
case 'sessions':
|
||||
runSessionsSlashCommand(commandArgs);
|
||||
break;
|
||||
case 'queue':
|
||||
focusQueuedFollowUps();
|
||||
break;
|
||||
case 'compact':
|
||||
setShowCommandHelp(false);
|
||||
void handleCompactSession();
|
||||
|
|
@ -4641,10 +4725,21 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
return (
|
||||
<div
|
||||
class="flex min-h-7 items-center gap-2 rounded-md bg-white/70 px-2 py-1 text-xs text-blue-900 outline-none transition-colors focus:bg-white focus:ring-2 focus:ring-blue-500/40 dark:bg-blue-900/30 dark:text-blue-100 dark:focus:bg-blue-900/50"
|
||||
classList={{
|
||||
'bg-blue-50 ring-2 ring-blue-500/60 dark:bg-blue-800/50':
|
||||
queuedFollowUpCommandTargetId() === queued.id,
|
||||
}}
|
||||
ref={(element) => {
|
||||
queuedFollowUpRowRefs.set(queued.id, element);
|
||||
}}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
aria-label={rowLabel()}
|
||||
data-testid="assistant-queued-follow-up-row"
|
||||
data-assistant-queued-follow-up-id={queued.id}
|
||||
data-assistant-queue-command-target={
|
||||
queuedFollowUpCommandTargetId() === queued.id ? 'true' : undefined
|
||||
}
|
||||
onKeyDown={(event) =>
|
||||
handleQueuedFollowUpRowKeyDown(event, queued.id)
|
||||
}
|
||||
|
|
@ -4715,6 +4810,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
</div>
|
||||
</Show>
|
||||
<form
|
||||
data-assistant-composer
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue