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
7 changes: 7 additions & 0 deletions apps/desktop/src/renderer/styles/model-switcher.css
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@
min-width: 0;
}

/* The prompt-cache notice leads the session switcher's list as an option
whose activation acknowledges it (chat-model-switcher.tsx). Its label wraps
because it is passed as a node; only the glyph needs the warning tone. */
.modelPickerSwitchNoticeGlyph {
color: var(--warning-text);
}

/* Selector's popover stretches to its positioning context, not its content —
left alone it renders far wider than the option labels. Hug the widest row
instead, and cap it so one long id cannot stretch the popup past the
Expand Down
2 changes: 1 addition & 1 deletion docs/astryx-surface-file-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `packages/ui/src/bot-brand-logo.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `packages/ui/src/capability-audit-strip.tsx` | ui-composition | Banner | aligned — uses Astryx (Banner) | aligned |
| `packages/ui/src/chat-empty-hero.tsx` | shell-chrome-or-panel | Item | aligned — uses Astryx (Item) | aligned |
| `packages/ui/src/chat-model-switcher.tsx` | shell-chrome-or-panel | Button, Selector | aligned — uses Astryx (Button, Selector) | aligned |
| `packages/ui/src/chat-model-switcher.tsx` | shell-chrome-or-panel | Button, Selector, SelectorOption | aligned — uses Astryx (Button, Selector, SelectorOption) | aligned |
| `packages/ui/src/chat-surface-layout.tsx` | shell-chrome-or-panel | ChatLayout | aligned — uses Astryx (ChatLayout) | aligned |
| `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText, HStack, Icon, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText) | aligned |
| `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner) | aligned |
Expand Down
133 changes: 133 additions & 0 deletions packages/ui/src/__tests__/chat-model-switcher-notice.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { parseHTML } from 'linkedom';
import type { ChatModelChoice } from '@maka/core/chat-model-choice';
import type { SessionSummary } from '@maka/core/session';
import { ChatModelSwitcher } from '../chat-model-switcher.js';
import { LocaleProvider } from '../locale-context.js';

const WARNING = 'Switching may rebuild the provider prompt cache';

function choice(connectionSlug: string, model: string, label: string): ChatModelChoice {
return {
connectionId: `connection-${connectionSlug}`,
connectionSlug,
connectionName: connectionSlug,
providerType: 'openrouter',
providerLabel: 'OpenRouter',
model,
label,
isDefault: false,
thinkingLevels: [],
};
}

test('the prompt-cache notice is acknowledged per Session without closing the list', async () => {
const original = {
document: globalThis.document,
window: globalThis.window,
Element: globalThis.Element,
HTMLElement: globalThis.HTMLElement,
Node: globalThis.Node,
matchMedia: globalThis.matchMedia,
requestAnimationFrame: globalThis.requestAnimationFrame,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}).IS_REACT_ACT_ENVIRONMENT,
};
const { document, window } = parseHTML('<div id="root"></div>');
window.getComputedStyle = () =>
new Proxy(
{ direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '' },
{ get: (target, key) => (key in target ? target[key as keyof typeof target] : '') },
) as unknown as CSSStyleDeclaration;
window.matchMedia = () =>
({ matches: false, addEventListener() {}, removeEventListener() {} }) as unknown as MediaQueryList;
window.scrollTo = () => {};
Object.assign(globalThis, {
document,
window,
Element: window.Element,
HTMLElement: window.HTMLElement,
Node: window.Node,
matchMedia: window.matchMedia,
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
IS_REACT_ACT_ENVIRONMENT: true,
});
const container = document.querySelector('#root');
assert.ok(container);
const root = createRoot(container);
const choices = [choice('openrouter', 'openai/gpt-5', 'GPT-5'), choice('openrouter', 'openai/o3', 'o3')];
const switches: unknown[] = [];
const session = (id: string) =>
({ id, llmConnectionId: 'connection-openrouter', llmConnectionSlug: 'openrouter', model: 'openai/gpt-5' }) as SessionSummary;
const render = (sessionId: string, openNonce: number) =>
act(() => root.render(
<LocaleProvider locale="en">
<ChatModelSwitcher
activeSession={session(sessionId)}
choices={choices}
hasConversationHistory
openNonce={openNonce}
onChange={(input) => { switches.push(input); }}
/>
</LocaleProvider>,
));
const options = () => [...document.querySelectorAll<HTMLElement>('[role="option"]')];
const noticeRow = () => options().find((option) => option.textContent?.includes(WARNING));
const expanded = () =>
document.querySelector('[aria-haspopup="listbox"]')?.getAttribute('aria-expanded');

try {
await render('session-a', 1);
assert.equal(expanded(), 'true', 'the nonce opens the list');
const notice = noticeRow();
assert.ok(notice, 'the notice leads the open list');
assert.equal(options()[0], notice);
assert.notEqual(notice.getAttribute('aria-disabled'), 'true', 'the notice row is activatable');
assert.equal(options().length, 3);

await act(() => notice.dispatchEvent(new window.Event('click', { bubbles: true })));

assert.equal(noticeRow(), undefined, 'activating the notice dismisses it');
assert.equal(options().length, 2, 'the model rows remain');
assert.equal(expanded(), 'true', 'the list is open again after the acknowledgement');
assert.deepEqual(switches, [], 'acknowledging is not a model switch');

await render('session-a', 2);
assert.equal(noticeRow(), undefined, 'a Selector remount keeps the mounted switcher acknowledgement');

await render('session-b', 0);
assert.equal(expanded(), 'false', 'a fresh Session mounts closed');
await render('session-b', 1);
assert.ok(noticeRow(), 'another Session raises its own notice');

await render('session-a', 3);
assert.equal(noticeRow(), undefined, 'direct Session switches preserve the mounted switcher acknowledgement');
} finally {
await act(() => root.unmount());
Object.assign(globalThis, original);
}
});
90 changes: 74 additions & 16 deletions packages/ui/src/chat-model-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@
* the inline wheel (`presentation="wheel"`) it was built for.
*/

