diff --git a/.agents/plans/PLANS.md b/.agents/plans/PLANS.md index 98380a6..7bec3d5 100644 --- a/.agents/plans/PLANS.md +++ b/.agents/plans/PLANS.md @@ -19,3 +19,5 @@ Persistent plans for multi-step work on **blxcode**. Individual plans live as Ma | done | [coordinated-subagents.md](coordinated-subagents.md) | Coordinated Subagents fuer BLXCode Agent mit Rollen, i18n Live-Subcards, Provider-Reuse, Environment Detection, Shell/Git/Web Toolsets und scoped Toolgruppen | | done | [better-harness.md](better-harness.md) | BetterHarness: Shrink system prompt by extracting tool docs into 6 embedded Core Skills; Skills tab gets Core/User sub-tabs | | done | [agent-chat-maximize.md](agent-chat-maximize.md) | Agent-Tab: Chat-Maximize-Toggle vor Reset; Voice-Hero kompakt (`agent-hero--compact`), mehr Platz fuer Chat-Verlauf | +| planned | [workspace-color-terminal-badge.md](workspace-color-terminal-badge.md) | Workspace-Farbe persistent in Sidebar (Dot, farbiges Unread-Badge), Terminal-Slot-Zahl vor Name, aenderbar via Kontextmenue-Dialog | +| done | [api-keys-centralized.md](api-keys-centralized.md) | API-Schlüssel zentral unter Settings → API Keys; Backend-Katalog + resolve; Agent-Tab ohne Key-Felder; Coming-soon-Provider mit CRUD | diff --git a/.agents/plans/api-keys-centralized.md b/.agents/plans/api-keys-centralized.md new file mode 100644 index 0000000..6a7bfa6 --- /dev/null +++ b/.agents/plans/api-keys-centralized.md @@ -0,0 +1,57 @@ +# API Keys zentralisieren + +## Summary + +Zentrale API-Schlüssel unter **Settings → API Keys**. UI folgt **App**- und **Workspace**-Harness-Stil; **ein** Speichern-Button für alle Keys. Backend: Katalog, Batch-`api_keys_apply`, zentraler `api_keys::resolve` als einzige Lookup-Quelle für Agent, Subagents, Image, Voice, Web und Model-Refresh. + +## Decisions + +### UI +- Design-Vorlage: `harness-pane`, `harness-subpane`, `harness-stack`, `workbench-plain-input`, Footer wie Workspace (`workbench-mini-btn--primary` + `LuSave` + `BtnSave`). +- **Kein Save pro Zeile**; optional „Key entfernen" pro Zeile → Draft, Ausführung mit globalem Save. +- **Draft-UX**: Discard-Button neben Save; Verlassen-Warnung bei Dirty-State (Pane-Wechsel / Tab-Schließen). + +### Backend +- Batch-IPC: `api_keys_apply` (setzt/löscht Keys in einem Aufruf). +- Zentrale Resolve-Funktion: `api_keys::resolve(provider) -> Option` (intern genutzt) plus `provider_key_pub` für IPC-Konsumenten. +- **Env-Precedence**: Store gewinnt. Env (z. B. `BLX_ANTHROPIC_API_KEY`) ist nur Fallback, wenn Store leer ist. UI zeigt „via env" als Hinweis, wenn Fallback aktiv. +- **Migration (one-shot, beim Start)**: Bestehende Pro-Provider-Eintr. aus dem alten `agent_api_key_set`-Store werden in den zentralen Katalog übernommen; alter Store wird danach geleert. Idempotent — mehrfacher Start überschreibt nichts. +- **Alte IPCs entfernen (selber PR)**: `agent_api_key_set`, `agent_api_key_delete` + Bridge-Wrapper raus. Kein Deprecate-Shim. + +### Image / Voice / Web +- Key-Felder vollständig aus `image_settings`, Voice-Settings und Web-Settings entfernen (UI **und** Backend-Struct). +- Runtime liest ausschließlich über `api_keys::resolve` mit den **reused** Provider-IDs (OpenAI / OpenRouter / …). +- Fehlermeldungen verweisen auf **Settings → API Keys**, nicht mehr auf „Image-Einstellungen" o. ä. + +### Cursor-Plan (inline) +> Folgende Abschnitte aus dem ursprünglichen Cursor-Plan müssen hier eingetragen werden, damit dieser Plan eigenständig review-/umsetzbar ist: +- **Keyring-Strategie**: OS-Keyring (Linux: secret-service, macOS: Keychain, Windows: Credential Manager) vs. Plaintext-Datei — Fallback-Policy, Speicherort, Verschlüsselung. _(TODO: Inhalte aus Cursor-Plan einfügen)_ +- **Pfade**: Konkreter Pfad des zentralen Katalogs (z. B. `~/.config/blxcode/api_keys.json` oder Keyring-Eintragsname). _(TODO)_ +- **Env-Vars**: Vollständige Liste der respektierten Env-Vars pro Provider. _(TODO)_ +- **Coming-soon-Provider**: Welche Provider erscheinen als deaktivierte Zeilen im UI? _(TODO)_ + +## Runtime (Review) + +**Heute**: Agent / Subagent / Image / Voice / Web haben jeweils eigenen Key-Pfad. Image-Fehler zeigt irreführend „Image-Einstellungen". Subagent macht separaten Lookup. + +**Ziel**: Agent, Subagents (gleicher Turn-Key), Image / Voice (Reuse OpenAI/OpenRouter-IDs), Web, Model-Refresh → **alle** über `api_keys::resolve` / `provider_key_pub`. Subagents ohne separaten Lookup. Image-Fehler auf API Keys umgestellt. + +## Tasks (in Ausführungsreihenfolge) + +1. [x] **`api-keys-backend`** — Katalog-Struct, Storage (Keyring/Datei), `api_keys_apply`, `api_keys::resolve`, One-shot-Migration aus altem `agent_api_key_set`-Store, Env-Fallback-Logik. +2. [x] **`api-keys-bridge`** — `tauri_bridge.rs`: `api_keys_status` / `api_keys_apply`; alte per-provider key commands entfernt. +3. [x] **`settings-scaffold`** — Docked center settings tab + sidebar categories (`harness_ui.rs` / `SettingsDock`). +4. [x] **`api-keys-ui`** — `api_keys_pane/`: Save/Discard footer, draft state, per-row remove, „via env" hint, brand icons. +5. [x] **`runtime-wiring`** — Agent/Subagent/Image/Voice/Web/Model-Refresh über zentralen resolve; Image/Voice ohne eigene Key-Felder. +6. [x] **`agent-pane-trim`** — BLXCode Agent: nur Status-Hinweis → API Keys (Text/Image/Voice). +7. [x] **`i18n-docs`** — Locales + user/developer docs + CHANGELOG (PR #13 branch). + +## Acceptance Criteria + +- [x] Subagent läuft mit zentral gesetztem Key (kein separater Lookup-Pfad). +- [x] Image-Fehlermeldung verweist auf **Settings → API Keys** (nicht mehr „Image-Einstellungen"). +- [x] Agent-Pane enthält kein Key-Eingabefeld mehr. +- [x] Migration-Smoke: Vorhandene Pro-Provider-Keys (alter Store) sind nach erstem Start im zentralen Katalog lesbar; alter Store geleert. +- [x] `agent_api_key_set` / `agent_api_key_delete` (Backend-Command + Bridge-Wrapper) sind aus dem Repo entfernt. +- [x] Env-Fallback: Bei leerem Store-Eintrag wird `BLX_*` env gelesen; UI zeigt „via env". +- [x] Discard-Button verwirft Draft; Dirty-State im Footer sichtbar. diff --git a/.agents/plans/workspace-color-terminal-badge.md b/.agents/plans/workspace-color-terminal-badge.md new file mode 100644 index 0000000..6809e19 --- /dev/null +++ b/.agents/plans/workspace-color-terminal-badge.md @@ -0,0 +1,76 @@ +# Workspace-Farbe und Terminal-Badge in der Sidebar + +## Summary + +Workspaces erhalten ein persistentes `color`-Feld (analog Memory-Kategorien), änderbar über den bestehenden Kontextmenü-Dialog. In der Sidebar erscheinen ein Farbpunkt links, eine Terminal-Slot-Zahl vor dem Namen (nur bei mehr als einem Slot) und ein farblich passendes Unread-Badge rechts. Persistenz über `workbench.json`; keine Backend-Änderungen. + +## Decisions + +- **Terminal-Zahl:** zählt Terminal-Slots (`slot_ids.len()`), nicht Split-Panes oder laufende PTY-Sessions. +- **Anzeige Terminal-Badge:** nur wenn `slot_ids.len() > 1` (bei einem Slot ausblenden). +- **Default-Farbe:** `stable_category_color(&storage_key)` für neue und backgefüllte Workspaces. +- **Farb-Presets:** bestehende `memory_color_presets()` wiederverwenden (kein neuer localStorage-Key). +- **Kontextmenü:** Rename-Dialog wird zu „Workspace bearbeiten" mit Name + Farbe (wie `MemoryCategoryEditDialog`). +- **Terminal-Badge-Farbe:** fest orange `#e8954a` (Mockup); Unread-Badge rechts nutzt Workspace-Farbe. + +## Implementation Notes + +### Datenmodell — [`src/workbench/state.rs`](../../src/workbench/state.rs) + +- `WorkspaceEntry.color: String` mit `#[serde(default)]` (leer = noch nicht gesetzt). +- `workspace_effective_color(entry)` — gespeicherte Farbe oder `stable_category_color(&entry.storage_key)`. +- Backfill leerer `color`-Felder beim Laden (`backfill_workspace_colors()` oder in `backfill_storage_keys()`). +- `create_workspace`: initiale Farbe setzen. +- Setter: `set_workspace_display(id, title, color)` oder `rename_workspace` + `set_workspace_color` erweitern. +- `normalize_memory_color` aus [`memory_panel.rs`](../../src/workbench/memory_panel.rs) nach shared Modul (`state.rs` oder `color_util.rs`) verschieben. + +### Kontextmenü & Dialog — [`src/workbench/sidebar.rs`](../../src/workbench/sidebar.rs) + +- Rechtsklick-Menü: Eintrag „Bearbeiten" öffnet Dialog mit Name, ``, Hex-Feld, Swatches aus `wb.memory_color_presets()`. +- Speichern schreibt Titel + normalisierte Farbe → debounced Auto-Save in [`mod.rs`](../../src/workbench/mod.rs). + +### Sidebar-Rendering — [`src/workbench/sidebar.rs`](../../src/workbench/sidebar.rs) + +Zeilenlayout (expanded): + +```mermaid +flowchart LR + dot[Farbpunkt] --> termBadge[Terminal-Zahl] --> name[Name] --> unreadBadge[Unread] +``` + +- Farbpunkt: `.workbench-sidebar__color-dot` mit `--workspace-color`. +- Terminal-Badge: `.workbench-sidebar__terminal-count` vor dem Namen. +- `▸`-Bullet entfernen oder durch Farbpunkt ersetzen. +- Unread-Badge: inline-style mit Workspace-Farbe statt festem Orange. +- Collapsed-Modus: Farbe am Icon-Rand oder als Hintergrund des Initialen-Kästchens. + +### CSS — [`styles.css`](../../styles.css) + +Neue/angepasste Klassen: `__color-dot`, `__terminal-count`, dynamisches `__badge--total`, optional `__row--active` mit `--workspace-color` für `border-left-color`. + +### i18n — [`src/i18n/keys.rs`](../../src/i18n/keys.rs) + alle `locales/*.rs` + +Neue Keys: `SbEditMenu`, `SbEditTitle`, `SbEditSubmit`, `SbColorLabel`, `SbTerminalCountAria`. + +## Tests + +- Neuer Workspace: Farbpunkt sichtbar, persistiert nach Neustart. +- Bestehende Workspaces ohne `color` in JSON: Backfill weist stabile Farbe zu. +- Kontextmenü → Bearbeiten → Farbe ändern → Neustart → Farbe bleibt. +- Terminal-Slots hinzufügen/entfernen: Zahl vor Name aktualisiert sich reaktiv. +- Unread-Badge rechts nutzt Workspace-Farbe. +- Collapsed-Sidebar: Farbe weiterhin erkennbar. + +```bash +cargo check -p blxcode-ui --target wasm32-unknown-unknown +cargo test --workspace +``` + +## Tasks + +- [ ] `model-color` - WorkspaceEntry.color, workspace_effective_color, Backfill und Setter in state.rs +- [ ] `shared-color-util` - normalize_hex_color aus memory_panel extrahieren und gemeinsam nutzen +- [ ] `edit-dialog` - Rename-Dialog in sidebar.rs zu Edit-Dialog mit Color-Picker und Presets erweitern +- [ ] `sidebar-ui` - Farbpunkt, Terminal-Slot-Badge und farbiges Unread-Badge rendern +- [ ] `css` - Neue Sidebar-Klassen in styles.css; Active-State mit Workspace-Farbe +- [ ] `i18n` - Neue I18nKeys in keys.rs und allen locales/*.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8afccd..8d82756 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,6 @@ # Build release bundles on macOS and Windows (no Linux CI). # macOS .app/.dmg require a macOS runner (Intel + Apple Silicon via universal target). +# Windows builds use the self-hosted runner (label: blxcode-win). name: Release permissions: @@ -15,6 +16,14 @@ on: tags: ["v*"] workflow_dispatch: inputs: + mode: + description: "testbuild = build only (workflow artifacts); release = draft GitHub release + upload" + type: choice + required: true + default: testbuild + options: + - testbuild + - release platforms: description: Build targets type: choice @@ -88,19 +97,49 @@ jobs: } core.setFailed("Unauthorized release actor"); - build: + resolve: needs: authorize + runs-on: ubuntu-latest + outputs: + mode: ${{ steps.mode.outputs.mode }} + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - id: mode + run: | + if [ "${{ github.event_name }}" = "push" ]; then + echo "mode=release" >> "$GITHUB_OUTPUT" + else + echo "mode=${{ inputs.mode }}" >> "$GITHUB_OUTPUT" + fi + + - id: matrix + run: | + platforms="${{ inputs.platforms }}" + if [ "${{ github.event_name }}" = "push" ]; then + platforms="Alle" + fi + + include="[" + sep="" + if [ "$platforms" = "Alle" ] || [ "$platforms" = "Mac Universal" ]; then + include+="${sep}{\"platform\":\"macos-latest\",\"platform_id\":\"mac\",\"args\":\"--target universal-apple-darwin\"}" + sep="," + fi + if [ "$platforms" = "Alle" ] || [ "$platforms" = "Windows" ]; then + include+="${sep}{\"platform\":[\"self-hosted\",\"Windows\",\"blxcode-win\"],\"platform_id\":\"windows\",\"args\":\"\"}" + fi + include+="]" + + echo "matrix={\"include\":${include}}" >> "$GITHUB_OUTPUT" + + build: + needs: [authorize, resolve] permissions: contents: write strategy: fail-fast: false max-parallel: 1 - matrix: - include: - - platform: macos-latest - args: --target universal-apple-darwin - - platform: windows-latest - args: "" + matrix: ${{ fromJson(needs.resolve.outputs.matrix) }} runs-on: ${{ matrix.platform }} steps: @@ -118,8 +157,11 @@ jobs: cache: npm cache-dependency-path: frontend-js/package-lock.json + - name: Install frontend dependencies + run: npm ci --prefix frontend-js + - name: Add macOS Rust targets - if: matrix.platform == 'macos-latest' + if: matrix.platform_id == 'mac' run: | rustup target add aarch64-apple-darwin x86_64-apple-darwin echo "macOS universal build: Apple Silicon (aarch64) + Intel (x86_64)" @@ -129,7 +171,28 @@ jobs: cargo install trunk --locked cargo install tauri-cli --version "^2" --locked - - name: Build bundles + - name: Build bundles (testbuild) + if: needs.resolve.outputs.mode == 'testbuild' + working-directory: src-tauri + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + CI: true + CSC_IDENTITY_AUTO_DISCOVERY: false + APPLE_SIGNING_IDENTITY: "-" + run: cargo tauri build ${{ matrix.args }} + + - name: Upload test build artifacts + if: needs.resolve.outputs.mode == 'testbuild' + uses: actions/upload-artifact@v4 + with: + name: blxcode-${{ matrix.platform_id }}-${{ github.run_id }} + path: target/**/release/bundle/**/* + retention-days: 7 + if-no-files-found: error + + - name: Build bundles (release) + if: needs.resolve.outputs.mode == 'release' uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -149,6 +212,7 @@ jobs: args: ${{ matrix.args || '' }} - name: Merge updater latest.json + if: needs.resolve.outputs.mode == 'release' shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f489b07..0ca5903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Center multi-view tabs**: the workspace pane now hosts a VS Code-style tab strip above the terminal grid. The pinned **Terminals** tab is non-closeable and always renders the existing PTY layout; additional tabs are opened dynamically and closed via the strip. Per-workspace state (`center_tabs`, `center_active_tab_id`, `center_next_tab_id`) is persisted in the workspace snapshot with tolerant serde defaults so older snapshots load cleanly and self-heal to include the Terminals tab. Switching tabs hides (rather than unmounts) the terminal grid so xterm sessions and PTYs are never recreated. Active-tab tracking is wired into `is_workspace_active` so terminal focus/resize observers only fire when the Terminals tab is visible. +- **File preview tab**: clicking a file row in the sidebar Project Explorer opens (or reuses) a center tab that renders the file's text contents. New Tauri command `read_workspace_text_file` (`src-tauri/src/fs_entries.rs`) reads UTF-8 text under the workspace root with the same `canonical_root` / `resolve_under_root` sandbox the existing `list_path_entries` uses, hard-caps at 512 KiB (`MAX_TEXT_PREVIEW_BYTES`) and returns `{ content, truncated, byteLen }`. Non-text payloads surface `file is not valid UTF-8 text`, directories `not a file`, and out-of-root paths the existing `outside workspace` error. The new `tauri_bridge::read_workspace_text_file` wrapper plus `TextFilePreview` mirror the payload shape. 4 unit tests pin the happy path, traversal rejection, directory rejection, and missing-file rejection. +- **Docked settings tab**: the harness settings UI moved out of the modal `SettingsChrome` overlay and into a `SettingsDock` component rendered inside a dynamic center tab. Opening settings (command palette `Open Settings`, etc.) now calls `WorkbenchService::open_center_settings_tab` which reuses any existing settings tab for the active workspace instead of stacking duplicates. The legacy scrim + focus-trap helpers (`focus_first_settings_control`, `settings_focusables`, `trap_settings_tab`) and `HarnessUiService::open_settings` are gone; `HarnessSettingsCategory` is now `Serialize/Deserialize` so it can ride along with the snapshot. The chat header, right panel and other categories continue to interact with the existing `settings_category` signal. - **GitHub Releases auto-updater**: BLXCode now wires Tauri v2's updater/process plugins with a signed GitHub Releases `latest.json` endpoint, desktop IPC for `app_version`, `updater_check`, `updater_install_start`, progress polling, and relaunch, plus a Leptos startup banner and themed update dialog with release notes, progress, speed, retry, and restart states. Settings -> App gains a persisted startup auto-check toggle and manual update check. Release automation now supports the hybrid flow: macOS/Windows artifacts are built in GitHub Actions, Linux artifacts can be built locally to save CI cost, `.deb`/`.rpm` stay manual download assets, and signed updater-capable payloads are merged into one canonical `latest.json`. Tauri updater signing is documented as separate from Apple/Microsoft installer certificates. - **Boot loading screen**: new branded loading experience that paints before the WASM bundle is ready and stays on screen until the workbench is mounted. `index.html` ships a static `#blx-static-boot` section (logo, eyebrow, faux workbench preview, animated rail) so the first paint happens immediately on Trunk-served HTML; once Leptos mounts, the static node is removed and `BootLoadingScreen` (`src/boot_loading.rs`) takes over with phased copy (`Starting BLXCode` → `Restoring workspace` → `Opening workbench`). 306 lines of dedicated CSS in `styles.css` add the radial-gradient backdrop, frame-enter / sheen / rail keyframes, and the sidebar/main/panel preview skeleton. The `App` boot fallback now renders `` instead of the prior empty `app-shell--boot` div. - **Agent question card (`harness.ask_user`)**: new client-side tool that lets the coordinator agent ask a clarifying multiple-choice question and receive the user's answer as a structured tool result. Backend registers `harness.ask_user` (`src-tauri/src/agent/tools.rs`) with a JSON schema accepting `question`, optional `header` (≤12 chars), 2–4 `options` (label + optional description), `multiSelect`, and `allowOther`; the tool sits in the `CoordinatorHarness` group (subagents excluded) and the system prompt instructs the model to use it only when 2–4 distinct options would unblock progress. Frontend adds a new `TimelineItem::AskUser` variant and `ask_user_card/` component (Angular-style folder with co-located CSS) rendering a chat bubble with numbered option buttons, single- or multi-select mode, an optional free-text “Other” field, and a cancel button; the card submits via `agent_submit_tool_result` and transitions the row state to `Answered { selected, other }` or `Cancelled` so the bubble stays visible with disabled controls. Persistence drops `Open` ask-user rows from the saved timeline (the awaiting backend loop is dead after reload). Tool-result payload: `{ "selected": [...], "other": "...", "cancelled": false }`; on cancel: `ok=false` + `{ "cancelled": true }`. 8 new i18n keys (`AgAskUser*`) added in `en_us.rs` and `de_de.rs`; other locales receive English placeholders pending a `render_i18n_locales_from_en.py` pass. +- **Centralized API Keys settings**: new **Settings → API Keys** pane (`src/workbench/api_keys_pane/`) replaces per-provider key inputs that previously lived under Settings → Agent. Backend module `src-tauri/src/api_keys.rs` exposes `api_keys_status` (catalog of LLM + search providers with masked values, env-var hints, and `comingSoon` placeholders for Google / Mistral / **Grok xAI**) and `api_keys_apply` (batch `set` / `delete` actions). Active keys still use the existing OS keyring accounts (`agent:*`, `agent:web:*`) with optional `BLX_*` env fallback; resolve order is keyring-first so UI-saved secrets are not overridden by shell env. The pane uses draft state, a single **Save** / **Discard** footer, row-level remove/undo, and shared `settings-field-card` / `api-keys-row` styling with brand icons (`public/brand-icons/`: OpenRouter, Anthropic, OpenAI, Google/Gemini, Mistral, Groq, Tavily, Brave). 15 new `ApiKeys*` i18n keys in all 13 locales. +- **Settings revamp (docked center tab)**: harness settings moved from a modal overlay into a **center workbench tab** (`SettingsDock` in `harness_ui.rs`). Sidebar categories: **App**, **Appearance** (stub), **API Keys**, **Workspace**, **BLXCode Agent**. Legacy `HarnessSettingsCategory` values (`Image`, `Voice`, `Memory`) still deserialize and route to the correct pane for older snapshots. Command palette / tmux chords open or focus a singleton settings tab per workspace. +- **BLXCode Agent settings pane**: `src/workbench/agent_provider_pane/` — responsive grid. **Text** (top-left): provider + **thinking level** pickers (`harness-level-picker` icons), muted `text-xs` API-key status line → Settings → API Keys, shared **`AgentModelPicker`** (catalog rows, pricing-only detail sub-row, custom model id, refresh). **Image** (top-right): `AgentImageColumn` in `harness_image_pane` — provider dropdown, **quality level** picker, `AgentModelPicker`, auto-save; **fal.ai** provider + key via API Keys catalog. **Voice** (bottom, full width): `AgentVoiceColumn` in `harness_voice_pane` — unified STT/TTS provider (OpenAI / OpenRouter / **AWS Polly**), `AgentModelPicker` for speech + TTS models, recording quality, behavior (post-STT flow, gender filter, fixed 6-voice catalog per provider, TTS autoplay). Web Tools below the grid (Tavily / Brave / disabled); one **Save** / **Discard** footer for text provider + web backend. Replaced standalone `model_picker/` with `agent_model_picker/`. +- **Voice in App settings**: STT language mode + push-to-talk hotkey moved to **Settings → App** (`voice_app_controls`); full voice provider/model/voice UI lives only under BLXCode Agent. +- **Workspace category colors**: former **Settings → Memory → Color presets** moved to **Settings → Workspace** section **Category colors** (`workspace_settings_pane/category_colors.rs`, `WsSectionCategoryColors` + related i18n). Same preset list/edit/add/reset backed by `memory_color_presets` in `WorkbenchService`. - **Per-turn chat metrics & session cost**: tokens, TTFT, decode speed and resolved USD cost are now reported per conversation row instead of as a single aggregated footer. Each provider round emits `TurnUsage { kind: ModelRound, .. }` and each tool dispatch emits `TurnUsage { kind: ToolExec, call_id, .. }`, in both the main agent (`openrouter.rs`, `anthropic.rs`) and `subagent_runner.rs`. OpenRouter requests opt into `usage: { include: true }` and parse the native `usage.cost` field; everything else (including Anthropic Direct) is priced locally via a new `agent/pricing.rs` module — `ProviderModelEntry` carries `Option` populated from OpenRouter `/v1/models`, and direct-provider model ids route through a static id-mapping table (`claude-opus-4-7` → `anthropic/claude-opus-4.7`, etc.). A monotonic `turn_generation` counter on `AgentEngineState`, bumped by `agent_clear_conversation`, lets the frontend drop late events from a cancelled turn instead of polluting the fresh chat. New `workbench/agent_panel/turn_metrics_bar/` component renders the per-row `in · out · tok/s · ttft · $cost` strip under Assistant, Tool, the new synthetic `ModelDecision` row (tool-only rounds), and per-tool inside subagent cards. Subagent cards show aggregated round metrics in the header. The chat header gains a `SessionCostChip` with the resolved USD total + turn count, replacing the retired `ChatUsageFooter`. `ToolGroup` bundling is gone — each tool is its own timeline row now. 14 new `I18nKey` variants (`AgMetricsIn/Out/Ttft/CostUnknown/TurnsOne/TurnsMany/Tooltip*`, `AgMetricsBarAria`, `AgMetricsModelRound`, `AgSessionCostAria`) added in all 12 locale files. Pricing module ships with 7 unit tests. ### Changed - **`ChatUsageStats` schema migrated**: `total_cost_usd: f64` and `current_turn_generation: u64` added; legacy `ttft_sum_ms` / `ttft_sample_count` fields removed (TTFT now lives per-row). Older `workbench.json` snapshots deserialize cleanly thanks to `#[serde(default)]` and rewrite without the removed fields on next save. `record_chat_turn_usage` signature changed to accept the new `turn_generation` and `cost_usd` and to return `bool` so callers can drop stale events. +- **Workspace settings layout aligned with API Keys**: the Workspace pane (`src/workbench/workspace_settings_pane/`) uses `harness-subpane` sections (**Paths & sandbox**, **Embedded browser**, **Category colors**), `api-keys-list` / `api-keys-row` field cards, and one footer **Save** / **Discard** for project directory, agent sandbox root, and embedded-browser URL. Category color edits apply immediately (same as former Memory tab). Shared footer/field CSS in `workspace_settings_pane.css` and `api_keys_pane.css`. +- **BLXCode Agent API-key hints**: relocation lines under Text / Image / Voice use `0.75rem` + `--text-muted` (`.agent-provider-pane__key-row`) instead of body-sized copy. +- **Voice catalog UX**: six fixed voices per provider (OpenAI + AWS Polly active; OpenRouter shows OpenAI set disabled with hint); gender filter always visible; disabled cards keep layout (no hide-on-disable). +- **Settings panel restructured**: `AppSettingsPane` (`src/workbench/harness_ui.rs`) now reads top-to-bottom as Language → Keyboard shortcuts → Notifications → **Terminal hooks** → **App updates** (updates moved below hooks to put install actions closer to the per-agent hook list). Keyboard shortcuts and Notifications switched from single-column lists to 2-column grids (new `.app-prefs-shortcut-modes--grid` and `.app-prefs-toggle-grid`). Current version no longer renders inside a readonly `` — it's now a styled `
` row, and a sibling **Available version** row only appears when `UpdateService::available_version` is `Some`. Terminal hooks list (`.harness-hooks__list--grid`) lays out as 3 columns on wide screens, collapsing to 2 / 1 below 900 px / 600 px; per-agent **status is icon-only** (check / X) with full text in `title` + `aria-label`, aligned to the end of the title row. The EULA status row was removed from the App pane (acceptance is still gated at boot; the field added no actionable signal here). Workspace pane changed the agent sandbox ` - -
- -
- -
- - - {move || format!("{} {}", i18n.tr(I18nKey::WsBrowserDefault)(), HARNESS_BROWSER_DEFAULT_URL)} - -
- - } -} - -fn provider_label(i18n: &I18nService, provider: AgentProviderKind) -> String { - let key = match provider { - AgentProviderKind::Openrouter => I18nKey::AgProviderOpenrouter, - AgentProviderKind::Anthropic => I18nKey::AgProviderAnthropic, - AgentProviderKind::Openai => I18nKey::AgProviderOpenai, - }; - i18n.tr(key)().to_string() -} - -fn provider_icon_url(provider: AgentProviderKind) -> &'static str { - match provider { - AgentProviderKind::Openrouter => "/public/brand-icons/openrouter.svg", - AgentProviderKind::Anthropic => "/public/brand-icons/anthropic.svg", - AgentProviderKind::Openai => "/public/brand-icons/openai.svg", - } -} - -fn thinking_levels() -> [ThinkingLevel; 5] { - [ - ThinkingLevel::Off, - ThinkingLevel::Low, - ThinkingLevel::Medium, - ThinkingLevel::High, - ThinkingLevel::Max, - ] -} - -fn thinking_label(i18n: &I18nService, level: ThinkingLevel) -> String { - let key = match level { - ThinkingLevel::Off => I18nKey::AgThinkingOff, - ThinkingLevel::Low => I18nKey::AgThinkingLow, - ThinkingLevel::Medium => I18nKey::AgThinkingMedium, - ThinkingLevel::High => I18nKey::AgThinkingHigh, - ThinkingLevel::Max => I18nKey::AgThinkingMax, - }; - i18n.tr(key)().to_string() -} - -fn provider_key_configured(view: &AgentProviderSettingsView, provider: AgentProviderKind) -> bool { - view.key_statuses - .iter() - .find(|status| status.provider == provider) - .map(|status| status.configured) - .unwrap_or(false) -} - -fn provider_key_mask( - view: &AgentProviderSettingsView, - provider: AgentProviderKind, -) -> Option { - view.key_statuses - .iter() - .find(|status| status.provider == provider) - .and_then(|status| status.masked_value.clone()) -} - -fn provider_key_status_text( - i18n: &I18nService, - view: &AgentProviderSettingsView, - provider: AgentProviderKind, -) -> String { - if provider_key_configured(view, provider) { - if let Some(mask) = provider_key_mask(view, provider) { - format!("{} ({mask})", i18n.tr(I18nKey::AgApiKeyConfigured)()) - } else { - i18n.tr(I18nKey::AgApiKeyConfigured)().to_string() - } - } else { - i18n.tr(I18nKey::AgApiKeyMissing)().to_string() - } -} - -fn provider_cache( - view: &AgentProviderSettingsView, - provider: AgentProviderKind, -) -> Vec { - match provider { - AgentProviderKind::Openrouter => view.model_cache_openrouter.clone(), - AgentProviderKind::Anthropic => view.model_cache_anthropic.clone(), - AgentProviderKind::Openai => view.model_cache_openai.clone(), - } -} - -fn hook_brand_icon(agent: &str) -> Option<&'static str> { - match agent { - "claude" => Some("/public/brand-icons/anthropic.svg"), - "codex" => Some("/public/brand-icons/openai.svg"), - "gemini" => Some("/public/brand-icons/gemini.svg"), - "cursor" => Some("/public/brand-icons/cursor.svg"), - _ => None, - } -} - -fn focus_provider_option(provider: AgentProviderKind) { - let id = format!("provider-option-{}", provider.as_str()); - let Some(doc) = web_sys::window().and_then(|w| w.document()) else { - return; - }; - let Some(el) = doc.get_element_by_id(&id) else { - return; - }; - let Ok(button) = el.dyn_into::() else { - return; - }; - let _ = button.focus(); -} - -fn next_provider(provider: AgentProviderKind) -> AgentProviderKind { - match provider { - AgentProviderKind::Openrouter => AgentProviderKind::Anthropic, - AgentProviderKind::Anthropic => AgentProviderKind::Openai, - AgentProviderKind::Openai => AgentProviderKind::Openrouter, - } -} - -fn prev_provider(provider: AgentProviderKind) -> AgentProviderKind { - match provider { - AgentProviderKind::Openrouter => AgentProviderKind::Openai, - AgentProviderKind::Anthropic => AgentProviderKind::Openrouter, - AgentProviderKind::Openai => AgentProviderKind::Anthropic, - } -} - -#[component] -fn ProviderPicker( - selected_provider: RwSignal, - settings: RwSignal>, - model_entries: RwSignal>, - provider_refresh_request: RwSignal>, -) -> impl IntoView { - let i18n = expect_context::(); - let open = RwSignal::new(false); - - let choose = move |provider: AgentProviderKind| { - selected_provider.set(provider); - if let Some(view) = settings.get_untracked() { - model_entries.set(provider_cache(&view, provider)); - } - open.set(false); - provider_refresh_request.set(Some(provider)); - }; - - view! { -
- - - -
- {move || { - [AgentProviderKind::Openrouter, AgentProviderKind::Anthropic, AgentProviderKind::Openai] - .into_iter() - .map(|provider| { - view! { - - } - }) - .collect_view() - }} -
-
-
- } -} - -#[component] -fn AgentProviderPane() -> impl IntoView { - let i18n = expect_context::(); - let settings: RwSignal> = RwSignal::new(None); - let selected_provider = RwSignal::new(AgentProviderKind::Openrouter); - let custom_model = RwSignal::new(String::new()); - let thinking_level = RwSignal::new(ThinkingLevel::Medium); - let api_key_input = RwSignal::new(String::new()); - let model_entries: RwSignal> = RwSignal::new(Vec::new()); - let models_source = RwSignal::new(String::new()); - let models_message: RwSignal> = RwSignal::new(None); - let busy = RwSignal::new(false); - let loading_models = RwSignal::new(false); - let status_msg: RwSignal> = RwSignal::new(None); - let error_msg: RwSignal> = RwSignal::new(None); - let provider_refresh_request: RwSignal> = RwSignal::new(None); - let web_settings: RwSignal> = RwSignal::new(None); - let web_provider = RwSignal::new(WebProviderKind::None); - let web_tavily_key = RwSignal::new(String::new()); - let web_brave_key = RwSignal::new(String::new()); - let web_status_msg: RwSignal> = RwSignal::new(None); - - let apply_web_settings = move |view: AgentWebSettingsView| { - web_provider.set(view.settings.provider); - web_settings.set(Some(view)); - }; - - let apply_settings = move |view: AgentProviderSettingsView| { - selected_provider.set(view.provider); - custom_model.set(view.model_id.clone()); - thinking_level.set(view.thinking_level); - model_entries.set(provider_cache(&view, view.provider)); - settings.set(Some(view)); - }; - - Effect::new(move |_| { - if !is_tauri_shell() { - return; - } - leptos::task::spawn_local(async move { - match agent_settings_get().await { - Ok(view) => { - error_msg.set(None); - status_msg.set(None); - apply_settings(view); - } - Err(err) => error_msg.set(Some(err)), - } - match agent_web_settings_get().await { - Ok(view) => { - web_status_msg.set(None); - apply_web_settings(view); - } - Err(err) => error_msg.set(Some(err)), - } - }); - }); - - let refresh_models = move |provider: AgentProviderKind| { - loading_models.set(true); - models_message.set(None); - error_msg.set(None); - leptos::task::spawn_local(async move { - match agent_provider_models(provider).await { - Ok(ProviderModelsResponse { - provider: _, - entries, - source, - used_fallback, - message, - }) => { - model_entries.set(entries); - models_source.set(source); - models_message.set(message.or_else(|| { - if used_fallback { - Some(i18n.tr(I18nKey::AgModelsFallback)().to_string()) - } else { - None - } - })); - } - Err(err) => error_msg.set(Some(err)), - } - loading_models.set(false); - }); - }; - - Effect::new(move |_| { - let Some(provider) = provider_refresh_request.get() else { - return; - }; - provider_refresh_request.set(None); - refresh_models(provider); - }); - - view! { -
-

