diff --git a/.agents/skills/lat-md/SKILL.md b/.agents/skills/lat-md/SKILL.md new file mode 100644 index 000000000..42083851f --- /dev/null +++ b/.agents/skills/lat-md/SKILL.md @@ -0,0 +1,169 @@ +--- +name: lat-md +description: >- + Writing and maintaining lat.md documentation files — structured markdown that + describes a project's architecture, design decisions, and test specs. Use when + creating, editing, or reviewing files in the lat.md/ directory. +--- + +# lat.md Authoring Guide + +This skill covers the syntax, structure rules, and conventions for writing `lat.md/` files. Load it whenever you need to create or edit sections in the `lat.md/` directory. + +## What belongs in lat.md + +`lat.md/` files describe **what** the project does and **why** — domain concepts, key design decisions, business logic, and test specifications. They do NOT duplicate source code. Think of each section as an anchor that source code references back to. + +Good candidates for sections: +- Architecture decisions and their rationale +- Domain concepts and business rules +- API contracts and protocols +- Test specifications (what is tested and why) +- Non-obvious constraints or invariants + +Bad candidates: +- Step-by-step code walkthroughs (the code itself is the walkthrough) +- Auto-generated API docs (use tools for that) +- Temporary notes or TODOs + +## Section structure + +Every section **must** have a leading paragraph — at least one sentence immediately after the heading, before any child headings or other block content. + +The first paragraph must be ≤250 characters (excluding `[[wiki link]]` content). This paragraph is the section's identity — it appears in search results, command output, and RAG context. + +```markdown +# Good Section + +Brief overview of what this section documents and why it matters. + +More detail can go in subsequent paragraphs, code blocks, or lists. + +## Child heading + +Details about this child topic. +``` + +```markdown +# Bad Section + +## Child heading + +This is invalid — "Bad Section" has no leading paragraph. +``` + +`lat check` enforces this rule. + +## Section IDs + +Sections are addressed by file path and heading chain: + +- **Full form**: `lat.md/path/to/file#Heading#SubHeading` +- **Short form**: `file#Heading#SubHeading` (when the file stem is unique) + +Examples: `lat.md/tests/search#RAG Replay Tests`, `cli#init`, `parser#Wiki Links`. + +## Wiki links + +Cross-reference other sections or source code with `[[target]]` or `[[target|alias]]`. + +### Section links + +```markdown +See [[cli#init]] for setup details. +The parser validates [[parser#Wiki Links|wiki link syntax]]. +``` + +### Source code links + +Reference functions, classes, constants, and methods in source files: + +```markdown +[[src/config.ts#getConfigDir]] — function +[[src/server.ts#App#listen]] — class method +[[lib/utils.py#parse_args]] — Python function +[[src/lib.rs#Greeter#greet]] — Rust impl method +[[src/app.go#Greeter#Greet]] — Go method +[[src/app.h#Greeter]] — C struct +``` + +`lat check` validates that all targets exist. + +## Code refs + +Tie source code back to `lat.md/` sections with `@lat:` comments: + +```typescript +// @lat: [[cli#init]] +export function init() { ... } +``` + +```python +# @lat: [[cli#init]] +def init(): + ... +``` + +Supported comment styles: `//` (JS/TS/Rust/Go/C) and `#` (Python). + +Place one `@lat:` comment per section, at the relevant code — not at the top of the file. + +## Test specs + +Describe tests as sections in `lat.md/` files. Add frontmatter to require that every leaf section has a matching `@lat:` comment in test code: + +```markdown +--- +lat: + require-code-mention: true +--- +# Tests + +Authentication test specifications. + +## User login + +Verify credential validation and error handling. + +### Rejects expired tokens + +Tokens past their expiry timestamp are rejected with 401, even if otherwise valid. + +### Handles missing password + +Login request without a password field returns 400 with a descriptive error. +``` + +Each test references its spec: + +```python +# @lat: [[tests#User login#Rejects expired tokens]] +def test_rejects_expired_tokens(): + ... +``` + +Rules: +- Every leaf section under `require-code-mention: true` must be referenced by exactly one `@lat:` comment +- Every section MUST have a description — at least one sentence explaining what the test verifies and why +- `lat check` flags unreferenced specs and dangling code refs + +## Frontmatter + +Optional YAML frontmatter at the top of `lat.md/` files: + +```yaml +--- +lat: + require-code-mention: true +--- +``` + +Currently the only supported field is `require-code-mention` for test spec enforcement. + +## Validation + +Always run `lat check` after editing `lat.md/` files. It validates: +- All wiki links point to existing sections or source code symbols +- All `@lat:` code refs point to existing sections +- Every section has a leading paragraph (≤250 chars) +- All `require-code-mention` leaf sections are referenced in code diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 000000000..36f1e5735 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,24 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "lat hook claude UserPromptSubmit" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "lat hook claude Stop" + } + ] + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..c54415023 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,105 @@ +# Before starting work + +- Run `lat search` to find sections relevant to your task. Read them to understand the design intent before writing code. +- Run `lat expand` on user prompts to expand any `[[refs]]` — this resolves section names to file locations and provides context. + +# Post-task checklist (REQUIRED — do not skip) + +After EVERY task, before responding to the user: + +- [ ] Update `lat.md/` if you added or changed any functionality, architecture, tests, or behavior +- [ ] Run `lat check` — all wiki links and code refs must pass +- [ ] Do not skip these steps. Do not consider your task done until both are complete. + +--- + +# What is lat.md? + +This project uses [lat.md](https://www.npmjs.com/package/lat.md) to maintain a structured knowledge graph of its architecture, design decisions, and test specs in the `lat.md/` directory. It is a set of cross-linked markdown files that describe **what** this project does and **why** — the domain concepts, key design decisions, business logic, and test specifications. Use it to ground your work in the actual architecture rather than guessing. + +# Commands + +```bash +lat locate "Section Name" # find a section by name (exact, fuzzy) +lat refs "file#Section" # find what references a section +lat search "natural language" # semantic search across all sections +lat expand "user prompt text" # expand [[refs]] to resolved locations +lat check # validate all links and code refs +``` + +Run `lat --help` when in doubt about available commands or options. + +If `lat search` fails because no API key is configured, explain to the user that semantic search requires a key provided via `LAT_LLM_KEY` (direct value), `LAT_LLM_KEY_FILE` (path to key file), or `LAT_LLM_KEY_HELPER` (command that prints the key). Supported key prefixes: `sk-...` (OpenAI) or `vck_...` (Vercel). If the user doesn't want to set it up, use `lat locate` for direct lookups instead. + +# Syntax primer + +- **Section ids**: `lat.md/path/to/file#Heading#SubHeading` — full form uses project-root-relative path (e.g. `lat.md/tests/search#RAG Replay Tests`). Short form uses bare file name when unique (e.g. `search#RAG Replay Tests`, `cli#search#Indexing`). +- **Wiki links**: `[[target]]` or `[[target|alias]]` — cross-references between sections. Can also reference source code: `[[src/foo.ts#myFunction]]`. +- **Source code links**: Wiki links in `lat.md/` files can reference functions, classes, constants, and methods in TypeScript/JavaScript/Python/Rust/Go/C files. Use the full path: `[[src/config.ts#getConfigDir]]`, `[[src/server.ts#App#listen]]` (class method), `[[lib/utils.py#parse_args]]`, `[[src/lib.rs#Greeter#greet]]` (Rust impl method), `[[src/app.go#Greeter#Greet]]` (Go method), `[[src/app.h#Greeter]]` (C struct). `lat check` validates these exist. +- **Code refs**: `// @lat: [[section-id]]` (JS/TS/Rust/Go/C) or `# @lat: [[section-id]]` (Python) — ties source code to concepts + +# Test specs + +Key tests can be described as sections in `lat.md/` files (e.g. `tests.md`). Add frontmatter to require that every leaf section is referenced by a `// @lat:` or `# @lat:` comment in test code: + +```markdown +--- +lat: + require-code-mention: true +--- +# Tests + +Authentication and authorization test specifications. + +## User login + +Verify credential validation and error handling for the login endpoint. + +### Rejects expired tokens +Tokens past their expiry timestamp are rejected with 401, even if otherwise valid. + +### Handles missing password +Login request without a password field returns 400 with a descriptive error. +``` + +Every section MUST have a description — at least one sentence explaining what the test verifies and why. Empty sections with just a heading are not acceptable. (This is a specific case of the general leading paragraph rule below.) + +Each test in code should reference its spec with exactly one comment placed next to the relevant test — not at the top of the file: + +```python +# @lat: [[tests#User login#Rejects expired tokens]] +def test_rejects_expired_tokens(): + ... + +# @lat: [[tests#User login#Handles missing password]] +def test_handles_missing_password(): + ... +``` + +Do not duplicate refs. One `@lat:` comment per spec section, placed at the test that covers it. `lat check` will flag any spec section not covered by a code reference, and any code reference pointing to a nonexistent section. + +# Section structure + +Every section in `lat.md/` **must** have a leading paragraph — at least one sentence immediately after the heading, before any child headings or other block content. The first paragraph must be ≤250 characters (excluding `[[wiki link]]` content). This paragraph serves as the section's overview and is used in search results, command output, and RAG context — keeping it concise guarantees the section's essence is always captured. + +```markdown +# Good Section + +Brief overview of what this section documents and why it matters. + +More detail can go in subsequent paragraphs, code blocks, or lists. + +## Child heading + +Details about this child topic. +``` + +```markdown +# Bad Section + +## Child heading + +Details about this child topic. +``` + +The second example is invalid because `Bad Section` has no leading paragraph. `lat check` validates this rule and reports errors for missing or overly long leading paragraphs. diff --git a/lat.md/context-folder.md b/lat.md/context-folder.md new file mode 100644 index 000000000..21f93c74d --- /dev/null +++ b/lat.md/context-folder.md @@ -0,0 +1,15 @@ +# Linked working folder + +A conversation can be bound to a working folder (issue #27) — a desktop-only binding that scopes the agent's work. It is sent to the agent per message as a system message, and persisted per session so re-opening a conversation restores its folder. + +## Desktop-only persistence + +The folder isn't part of hermes-agent's session schema, so it lives in a desktop-owned table in the active profile's `state.db`, keyed by `session_id`. + +[[src/main/session-context-folder-store.ts]] holds `desktop_session_context_folders` (mirroring [[src/main/session-continuation-store.ts]]): [[src/main/session-context-folder-store.ts#setSessionContextFolder]] upserts or, for a null folder, deletes the row; [[src/main/session-context-folder-store.ts#getSessionContextFolder]] reads it. The row is dropped with the rest of a session's data in [[src/main/sessions.ts#deleteSessionRows]] so a deleted session leaves no orphan binding. + +## Restore and save in the chat + +The chat loads the stored folder when resuming a session and saves it whenever it changes, once the conversation has a gateway session id. + +In [[src/renderer/src/screens/Chat/Chat.tsx#Chat]] a load effect fetches the folder for `initialSessionId` on mount; a save effect writes `contextFolder` via `setSessionContextFolder` on every change. The save is gated on a "loaded" ref so the initial null can't overwrite a resumed session's stored folder before the load resolves. A brand-new chat saves once its session id resolves after the first message, binding the pre-selected folder to the new session. diff --git a/lat.md/lat.md b/lat.md/lat.md index 241632106..5e004af13 100644 --- a/lat.md/lat.md +++ b/lat.md/lat.md @@ -2,6 +2,9 @@ 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. +- [[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. +- [[context-folder]] — the per-session linked working folder, persisted in a desktop-owned state.db table so a re-opened conversation restores its folder. diff --git a/lat.md/model-selection.md b/lat.md/model-selection.md new file mode 100644 index 000000000..8a08d8867 --- /dev/null +++ b/lat.md/model-selection.md @@ -0,0 +1,29 @@ +# 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]]), persisted by session id, 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`. + +## Desktop-only persistence + +The selected model/provider is saved in a desktop-owned table keyed by session id, without storing API keys. + +[[src/main/session-model-override-store.ts]] holds `desktop_session_model_overrides` with `provider`, `model`, and `base_url` only. [[src/renderer/src/screens/Chat/Chat.tsx#Chat]] restores the saved value for a resumed session, applies it to the local picker with `persist:false`, and saves later changes once a gateway session id exists. Deleting a session removes the row through [[src/main/sessions.ts#deleteSessionRows]]. + +## Text-only legacy fallback routes via CLI + +Text-only legacy turns can use the CLI fallback when a session override changes provider or base URL away from `config.yaml`. + +The upstream desktop model applies the session switch on the active gateway session with `/model --provider `, then attaches media and submits on that same session. Hermes Desktop's dashboard transport follows that path; [[src/main/hermes.ts#shouldForceCliForSessionOverride]] keeps the CLI escape hatch only for text-only legacy fallback, where it can pass `-m ` and `--provider` without dropping attachments. Same-provider model swaps stay on the gateway/API path, where the new `model` string is sufficient. Remote (SSH) mode has no local CLI transport, so it remains limited to the model string. + +## Attachment turns stay on session transport + +Attachment turns must not be forced through the CLI override fallback because the CLI path cannot carry multimodal input. + +[[src/main/hermes.ts#sendMessageViaCli]] can inline text-file attachments but ignores images, while the gateway/API path preserves image parts and path refs through [[src/main/hermes.ts#buildUserContent]]. When a session override is active and the user sends attachments, [[src/main/hermes.ts#shouldForceCliForSessionOverride]] leaves the turn eligible for the dashboard/gateway or API transport instead of silently dropping media. diff --git a/lat.md/sidebar-navigation.md b/lat.md/sidebar-navigation.md new file mode 100644 index 000000000..238847986 --- /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 plus-icon "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. Loading rows use a rotating lucide Loader icon; clicking "Show more" 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/main/hermes.test.ts b/src/main/hermes.test.ts index e56e5534e..2a2de367f 100644 --- a/src/main/hermes.test.ts +++ b/src/main/hermes.test.ts @@ -43,14 +43,26 @@ 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, + shouldForceCliForSessionOverride, + 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 +124,98 @@ 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#Text-only legacy fallback 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"); + }); + + // @lat: [[model-selection#Session model override#Attachment turns stay on session transport]] + it("keeps attachment turns off the CLI override fallback", () => { + const persisted = { + provider: "openai-codex", + model: "gpt-5.5", + baseUrl: "https://chatgpt.com/backend-api/codex", + } as ReturnType; + const effective = { + provider: "gemini", + model: "gemini-2.5-pro", + baseUrl: "", + } as ReturnType; + + expect( + shouldForceCliForSessionOverride( + persisted, + effective, + { provider: "gemini", model: "gemini-2.5-pro", baseUrl: "" }, + [ + { + id: "img-1", + kind: "image", + name: "cat.png", + mime: "image/png", + size: 12, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + ), + ).toBe(false); + }); +}); diff --git a/src/main/hermes.ts b/src/main/hermes.ts index 2569e4876..18824aece 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,65 @@ 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 hasAttachments(attachments?: Attachment[]): boolean { + return (attachments?.length ?? 0) > 0; +} + +/** + * Legacy CLI is only a safe session-override escape hatch for text-only turns. + * Upstream desktop applies `/model --provider ` on the active + * gateway session, then attaches media and submits through that same session. + * If we force an attachment turn through the CLI, images/path refs are silently + * dropped by `sendMessageViaCli`, so leave attachment turns on the gateway/API + * path whenever it is available. + */ +export function shouldForceCliForSessionOverride( + persisted: ModelConfig, + effective: ModelConfig, + override: SessionModelOverride | undefined, + attachments?: Attachment[], +): boolean { + if (hasAttachments(attachments)) return false; + const overrideChangesRouting = + !!override && + (effective.provider !== persisted.provider || + effective.baseUrl !== persisted.baseUrl); + return ( + !!CLI_COMPAT_PROVIDER_OVERRIDE[effective.provider] || overrideChangesRouting + ); +} + 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 +2152,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 +2173,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 +2474,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 +2488,7 @@ async function sendMessageViaNonGatewayApi( history, attachments, contextFolder, - modelOverride, + override, ); } } @@ -2435,7 +2501,7 @@ async function sendMessageViaNonGatewayApi( history, attachments, contextFolder, - modelOverride, + override, ); } @@ -2447,18 +2513,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 +2551,7 @@ async function sendMessageViaBestApi( history, attachments, contextFolder, - modelOverride, + override, ); } @@ -2497,7 +2563,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 +2608,7 @@ async function sendMessageViaBestApiWithLocalRecovery( history, attachments, contextFolder, - modelOverride, + override, ); return; } @@ -2553,7 +2619,7 @@ async function sendMessageViaBestApiWithLocalRecovery( profile, resumeSessionId, attachments, - modelOverride, + override, ); }; @@ -2631,7 +2697,7 @@ async function sendMessageViaBestApiWithLocalRecovery( history, attachments, contextFolder, - modelOverride, + override, ); return handle; @@ -2645,11 +2711,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 +2726,24 @@ 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); + // Official upstream desktop hot-swaps the active gateway session with + // `/model ... --provider ...` before attaching media and submitting. Our + // renderer dashboard transport follows that path. The legacy CLI fallback is + // kept only for text-only turns; it cannot preserve image/path attachments. + if (shouldForceCliForSessionOverride(mc, eff, override, attachments)) { return sendMessageViaCli( message, cb, profile, resumeSessionId, attachments, - modelOverride, + override, ); } @@ -2695,7 +2767,7 @@ export async function sendMessage( history, attachments, contextFolder, - modelOverride, + override, ); } @@ -2706,7 +2778,7 @@ export async function sendMessage( profile, resumeSessionId, attachments, - modelOverride, + override, ); } diff --git a/src/main/index.ts b/src/main/index.ts index 49a076e75..b4b308101 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 { @@ -26,6 +27,14 @@ import { persistSessionContinuation, persistSessionLocalError, } from "./session-continuation-store"; +import { + getSessionContextFolder, + setSessionContextFolder, +} from "./session-context-folder-store"; +import { + getSessionModelOverride, + setSessionModelOverride, +} from "./session-model-override-store"; import type { DesktopSessionContinuationItem, DesktopSessionLocalError, @@ -1252,7 +1261,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. @@ -1797,6 +1806,34 @@ function setupIPC(): void { }, ); + // Per-session linked working folder (issue #27): a desktop-only binding + // persisted in the local state.db so a re-opened session restores its folder. + ipcMain.handle("get-session-context-folder", (_event, sessionId: string) => { + return getSessionContextFolder(sessionId); + }); + + ipcMain.handle( + "set-session-context-folder", + (_event, sessionId: string, folder: string | null) => { + setSessionContextFolder(sessionId, folder); + return true; + }, + ); + + // Per-session model/provider selected from the in-chat picker. This is a + // desktop-only routing binding and intentionally stores no API keys. + ipcMain.handle("get-session-model-override", (_event, sessionId: string) => { + return getSessionModelOverride(sessionId); + }); + + ipcMain.handle( + "set-session-model-override", + (_event, sessionId: string, override: SessionModelOverride | null) => { + setSessionModelOverride(sessionId, override); + return true; + }, + ); + ipcMain.handle("delete-session", (_event, sessionId: string) => { const conn = getConnectionConfig(); if (conn.mode === "remote") return remoteDeleteSession(conn, sessionId); diff --git a/src/main/session-context-folder-store.ts b/src/main/session-context-folder-store.ts new file mode 100644 index 000000000..ffe60ff81 --- /dev/null +++ b/src/main/session-context-folder-store.ts @@ -0,0 +1,82 @@ +import type Database from "better-sqlite3"; +import { getDbConnection } from "./db"; + +/** + * Desktop-owned, per-session store for the working folder the user links to a + * conversation (issue #27). The folder is a desktop-only UI binding — the agent + * receives it per message as a context-folder system message — so it isn't part + * of hermes-agent's session schema. Persisting it here lets a re-opened session + * restore its linked folder instead of losing it when the app restarts. + * + * Mirrors the [[src/main/session-continuation-store.ts]] pattern: a desktop + * table in the active profile's state.db, keyed by `session_id`. + */ +const TABLE = "desktop_session_context_folders"; + +function ensureTable(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS ${TABLE} ( + session_id TEXT PRIMARY KEY, + folder_path TEXT NOT NULL, + updated_at REAL NOT NULL DEFAULT (strftime('%s', 'now')) + ); + `); +} + +function tableExists(db: Database.Database): boolean { + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?") + .get(TABLE) as { name: string } | undefined; + return !!row; +} + +/** + * Persist (or clear) the folder linked to a session. A null/empty folder + * removes the row so an unlinked session doesn't restore a stale path. + */ +export function setSessionContextFolder( + sessionId: string, + folder: string | null, +): void { + if (!sessionId) return; + const db = getDbConnection(false); + if (!db) return; + ensureTable(db); + + if (!folder) { + db.prepare(`DELETE FROM ${TABLE} WHERE session_id = ?`).run(sessionId); + return; + } + + db.prepare( + `INSERT INTO ${TABLE} (session_id, folder_path, updated_at) + VALUES (?, ?, strftime('%s', 'now')) + ON CONFLICT(session_id) DO UPDATE SET + folder_path = excluded.folder_path, + updated_at = excluded.updated_at`, + ).run(sessionId, folder); +} + +/** Read the folder linked to a session, or null when none is stored. */ +export function getSessionContextFolder(sessionId: string): string | null { + if (!sessionId) return null; + const db = getDbConnection(true); + if (!db || !tableExists(db)) return null; + const row = db + .prepare(`SELECT folder_path FROM ${TABLE} WHERE session_id = ?`) + .get(sessionId) as { folder_path: string } | undefined; + return row?.folder_path || null; +} + +/** + * Drop a session's linked-folder row. Called from `deleteSessionRows` so it + * runs inside the same delete transaction as the other per-session cleanup. + */ +export function deleteSessionContextFolderForSession( + db: Database.Database, + sessionId: string, +): void { + if (tableExists(db)) { + db.prepare(`DELETE FROM ${TABLE} WHERE session_id = ?`).run(sessionId); + } +} diff --git a/src/main/session-model-override-store.test.ts b/src/main/session-model-override-store.test.ts new file mode 100644 index 000000000..db6d19814 --- /dev/null +++ b/src/main/session-model-override-store.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getDbConnection } from "./db"; +import { + deleteSessionModelOverrideForSession, + getSessionModelOverride, + setSessionModelOverride, +} from "./session-model-override-store"; + +vi.mock("./db", () => ({ + getDbConnection: vi.fn(), +})); + +const mockedGetDbConnection = vi.mocked(getDbConnection); + +class FakeStatement { + constructor( + private readonly sql: string, + private readonly db: FakeDb, + ) {} + + get(sessionId?: string): unknown { + if (this.sql.includes("sqlite_master")) { + return this.db.tableCreated + ? { name: "desktop_session_model_overrides" } + : undefined; + } + if (this.sql.includes("SELECT provider, model, base_url")) { + return sessionId ? this.db.rows.get(sessionId) : undefined; + } + return undefined; + } + + run(...args: string[]): void { + if (this.sql.startsWith("DELETE")) { + this.db.rows.delete(args[0]); + return; + } + if (this.sql.startsWith("INSERT")) { + const [sessionId, provider, model, baseUrl] = args; + this.db.rows.set(sessionId, { + provider, + model, + base_url: baseUrl, + }); + } + } + + all(): unknown[] { + if (this.sql.startsWith("PRAGMA table_info")) { + return ["session_id", "provider", "model", "base_url", "updated_at"].map( + (name) => ({ name }), + ); + } + return []; + } +} + +class FakeDb { + readonly rows = new Map< + string, + { provider: string; model: string; base_url: string } + >(); + tableCreated = false; + + exec(): void { + this.tableCreated = true; + } + + prepare(sql: string): FakeStatement { + return new FakeStatement(sql.trim(), this); + } + + close(): void { + this.rows.clear(); + } +} + +describe("session model override store", () => { + let db: FakeDb; + + beforeEach(() => { + db = new FakeDb(); + mockedGetDbConnection.mockReset(); + mockedGetDbConnection.mockReturnValue(db as never); + }); + + afterEach(() => { + db.close(); + }); + + it("stores and reads provider/model routing identity without credentials", () => { + setSessionModelOverride("s1", { + provider: "gemini", + model: "gemini-2.5-pro", + baseUrl: "", + }); + + expect(getSessionModelOverride("s1")).toEqual({ + provider: "gemini", + model: "gemini-2.5-pro", + baseUrl: "", + }); + const columns = db + .prepare("PRAGMA table_info(desktop_session_model_overrides)") + .all() as Array<{ name: string }>; + expect(columns.map((column) => column.name)).not.toContain("api_key"); + }); + + it("clears and deletes saved overrides", () => { + setSessionModelOverride("s1", { + provider: "custom", + model: "local-model", + baseUrl: "http://localhost:11434/v1", + }); + setSessionModelOverride("s1", null); + expect(getSessionModelOverride("s1")).toBeNull(); + + setSessionModelOverride("s2", { + provider: "groq", + model: "llama-3.3", + baseUrl: "", + }); + deleteSessionModelOverrideForSession(db as never, "s2"); + expect(getSessionModelOverride("s2")).toBeNull(); + }); +}); diff --git a/src/main/session-model-override-store.ts b/src/main/session-model-override-store.ts new file mode 100644 index 000000000..40066103c --- /dev/null +++ b/src/main/session-model-override-store.ts @@ -0,0 +1,84 @@ +import type Database from "better-sqlite3"; +import type { SessionModelOverride } from "../shared/model-override"; +import { getDbConnection } from "./db"; + +/** + * Desktop-owned, per-session store for the model/provider chosen from the + * in-chat model picker. Only routing identity is stored; API keys remain in the + * profile/global credential stores. + */ +const TABLE = "desktop_session_model_overrides"; + +function ensureTable(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS ${TABLE} ( + session_id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + model TEXT NOT NULL, + base_url TEXT NOT NULL DEFAULT '', + updated_at REAL NOT NULL DEFAULT (strftime('%s', 'now')) + ); + `); +} + +function tableExists(db: Database.Database): boolean { + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?") + .get(TABLE) as { name: string } | undefined; + return !!row; +} + +export function setSessionModelOverride( + sessionId: string, + override: SessionModelOverride | null, +): void { + if (!sessionId) return; + const db = getDbConnection(false); + if (!db) return; + ensureTable(db); + + if (!override?.model) { + db.prepare(`DELETE FROM ${TABLE} WHERE session_id = ?`).run(sessionId); + return; + } + + db.prepare( + `INSERT INTO ${TABLE} (session_id, provider, model, base_url, updated_at) + VALUES (?, ?, ?, ?, strftime('%s', 'now')) + ON CONFLICT(session_id) DO UPDATE SET + provider = excluded.provider, + model = excluded.model, + base_url = excluded.base_url, + updated_at = excluded.updated_at`, + ).run(sessionId, override.provider, override.model, override.baseUrl || ""); +} + +export function getSessionModelOverride( + sessionId: string, +): SessionModelOverride | null { + if (!sessionId) return null; + const db = getDbConnection(true); + if (!db || !tableExists(db)) return null; + const row = db + .prepare( + `SELECT provider, model, base_url FROM ${TABLE} WHERE session_id = ?`, + ) + .get(sessionId) as + | { provider: string; model: string; base_url: string } + | undefined; + if (!row?.provider || !row.model) return null; + return { + provider: row.provider, + model: row.model, + baseUrl: row.base_url || "", + }; +} + +export function deleteSessionModelOverrideForSession( + db: Database.Database, + sessionId: string, +): void { + if (tableExists(db)) { + db.prepare(`DELETE FROM ${TABLE} WHERE session_id = ?`).run(sessionId); + } +} diff --git a/src/main/sessions.ts b/src/main/sessions.ts index d75994395..66fc73637 100644 --- a/src/main/sessions.ts +++ b/src/main/sessions.ts @@ -17,6 +17,8 @@ import { loadSessionLocalErrors, mergeSessionLocalErrors, } from "./session-continuation-store"; +import { deleteSessionContextFolderForSession } from "./session-context-folder-store"; +import { deleteSessionModelOverrideForSession } from "./session-model-override-store"; // Sentinel prefix used by hermes-agent's hermes_state.py to mark // JSON-encoded multimodal content in the messages.content column. @@ -727,6 +729,8 @@ function normalizeSessionIds(sessionIds: string[]): string[] { function deleteSessionRows(db: Database.Database, sessionId: string): number { deletePromptImageAttachmentsForSession(db, sessionId); deleteSessionContinuationForSession(db, sessionId); + deleteSessionContextFolderForSession(db, sessionId); + deleteSessionModelOverrideForSession(db, sessionId); db.prepare("DELETE FROM messages WHERE session_id = ?").run(sessionId); const result = db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId); return result.changes; diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index e48be45e6..fe0c5b2af 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: ( @@ -562,6 +563,18 @@ interface HermesAPI { sessionId: string, error: DesktopSessionLocalError, ) => Promise; + getSessionContextFolder: (sessionId: string) => Promise; + setSessionContextFolder: ( + sessionId: string, + folder: string | null, + ) => Promise; + getSessionModelOverride: ( + sessionId: string, + ) => Promise; + setSessionModelOverride: ( + sessionId: string, + override: SessionModelOverride | null, + ) => Promise; // Profiles listProfiles: () => Promise< diff --git a/src/preload/index.ts b/src/preload/index.ts index 716d3dac6..c6a6b9f61 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", @@ -718,6 +719,26 @@ const hermesAPI = { ): Promise => ipcRenderer.invoke("record-session-local-error", sessionId, error), + getSessionContextFolder: (sessionId: string): Promise => + ipcRenderer.invoke("get-session-context-folder", sessionId), + + setSessionContextFolder: ( + sessionId: string, + folder: string | null, + ): Promise => + ipcRenderer.invoke("set-session-context-folder", sessionId, folder), + + getSessionModelOverride: ( + sessionId: string, + ): Promise => + ipcRenderer.invoke("get-session-model-override", sessionId), + + setSessionModelOverride: ( + sessionId: string, + override: SessionModelOverride | null, + ): Promise => + ipcRenderer.invoke("set-session-model-override", sessionId, override), + // Profiles listProfiles: (): Promise< Array<{ diff --git a/src/renderer/src/assets/icons/index.tsx b/src/renderer/src/assets/icons/index.tsx index 4ef853503..e0b481903 100644 --- a/src/renderer/src/assets/icons/index.tsx +++ b/src/renderer/src/assets/icons/index.tsx @@ -16,6 +16,7 @@ export { ExternalLink, KeyRound, Layers, + Loader, Monitor, Moon, PanelLeftClose, diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 58f3784ca..5844b3d16 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1418,10 +1418,15 @@ body { gap: 2px; } +/* The Chat item and its chevron read as one container: the row owns the + hover/active background while the two buttons inside stay transparent and + remain separate click targets (Chat navigates, chevron toggles the list). */ .sidebar-nav-row { display: flex; align-items: center; gap: 2px; + border-radius: var(--radius-md); + transition: background var(--transition); } .sidebar-nav-row .sidebar-nav-item { @@ -1429,6 +1434,24 @@ body { min-width: 0; } +/* Inner buttons keep their text-color states but defer the background to the + row, so the highlight spans the whole container rather than just one half. */ +.sidebar-nav-row .sidebar-nav-item, +.sidebar-nav-row .sidebar-nav-item:hover, +.sidebar-nav-row .sidebar-nav-item.active { + background: none; +} + +.sidebar-nav-row:hover { + background: var(--bg-tertiary); +} + +/* Active wins over hover (higher specificity via :has), so hovering the active + row keeps the accent fill rather than flipping to the neutral hover tint. */ +.sidebar-nav-row:has(.sidebar-nav-item.active) { + background: var(--accent-subtle); +} + .sidebar-nav-chevron { display: flex; align-items: center; @@ -1442,14 +1465,33 @@ body { border-radius: var(--radius-md); color: var(--text-muted); cursor: pointer; - transition: all var(--transition); + transition: color var(--transition); } -.sidebar-nav-chevron:hover { - background: var(--bg-tertiary); +.sidebar-nav-row .sidebar-nav-chevron:hover { + /* Background comes from the row; just brighten the icon on hover. */ + background: none; color: var(--text-primary); } +/* Collapsed sidebar shows only the icon (no chevron), so revert to the + per-item background — a full-width row fill would extend past the 40px icon. */ +.sidebar-collapsed .sidebar-nav-row:hover { + background: none; +} + +.sidebar-collapsed .sidebar-nav-row:has(.sidebar-nav-item.active) { + background: none; +} + +.sidebar-collapsed .sidebar-nav-row .sidebar-nav-item:hover { + background: var(--bg-tertiary); +} + +.sidebar-collapsed .sidebar-nav-row .sidebar-nav-item.active { + background: var(--accent-subtle); +} + .sidebar-recent-sessions-wrap { display: grid; grid-template-rows: 0fr; @@ -1518,7 +1560,8 @@ body { } .sidebar-recent-session.active { - background: var(--accent-subtle); + /* Active state is shown by the bullet + text color only — no filled + background (keeps the list visually quiet). */ color: var(--accent-text); } @@ -1528,6 +1571,53 @@ 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; + gap: 9px; + 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-icon { + flex-shrink: 0; + color: var(--text-muted); +} + +.sidebar-recent-sessions-more:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.sidebar-recent-sessions-more:hover .sidebar-recent-sessions-more-icon { + 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/Chat/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx index e1fe36e69..9cd6ba0ea 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"; @@ -114,9 +118,46 @@ function Chat({ "auto" | "dashboard" | "legacy" >("auto"); const [connectionModeLoaded, setConnectionModeLoaded] = useState(false); - // Working folder bound to this conversation (issue #27). Per-conversation, - // held in memory; reset on session switch / new chat below. + // Working folder bound to this conversation (issue #27). Per-conversation; + // persisted per session so a re-opened conversation restores its folder, and + // reset on new chat below. const [contextFolder, setContextFolder] = useState(null); + // Gate folder persistence until the stored value for a resumed session has + // been loaded — otherwise the initial null would overwrite the saved folder + // before the load resolves. A brand-new chat (no initialSessionId) has + // nothing to load, so it starts unblocked. + const contextFolderLoadedRef = useRef(!initialSessionId); + + // Restore the folder linked to a resumed session (once, on mount). + useEffect(() => { + if (!initialSessionId) return; + let cancelled = false; + void (async () => { + try { + const folder = + await window.hermesAPI.getSessionContextFolder(initialSessionId); + if (!cancelled && folder) setContextFolder(folder); + } catch { + /* best-effort — a missing folder just leaves the session unlinked */ + } finally { + if (!cancelled) contextFolderLoadedRef.current = true; + } + })(); + return () => { + cancelled = true; + }; + }, [initialSessionId]); + + // Persist the linked folder for this session whenever it changes, once a + // gateway session id exists. Gated on the load above so a resumed session's + // stored folder is never clobbered by the initial null. + useEffect(() => { + if (!hermesSessionId || !contextFolderLoadedRef.current) return; + void window.hermesAPI.setSessionContextFolder( + hermesSessionId, + contextFolder, + ); + }, [hermesSessionId, contextFolder]); // Whether the worktree panel is visible (only applies when contextFolder is set) // Default false so the panel doesn't open automatically and interfere with scrolling const [worktreeVisible, setWorktreeVisible] = useState(false); @@ -128,8 +169,9 @@ 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 sessionModelOverrideLoadedRef = useRef(!initialSessionId); const dragCounter = useRef(0); const chatInputRef = useRef(null); const queueRef = useRef([]); @@ -189,6 +231,57 @@ function Chat({ const { containerRef, bottomRef } = useChatScroll(messages); const modelConfig = useModelConfig(profile); + const chatCurrentModel = + sessionModelOverride?.model ?? modelConfig.currentModel; + const chatCurrentProvider = + sessionModelOverride?.provider ?? modelConfig.currentProvider; + const chatCurrentBaseUrl = + sessionModelOverride?.baseUrl ?? modelConfig.currentBaseUrl; + const chatDisplayModel = sessionModelOverride?.model + ? sessionModelOverride.model.split("/").pop() || sessionModelOverride.model + : modelConfig.displayModel; + + // Restore the model/provider linked to a resumed session. The saved value is + // applied only to this chat's local picker state (`persist:false`) so it never + // rewrites the global config.yaml default. + useEffect(() => { + if (!initialSessionId) return; + let cancelled = false; + void (async () => { + try { + const override = + await window.hermesAPI.getSessionModelOverride(initialSessionId); + if (!cancelled && override) { + setSessionModelOverride(override); + await modelConfig.selectModel( + override.provider, + override.model, + override.baseUrl, + { persist: false }, + ); + } + } catch { + /* best-effort — sessions without a saved pick use the global default */ + } finally { + if (!cancelled) sessionModelOverrideLoadedRef.current = true; + } + })(); + return () => { + cancelled = true; + }; + }, [initialSessionId, modelConfig.selectModel]); + + // Persist the chat-local model/provider once a session exists. This stores + // only routing identity, never API keys, and is gated so a resumed session's + // initial undefined state cannot erase its saved model before restore. + useEffect(() => { + if (!hermesSessionId || !sessionModelOverrideLoadedRef.current) return; + void window.hermesAPI.setSessionModelOverride( + hermesSessionId, + sessionModelOverride ?? null, + ); + }, [hermesSessionId, sessionModelOverride]); + const { fastMode, toggle: toggleFastMode, @@ -222,12 +315,7 @@ function Chat({ return (): void => { cancelled = true; }; - }, [ - profile, - modelConfig.currentModel, - modelConfig.currentProvider, - modelConfig.currentBaseUrl, - ]); + }, [profile, chatCurrentModel, chatCurrentProvider, chatCurrentBaseUrl]); // Authoritative context-window size for the active model, resolved from the // provider's /models catalogue (issue #597). Null until/unless the provider @@ -238,12 +326,12 @@ function Chat({ useEffect(() => { let cancelled = false; setRealContextWindow(null); - if (!modelConfig.currentModel) return; + if (!chatCurrentModel) return; window.hermesAPI .getModelContextWindow( - modelConfig.currentProvider, - modelConfig.currentModel, - modelConfig.currentBaseUrl, + chatCurrentProvider, + chatCurrentModel, + chatCurrentBaseUrl, profile, ) .then((w) => { @@ -257,12 +345,7 @@ function Chat({ return (): void => { cancelled = true; }; - }, [ - profile, - modelConfig.currentModel, - modelConfig.currentProvider, - modelConfig.currentBaseUrl, - ]); + }, [profile, chatCurrentModel, chatCurrentProvider, chatCurrentBaseUrl]); const visibleSessionScopeId = messages.length === 0 ? null : hermesSessionId; @@ -399,12 +482,16 @@ 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); + void modelConfig.reload(); activeTurnRef.current = null; setUsage(null); setToolProgress(null); queueRef.current = []; setQueuedMessages([]); - }, [isLoading, runId, hermesSessionId, setMessages]); + }, [isLoading, runId, hermesSessionId, setMessages, modelConfig.reload]); const localCommands = useLocalCommands({ profile, @@ -434,10 +521,10 @@ function Chat({ fallbackOnUnavailable: chatTransportPreference === "auto", hermesSessionId, messages, - model: modelConfig.currentModel, - modelBaseUrl: modelConfig.currentBaseUrl, + model: chatCurrentModel, + modelBaseUrl: chatCurrentBaseUrl, profile, - provider: modelConfig.currentProvider, + provider: chatCurrentProvider, setHermesSessionId, setIsLoading, setMessages, @@ -612,8 +699,7 @@ function Chat({ const contextUsage: ContextUsage | null = usage?.contextTokens ? { used: usage.contextTokens, - window: - realContextWindow ?? contextWindowForModel(modelConfig.currentModel), + window: realContextWindow ?? contextWindowForModel(chatCurrentModel), cacheReadTokens: usage.cacheReadTokens, cacheWriteTokens: usage.cacheWriteTokens, } @@ -768,17 +854,28 @@ function Chat({ toolbarExtras={ <> { 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/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({