From ba542338ea8865fe2d489afc643bda7b7c69c7a8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Jul 2026 14:31:10 -0400 Subject: [PATCH] fix(desktop): session-scope fast mode, surface profile ownership + pinned model override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model-picker audit follow-through — closes the remaining pieces of the "switch one session, switches everywhere / can't tell whose session this is" report class: - tui_gateway: `config.set key=fast` with a session no longer writes the global agent.service_tier to config.yaml (sibling of the earlier `reasoning` scoping fix). It pins create_service_tier_override ("priority" / "" for explicit normal) so lazy builds and rebuilds keep the choice; the desktop's per-model presets were rewriting the global tier on every model pick. Fast-support validation now checks a draft's picked model, and `config.get key=fast` reads the pre-build pin. - desktop: owning-profile tag (initial chip + tooltip/aria label) on pinned rows and search results in the All-profiles sidebar, and on the chat header once a second profile exists (#66003). - desktop: composer model pill shows a pin dot + tooltip when a manual sticky pick is overriding the Settings default for new chats (#62055). Closes #66003. Addresses #62055. --- .../src/app/chat/composer/model-pill.test.tsx | 76 +++++++++ .../src/app/chat/composer/model-pill.tsx | 24 ++- apps/desktop/src/app/chat/index.tsx | 14 +- .../desktop/src/app/chat/profile-tag.test.tsx | 49 ++++++ apps/desktop/src/app/chat/profile-tag.tsx | 36 ++++ apps/desktop/src/app/chat/sidebar/index.tsx | 2 + .../src/app/chat/sidebar/session-row.tsx | 7 + .../src/app/chat/sidebar/sessions-section.tsx | 11 +- .../app/chat/sidebar/virtual-session-list.tsx | 6 +- apps/desktop/src/i18n/en.ts | 2 + apps/desktop/src/i18n/ja.ts | 2 + apps/desktop/src/i18n/types.ts | 2 + apps/desktop/src/i18n/zh-hant.ts | 2 + apps/desktop/src/i18n/zh.ts | 2 + apps/desktop/src/store/session.ts | 6 + tests/test_tui_gateway_server.py | 18 +- tests/tui_gateway/test_fast_session_scope.py | 161 ++++++++++++++++++ tui_gateway/server.py | 61 +++++-- 18 files changed, 454 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/model-pill.test.tsx create mode 100644 apps/desktop/src/app/chat/profile-tag.test.tsx create mode 100644 apps/desktop/src/app/chat/profile-tag.tsx create mode 100644 tests/tui_gateway/test_fast_session_scope.py diff --git a/apps/desktop/src/app/chat/composer/model-pill.test.tsx b/apps/desktop/src/app/chat/composer/model-pill.test.tsx new file mode 100644 index 000000000000..165441dbd462 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/model-pill.test.tsx @@ -0,0 +1,76 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import type { ChatBarState } from '@/app/chat/composer/types' +import { + $activeSessionId, + $currentModel, + setCurrentModel, + setCurrentModelSource +} from '@/store/session' + +import { ModelPill } from './model-pill' + +const modelState = (over: Partial = {}): ChatBarState['model'] => ({ + canSwitch: true, + model: 'gpt-6', + provider: 'openai', + ...over +}) + +afterEach(() => { + cleanup() + $activeSessionId.set(null) + setCurrentModel('') + setCurrentModelSource('') +}) + +// #62055: a manual composer pick is sticky and silently overrides the +// Settings → Model default for every NEW chat. The pill must say so. +describe('ModelPill pinned-override badge', () => { + it('shows the pin dot on a draft running a manual pick', () => { + setCurrentModel('deepseek/deepseek-v4-flash') + setCurrentModelSource('manual') + $activeSessionId.set(null) + + render() + + expect(screen.getByTestId('model-pinned-dot')).toBeTruthy() + }) + + it('stays quiet when the composer reflects the profile default', () => { + setCurrentModel('google/gemma-4-26b-a4b-it:free') + setCurrentModelSource('default') + $activeSessionId.set(null) + + render() + + expect(screen.queryByTestId('model-pinned-dot')).toBeNull() + }) + + it('stays quiet on a live session (footer shows that session, not the pin)', () => { + setCurrentModel('deepseek/deepseek-v4-flash') + setCurrentModelSource('manual') + $activeSessionId.set('live-1') + + render() + + expect(screen.queryByTestId('model-pinned-dot')).toBeNull() + }) + + it('is exercised in both render paths', () => { + setCurrentModel('deepseek/deepseek-v4-flash') + setCurrentModelSource('manual') + $activeSessionId.set(null) + + // Fallback (no live menu) path. + const { unmount } = render() + expect(screen.getByTestId('model-pinned-dot')).toBeTruthy() + unmount() + + // Live-menu (dropdown) path. + render( })} />) + expect(screen.getByTestId('model-pinned-dot')).toBeTruthy() + expect($currentModel.get()).toBe('deepseek/deepseek-v4-flash') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index afaca08c9c8c..e232616ceda5 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -11,8 +11,10 @@ import { ChevronDown } from '@/lib/icons' import { formatModelStatusLabel } from '@/lib/model-status-label' import { cn } from '@/lib/utils' import { + $activeSessionId, $currentFastMode, $currentModel, + $currentModelSource, $currentProvider, $currentReasoningEffort, setModelPickerOpen @@ -44,8 +46,17 @@ export function ModelPill({ const currentProvider = useStore($currentProvider) const fastMode = useStore($currentFastMode) const reasoningEffort = useStore($currentReasoningEffort) + const modelSource = useStore($currentModelSource) + const activeSessionId = useStore($activeSessionId) const [open, setOpen] = useState(false) + // The composer pick is sticky: a manual selection is pinned and every NEW + // chat uses it instead of the Settings → Model default — silently, which has + // cost users real money on a forgotten paid-model pick (#62055). Surface the + // pin whenever a draft (no live session) is running on a manual override. A + // live session's footer reflects that session's model, so no badge there. + const pinnedOverride = !activeSessionId && modelSource === 'manual' && Boolean(currentModel.trim()) + // The model resolves a beat after the gateway/session comes up. Rather than // flash a literal "No model", show a quiet loader (inherits the pill text // color at half opacity) until a model lands. @@ -58,6 +69,14 @@ export function ModelPill({ ) : ( )} + {pinnedOverride && ( + + )} ) @@ -71,11 +90,12 @@ export function ModelPill({ ) : PILL - const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel + const baseTitle = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel + const title = pinnedOverride ? `${baseTitle} — ${copy.modelPinned}` : baseTitle if (!model.modelMenuContent) { return ( - +