Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ui/desktop/src/components/BaseChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import ArtifactViewer from './artifacts/ArtifactViewer';
import { useArtifactPanel } from './artifacts/useArtifactPanel';
import InAppTerminalDock from './InAppTerminalDock';
import { ChatTurnError, hasVisibleTurnErrorMessage } from './conversation/ChatTurnError';
import { ChatTurnStopped } from './conversation/ChatTurnStopped';
import type { ArtifactRenderError } from './artifacts/ArtifactViewer';
import type { ArtifactSource } from './artifacts/artifactTypes';
import type { LiveBrowserShare } from './artifacts/WebPagePreview';
Expand Down Expand Up @@ -1387,6 +1388,7 @@ function BaseChatContent({
steer,
sessionLoadError,
turnError,
stopConfirmed,
setWorkflowUserParams,
tokenState,
turnStartedAt,
Expand Down Expand Up @@ -2462,6 +2464,10 @@ function BaseChatContent({
{turnError && !hasVisibleTurnErrorMessage(turnError, messages) && (
<ChatTurnError error={turnError} onRetry={retryTurn} />
)}
{/* F5: a CONFIRMED Stop's outcome, in the slot a
failed Stop's notice takes. Transient — the
store decides when it shows and when it goes. */}
{stopConfirmed && <ChatTurnStopped />}
</>
</SearchView>
{/* No tail spacer. A `block h-8` used to sit here, and
Expand Down
43 changes: 43 additions & 0 deletions ui/desktop/src/components/conversation/ChatTurnStopped.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { ChatTurnStopped } from './ChatTurnStopped';

/** vitest runs with `ui/desktop` as its root — the idiom `BaseChat.privacy.test.tsx` uses. */
const read = (...p: string[]) => readFileSync(path.join(process.cwd(), ...p), 'utf8');

/**
* F5 (QA of 7c96d796, 2026-09-10): a Stop that worked stated no outcome. The
* store decides WHEN the line shows (`chatStreamStore.test.ts`, "a Stop the
* daemon confirms"); this file pins what it says and where it goes.
*/
describe('ChatTurnStopped', () => {
it('states the outcome in words, in a polite live region', () => {
render(<ChatTurnStopped />);
expect(screen.getByRole('status')).toHaveTextContent('Stopped.');
});

// The success half of M2's notice is quiet: nothing about this ending is an
// error, and nothing is left for the user to do about it.
it('is neither an alert nor the error card', () => {
render(<ChatTurnStopped />);
expect(screen.queryByRole('alert')).toBeNull();
expect(screen.queryByTestId('chat-turn-error')).toBeNull();
});

/**
* BaseChat cannot be mounted in jsdom (see `BaseChat.privacy.test.tsx`), so
* its half is asserted at the source: the line is fed by the store's
* `stopConfirmed`, and it sits in the slot a failed Stop's notice takes —
* the transcript's tail, after the pending tool calls, beside `ChatTurnError`.
*/
it('is rendered by BaseChat from the store, in the failed Stop notice’s slot', () => {
const source = read('src', 'components', 'BaseChat.tsx');

expect(source).toMatch(/const \{[^}]*\bstopConfirmed,[^}]*\} = useChatStream\(/);
const tail = /<PendingToolCallList\b[\s\S]*?<\/SearchView>/.exec(source);
expect(tail, 'BaseChat no longer ends its transcript with PendingToolCallList').not.toBeNull();
expect(tail![0]).toMatch(/<ChatTurnError\b[\s\S]*\{stopConfirmed && <ChatTurnStopped \/>\}/);
});
});
40 changes: 40 additions & 0 deletions ui/desktop/src/components/conversation/ChatTurnStopped.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Stop from '../ui/Stop';

/**
* F5 — what a Stop the daemon CONFIRMED did, said once and quietly.
*
* The success half of M2's notice (`ChatTurnError`'s "Stop not confirmed"). A
* failed Stop gets a card because the user has something to do about it: the
* turn may still be running. A confirmed one gets a line because nothing is left
* to do. The turn is over, Send is back, and the only thing missing was the
* words. So it carries no surface, no status hue and no action —
* `text-supporting` in `--text-muted`, the settings vocabulary's status line
* (rule 6) — in the slot the failed Stop's card would take.
*
* It borrows the trailing activity line's geometry on purpose. That line
* (`TurnActivityIndicator`) is what the user was watching when they pressed
* Stop, and this one lands where it was, at its height, with the Stop button's
* own glyph where the working pulse had been: the working line's last state
* rather than a new element.
*
* WHEN it shows is the store's decision, not this component's
* (`ChatStreamSnapshot.stopConfirmed`): only for a cancel the daemon answered
* `cancelled: true`, never persisted, and retracted after
* `STOP_CONFIRMED_NOTICE_MS` or by the next turn. Reduced motion is handled by
* the global reset in `styles/main.css`.
*/
export function ChatTurnStopped() {
return (
<div data-testid="chat-turn-stopped" className="mt-4 w-full animate-fade-slide-up">
<div
role="status"
className="inline-flex items-center gap-2 px-1 py-1 text-supporting text-text-muted"
>
<span aria-hidden="true" className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
<Stop size={12} />
</span>
Stopped.
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,79 @@ describe('SwitchModelModal — pre-flight, not post-refusal', () => {
expect(row).toHaveTextContent(/private chat/i);
});

/**
* F3 (QA of 7c96d796, 2026-09-10). A disabled ROW is not a disabled SELECTION.
* The auto-select fills the field with the provider's first model without
* asking `isOptionDisabled`, so opening this on a public provider in a private
* chat put a barred model in the field — and "Select model" stayed live,
* because validity started `true` and was first computed inside the click.
* The click was refused, so the gate held; the pre-flight did not.
*
* ⚠ Fails against the code before the fix: the confirm is enabled and the
* reason is nowhere on screen until something is clicked.
*/
it('disables the confirm, with the reason beside it, before any click on a barred selection', async () => {
render(
<SwitchModelModal sessionId="s1" privacyTier="private" onClose={vi.fn()} setView={vi.fn()} />
);

// The auto-selected model: every row of this provider is barred here.
await screen.findByText('Claude Opus 4.8');

const confirm = screen.getByRole('button', { name: 'Select model' });
expect(confirm).toBeDisabled();
// The reason is on screen with the menu closed, and it is the confirm's
// own description rather than a sentence that merely happens to be nearby.
const reason = screen.getByText(/private chat, so only private models/i);
expect(reason.id).not.toBe('');
expect(confirm).toHaveAttribute('aria-describedby', reason.id);

fireEvent.click(confirm);
expect(mocks.changeModel).not.toHaveBeenCalled();
});

// "Every selection change", not "whatever was there at mount": moving the
// same dialog onto a private provider has to bring the confirm back.
it('re-validates when the selection moves off the barred provider', async () => {
render(
<SwitchModelModal sessionId="s1" privacyTier="private" onClose={vi.fn()} setView={vi.fn()} />
);

await screen.findByText('Claude Opus 4.8');
const confirm = screen.getByRole('button', { name: 'Select model' });
expect(confirm).toBeDisabled();

fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'Versa' } });
fireEvent.click(await screen.findByRole('option', { name: 'Versa' }));

await waitFor(() => expect(confirm).toBeEnabled());
expect(confirm).not.toHaveAttribute('aria-describedby');
expect(screen.queryByText(/private chat/i)).toBeNull();
});

// The control for the two above: a private model in the same private chat
// leaves the confirm live and says nothing, so the fix cannot have been
// "disable the confirm in every private chat".
it('leaves the confirm live for a private model in a private chat', async () => {
render(
<SwitchModelModal
sessionId="s1"
privacyTier="private"
initialProvider="versa_azure"
onClose={vi.fn()}
setView={vi.fn()}
/>
);

await screen.findByText('Claude Opus 4.8');
const confirm = screen.getByRole('button', { name: 'Select model' });
await waitFor(() => expect(confirm).toBeEnabled());
expect(screen.queryByText(/private chat/i)).toBeNull();

fireEvent.click(confirm);
await waitFor(() => expect(mocks.changeModel).toHaveBeenCalledTimes(1));
});

// Without this the assertion above passes for a modal that disables EVERY
// row, which would be a worse bug than the one it is meant to catch.
it('leaves the same row selectable in a public chat', async () => {
Expand Down
Loading
Loading