- - {move || i18n.tr(I18nKey::AgProviderHeading)()} -

-
-
- - - {move || i18n.tr(I18nKey::AgProviderField)()} - - -
- - -
- - - -
- - - {move || match models_source.get().as_str() { - "live" => i18n.tr(I18nKey::AgModelsSourceLive)().to_string(), - "cache" => i18n.tr(I18nKey::AgModelsSourceCache)().to_string(), - "curated" | "fallback" => i18n.tr(I18nKey::AgModelsSourceCurated)().to_string(), - _ => String::new(), - }} - -
- - -

{move || models_message.get().unwrap_or_default()}

-
- -
-
-

- - - {move || format!("{} {}", i18n.tr(I18nKey::AgApiKeyField)(), provider_label(&i18n, selected_provider.get()))} - -

- - {move || { - settings - .get() - .map(|view| provider_key_status_text(&i18n, &view, selected_provider.get())) - .unwrap_or_else(|| i18n.tr(I18nKey::AgApiKeyMissing)().to_string()) - }} - -
-

{move || i18n.tr(I18nKey::AgApiKeyHint)()}

- -
- - -
-
- -
- -
- -
-

- - {move || i18n.tr(I18nKey::AgWebToolsHeading)()} -

-

{move || i18n.tr(I18nKey::AgWebToolsDescription)()}

