feat(gui): profile switcher chip in header

Visible visual indicator of the active profile + click-to-swap dropdown.
Closes the last small UX gap pointed out in earlier sessions.

Tauri (crates/jarvis-gui/src/tauri_commands/profile.rs)
  - profile_list()   → Vec<String> of available profile names
  - profile_active() → {name, description, icon, greeting}
  - profile_set(name) → swaps active profile, returns new active

Frontend (frontend/src/components/Header.svelte)
  - Polls profile_active every 5 seconds.
  - Shows a coloured chip with icon+name when profile != "default"
    (orange tint, attention-grabbing). For "default" — muted star chip.
  - Click opens a dropdown listing all profile names; click one → swap.
  - Dropdown closes on outside-click (shared handler with lang dropdown).
  - SCSS styled to match existing aesthetic.

Build: cargo build --release -p jarvis-gui green (2m).

Now the user can see at a glance which profile is active (e.g. "💼 work")
without having to ask voice or open Settings.
This commit is contained in:
Bossiara13 2026-05-16 00:48:23 +03:00
parent ea4341fbc9
commit 4efe306b3a
4 changed files with 225 additions and 6 deletions

View file

@ -128,6 +128,11 @@ fn main() {
tauri_commands::memory_remember,
tauri_commands::memory_forget,
tauri_commands::memory_search,
// Profile switching
tauri_commands::profile_list,
tauri_commands::profile_active,
tauri_commands::profile_set,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View file

@ -53,4 +53,8 @@ pub use scheduler::*;
// Long-term memory facts
mod memory;
pub use memory::*;
pub use memory::*;
// Profile switching
mod profile;
pub use profile::*;

View file

@ -0,0 +1,38 @@
//! Tauri commands for profile switching.
use serde::Serialize;
#[derive(Serialize)]
pub struct ProfileInfo {
pub name: String,
pub description: String,
pub icon: String,
pub greeting: String,
}
#[tauri::command]
pub fn profile_list() -> Vec<String> {
jarvis_core::profiles::list()
}
#[tauri::command]
pub fn profile_active() -> ProfileInfo {
let p = jarvis_core::profiles::active();
ProfileInfo {
name: p.name,
description: p.description,
icon: p.icon,
greeting: p.greeting,
}
}
#[tauri::command]
pub fn profile_set(name: String) -> Result<ProfileInfo, String> {
let p = jarvis_core::profiles::set_active(&name)?;
Ok(ProfileInfo {
name: p.name,
description: p.description,
icon: p.icon,
greeting: p.greeting,
})
}

View file

@ -1,9 +1,9 @@
<script lang="ts">
import { goto } from "@roxi/routify"
import { invoke } from "@tauri-apps/api/core"
import { onMount } from "svelte"
import { onMount, onDestroy } from "svelte"
import { currentLanguage, setLanguage, translations, translate } from "@/stores"
let appVersion = ""
let commandsCount = 0
@ -16,12 +16,52 @@
{ code: "ua", label: "UA", flag: "🇺🇦", name: "Українська" },
]
// Profile chip — shows the active profile icon + name, click to swap.
interface ProfileInfo {
name: string
description: string
icon: string
greeting: string
}
let activeProfile: ProfileInfo | null = null
let profileNames: string[] = []
let profileDropdownOpen = false
let profilePoll: ReturnType<typeof setInterval> | null = null
async function refreshProfile() {
try {
activeProfile = await invoke<ProfileInfo>("profile_active")
} catch { /* ignore */ }
}
async function loadProfileNames() {
try {
profileNames = await invoke<string[]>("profile_list")
} catch { /* ignore */ }
}
async function selectProfile(name: string) {
try {
activeProfile = await invoke<ProfileInfo>("profile_set", { name })
} catch (err) {
console.error("profile set failed:", err)
}
profileDropdownOpen = false
}
function toggleProfileDropdown(e: MouseEvent) {
e.stopPropagation()
if (!profileDropdownOpen) {
loadProfileNames()
}
profileDropdownOpen = !profileDropdownOpen
}
onMount(async () => {
try {
appVersion = await invoke<string>("get_app_version")
commandsCount = await invoke<number>("get_commands_count")
// load saved language
const savedLang = await invoke<string>("db_read", { key: "language" })
if (savedLang) {
selectedLang = savedLang
@ -29,6 +69,13 @@
} catch {
commandsCount = 0
}
refreshProfile()
loadProfileNames()
profilePoll = setInterval(refreshProfile, 5000)
})
onDestroy(() => {
if (profilePoll !== null) clearInterval(profilePoll)
})
async function selectLanguage(code: string) {
@ -40,18 +87,21 @@
langDropdownOpen = !langDropdownOpen
}
function closeLangDropdown(e: MouseEvent) {
function closeDropdowns(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.lang-selector')) {
langDropdownOpen = false
}
if (!target.closest('.profile-selector')) {
profileDropdownOpen = false
}
}
$: currentLang = languages.find(l => l.code === $currentLanguage) || languages[0]
$: t = (key: string) => translate($translations, key)
</script>
<svelte:window on:click={closeLangDropdown} />
<svelte:window on:click={closeDropdowns} />
<header id="header" class="header">
<div class="header-left">
@ -67,6 +117,55 @@
</div>
<div class="header-right">
{#if activeProfile && activeProfile.name !== 'default'}
<div class="profile-selector">
<button
class="profile-chip"
title="{activeProfile.name}{activeProfile.description}. Кликни чтобы сменить."
on:click|stopPropagation={toggleProfileDropdown}
>
<span class="profile-icon">{activeProfile.icon || '★'}</span>
<span class="profile-name">{activeProfile.name}</span>
</button>
{#if profileDropdownOpen}
<div class="profile-dropdown">
{#each profileNames as pname}
<button
class="profile-option"
class:active={pname === activeProfile.name}
on:click|stopPropagation={() => selectProfile(pname)}
>
{pname}
</button>
{/each}
</div>
{/if}
</div>
{:else if activeProfile}
<div class="profile-selector">
<button
class="profile-chip default"
title="Текущий профиль — обычный. Кликни чтобы сменить."
on:click|stopPropagation={toggleProfileDropdown}
>
<span class="profile-icon"></span>
</button>
{#if profileDropdownOpen}
<div class="profile-dropdown">
{#each profileNames as pname}
<button
class="profile-option"
class:active={pname === activeProfile.name}
on:click|stopPropagation={() => selectProfile(pname)}
>
{pname}
</button>
{/each}
</div>
{/if}
</div>
{/if}
<button class="header-btn" on:click={() => $goto('/commands')}>
<span class="btn-text">{t('header-commands')}</span>
<span class="btn-badge purple">{commandsCount}+</span>
@ -112,6 +211,79 @@
</header>
<style lang="scss">
.profile-selector {
position: relative;
margin-right: 6px;
}
.profile-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 9px;
background: rgba(255, 168, 60, 0.12);
border: 1px solid rgba(255, 168, 60, 0.35);
border-radius: 14px;
color: #ffa83c;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&.default {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.55);
}
&:hover {
background: rgba(255, 168, 60, 0.22);
border-color: rgba(255, 168, 60, 0.55);
}
}
.profile-icon {
font-size: 14px;
line-height: 1;
}
.profile-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
background: rgba(20, 30, 35, 0.98);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
z-index: 100;
min-width: 130px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.profile-option {
display: block;
width: 100%;
padding: 0.55rem 0.85rem;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.75);
font-size: 0.78rem;
cursor: pointer;
text-align: left;
transition: all 0.15s ease;
&:hover {
background: rgba(255, 168, 60, 0.12);
color: #fff;
}
&.active {
background: rgba(255, 168, 60, 0.18);
color: #ffa83c;
font-weight: 600;
}
}
.lang-selector {
position: relative;
}