diff --git a/public/fofos-favicon-lean.ico b/public/fofos-favicon-lean.ico new file mode 100644 index 00000000..36c2eadd Binary files /dev/null and b/public/fofos-favicon-lean.ico differ diff --git a/public/fofos-favicon.ico b/public/fofos-favicon.ico new file mode 100644 index 00000000..0203109b Binary files /dev/null and b/public/fofos-favicon.ico differ diff --git a/public/fofos-logo-saffron.png b/public/fofos-logo-saffron.png new file mode 100644 index 00000000..3941de71 Binary files /dev/null and b/public/fofos-logo-saffron.png differ diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.test.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.test.tsx index 51a0a164..a349246f 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.test.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'bun:test'; +import { describe, expect, mock, test } from 'bun:test'; import { render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { ModelOption } from 'state/useModels'; @@ -127,3 +127,30 @@ describe('ModelSelector display of an unknown model', () => { expect(screen.queryByTitle('claude-code/opus')).toBeNull(); }); }); + +describe('a live session whose model has not resolved yet', () => { + // Reported 2026-08-19 from a fresh server: a chat running `opusplan` came back as `fable` after a + // reload, and the header showed a real mid-session switch (`OPUSPLAN → FABLE`) followed by a 429 on a + // key with no fable entitlement. `fable` is simply the FIRST entry in the claude-code catalogue, so it + // is whatever this component picks when it decides a conversation needs a default. + test('does not invent a model for a conversation that has started', () => { + const onModelChange = mock(() => {}); + mount({ selectedModel: null, model: null, hasStarted: true, onModelChange }); + // The old behaviour was `onModelChange('claude-code/fable')` — a confident wrong answer about a + // session it knows nothing about. An unset picker is the honest one. + expect(onModelChange).not.toHaveBeenCalled(); + }); + + test('still picks a default for a composer that has NOT started', () => { + const onModelChange = mock(() => {}); + mount({ selectedModel: null, model: null, hasStarted: false, onModelChange }); + expect(onModelChange).toHaveBeenCalled(); + }); + + // Unknown harness is not permission to switch harness: handing a `ses_…` id to `claude --resume` + // dispatches to the wrong sidecar, which is why this is pinned rather than left to read nicely. + test('offers no harness switch while the harness is unknown', () => { + mount({ selectedModel: null, model: null, hasStarted: true }); + expect(screen.queryByRole('button', { name: /^openai$/i })).toBeNull(); + }); +}); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx index 34a021fd..53c038b4 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx @@ -113,9 +113,22 @@ export function ModelSelector({ const harnessOf = (id: string) => (id.startsWith('claude-code') ? 'claude-code' : 'opencode'); const currentHarness = displayModel ? harnessOf(displayModel) : null; + /* + A started conversation whose model has NOT resolved yet. + + `crossesHarness` used to return false here, which reads as "nothing crosses the harness" and is the + opposite of what is known: with no current harness, EVERY provider might cross it. So a live session + offered a switch to opencode during the window before its model arrived — a switch that cannot work, + because it hands a `ses_…` id to `claude --resume` and dispatches to the wrong sidecar. + + Unknown is not permission. The strip locks until the answer arrives. + */ + const harnessUnknown = hasStarted && !currentHarness; + /** Would picking this provider move the conversation to the other sidecar? */ const crossesHarness = (provider: string) => { - if (!hasStarted || !currentHarness) return false; + if (!hasStarted) return false; + if (!currentHarness) return true; // unknown — refuse rather than guess const target = availableModels.find((m) => m.provider === provider); return !!target && harnessOf(target.id) !== currentHarness; }; @@ -123,7 +136,7 @@ export function ModelSelector({ // The provider strip still collapses to a label when there is nothing to choose between — but now that // means "one harness on offer", not "you have already sent a message". const selectableProviders = providers.filter((p) => !crossesHarness(p)); - const providerStripLocked = isBusy || selectableProviders.length < 2; + const providerStripLocked = isBusy || harnessUnknown || selectableProviders.length < 2; const handleProviderClick = (provider: string) => { if (isBusy || crossesHarness(provider)) return; diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 68bd0cd3..7f397701 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -112,19 +112,42 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, const cursorRef = useRef(0); const sendRef = useRef<(d: Record) => void>(() => {}); - // Track if session has started (first message sent) - const [hasStarted, setHasStarted] = useState(!!preloadedMessages?.length); + /* + Has this conversation already happened? + + `!!preloadedMessages?.length` alone was wrong for the window that matters. A resumed chat renders + before its messages arrive, so for those first frames `hasStarted` was FALSE on a conversation with a + hundred turns behind it — and `ModelSelector` treats "not started" as permission to pick a default. + The catalogue's first entry is `fable`, so that is what a reload reassigned live sessions to, and the + change propagated to the running session as a real model switch (`OPUSPLAN → FABLE` in the header, + followed by a 429 on a key with no fable entitlement). + + A session id is the earlier and more reliable signal: it is known on the first render, and a chat that + has one is by definition not new. + */ + const [hasStarted, setHasStarted] = useState(!!preloadedMessages?.length || !!resumeSessionId || !!initialSessionId); // Get user settings for default model const { settings } = useSettings(); - // Set default model from settings when starting a new chat (no initialSessionId, no initialModel) + /* + The settings default, for a genuinely NEW chat only. + + This guarded on `initialSessionId`, which no chat caller ever passes when resuming — the id arrives as + `options.resumeSessionId`, and `ChatPanelWrapper` goes as far as `resume ? undefined : sessionKey`. + So the guard was true for every resumed conversation and the user's default was applied on top of a + session that already had a model of its own. + + Both names mean "this conversation exists", so both are checked. They are separate parameters for + reasons that have nothing to do with this question. + */ + const existingSessionId = initialSessionId ?? resumeSessionId; + useEffect(() => { - if (!initialSessionId && !initialModel) { - const defaultModel = (projectScoped && settings?.chat?.defaultProjectModel) || settings?.chat?.defaultModel; - if (defaultModel) setSelectedModel(defaultModel); - } - }, [initialSessionId, initialModel, settings, projectScoped]); + if (existingSessionId || initialModel) return; + const defaultModel = (projectScoped && settings?.chat?.defaultProjectModel) || settings?.chat?.defaultModel; + if (defaultModel) setSelectedModel(defaultModel); + }, [existingSessionId, initialModel, settings, projectScoped]); // Sync selectedModel when initialModel changes (e.g. resuming a session) useEffect(() => {