- -

{move || i18n.tr(I18nKey::AgWebKeyHint)()}

- -
- - -
- -
- - -
-
- -
-
- - -

{move || web_status_msg.get().unwrap_or_default()}

-
- -

{move || status_msg.get().unwrap_or_default()}

-
- -

{move || error_msg.get().unwrap_or_default()}

-
-
- } -} - -fn web_provider_kind_value(kind: WebProviderKind) -> &'static str { - match kind { - WebProviderKind::None => "none", - WebProviderKind::Tavily => "tavily", - WebProviderKind::Brave => "brave", - } -} - -fn web_provider_from_value(value: &str) -> WebProviderKind { - match value { - "tavily" => WebProviderKind::Tavily, - "brave" => WebProviderKind::Brave, - _ => WebProviderKind::None, - } -} - -fn web_key_entry<'a>( - view: Option<&'a AgentWebSettingsView>, - kind: &str, -) -> Option<&'a WebKeyStatus> { - view?.key_statuses.iter().find(|k| k.kind == kind) -} - -fn web_key_mask(view: Option, kind: &str) -> Option { - web_key_entry(view.as_ref(), kind).and_then(|k| k.masked_value.clone()) -} - -fn web_key_status_text( - i18n: &I18nService, - view: Option, - kind: &str, -) -> String { - match web_key_entry(view.as_ref(), kind) { - Some(k) if k.configured => k - .masked_value - .clone() - .unwrap_or_else(|| i18n.tr(I18nKey::AgApiKeyConfigured)().to_string()), - _ => i18n.tr(I18nKey::AgApiKeyMissing)().to_string(), - } -} - -#[component] -fn AgentHooksPanel() -> impl IntoView { +fn AgentHooksPanel() -> impl IntoView { let i18n = expect_context::(); let report: RwSignal> = RwSignal::new(None); let busy = RwSignal::new(false); @@ -2390,7 +1201,7 @@ fn AgentHooksPanel() -> impl IntoView { {move || i18n.tr(I18nKey::AgHooksHeading)()}

{move || i18n.tr(I18nKey::AgHooksDesc)()}

-
    +
      {move || { let rendered = report.get(); let installed_label = i18n.tr(I18nKey::AgHooksStatusInstalled)().to_string(); @@ -2409,6 +1220,7 @@ fn AgentHooksPanel() -> impl IntoView { let note = entry.note.unwrap_or_default(); let has_note = !note.is_empty(); let icon_url = hook_brand_icon(&entry.agent); + let status_tip = status.clone(); view! {
    • @@ -2429,23 +1241,30 @@ fn AgentHooksPanel() -> impl IntoView {
      - {entry.agent} +
      + {entry.agent} + + + +
      {note.clone()}
      - - - {status} -
    • } .into_any() @@ -2495,34 +1314,3 @@ fn AgentHooksPanel() -> impl IntoView { } } - -fn eula_preview(loc: Locale) -> String { - web_sys::window() - .and_then(|w| w.local_storage().ok().flatten()) - .and_then(|s| s.get_item(EULA_STORAGE_KEY).ok().flatten()) - .map(|v| match v.as_str() { - "1" => lookup(loc, I18nKey::EulaAccepted).to_string(), - other => format!("„{other}“"), - }) - .unwrap_or_else(|| lookup(loc, I18nKey::EulaUnknown).to_string()) -} - -fn persist_browser_defaults( - wb: WorkbenchService, - ui: HarnessUiService, - embed: BrowserEmbedSurface, -) { - let mut trimmed = wb.browser_url().get_untracked().trim().to_owned(); - if trimmed.is_empty() { - trimmed = HARNESS_BROWSER_DEFAULT_URL.into(); - } - wb.persist_browser_url_from_input(trimmed.clone()); - let wclone = wb; - let aid = wb.embedded_browser_active_id().get_untracked(); - leptos::task::spawn_local(async move { - let _ = crate::tauri_bridge::browser_navigate(aid, trimmed.as_str()).await; - TimeoutFuture::new(12).await; - sync_embedded_browser_layer(wclone, embed).await; - }); - ui.close_settings(); -} diff --git a/src/workbench/harness_voice_pane/mod.rs b/src/workbench/harness_voice_pane/mod.rs index d8d4b22..6218e95 100644 --- a/src/workbench/harness_voice_pane/mod.rs +++ b/src/workbench/harness_voice_pane/mod.rs @@ -1,16 +1,18 @@ //! Voice settings tab: STT/TTS provider+model, voice with gender filter, -//! recording quality, post-STT behaviour, STT language, push-to-talk hotkey. +//! recording quality, post-STT behaviour. STT language + PTT live under App. use crate::i18n::I18nKey; use crate::service::I18nService; use crate::tauri_bridge::{ - agent_provider_models, is_tauri_shell, voice_provider_voices, voice_settings_get, - voice_settings_save, voice_tts_preview, AgentProviderKind, PostSttFlow, ProviderModelEntry, - PttHotkey, SttLanguageMode, SttSettings, TtsSettings, VoiceEntry, VoiceGender, - VoiceProviderKind, VoiceSettings, + agent_provider_models, agent_settings_get, api_keys_status, is_tauri_shell, + voice_settings_get, voice_settings_save, voice_tts_preview, + AgentProviderKind, AgentProviderSettingsView, ApiKeyEntry, ApiKeysStatus, PostSttFlow, + ProviderModelEntry, SttSettings, TtsSettings, VoiceEntry, VoiceGender, VoiceProviderKind, + VoiceSettings, }; -use crate::workbench::model_picker::ModelPicker; +use crate::workbench::agent_model_picker::AgentModelPicker; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use gloo_timers::future::TimeoutFuture; use js_sys::Uint8Array; use leptos::prelude::*; use leptos_icons::Icon as LxIcon; @@ -33,10 +35,86 @@ impl ModelKind { } } -fn voice_to_agent_provider(v: VoiceProviderKind) -> AgentProviderKind { +fn voice_to_agent_provider(v: VoiceProviderKind) -> Option { match v { - VoiceProviderKind::Openai => AgentProviderKind::Openai, - VoiceProviderKind::Openrouter => AgentProviderKind::Openrouter, + VoiceProviderKind::Openai => Some(AgentProviderKind::Openai), + VoiceProviderKind::Openrouter => Some(AgentProviderKind::Openrouter), + VoiceProviderKind::Aws => None, + } +} + +fn voice_model_entry(id: &str, label: &str, description: &str) -> ProviderModelEntry { + ProviderModelEntry { + id: id.into(), + label: label.into(), + description: Some(description.into()), + pricing: None, + } +} + +fn curated_voice_models(provider: VoiceProviderKind, kind: ModelKind) -> Vec { + if provider != VoiceProviderKind::Aws { + return Vec::new(); + } + match kind { + ModelKind::Stt => vec![voice_model_entry( + "amazon-transcribe", + "Amazon Transcribe", + "AWS speech-to-text (curated list).", + )], + ModelKind::Tts => vec![ + voice_model_entry("neural", "Polly Neural", "Neural TTS engine."), + voice_model_entry("standard", "Polly Standard", "Standard TTS engine."), + ], + } +} + +fn media_key_entry<'a>(api_keys: &'a ApiKeysStatus, kind: &str) -> Option<&'a ApiKeyEntry> { + api_keys.entries.iter().find(|e| e.kind == kind) +} + +fn voice_provider_key_status( + i18n: &I18nService, + agent_settings: Option<&AgentProviderSettingsView>, + api_keys: Option<&ApiKeysStatus>, + provider: VoiceProviderKind, +) -> String { + if provider == VoiceProviderKind::Aws { + let Some(entry) = api_keys.and_then(|s| media_key_entry(s, "aws_polly")) else { + return i18n.tr(I18nKey::AgApiKeyMissing)().to_string(); + }; + if entry.configured { + if let Some(mask) = entry.masked_value.as_ref() { + format!("{} ({mask})", i18n.tr(I18nKey::AgApiKeyConfigured)()) + } else { + i18n.tr(I18nKey::AgApiKeyConfigured)().to_string() + } + } else { + i18n.tr(I18nKey::AgApiKeyMissing)().to_string() + } + } else if let (Some(view), Some(agent)) = (agent_settings, voice_to_agent_provider(provider)) { + let configured = view + .key_statuses + .iter() + .find(|s| s.provider == agent) + .map(|s| s.configured) + .unwrap_or(false); + if configured { + let mask = view + .key_statuses + .iter() + .find(|s| s.provider == agent) + .and_then(|s| s.masked_value.clone()); + if let Some(mask) = mask { + format!("{} ({mask})", i18n.tr(I18nKey::AgApiKeyConfigured)()) + } else { + i18n.tr(I18nKey::AgApiKeyConfigured)().to_string() + } + } else { + i18n.tr(I18nKey::AgApiKeyMissing)().to_string() + } + } else { + i18n.tr(I18nKey::AgApiKeyMissing)().to_string() } } @@ -45,7 +123,14 @@ async fn fetch_models_for( kind: ModelKind, out: RwSignal>, ) { - let agent_provider = voice_to_agent_provider(provider); + if provider == VoiceProviderKind::Aws { + out.set(curated_voice_models(provider, kind)); + return; + } + let Some(agent_provider) = voice_to_agent_provider(provider) else { + out.set(Vec::new()); + return; + }; let all = match agent_provider_models(agent_provider).await { Ok(resp) => resp.entries, Err(_) => Vec::new(), @@ -83,24 +168,156 @@ impl GenderFilter { } } +fn voice_providers() -> [VoiceProviderKind; 3] { + [ + VoiceProviderKind::Openai, + VoiceProviderKind::Openrouter, + VoiceProviderKind::Aws, + ] +} + +fn voice_provider_icon_url(provider: VoiceProviderKind) -> &'static str { + match provider { + VoiceProviderKind::Openai => "/public/brand-icons/openai.svg", + VoiceProviderKind::Openrouter => "/public/brand-icons/openrouter.svg", + VoiceProviderKind::Aws => "/public/brand-icons/aws.svg", + } +} + +fn voice_provider_label(i18n: &I18nService, provider: VoiceProviderKind) -> String { + let key = match provider { + VoiceProviderKind::Openai => I18nKey::AgProviderOpenai, + VoiceProviderKind::Openrouter => I18nKey::AgProviderOpenrouter, + VoiceProviderKind::Aws => I18nKey::AgProviderAws, + }; + i18n.tr(key)().to_string() +} + +fn apply_voice_provider_defaults(next: &mut VoiceSettings, provider: VoiceProviderKind) { + match provider { + VoiceProviderKind::Aws => { + if !next.stt.model_id.contains("transcribe") { + next.stt.model_id = "amazon-transcribe".into(); + } + if !matches!(next.tts.model_id.as_str(), "neural" | "standard") { + next.tts.model_id = "neural".into(); + } + if next.tts.voice.is_empty() || is_openai_voice_id(&next.tts.voice) { + next.tts.voice = "Joanna".into(); + } + } + VoiceProviderKind::Openai => { + if next.tts.voice.is_empty() || is_aws_polly_voice_id(&next.tts.voice) { + next.tts.voice = "nova".into(); + } + } + VoiceProviderKind::Openrouter => {} + } +} + +fn voice_entry(id: &str, label: &str, gender: VoiceGender) -> VoiceEntry { + VoiceEntry { + id: id.into(), + label: label.into(), + gender, + } +} + +/// Six curated OpenAI built-in TTS voices (see OpenAI Audio API speech guide). +fn fixed_openai_voices() -> Vec { + vec![ + voice_entry("alloy", "Alloy", VoiceGender::Neutral), + voice_entry("nova", "Nova", VoiceGender::Female), + voice_entry("echo", "Echo", VoiceGender::Male), + voice_entry("shimmer", "Shimmer", VoiceGender::Female), + voice_entry("onyx", "Onyx", VoiceGender::Male), + voice_entry("coral", "Coral", VoiceGender::Female), + ] +} + +fn fixed_aws_polly_voices() -> Vec { + vec![ + voice_entry("Joanna", "Joanna", VoiceGender::Female), + voice_entry("Matthew", "Matthew", VoiceGender::Male), + voice_entry("Amy", "Amy", VoiceGender::Female), + voice_entry("Brian", "Brian", VoiceGender::Male), + voice_entry("Ivy", "Ivy", VoiceGender::Female), + voice_entry("Justin", "Justin", VoiceGender::Male), + ] +} + +fn voice_catalog_for(provider: VoiceProviderKind) -> Vec { + match provider { + VoiceProviderKind::Openai | VoiceProviderKind::Openrouter => fixed_openai_voices(), + VoiceProviderKind::Aws => fixed_aws_polly_voices(), + } +} + +fn voices_pick_enabled(provider: VoiceProviderKind) -> bool { + matches!( + provider, + VoiceProviderKind::Openai | VoiceProviderKind::Aws + ) +} + +fn is_openai_voice_id(id: &str) -> bool { + fixed_openai_voices().iter().any(|v| v.id == id) +} + +fn is_aws_polly_voice_id(id: &str) -> bool { + fixed_aws_polly_voices().iter().any(|v| v.id == id) +} + +fn focus_voice_provider_option(provider: VoiceProviderKind) { + let id = format!("voice-provider-option-{}", provider.as_str()); + if let Some(doc) = web_sys::window().and_then(|w| w.document()) { + if let Some(el) = doc.get_element_by_id(&id) { + let _ = el.dyn_into::().map(|e| e.focus()); + } + } +} + +fn next_voice_provider(provider: VoiceProviderKind) -> VoiceProviderKind { + let list = voice_providers(); + let i = list.iter().position(|&p| p == provider).unwrap_or(0); + list[(i + 1) % list.len()] +} + +fn prev_voice_provider(provider: VoiceProviderKind) -> VoiceProviderKind { + let list = voice_providers(); + let i = list.iter().position(|&p| p == provider).unwrap_or(0); + list[(i + list.len() - 1) % list.len()] +} + +/// Voice settings column (BLXCode Agent grid, bottom row spanning both columns). #[component] -pub fn VoicePane() -> impl IntoView { +pub fn AgentVoiceColumn() -> impl IntoView { let i18n = expect_context::(); let settings = RwSignal::new(Option::::None); - let voices = RwSignal::new(Vec::::new()); + let agent_settings = RwSignal::new(Option::::None); + let api_keys = RwSignal::new(Option::::None); let gender_filter = RwSignal::new(GenderFilter::All); let status = RwSignal::new(Option::::None); - let recording_hotkey = RwSignal::new(false); let stt_models = RwSignal::new(Vec::::new()); let tts_models = RwSignal::new(Vec::::new()); + let voice_provider = RwSignal::new(VoiceProviderKind::Openai); + let stt_model_id = RwSignal::new(String::new()); + let tts_model_id = RwSignal::new(String::new()); + let stt_loading_models = RwSignal::new(false); + let tts_loading_models = RwSignal::new(false); - // Load current settings + initial voice catalogue + model lists. if is_tauri_shell() { leptos::task::spawn_local(async move { + if let Ok(view) = agent_settings_get().await { + agent_settings.set(Some(view)); + } + if let Ok(keys) = api_keys_status().await { + api_keys.set(Some(keys)); + } if let Ok(v) = voice_settings_get().await { - if let Ok(catalog) = voice_provider_voices(v.tts.provider).await { - voices.set(catalog.voices); - } + voice_provider.set(v.stt.provider); + stt_model_id.set(v.stt.model_id.clone()); + tts_model_id.set(v.tts.model_id.clone()); fetch_models_for(v.stt.provider, ModelKind::Stt, stt_models).await; fetch_models_for(v.tts.provider, ModelKind::Tts, tts_models).await; settings.set(Some(v)); @@ -109,6 +326,9 @@ pub fn VoicePane() -> impl IntoView { } let save = move |patch: VoiceSettings| { + voice_provider.set(patch.stt.provider); + stt_model_id.set(patch.stt.model_id.clone()); + tts_model_id.set(patch.tts.model_id.clone()); if !is_tauri_shell() { settings.set(Some(patch)); return; @@ -116,37 +336,46 @@ pub fn VoicePane() -> impl IntoView { leptos::task::spawn_local(async move { match voice_settings_save(patch).await { Ok(v) => { + voice_provider.set(v.stt.provider); + stt_model_id.set(v.stt.model_id.clone()); + tts_model_id.set(v.tts.model_id.clone()); settings.set(Some(v)); - status.set(Some("saved".into())); + status.set(Some(i18n.tr(I18nKey::ApiKeysSaved)().to_string())); } Err(e) => status.set(Some(e)), } }); }; - let reload_voices = move |provider: VoiceProviderKind| { + let reload_tts_models = move |provider: VoiceProviderKind| { + tts_loading_models.set(true); leptos::task::spawn_local(async move { - if let Ok(catalog) = voice_provider_voices(provider).await { - voices.set(catalog.voices); - } fetch_models_for(provider, ModelKind::Tts, tts_models).await; + tts_loading_models.set(false); }); }; let reload_stt_models = move |provider: VoiceProviderKind| { + stt_loading_models.set(true); leptos::task::spawn_local(async move { fetch_models_for(provider, ModelKind::Stt, stt_models).await; + stt_loading_models.set(false); }); }; + let reload_all_for_provider = move |provider: VoiceProviderKind| { + reload_tts_models(provider); + reload_stt_models(provider); + }; + view! { -
      -
      -

      - - {move || i18n.tr(I18nKey::VoicePaneTitle)()} -

      -
      + <> +

      + + {move || i18n.tr(I18nKey::AgColumnVoice)()} +

      impl IntoView { return view! { <> }.into_any(); }; - let stt_provider = current.stt.provider; - let tts_provider = current.tts.provider; - let stt_model = current.stt.model_id.clone(); - let tts_model = current.tts.model_id.clone(); let sample_rate = current.stt.sample_rate_hz; let voice_id = current.tts.voice.clone(); let post_flow = current.post_stt_flow; - let stt_lang = current.stt_language.clone(); - let ptt = current.ptt_hotkey.clone(); let tts_enabled = current.tts.enabled; + let on_voice_provider = { + let current = current.clone(); + move |p: VoiceProviderKind| { + voice_provider.set(p); + let mut next = current.clone(); + next.stt.provider = p; + next.tts.provider = p; + apply_voice_provider_defaults(&mut next, p); + stt_model_id.set(next.stt.model_id.clone()); + tts_model_id.set(next.tts.model_id.clone()); + save(next); + reload_all_for_provider(p); + } + }; + view! { - - - - - +
      +
      + +
      + {move || i18n.tr(I18nKey::ApiKeysManageHint)()} + + {move || { + voice_provider_key_status( + &i18n, + agent_settings.get().as_ref(), + api_keys.get().as_ref(), + voice_provider.get(), + ) + }} + +
      +
      + + + +
      }.into_any() }}
      @@ -215,100 +469,288 @@ pub fn VoicePane() -> impl IntoView {

      {move || status.get().unwrap_or_default()}

      -
      + } } // --------------------------------------------------------------------------- -// STT section +// Shared voice provider dropdown (STT + TTS) // --------------------------------------------------------------------------- #[component] -fn SttSection( - current: VoiceSettings, - stt_provider: VoiceProviderKind, - stt_model: String, +fn VoiceProviderPicker( + selected_provider: RwSignal, + on_select: Callback, +) -> impl IntoView { + let i18n = expect_context::(); + let open = RwSignal::new(false); + + let choose = move |provider: VoiceProviderKind| { + selected_provider.set(provider); + open.set(false); + on_select.run(provider); + }; + + view! { +
      + + + +
      + {move || { + voice_providers() + .into_iter() + .map(|provider| { + view! { + + } + }) + .collect_view() + }} +
      +
      +
      + } +} + +// --------------------------------------------------------------------------- +// Speech column (STT + TTS models, then recording quality) +// --------------------------------------------------------------------------- + +#[component] +fn SpeechSection( + settings: RwSignal>, + voice_provider: RwSignal, + stt_model_id: RwSignal, + tts_model_id: RwSignal, sample_rate: u32, - models: RwSignal>, + stt_models: RwSignal>, + tts_models: RwSignal>, + stt_loading_models: RwSignal, + tts_loading_models: RwSignal, save: F, - reload_models: RM, + reload_stt_models: RS, + reload_tts_models: RT, ) -> impl IntoView where F: Fn(VoiceSettings) + Send + Sync + 'static + Copy, - RM: Fn(VoiceProviderKind) + Send + Sync + 'static + Copy, + RS: Fn(VoiceProviderKind) + Send + Sync + 'static + Copy, + RT: Fn(VoiceProviderKind) + Send + Sync + 'static + Copy, { let i18n = expect_context::(); - let on_provider = { - let current = current.clone(); - move |p: VoiceProviderKind| { - let mut next = current.clone(); - next.stt.provider = p; - save(next); - reload_models(p); - } - }; - let on_model = { - let current = current.clone(); - move |m: String| { - let mut next = current.clone(); - next.stt.model_id = m; - save(next); - } + + let on_stt_model = Callback::new(move |m: String| { + let Some(mut next) = settings.get_untracked() else { + return; + }; + stt_model_id.set(m.clone()); + next.stt.model_id = m; + save(next); + }); + + let on_tts_model = Callback::new(move |m: String| { + let Some(mut next) = settings.get_untracked() else { + return; + }; + tts_model_id.set(m.clone()); + next.tts.model_id = m; + save(next); + }); + + let on_rate = move |r: u32| { + let Some(mut next) = settings.get_untracked() else { + return; + }; + next.stt.sample_rate_hz = r; + save(next); }; - let on_rate = { - let current = current.clone(); - move |r: u32| { - let mut next = current.clone(); - next.stt.sample_rate_hz = r; - save(next); + + let models_source_hint = move || { + if voice_provider.get() == VoiceProviderKind::Aws { + i18n.tr(I18nKey::AgModelsSourceCurated)().to_string() + } else { + i18n.tr(I18nKey::AgModelsSourceLive)().to_string() } }; view! { -
      -

      {move || i18n.tr(I18nKey::VoiceSttSection)()}

      - -
      - -
      - - -
      +
      + +
      + + {models_source_hint}
      - + +
      + + {models_source_hint} +
      - - - + + +

      {move || i18n.tr(I18nKey::VoiceQualityHint)()}

      @@ -324,24 +766,6 @@ where } } -#[component] -fn ProviderBtn( - label: &'static str, - target: VoiceProviderKind, - active: VoiceProviderKind, -) -> impl IntoView { - let is_active = move || target == active; - view! { - - } -} - #[component] fn QualityBtn(rate: u32, label_key: I18nKey, active: u32) -> impl IntoView { let i18n = expect_context::(); @@ -357,192 +781,12 @@ fn QualityBtn(rate: u32, label_key: I18nKey, active: u32) -> impl IntoView { } } -// --------------------------------------------------------------------------- -// TTS section -// --------------------------------------------------------------------------- - -#[component] -fn TtsSection( - current: VoiceSettings, - tts_provider: VoiceProviderKind, - tts_model: String, - voice_id: String, - voices: RwSignal>, - gender_filter: RwSignal, - tts_enabled: bool, - models: RwSignal>, - save: F, - reload_voices: RV, -) -> impl IntoView -where - F: Fn(VoiceSettings) + Send + Sync + 'static + Copy, - RV: Fn(VoiceProviderKind) + Send + Sync + 'static + Copy, -{ - let i18n = expect_context::(); - let audio_ref = NodeRef::::new(); - - let on_provider = { - let current = current.clone(); - move |p: VoiceProviderKind| { - let mut next = current.clone(); - next.tts.provider = p; - save(next); - reload_voices(p); - } - }; - let on_model = { - let current = current.clone(); - move |m: String| { - let mut next = current.clone(); - next.tts.model_id = m; - save(next); - } - }; - let on_voice = { - let current = current.clone(); - move |id: String| { - let mut next = current.clone(); - next.tts.voice = id; - save(next); - } - }; - let on_enabled = { - let current = current.clone(); - move |enabled: bool| { - let mut next = current.clone(); - next.tts.enabled = enabled; - save(next); - } - }; - - let preview_voice = { - let current = current.clone(); - move |voice: String| { - let model = current.tts.model_id.clone(); - let provider = current.tts.provider; - let text = i18n.tr(I18nKey::VoicePreviewText)().to_string(); - leptos::task::spawn_local(async move { - if let Ok(resp) = voice_tts_preview(provider, model, voice, text).await { - play_b64(audio_ref, &resp.audio_b64, &resp.mime); - } - }); - } - }; - - view! { -

      -

      {move || i18n.tr(I18nKey::VoiceTtsSection)()}

      - -
      - -
      - -
      -
      - - - -
      - -
      - - - - -
      -
      - {move || { - let active = voice_id.clone(); - let filter = gender_filter.get(); - let on_voice = on_voice.clone(); - let preview_voice = preview_voice.clone(); - voices.get() - .into_iter() - .filter(|v| filter.matches(v.gender)) - .map(|v| { - let is_active = v.id == active; - let id_choose = v.id.clone(); - let id_preview = v.id.clone(); - let on_voice = on_voice.clone(); - let preview_voice = preview_voice.clone(); - view! { -
      - - -
      - } - }) - .collect_view() - }} -
      -
      - -
      - -

      {move || i18n.tr(I18nKey::VoiceTtsAutoplayHint)()}

      -

      - {move || i18n.tr(I18nKey::VoiceTtsLangAutoNote)()} -

      -
      - -
      - } -} - #[component] fn GenderBtn( target: GenderFilter, label_key: I18nKey, filter: RwSignal, + #[prop(default = false)] disabled: bool, ) -> impl IntoView { let i18n = expect_context::(); let is_active = move || filter.get() == target; @@ -551,7 +795,12 @@ fn GenderBtn( type="button" class="voice-pane__choice voice-pane__choice--gender" class:voice-pane__choice--active=is_active - on:click=move |_| filter.set(target) + disabled=disabled + on:click=move |_| { + if !disabled { + filter.set(target); + } + } > {move || i18n.tr(label_key)()} @@ -566,10 +815,13 @@ fn gender_label_for(g: VoiceGender) -> &'static str { } } -fn play_b64(audio_ref: NodeRef, b64: &str, mime: &str) { +fn play_b64(b64: &str, mime: &str) { let Ok(bytes) = BASE64.decode(b64) else { return; }; + let Ok(el) = HtmlAudioElement::new() else { + return; + }; let arr = Uint8Array::new_with_length(bytes.len() as u32); arr.copy_from(&bytes); let parts = js_sys::Array::new(); @@ -582,290 +834,251 @@ fn play_b64(audio_ref: NodeRef, b64: &str, mime: &str) { let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else { return; }; - if let Some(audio) = audio_ref.get_untracked() { - let el: HtmlAudioElement = audio.unchecked_into(); - let old = el.src(); - if old.starts_with("blob:") { - let _ = web_sys::Url::revoke_object_url(&old); - } - el.set_src(&url); - let _ = el.play(); + let old = el.src(); + if old.starts_with("blob:") { + let _ = web_sys::Url::revoke_object_url(&old); } + el.set_src(&url); + let _ = el.play(); } -// --------------------------------------------------------------------------- -// Behavior section -// --------------------------------------------------------------------------- +#[component] +fn VoicePickCard( + entry: VoiceEntry, + active: bool, + disabled: bool, + settings: RwSignal>, + on_pick: Callback, +) -> impl IntoView { + let id_pick = entry.id.clone(); + let id_preview = entry.id.clone(); + view! { +
      + + +
      + } +} #[component] -fn BehaviorSection(current: VoiceSettings, post_flow: PostSttFlow, save: F) -> impl IntoView -where - F: Fn(VoiceSettings) + Send + Sync + 'static + Copy, -{ +fn VoicePicksGrid( + settings: RwSignal>, + voice_provider: RwSignal, + voice_id: String, + gender_filter: RwSignal, + on_pick: Callback, +) -> impl IntoView { let i18n = expect_context::(); - let on_flow = { - let current = current.clone(); - move |flow: PostSttFlow| { - let mut next = current.clone(); - next.post_stt_flow = flow; - save(next); - } - }; + let picks_enabled = + Memo::new(move |_| voices_pick_enabled(voice_provider.get())); view! { -
      -

      {move || i18n.tr(I18nKey::VoiceBehaviorSection)()}

      -
      - -
      - - -
      +
      +
      + + + +
      -
      +

      + {move || i18n.tr(I18nKey::VoiceVoicesAwsOnly)()} +

      +
      + {move || { + let active = voice_id.clone(); + let filter = gender_filter.get(); + let picks_disabled = !picks_enabled.get(); + let catalog = voice_catalog_for(voice_provider.get()); + catalog + .iter() + .filter(|v| filter.matches(v.gender)) + .map(|v| { + view! { + + } + }) + .collect_view() + }} +
      +
      + } +} + +#[component] +fn VoicePickerBlock( + settings: RwSignal>, + voice_provider: RwSignal, + voice_id: String, + gender_filter: RwSignal, + on_pick: Callback, +) -> impl IntoView { + let i18n = expect_context::(); + + view! { +
      + + +
      } } // --------------------------------------------------------------------------- -// Language section +// Behavior section // --------------------------------------------------------------------------- #[component] -fn LanguageSection(current: VoiceSettings, stt_lang: SttLanguageMode, save: F) -> impl IntoView +fn BehaviorSection( + settings: RwSignal>, + voice_provider: RwSignal, + post_flow: PostSttFlow, + voice_id: String, + gender_filter: RwSignal, + tts_enabled: bool, + save: F, +) -> impl IntoView where F: Fn(VoiceSettings) + Send + Sync + 'static + Copy, { let i18n = expect_context::(); - let on_mode = { - let current = current.clone(); - move |mode: SttLanguageMode| { - let mut next = current.clone(); - next.stt_language = mode; - save(next); - } - }; - let is_follow = matches!(stt_lang, SttLanguageMode::FollowApp); - let is_auto = matches!(stt_lang, SttLanguageMode::AutoDetect); - let is_manual = matches!(stt_lang, SttLanguageMode::Manual { .. }); - let manual_code = if let SttLanguageMode::Manual { ref code } = stt_lang { - code.clone() - } else { - String::new() - }; + let on_flow = Callback::new(move |flow: PostSttFlow| { + let Some(mut next) = settings.get_untracked() else { + return; + }; + next.post_stt_flow = flow; + save(next); + }); + let on_voice = Callback::new(move |id: String| { + let Some(mut next) = settings.get_untracked() else { + return; + }; + next.tts.voice = id; + save(next); + }); + let on_enabled = Callback::new(move |enabled: bool| { + let Some(mut next) = settings.get_untracked() else { + return; + }; + next.tts.enabled = enabled; + save(next); + }); view! {
      -

      {move || i18n.tr(I18nKey::VoiceLanguageSection)()}

      +

      {move || i18n.tr(I18nKey::VoiceBehaviorSection)()}

      - +
      -
      - - () { - on_mode(SttLanguageMode::Manual { code: inp.value() }); - } - } - } - } - /> -
      -
      - } -} - -// --------------------------------------------------------------------------- -// PTT hotkey section -// --------------------------------------------------------------------------- -#[component] -fn PttSection( - current: VoiceSettings, - ptt: PttHotkey, - recording: RwSignal, - save: F, -) -> impl IntoView -where - F: Fn(VoiceSettings) + Send + Sync + 'static + Copy, -{ - let i18n = expect_context::(); - let on_enabled = { - let current = current.clone(); - move |v: bool| { - let mut next = current.clone(); - next.ptt_hotkey.enabled = v; - save(next); - } - }; - let begin_capture = move || recording.set(true); - let capture_keydown = { - let current = current.clone(); - move |ev: web_sys::KeyboardEvent| { - if !recording.get_untracked() { - return; - } - ev.prevent_default(); - if ev.key() == "Escape" { - recording.set(false); - return; - } - if matches!( - ev.code().as_str(), - "ControlLeft" - | "ControlRight" - | "ShiftLeft" - | "ShiftRight" - | "AltLeft" - | "AltRight" - | "MetaLeft" - | "MetaRight" - ) { - return; - } - let mut next = current.clone(); - next.ptt_hotkey = PttHotkey { - enabled: next.ptt_hotkey.enabled, - code: ev.code(), - ctrl: ev.ctrl_key(), - shift: ev.shift_key(), - alt: ev.alt_key(), - meta: ev.meta_key(), - }; - save(next); - recording.set(false); - } - }; - - let display = format_hotkey(&ptt); - let enabled = ptt.enabled; + - view! { -
      -

      {move || i18n.tr(I18nKey::VoicePttSection)()}

      +

      {move || i18n.tr(I18nKey::VoiceTtsAutoplayHint)()}

      +

      + {move || i18n.tr(I18nKey::VoiceTtsLangAutoNote)()} +

      -
      - - -
      -
      - } -} -fn format_hotkey(spec: &PttHotkey) -> String { - let mut parts: Vec<&'static str> = Vec::new(); - if spec.ctrl { - parts.push("Ctrl"); - } - if spec.shift { - parts.push("Shift"); - } - if spec.alt { - parts.push("Alt"); - } - if spec.meta { - parts.push("Meta"); - } - let mut out = parts.join("+"); - if !out.is_empty() { - out.push('+'); +
      } - let key = spec.code.strip_prefix("Key").unwrap_or(&spec.code); - out.push_str(key); - out } // Convince the compiler we still need these types (referenced via trait bounds only). diff --git a/src/workbench/mod.rs b/src/workbench/mod.rs index 29a1a3c..88f63cf 100644 --- a/src/workbench/mod.rs +++ b/src/workbench/mod.rs @@ -3,6 +3,10 @@ mod agent_accent; mod agent_context_handoff; mod agent_panel; mod agent_timeline; +mod agent_model_picker; +mod agent_provider_pane; +mod api_keys_pane; +mod workspace_settings_pane; mod app_prefs; mod browser_tab; mod chat_markdown; @@ -14,7 +18,6 @@ mod harness_ui; mod harness_voice_pane; mod memory_graph; mod memory_panel; -mod model_picker; mod notification_sound; mod path_nav; mod plans_panel; @@ -25,6 +28,7 @@ mod sidebar_resizer; mod sidebar_view_section; pub mod skills_rules_panel; pub mod state; +mod voice_app_controls; mod terminal_cell; mod terminal_glue; mod toast; @@ -33,6 +37,9 @@ mod update_service; mod workspace_panel; pub use agent_panel::AgentPanelDock; +pub use agent_provider_pane::AgentProviderPane; +pub use api_keys_pane::ApiKeysPane; +pub use workspace_settings_pane::WorkspaceSettingsPane; pub use browser_tab::{BrowserTabDock, EmbeddedBrowserGlue}; pub use memory_panel::MemoryPanel; pub use plans_panel::PlansPanel; @@ -51,7 +58,7 @@ use crate::i18n::I18nKey; use crate::open_http::{dom_click_nav_href, DomNavHref}; use crate::service::I18nService; use crate::tauri_bridge::{ - browser_embedding_kind, harness_ensure_default_sandbox, is_tauri_shell, + browser_embedding_kind, harness_ensure_default_sandbox, harness_user_home_dir, is_tauri_shell, workbench_extract_sessions_prefix, workbench_load_state, workbench_merge_sessions_workspace, workbench_prune_notifications, workbench_prune_sessions, workbench_save_state, }; @@ -171,6 +178,11 @@ pub fn WorkbenchShell() -> impl IntoView { wb.persist_harness_workspace_root(path); } } + if wb.default_project_dir().get_untracked().trim().is_empty() { + if let Ok(home) = harness_user_home_dir().await { + wb.persist_default_project_dir(home); + } + } persistence_enabled.set(allow_save); hydrated.set(true); }); diff --git a/src/workbench/model_picker/mod.rs b/src/workbench/model_picker/mod.rs deleted file mode 100644 index 9dd6b45..0000000 --- a/src/workbench/model_picker/mod.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Shared model picker (datalist input + refresh button). -//! -//! Originally inlined in `harness_voice_pane`; lifted here so the image -//! settings pane (and any future provider-bound picker) can reuse the same -//! UX without duplicating logic. -//! -//! Component contract: -//! - `label_key` – i18n key for the field label. -//! - `datalist_id` – DOM id for the inline `` (must be unique per -//! picker on the page; both panes set their own id). -//! - `current` – the currently-persisted model id (displayed in the input). -//! - `models` – reactive list of suggestions. -//! - `on_change` – fired on every keystroke; caller persists. -//! - `on_refresh` – fired when the user clicks the refresh button. - -use crate::i18n::I18nKey; -use crate::service::I18nService; -use crate::tauri_bridge::ProviderModelEntry; -use leptos::prelude::*; -use leptos_icons::Icon as LxIcon; -use wasm_bindgen::JsCast; - -#[component] -pub fn ModelPicker( - label_key: I18nKey, - datalist_id: &'static str, - current: String, - models: RwSignal>, - on_change: F, - on_refresh: R, -) -> impl IntoView -where - F: Fn(String) + Send + Sync + 'static + Clone, - R: Fn() + Send + Sync + 'static + Copy, -{ - let i18n = expect_context::(); - let buf = RwSignal::new(current.clone()); - let loading = RwSignal::new(false); - - // Keep the buffer aligned with the persisted value when settings reload. - Effect::new({ - let current = current.clone(); - move |_| { - let _ = models.get(); - buf.set(current.clone()); - } - }); - - let on_input = { - let on_change = on_change.clone(); - move |ev: web_sys::Event| { - if let Some(t) = ev.target() { - if let Ok(inp) = t.dyn_into::() { - let v = inp.value(); - buf.set(v.clone()); - on_change(v); - } - } - } - }; - - let on_refresh_click = move |_| { - if loading.get_untracked() { - return; - } - loading.set(true); - on_refresh(); - }; - - // Reset the loading flag whenever the model list arrives. - Effect::new(move |_| { - let _ = models.get(); - loading.set(false); - }); - - view! { -
      - - - - {move || { - models.get() - .into_iter() - .map(|m| view! { }) - .collect_view() - }} - -
      - - - {move || format!("{} entries", models.get().len())} - -
      -
      - } -} diff --git a/src/workbench/model_picker/model_picker.css b/src/workbench/model_picker/model_picker.css deleted file mode 100644 index 2745ef4..0000000 --- a/src/workbench/model_picker/model_picker.css +++ /dev/null @@ -1,33 +0,0 @@ -/* Shared model picker — keeps the field layout consistent across the - voice and image settings panes. Globals (workbench-plain-input, - workbench-mini-btn, harness-muted) come from styles.css. */ - -.model-picker { - display: flex; - flex-direction: column; - gap: 0.4rem; - margin-bottom: 0.6rem; -} - -.model-picker__label { - font-size: 0.78rem; - font-weight: 600; - color: var(--text-muted, #9aa); - letter-spacing: 0.02em; - text-transform: uppercase; -} - -.model-picker__input { - width: 100%; -} - -.model-picker__row { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.model-picker__count { - font-size: 0.7rem; - opacity: 0.7; -} diff --git a/src/workbench/project_explorer/mod.rs b/src/workbench/project_explorer/mod.rs index 245b8a1..bef7ee5 100644 --- a/src/workbench/project_explorer/mod.rs +++ b/src/workbench/project_explorer/mod.rs @@ -354,7 +354,15 @@ fn ExplorerNode( } style=pad.clone() role="treeitem" - on:click=|ev: web_sys::MouseEvent| ev.stop_propagation() + on:click={ + let rel_path = rel_path.clone(); + move |ev: web_sys::MouseEvent| { + ev.stop_propagation(); + if let Some(ws) = wb.with_active_workspace_entry() { + wb.open_center_file_tab(ws.id, rel_path.clone()); + } + } + } >