From 197010f0c575ccda5205e0eac22b2038e6b00f1b Mon Sep 17 00:00:00 2001 From: Fathah KA <48355244+fathah@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:02:43 +0530 Subject: [PATCH 1/5] Model Selection Bug Fix --- lat.md/lat.md | 1 + lat.md/model-selection.md | 17 +++ src/main/hermes.test.ts | 72 +++++++++++- src/main/hermes.ts | 107 +++++++++++++----- src/main/index.ts | 3 +- src/preload/index.d.ts | 3 +- src/preload/index.ts | 3 +- src/renderer/src/screens/Chat/Chat.tsx | 24 +++- .../src/screens/Chat/hooks/useChatActions.ts | 6 +- .../src/screens/Chat/hooks/useModelConfig.ts | 29 +++-- src/shared/model-override.ts | 18 +++ 11 files changed, 233 insertions(+), 50 deletions(-) create mode 100644 lat.md/model-selection.md create mode 100644 src/shared/model-override.ts diff --git a/lat.md/lat.md b/lat.md/lat.md index 241632106..ce4ef729c 100644 --- a/lat.md/lat.md +++ b/lat.md/lat.md @@ -2,6 +2,7 @@ This directory defines the high-level concepts, business logic, and architecture - [[chat-commands]] — how typed slash commands are routed through the gateway's `slash.exec`/`command.dispatch` pipeline instead of being sent as prompt text. - [[model-context]] — the per-model context-window override that drives the context gauge and the agent's auto-compaction. +- [[model-selection]] — the session-scoped in-chat model override that switches the model (and provider) for one conversation without touching the global default. - [[web-preview]] — the in-app split-screen webview and the `partition`-based gate that lets only it load remote HTTPS while staying sandboxed. - [[code-blocks]] — collapsible long code blocks, and why expansion state is keyed on source position to survive react-markdown's streaming remounts. - [[window-chrome]] — the browser-style title bar where open-conversation tabs sit on top of the window drag region, clickable while empty space still drags. diff --git a/lat.md/model-selection.md b/lat.md/model-selection.md new file mode 100644 index 000000000..874509578 --- /dev/null +++ b/lat.md/model-selection.md @@ -0,0 +1,17 @@ +# Session model override + +The in-chat (bottom) model picker selects a model for the **current conversation only** — it never rewrites `config.yaml`, so the Settings global default is preserved (#688), and carries the full model identity so cross-provider switches route correctly. + +The override is held in renderer state on each `` run ([[src/renderer/src/screens/Chat/Chat.tsx]]) and sent with every message; it is cleared when the conversation is cleared/reset and is absent on a fresh chat, so new conversations start on the global default. This is distinct from the persisted [[model-context]] default that non-chat surfaces read. + +## Full identity, not just the model name + +The override is a `SessionModelOverride` (`{provider, model, baseUrl}`), not a bare model string — because switching across providers must change routing, not only the `model` field. + +The picker builds it via [[src/renderer/src/screens/Chat/hooks/useModelConfig.ts#effectiveOverrideBaseUrl]], the same baseUrl rule `selectModel` applies (keep the URL only for `custom`/`ollama-cloud`; clear it for named providers that have a canonical base URL), so the session pick and a persisted save can't drift. It is threaded renderer → preload IPC → main `sendMessage` as `modelOverride`. + +## Cross-provider switch routes via CLI + +A session override that changes the provider or base URL away from `config.yaml` is sent through the **CLI transport**, the only path that can be parameterized per call. + +The gateway/API transport resolves the provider server-side from `config.yaml` — the request body carries only `model` — so it cannot honour a per-request provider change (a Gemini model id routed through a sticky `openai-codex` default is ignored, reporting the old model). [[src/main/hermes.ts#sendMessage]] computes an effective config (persisted config overlaid with the override) and routes to `sendMessageViaCli` when the override changes provider/baseUrl, passing `-m ` and an explicit `--provider`. Same-provider model swaps stay on the gateway/API path, where the new `model` string is sufficient. Remote (SSH) mode has no CLI transport, so it remains limited to the model string. diff --git a/src/main/hermes.test.ts b/src/main/hermes.test.ts index e56e5534e..9b3eb37d6 100644 --- a/src/main/hermes.test.ts +++ b/src/main/hermes.test.ts @@ -43,14 +43,21 @@ vi.mock("./utils", () => ({ vi.mock("./gateway-ports", () => ({ getProfilePort: vi.fn(() => 8642) })); vi.mock("./models", () => ({ readModels: vi.fn(() => []) })); vi.mock("./secrets", () => ({ providerListSafe: vi.fn(() => ({})) })); +vi.mock("child_process", () => { + const spawn = vi.fn(); + return { spawn, ChildProcess: class {}, default: { spawn } }; +}); +import { spawn } from "child_process"; import { getModelConfig, readEnv } from "./config"; import { providerListSafe } from "./secrets"; -import { transcribeAudio } from "./hermes"; +import { sendMessage, stopHealthPolling, transcribeAudio } from "./hermes"; +import type { ChatCallbacks } from "./hermes"; const mockedGetModelConfig = vi.mocked(getModelConfig); const mockedReadEnv = vi.mocked(readEnv); const mockedProviderListSafe = vi.mocked(providerListSafe); +const mockedSpawn = vi.mocked(spawn); describe("transcribeAudio API-key resolution", () => { const fetchMock = vi.fn(); @@ -112,3 +119,66 @@ describe("transcribeAudio API-key resolution", () => { expect(sentAuthHeader()).toBe("Bearer from-vault"); }); }); + +describe("sendMessage session model override routing", () => { + const noopCallbacks: ChatCallbacks = { + onChunk: vi.fn(), + onDone: vi.fn(), + onError: vi.fn(), + }; + + function fakeChildProcess(): unknown { + return { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + kill: vi.fn(), + killed: false, + }; + } + + function cliArgs(): string[] { + expect(mockedSpawn).toHaveBeenCalledTimes(1); + return mockedSpawn.mock.calls[0][1] as string[]; + } + + beforeEach(() => { + mockedGetModelConfig.mockReset(); + mockedReadEnv.mockReset(); + mockedReadEnv.mockReturnValue({}); + mockedProviderListSafe.mockReset(); + mockedProviderListSafe.mockReturnValue({}); + mockedSpawn.mockReset(); + mockedSpawn.mockReturnValue(fakeChildProcess() as ReturnType); + // Persisted default: GPT-5.5 on the (sticky) OpenAI-Codex provider. + mockedGetModelConfig.mockReturnValue({ + provider: "openai-codex", + model: "gpt-5.5", + baseUrl: "https://chatgpt.com/backend-api/codex", + } as ReturnType); + }); + + afterEach(() => { + stopHealthPolling(); + }); + + // @lat: [[model-selection#Session model override#Cross-provider switch routes via CLI]] + it("routes a cross-provider override through the CLI with its provider + model", async () => { + await sendMessage( + "hello", + noopCallbacks, + "default", + undefined, + undefined, + undefined, + undefined, + { provider: "gemini", model: "gemini-2.5-pro", baseUrl: "" }, + ); + + const args = cliArgs(); + expect(args).toContain("-m"); + expect(args[args.indexOf("-m") + 1]).toBe("gemini-2.5-pro"); + expect(args).toContain("--provider"); + expect(args[args.indexOf("--provider") + 1]).toBe("gemini"); + }); +}); diff --git a/src/main/hermes.ts b/src/main/hermes.ts index 2569e4876..784adcfda 100644 --- a/src/main/hermes.ts +++ b/src/main/hermes.ts @@ -49,6 +49,7 @@ import { readModels } from "./models"; import { providerListSafe } from "./secrets"; import { HIDDEN_SUBPROCESS_OPTIONS } from "./process-options"; import { type Attachment, escapeXmlAttr } from "../shared/attachments"; +import { type SessionModelOverride } from "../shared/model-override"; import { URL_KEY_MAP, OPENAI_COMPAT_PROVIDERS } from "../shared/url-key-map"; import { chatToolEventFromPayload, @@ -1109,9 +1110,9 @@ function sendMessageViaApi( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, - modelOverride?: string, + override?: SessionModelOverride, ): ChatHandle { - const mc = getModelConfig(profile); + const mc = effectiveModelConfig(profile, override); const controller = new AbortController(); // Build full conversation from history + current message (standard OpenAI format). @@ -1139,7 +1140,7 @@ function sendMessageViaApi( const reasoningEffort = reasoningEffortForProfile(profile); const bodyObj: Record = { - model: modelOverride || mc.model || "hermes-agent", + model: mc.model || "hermes-agent", messages, stream: true, ...(_resumeSessionId ? { session_id: _resumeSessionId } : {}), @@ -1225,7 +1226,7 @@ function sendMessageViaApi( function probeRealError(): void { // When streaming returns empty, make a non-streaming request to surface the real error const probeBodyObj: Record = { - model: modelOverride || mc.model || "hermes-agent", + model: mc.model || "hermes-agent", messages: [{ role: "user", content: userContent }], stream: false, }; @@ -1514,9 +1515,9 @@ function sendMessageViaRuns( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, - modelOverride?: string, + override?: SessionModelOverride, ): ChatHandle { - const mc = getModelConfig(profile); + const mc = effectiveModelConfig(profile, override); const controller = new AbortController(); const apiUrl = getApiUrl(profile); const headersForAuth = getApiAuthHeaders(profile); @@ -1525,7 +1526,7 @@ function sendMessageViaRuns( (headersForAuth.Authorization ? `desk-${Date.now()}-${randomUUID()}` : ""); const ctxSystem = contextFolderSystemMessage(contextFolder); const bodyObj: Record = { - model: modelOverride || mc.model || "hermes-agent", + model: mc.model || "hermes-agent", input: message, conversation_history: apiHistory(history), }; @@ -1569,7 +1570,7 @@ function sendMessageViaRuns( history, attachments, contextFolder, - modelOverride, + override, ); } @@ -2074,13 +2075,37 @@ const CLI_COMPAT_PROVIDER_OVERRIDE: Record = { aimlapi: "custom", }; +type ModelConfig = ReturnType; + +/** + * Overlay a session-scoped model override on top of the persisted config.yaml + * model config. Non-empty override fields win; empty/absent fields fall back to + * the persisted value. The result drives request routing for a single turn + * without ever touching config.yaml (the global default is preserved — #688). + */ +function effectiveModelConfig( + profile: string | undefined, + override?: SessionModelOverride, +): ModelConfig { + const mc = getModelConfig(profile); + if (!override) return mc; + return { + provider: override.provider || mc.provider, + model: override.model || mc.model, + // baseUrl is intentionally taken verbatim from the override (including an + // empty string) so a switch to a built-in provider clears a stale custom + // URL; only fall back to the persisted value when the override omits it. + baseUrl: override.baseUrl !== undefined ? override.baseUrl : mc.baseUrl, + }; +} + function sendMessageViaCli( message: string, cb: ChatCallbacks, profile?: string, resumeSessionId?: string, attachments?: Attachment[], - modelOverride?: string, + override?: SessionModelOverride, ): ChatHandle { // CLI fallback can't pipe multimodal content; inline text-file attachments // and ignore images. The gateway is the supported attachment path; this @@ -2099,7 +2124,15 @@ function sendMessageViaCli( message = message.trim() ? `${message}\n\n${wrapped}` : wrapped; } } - const mc = getModelConfig(profile); + // Effective config = persisted config.yaml overlaid with the session + // override. Everything downstream (provider routing, base_url env, key + // resolution, apiMode lookup) reads from `mc`, so the override drives the + // whole CLI invocation without touching config.yaml. + const mc = effectiveModelConfig(profile, override); + const baseMc = getModelConfig(profile); + const overrideChangesRouting = + !!override && + (mc.provider !== baseMc.provider || mc.baseUrl !== baseMc.baseUrl); const profileEnv = readEnv(profile); const args = hermesCliArgs(); @@ -2112,13 +2145,18 @@ function sendMessageViaCli( args.push("--resume", resumeSessionId); } - if (modelOverride || mc.model) { - args.push("-m", modelOverride || mc.model); + if (mc.model) { + args.push("-m", mc.model); } const cliProvider = CLI_COMPAT_PROVIDER_OVERRIDE[mc.provider]; if (cliProvider) { args.push("--provider", cliProvider); + } else if (overrideChangesRouting && mc.provider && mc.provider !== "auto") { + // A session override that switches to a named provider (e.g. gemini) must + // select it explicitly — otherwise the CLI would infer the provider from + // the now-stale config/env and route to the wrong host. + args.push("--provider", mc.provider); } const env: Record = { @@ -2408,7 +2446,7 @@ async function sendMessageViaNonGatewayApi( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, - modelOverride?: string, + override?: SessionModelOverride, ): Promise { const approvalCommand = /^\/(?:approve|deny)\b/i.test(message.trim()); if (!attachments?.length && !approvalCommand) { @@ -2422,7 +2460,7 @@ async function sendMessageViaNonGatewayApi( history, attachments, contextFolder, - modelOverride, + override, ); } } @@ -2435,7 +2473,7 @@ async function sendMessageViaNonGatewayApi( history, attachments, contextFolder, - modelOverride, + override, ); } @@ -2447,18 +2485,18 @@ async function sendMessageViaBestApi( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, - modelOverride?: string, + override?: SessionModelOverride, ): Promise { const approvalCommand = /^\/(?:approve|deny)\b/i.test(message.trim()); // Skip the TUI gateway when a session-scoped model override is active — the // TUI gateway reads its model from config.yaml and has no per-request - // override mechanism. The API path below already honours modelOverride. + // override mechanism. The API path below already honours the override. if ( shouldUseTuiGatewayClient() && !isRemoteMode() && !attachments?.length && !approvalCommand && - !modelOverride + !override ) { try { return await sendMessageViaTuiGateway( @@ -2485,7 +2523,7 @@ async function sendMessageViaBestApi( history, attachments, contextFolder, - modelOverride, + override, ); } @@ -2497,7 +2535,7 @@ async function sendMessageViaBestApiWithLocalRecovery( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, - modelOverride?: string, + override?: SessionModelOverride, ): Promise { let aborted = false; let retrying = false; @@ -2542,7 +2580,7 @@ async function sendMessageViaBestApiWithLocalRecovery( history, attachments, contextFolder, - modelOverride, + override, ); return; } @@ -2553,7 +2591,7 @@ async function sendMessageViaBestApiWithLocalRecovery( profile, resumeSessionId, attachments, - modelOverride, + override, ); }; @@ -2631,7 +2669,7 @@ async function sendMessageViaBestApiWithLocalRecovery( history, attachments, contextFolder, - modelOverride, + override, ); return handle; @@ -2645,11 +2683,12 @@ export async function sendMessage( history?: Array<{ role: string; content: string }>, attachments?: Attachment[], contextFolder?: string, - modelOverride?: string, + override?: SessionModelOverride, ): Promise { ensureInitialized(); - // Remote mode: always use API, no CLI fallback + // Remote mode: always use API, no CLI fallback. Cross-provider session + // overrides are limited to the model string here (no CLI transport remotely). if (isRemoteMode()) { return sendMessageViaBestApi( message, @@ -2659,19 +2698,27 @@ export async function sendMessage( history, attachments, contextFolder, - modelOverride, + override, ); } const mc = getModelConfig(profile); - if (CLI_COMPAT_PROVIDER_OVERRIDE[mc.provider]) { + const eff = effectiveModelConfig(profile, override); + // The gateway/API transport resolves the provider server-side from + // config.yaml, so a session override that changes the provider or base URL + // can only be honoured by the CLI transport (which is parameterized per call + // via `--provider`, base_url + key env). Same-provider model swaps stay on + // the gateway/API path below (the `model` string is enough). + const overrideChangesRouting = + !!override && (eff.provider !== mc.provider || eff.baseUrl !== mc.baseUrl); + if (CLI_COMPAT_PROVIDER_OVERRIDE[eff.provider] || overrideChangesRouting) { return sendMessageViaCli( message, cb, profile, resumeSessionId, attachments, - modelOverride, + override, ); } @@ -2695,7 +2742,7 @@ export async function sendMessage( history, attachments, contextFolder, - modelOverride, + override, ); } @@ -2706,7 +2753,7 @@ export async function sendMessage( profile, resumeSessionId, attachments, - modelOverride, + override, ); } diff --git a/src/main/index.ts b/src/main/index.ts index 49a076e75..1f3d05cd6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,6 +16,7 @@ import { electronApp, optimizer, is } from "@electron-toolkit/utils"; import type { AppUpdater } from "electron-updater"; import icon from "../../resources/icon.png?asset"; import type { Attachment } from "../shared/attachments"; +import type { SessionModelOverride } from "../shared/model-override"; import { stageAttachment, clearStagedAttachments } from "./attachment-staging"; import { persistPromptImageAttachments } from "./session-attachment-store"; import { @@ -1252,7 +1253,7 @@ function setupIPC(): void { attachments?: Attachment[], contextFolder?: string, runId?: string, - modelOverride?: string, + modelOverride?: SessionModelOverride, ) => { // Each conversation has a stable runId minted by the renderer. Fall back // to a generated id for legacy callers so the run is still tracked. diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index e48be45e6..04978b1f2 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,5 +1,6 @@ import type { AppLocale } from "../shared/i18n/types"; import type { Attachment } from "../shared/attachments"; +import type { SessionModelOverride } from "../shared/model-override"; import type { DesktopSessionContinuationItem } from "../shared/session-continuation"; import type { DesktopSessionLocalError } from "../shared/session-continuation"; import type { @@ -368,7 +369,7 @@ interface HermesAPI { attachments?: Attachment[], contextFolder?: string, runId?: string, - modelOverride?: string, + modelOverride?: SessionModelOverride, ) => Promise<{ response: string; sessionId?: string }>; abortChat: (runId?: string) => Promise; transcribeAudio: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index 716d3dac6..ee2449ee4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer, webUtils } from "electron"; import type { AppLocale } from "../shared/i18n/types"; import type { Attachment } from "../shared/attachments"; +import type { SessionModelOverride } from "../shared/model-override"; import type { DesktopSessionContinuationItem } from "../shared/session-continuation"; import type { DesktopSessionLocalError } from "../shared/session-continuation"; import type { @@ -368,7 +369,7 @@ const hermesAPI = { attachments?: Attachment[], contextFolder?: string, runId?: string, - modelOverride?: string, + modelOverride?: SessionModelOverride, ): Promise<{ response: string; sessionId?: string }> => ipcRenderer.invoke( "send-message", diff --git a/src/renderer/src/screens/Chat/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx index e1fe36e69..95850a875 100644 --- a/src/renderer/src/screens/Chat/Chat.tsx +++ b/src/renderer/src/screens/Chat/Chat.tsx @@ -12,7 +12,10 @@ import { WebPreviewPanel } from "./WebPreviewPanel"; import { useChatScroll } from "./hooks/useChatScroll"; import { useChatIPC } from "./hooks/useChatIPC"; import { useChatActions, parseBackgroundCommand } from "./hooks/useChatActions"; -import { useModelConfig } from "./hooks/useModelConfig"; +import { + useModelConfig, + effectiveOverrideBaseUrl, +} from "./hooks/useModelConfig"; import { useFastMode } from "./hooks/useFastMode"; import { useReasoningEffort } from "./hooks/useReasoningEffort"; import { useLocalCommands } from "./hooks/useLocalCommands"; @@ -25,6 +28,7 @@ import { buildChatTranscript } from "./transcriptUtils"; import { ConfigHealthBanner } from "../../components/ConfigHealthBanner"; import FollowUsModal from "../../components/FollowUsModal"; import type { Attachment } from "../../../../shared/attachments"; +import type { SessionModelOverride } from "../../../../shared/model-override"; import type { ActiveTurn, ChatMessage, UsageState } from "./types"; import type { ContextUsage } from "./ContextGauge"; import { contextWindowForModel } from "./contextWindows"; @@ -128,7 +132,7 @@ function Chat({ // TUI gateway bypass in sendMessageViaBestApi is not triggered for normal // chats where the user never changed the model (issue #688). const [sessionModelOverride, setSessionModelOverride] = useState< - string | undefined + SessionModelOverride | undefined >(undefined); const dragCounter = useRef(0); const chatInputRef = useRef(null); @@ -399,6 +403,9 @@ function Chat({ setMessages([]); setHermesSessionId(null); setContextFolder(null); + // Clearing the conversation reverts to the global default model — the + // session-scoped pick belongs to the conversation being cleared (#688). + setSessionModelOverride(undefined); activeTurnRef.current = null; setUsage(null); setToolProgress(null); @@ -778,7 +785,18 @@ function Chat({ void modelConfig.selectModel(provider, model, baseUrl, { persist: false, }); - setSessionModelOverride(model || undefined); + // Carry the full identity (not just the model name) so a + // cross-provider switch reaches the right backend. Mirror the + // baseUrl rule selectModel applies so they can't drift. + setSessionModelOverride( + model + ? { + provider, + model, + baseUrl: effectiveOverrideBaseUrl(provider, baseUrl), + } + : undefined, + ); }} /> => { - // Named providers (deepseek, groq, anthropic, …) have a hardcoded - // canonical base_url in `hermes-agent`'s PROVIDER_REGISTRY. A stored - // model entry that carries a stale `baseUrl` from an earlier confused - // save (e.g. a deepseek-tagged entry whose baseUrl points at the codex - // endpoint) would route the request to the wrong host. Drop the - // baseUrl whenever the entry isn't `custom`; the gateway falls back - // to the provider's canonical URL. - const effectiveBaseUrl = - provider === "custom" || provider === OLLAMA_CLOUD_PROVIDER - ? baseUrl - : ""; + const effectiveBaseUrl = effectiveOverrideBaseUrl(provider, baseUrl); setCurrentModel(model); setCurrentProvider(provider); setCurrentBaseUrl(effectiveBaseUrl); diff --git a/src/shared/model-override.ts b/src/shared/model-override.ts new file mode 100644 index 000000000..2f4cdb749 --- /dev/null +++ b/src/shared/model-override.ts @@ -0,0 +1,18 @@ +/** + * A session-scoped model selection made from the in-chat (bottom) model + * picker. Unlike the persisted `config.yaml` default, this override applies to + * a single conversation only and is threaded through the send pipeline on every + * message (renderer → preload IPC → main `sendMessage`). + * + * It carries the *full* model identity — not just the model name — because a + * cross-provider switch (e.g. an OpenAI-Codex default → Gemini) must change the + * provider and base URL that the request routes through, not only the `model` + * string. The gateway/API transport resolves the provider server-side from + * `config.yaml`, so an override that changes `provider`/`baseUrl` is routed + * through the CLI transport, which can be parameterized per call. + */ +export interface SessionModelOverride { + provider: string; + model: string; + baseUrl: string; +} From 8ff68e2514d6c3710096088f5e268a3cfa82df6e Mon Sep 17 00:00:00 2001 From: Fathah KA <48355244+fathah@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:18:17 +0530 Subject: [PATCH 2/5] Sidebar Adjustment --- lat.md/lat.md | 1 + lat.md/sidebar-navigation.md | 17 ++++++ src/renderer/src/assets/main.css | 38 ++++++++++++ src/renderer/src/screens/Layout/Layout.tsx | 61 ++++++++++++++----- .../screens/Layout/SidebarRecentSessions.tsx | 26 +++++++- src/shared/i18n/locales/en/navigation.ts | 1 + 6 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 lat.md/sidebar-navigation.md diff --git a/lat.md/lat.md b/lat.md/lat.md index ce4ef729c..3e200c7f3 100644 --- a/lat.md/lat.md +++ b/lat.md/lat.md @@ -6,3 +6,4 @@ This directory defines the high-level concepts, business logic, and architecture - [[web-preview]] — the in-app split-screen webview and the `partition`-based gate that lets only it load remote HTTPS while staying sandboxed. - [[code-blocks]] — collapsible long code blocks, and why expansion state is keyed on source position to survive react-markdown's streaming remounts. - [[window-chrome]] — the browser-style title bar where open-conversation tabs sit on top of the window drag region, clickable while empty space still drags. +- [[sidebar-navigation]] — the recent-sessions list under the Chat nav item, capped at five with a "Show more" button that opens the full session list in a modal. diff --git a/lat.md/sidebar-navigation.md b/lat.md/sidebar-navigation.md new file mode 100644 index 000000000..a04dfceb3 --- /dev/null +++ b/lat.md/sidebar-navigation.md @@ -0,0 +1,17 @@ +# Sidebar recent sessions + +The sidebar has no standalone "Sessions" nav item — the recent-chats list lives directly under the **Chat** nav item (ChatGPT-style), and the full session list opens in a modal via "Show more". + +[[src/renderer/src/screens/Layout/Layout.tsx#Layout]] special-cases the `chat` entry of `NAV_ITEMS` to render the Chat button, a collapse chevron (state persisted under `hermes.sidebar.sessionsExpanded`), and [[src/renderer/src/screens/Layout/SidebarRecentSessions.tsx]] beneath it. There is no `sessions` view in the `View` union. + +## Inline list and "Show more" + +The inline list shows at most `RECENT_SESSIONS_LIMIT` (5) most-recent sessions; a "Show more" button appears only when the profile has more than that. + +[[src/renderer/src/screens/Layout/SidebarRecentSessions.tsx]] fetches one row over the limit (from the `sessions.json` cache, then a `state.db` sync) so a single query decides whether to render the button — it slices to 5 for display and sets `hasMore` from the raw length. Clicking it calls `onShowMore`, which opens the full-list modal. + +## Full-list modal + +"Show more" (and the Cmd/Ctrl+K menu action) open an 80%×80% modal that reuses the existing Sessions screen rather than a separate route. + +The modal in [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] renders [[src/renderer/src/screens/Sessions/Sessions.tsx]] inside a `.sessions-modal` over the shared `.models-modal-overlay` backdrop. Resuming a session or starting a new chat from the modal closes it; Esc and a backdrop click also close it. Because the Sessions screen owns its own fetching gated on `visible`, it loads only while the modal is open. diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 58f3784ca..59d2462c3 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1528,6 +1528,44 @@ body { white-space: nowrap; } +/* "Show more" affordance under the recent list — opens the full-list modal. */ +.sidebar-recent-sessions-more { + display: flex; + align-items: center; + /* Align with the session titles (dot 7 + gap 9). */ + padding: 6px 10px; + margin-top: 1px; + border: none; + background: none; + border-radius: var(--radius-md); + cursor: pointer; + text-align: left; + font-family: var(--font-sans); + font-size: 12px; + font-weight: 500; + color: var(--text-muted); + transition: all var(--transition); +} + +.sidebar-recent-sessions-more:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +/* Full-list sessions modal (sidebar "Show more" / Cmd+K). Reuses + .models-modal-overlay for the backdrop; sized to 80% of the viewport. */ +.sessions-modal { + background: var(--bg-secondary); + border: 1px solid var(--border-bright); + border-radius: var(--radius-lg); + width: 80vw; + height: 80vh; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4); + overflow: hidden; + display: flex; + flex-direction: column; +} + /* ── Profile avatar (shared: nav, active bar, manage page) ── */ .profile-avatar { flex-shrink: 0; diff --git a/src/renderer/src/screens/Layout/Layout.tsx b/src/renderer/src/screens/Layout/Layout.tsx index 3c453f31c..99c824e36 100644 --- a/src/renderer/src/screens/Layout/Layout.tsx +++ b/src/renderer/src/screens/Layout/Layout.tsx @@ -32,7 +32,6 @@ import VerifyWarningBanner from "../../components/VerifyWarningBanner"; import hermeslogo from "../../assets/hermes-one.svg"; import { ChatBubble, - Clock, Compass, Settings as SettingsIcon, Brain, @@ -54,7 +53,6 @@ import { useI18n } from "../../components/useI18n"; type View = | "chat" - | "sessions" | "discover" | "agents" | "office" @@ -70,7 +68,6 @@ type View = const NAV_ITEMS: { view: View; icon: LucideIcon; labelKey: string }[] = [ { view: "chat", icon: ChatBubble, labelKey: "navigation.chat" }, - { view: "sessions", icon: Clock, labelKey: "navigation.sessions" }, { view: "discover", icon: Compass, labelKey: "navigation.discover" }, // "agents" (Profiles) is reached from the sidebar-footer ProfileSwitcher's // "Manage profiles" action rather than a top-level nav item. @@ -176,8 +173,8 @@ function Layout({ return false; } }); - // Sessions nav section expanded → shows the last few chats inline - // (ChatGPT-style). Defaults to expanded; persisted across launches. + // Recent-sessions list under the Chat nav item expanded → shows the last few + // chats inline (ChatGPT-style). Defaults to expanded; persisted across launches. const [sessionsExpanded, setSessionsExpanded] = useState(() => { try { return localStorage.getItem(SESSIONS_EXPANDED_KEY) !== "false"; @@ -185,6 +182,10 @@ function Layout({ return true; } }); + // Full-list sessions modal (opened from the sidebar "Show more" affordance or + // the Cmd/Ctrl+K menu action). Reuses the Sessions screen inside a modal — + // there is no longer a top-level Sessions view. + const [sessionsModalOpen, setSessionsModalOpen] = useState(false); // Tabs lazy-mount on first visit, then stay mounted (display:none toggle). // Keeps IPC refetch / DOM rebuild off the tab-switch hot path. const [visitedViews, setVisitedViews] = useState>( @@ -326,13 +327,23 @@ function Layout({ handleNewChat(); }); const cleanupSearch = window.hermesAPI.onMenuSearchSessions(() => { - goTo("sessions"); + setSessionsModalOpen(true); }); return () => { cleanupNewChat(); cleanupSearch(); }; - }, [handleNewChat, goTo]); + }, [handleNewChat]); + + // Esc closes the full-list sessions modal. + useEffect(() => { + if (!sessionsModalOpen) return; + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key === "Escape") setSessionsModalOpen(false); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [sessionsModalOpen]); // A run with no session, not loading and no title hasn't been used yet — a // blank "scratch" chat we can re-home to another agent without spawning a tab. @@ -507,7 +518,10 @@ function Layout({