import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { Button as UiButton } from '@astryxdesign/core';
import { Selector } from '@astryxdesign/core/Selector';
import { Selector, SelectorOption, type SelectorOptionData } from '@astryxdesign/core/Selector';
import { ModelWheelPicker, type ModelWheelOption } from './model-wheel-picker.js';
import { ICON_SIZE, Settings } from './icons.js';
import { ICON_SIZE, AlertTriangle, Settings, X } from './icons.js';
import {
type ChatModelChoice,
exactModelChoiceValue,
Expand All @@ -59,7 +59,11 @@ import { usePendingSelection } from './use-pending-selection.js';
import type { ComposerModelSwitchAvailability } from './composer-helpers.js';

const DEFAULT_THINKING_LEVEL = '__default__';
/** Sentinel option value for the switch-warning row; disabled so it can never be picked. */
/**
* Sentinel option value for the switch-warning row. Selecting it acknowledges
* the notice for the Session while the switcher remains mounted; it never
* reaches the pending selection or `onChange`.
*/
const SWITCH_WARNING_VALUE = '__maka_model_switch_warning__';

const exactChoiceValue = (choice: ChatModelChoice) =>
Expand Down Expand Up @@ -152,8 +156,9 @@ export function ChatModelSwitcher(props: {
/**
* Selector has no controlled open prop, so recovery bumps this instead: the
* remount keyed on it opens the panel via `isDefaultOpen`, an entirely
* documented surface. Session changes share the key so a switch always lands
* closed.
* documented surface. The key combines the Session id, this recovery nonce,
* and the notice acknowledgement nonce. The Composer resets recovery on
* Session changes so the new Session lands closed.
*/
openNonce?: number;
/** Force any open surface closed while an interaction prompt occludes the composer. */
Expand Down Expand Up @@ -195,6 +200,16 @@ export function ChatModelSwitcher(props: {
() => new Map(props.choices.map((choice) => [exactChoiceValue(choice), choice] as const)),
[props.choices],
);
// Switching can abandon a provider prompt cache — a property of the pending
// action, so the notice leads the list whenever the panel opens, until the
// user acknowledges it. Acknowledgements survive direct Session switches
// while this switcher remains mounted. Home / new-task replaces it with
// NewChatModelPicker, so returning from there starts with fresh notices.
const [acknowledgedSessions, setAcknowledgedSessions] = useState<ReadonlySet<string>>(
() => new Set(),
);
const noticeShown =
props.hasConversationHistory === true && !acknowledgedSessions.has(props.activeSession.id);
const options = useMemo(() => {
const list = buildModelPickerOptions(
grouped,
Expand All @@ -204,15 +219,51 @@ export function ChatModelSwitcher(props: {
exactChoiceValue,
{ locale, renderProviderMark: props.renderProviderMark },
);
// Switching can abandon a provider prompt cache — a property of the pending
// action, shown as a disabled first row whenever the panel opens.
if (props.hasConversationHistory === true) {
list.unshift({ value: SWITCH_WARNING_VALUE, label: copy.switchWarning, disabled: true });
// A regular option, not a disabled one: activating it is the dismissal,
// so a click, Enter on the highlight, and a screen reader's activate all
// reach it. A close button nested in the row could not be — option
// children are presentational, and Tab closes the panel.
if (noticeShown) {
list.unshift({
value: SWITCH_WARNING_VALUE,
label: copy.switchWarning,
description: copy.switchWarningDismiss,
});
}
return list;
}, [grouped, currentKnownChoice, currentValue, props.hideUnavailableCurrentOption,
props.hasConversationHistory, displayLabel, props.currentProviderType, copy.switchWarning,
locale, props.renderProviderMark]);
noticeShown, displayLabel, props.currentProviderType, copy.switchWarning,
copy.switchWarningDismiss, locale, props.renderProviderMark]);
const renderOption = useCallback((option: SelectorOptionData) => (
option.value === SWITCH_WARNING_VALUE
? (
<SelectorOption
className="modelPickerOption modelPickerSwitchNotice"
icon={<AlertTriangle className="modelPickerSwitchNoticeGlyph" size={ICON_SIZE.control} aria-hidden="true" />}
// A node, not a string: Item ellipsizes string labels to one line,
// and this is a sentence to read, not an id to recognise.
label={<span>{option.label}</span>}
description={option.description}
endContent={<X size={ICON_SIZE.control} aria-hidden="true" />}
/>
)
: renderModelPickerOption(option)
), []);

// Selecting any option closes the Selector, the notice included, and the
// acknowledgement must not cost the user their open list. So it bumps a
// nonce and the remount keyed on it lands open — the same `isDefaultOpen`
// path `openNonce` recovery uses. `pending` is consumed right after that
// mount: a later fresh mount in the same Session (a presentation flip, say)
// must not open on its own.
const [reopen, setReopen] = useState({ nonce: 0, pending: false });
useEffect(() => {
if (reopen.pending) setReopen((current) => ({ nonce: current.nonce, pending: false }));
}, [reopen.pending]);
const acknowledgeNotice = () => {
setAcknowledgedSessions((current) => new Set(current).add(props.activeSession.id));
setReopen((current) => ({ nonce: current.nonce + 1, pending: true }));
};

// Reflect the pick on the trigger immediately and hold it until the write
// settles, then defer to the authoritative value. See usePendingSelection.
Expand All @@ -227,6 +278,13 @@ export function ChatModelSwitcher(props: {
});
} catch { /* The action owner reports the failure. */ }
});
const onSelect = (value: string) => {
if (value === SWITCH_WARNING_VALUE) {
acknowledgeNotice();
return;
}
void selection.onChange(value);
};

// The collapsed WorkHub window keeps the wheel it was built for. The nonce
// edge opens it once, then the wheel owns its own open state.
Expand Down Expand Up @@ -275,7 +333,7 @@ export function ChatModelSwitcher(props: {

return (
<Selector
key={`${props.activeSession.id}:${props.openNonce ?? 0}`}
key={`${props.activeSession.id}:${props.openNonce ?? 0}:${reopen.nonce}`}
label={`${copy.switchAriaLabel}: ${displayLabel}`}
isLabelHidden
options={options}
Expand All @@ -289,11 +347,11 @@ export function ChatModelSwitcher(props: {
isDisabled={disabled}
isReadOnly={props.isReadOnly}
disabledMessage={props.disabledReason}
isDefaultOpen={Boolean(props.openNonce)}
isDefaultOpen={Boolean(props.openNonce) || reopen.pending}
placeholder={displayLabel}
className="maka-model-switcher-trigger"
onChange={selection.onChange}
renderOption={renderModelPickerOption}
onChange={onSelect}
renderOption={renderOption}
renderValue={renderModelPickerValue}
/>
);
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ export interface ConversationCopy {
model: string;
switchAriaLabel: string;
switchWarning: string;
/** Secondary line on the cache warning row: activating the row dismisses it. */
switchWarningDismiss: string;
newChatAriaLabel: (label: string) => string;
newChatTitle: (label: string) => string;
configureAriaLabel: (label: string) => string;
Expand Down Expand Up @@ -529,6 +531,7 @@ const CONVERSATION_COPY = {
level: { off: '关', minimal: '最少', low: '低', medium: '中', high: '高', xhigh: '超高', max: '最高' },
switching: '切换中', model: '模型', switchAriaLabel: '切换当前任务模型',
switchWarning: '切换模型可能需要重建服务商提示缓存,使下一次请求更慢或成本更高。',
switchWarningDismiss: '选择即可关闭',
newChatAriaLabel: (label) => `选择新任务模型,当前 ${label}`, newChatTitle: (label) => `新任务使用的模型:${label}`,
configureAriaLabel: (label) => `配置模型连接,当前 ${label}`, configureTitle: '配置模型连接',
},
Expand Down Expand Up @@ -687,6 +690,7 @@ const CONVERSATION_COPY = {
level: { off: '關', minimal: '最少', low: '低', medium: '中', high: '高', xhigh: '超高', max: '最高' },
switching: '切換中', model: '模型', switchAriaLabel: '切換目前任務模型',
switchWarning: '切換模型可能需要重建服務商提示快取,使下一次請求更慢或成本更高。',
switchWarningDismiss: '選擇即可關閉',
newChatAriaLabel: (label) => `選擇新任務模型,目前 ${label}`, newChatTitle: (label) => `新任務使用的模型:${label}`,
configureAriaLabel: (label) => `設定模型連線,目前 ${label}`, configureTitle: '設定模型連線',
},
Expand Down Expand Up @@ -871,6 +875,7 @@ const CONVERSATION_COPY = {
level: { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Maximum' },
switching: 'Switching', model: 'Model', switchAriaLabel: 'Switch model for this task',
switchWarning: 'Switching may rebuild the provider prompt cache, making the next request slower or more expensive.',
switchWarningDismiss: 'Select to dismiss',
newChatAriaLabel: (label) => `Choose a model for the new task, currently ${label}`, newChatTitle: (label) => `Model for the new task: ${label}`,
configureAriaLabel: (label) => `Configure model connections, currently ${label}`, configureTitle: 'Configure model connections',
},
Expand Down
Loading