Let Assistant send during provider checks

This commit is contained in:
rcourtman
2026-06-05 13:38:00 +01:00
parent 2f918a3b03
commit df203b0ce6
5 changed files with 28 additions and 23 deletions
@@ -77,10 +77,11 @@ runtime cost control, and shared AI transport surfaces.
by `internal/ai/`, not Patrol runtime-finding wording, and the drawer may
surface the result as actionable retry/settings status plus same-model
configured-provider alternatives without converting it into
assistant-authored output. The composer must keep typed text and focus while
the selected provider is checking or failed, but it must not dispatch a user
turn until that selected provider route is ready or the operator chooses a
ready alternative.
assistant-authored output. Provider checking is a background diagnostic and
must not delay the user's first useful chat turn; a confirmed selected-route
provider error must keep typed text and focus while blocking dispatch until
the route is rechecked successfully or the operator chooses a ready
alternative.
Operator interruption is likewise chat-runtime state: Stop and replacement
sends must abort the active stream, clear pending tool/approval/question
affordances, preserve any partial model text, return focus to the composer,
@@ -283,12 +283,12 @@ function setViewportWidth(width: number) {
window.dispatchEvent(new Event('resize'));
}
async function waitForComposerSendReady() {
async function waitForProviderCheckSettled() {
await waitFor(() => {
expect(mockAIAPI.testProvider).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.queryByText('Checking OpenAI provider')).not.toBeInTheDocument();
expect(screen.queryByText('Verifying OpenAI provider')).not.toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
@@ -409,7 +409,7 @@ describe('AIChat', () => {
);
});
it('keeps user input queued while the selected provider check is still running', async () => {
it('sends user input while the selected provider check is still running', async () => {
mockAIAPI.getSettings.mockResolvedValue({
model: 'openai:gpt-4',
chat_model: '',
@@ -421,17 +421,21 @@ describe('AIChat', () => {
renderChat();
await screen.findByText('Checking OpenAI provider');
await screen.findByText('Verifying OpenAI provider');
const textarea = screen.getByPlaceholderText(
'Ask about your infrastructure...',
) as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: 'summarize the cluster' } });
expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
fireEvent.keyDown(textarea, { key: 'Enter' });
expect(mockChat.sendMessage).not.toHaveBeenCalled();
expect(textarea.value).toBe('summarize the cluster');
expect(mockChat.sendMessage).toHaveBeenCalledWith(
'summarize the cluster',
undefined,
undefined,
);
expect(textarea.value).toBe('');
expect(document.activeElement).toBe(textarea);
});
@@ -2074,7 +2078,7 @@ describe('AIChat', () => {
expect(textarea.value).toBe('@redacted by policy ');
});
await waitForComposerSendReady();
await waitForProviderCheckSettled();
fireEvent.keyDown(textarea, { key: 'Enter' });
expect(mockChat.sendMessage).toHaveBeenCalledWith(
'@redacted by policy',
@@ -2280,7 +2284,7 @@ describe('AIChat', () => {
const textarea = screen.getByPlaceholderText('Ask about your infrastructure...');
fireEvent.input(textarea, { target: { value: 'what happened here?' } });
await waitForComposerSendReady();
await waitForProviderCheckSettled();
fireEvent.keyDown(textarea, { key: 'Enter' });
expect(mockChat.sendMessage).toHaveBeenCalledWith(
@@ -2361,7 +2365,7 @@ describe('AIChat', () => {
expect(mockAiChatStore.context.briefing?.title).toBe('Patrol finding on web-server');
fireEvent.input(textarea, { target: { value: 'what changed next?' } });
await waitForComposerSendReady();
await waitForProviderCheckSettled();
fireEvent.keyDown(textarea, { key: 'Enter' });
await waitFor(() => {
@@ -554,9 +554,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
);
};
const providerReadinessBlocksSend = createMemo(() => {
const providerReadinessHasBlockingError = createMemo(() => {
const readiness = providerReadiness();
if (readiness.status !== 'checking' && readiness.status !== 'error') return false;
if (readiness.status !== 'error') return false;
return providerReadinessMatchesSelection(readiness);
});
@@ -591,7 +591,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
provider,
model,
message: 'Provider check failed',
summary: 'Pulse could not verify the selected provider before this chat sends work.',
summary: 'Pulse could not verify the selected provider route.',
recommendation:
'Check provider settings and network reachability, then retry the provider check.',
action: 'open_provider_settings',
@@ -1174,7 +1174,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
const handleSubmit = () => {
const prompt = input().trim();
if (!prompt) return;
if (providerReadinessBlocksSend()) {
if (providerReadinessHasBlockingError()) {
focusComposer();
return;
}
@@ -2041,7 +2041,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
</Show>
<button
type="submit"
disabled={!input().trim() || providerReadinessBlocksSend()}
disabled={!input().trim() || providerReadinessHasBlockingError()}
class="flex h-9 w-9 items-center justify-center rounded-md bg-blue-600 text-white shadow-sm transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-45"
title="Send"
aria-label="Send message"
@@ -93,6 +93,6 @@ describe('aiChatPresentation', () => {
status: 'checking',
providerLabel: 'OpenRouter',
}).title,
).toBe('Checking OpenRouter provider');
).toBe('Verifying OpenRouter provider');
});
});
@@ -85,15 +85,15 @@ export function getAIChatProviderReadinessPresentation(args: {
if (args.status === 'checking') {
return {
tone: 'checking',
title: `Checking ${providerLabel} provider`,
body: 'Pulse is verifying the selected provider before this chat sends work.',
title: `Verifying ${providerLabel} provider`,
body: 'Pulse is checking the selected provider route in the background.',
};
}
const body =
args.summary?.trim() ||
args.message?.trim() ||
'Pulse could not verify the selected provider before this chat sends work.';
'Pulse could not verify the selected provider route.';
const recommendation = args.recommendation?.trim() || undefined;
return {