Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,13 @@
"@reduxjs/toolkit": "^2.11.2",
"@remotion/player": "4.0.454",
"@remotion/zod-types": "4.0.454",
"@rive-app/react-webgl2": "^4.28.6",
"@scure/base": "^2.2.0",
"@scure/bip32": "^2.0.1",
"@scure/bip39": "^2.0.1",
"@sentry/react": "^10.38.0",
"@tauri-apps/api": "^2.10.0",
"@tauri-apps/plugin-barcode-scanner": "^2.4.4",
"tauri-plugin-ptt-api": "workspace:*",
"@tauri-apps/plugin-deep-link": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2.3.2",
Expand All @@ -104,6 +104,7 @@
"redux-persist": "^6.0.0",
"remotion": "4.0.454",
"socket.io-client": "^4.8.3",
"tauri-plugin-ptt-api": "workspace:*",
"three": "^0.183.2",
"util": "^0.12.5",
"zod": "4.3.6"
Expand Down
Binary file added app/public/tiny_mascot.riv
Binary file not shown.
79 changes: 74 additions & 5 deletions app/src/components/settings/panels/MascotPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';

import { CustomGifMascot } from '../../../features/human/Mascot';
import { CustomGifMascot, RiveMascot } from '../../../features/human/Mascot';
import { BackendMascot } from '../../../features/human/Mascot/backend/BackendMascot';
import type { MascotDetail, MascotSummary } from '../../../features/human/Mascot/backend/types';
import { getMascotPalette, type MascotColor } from '../../../features/human/Mascot/mascotPalette';
import {
getMascotPalette,
hexToArgbInt,
type MascotColor,
} from '../../../features/human/Mascot/mascotPalette';
import { synthesizeSpeech } from '../../../features/human/voice/ttsClient';
import { useT } from '../../../lib/i18n/I18nContext';
import { fetchMascotList, getCachedMascotDetail } from '../../../services/mascotService';
Expand All @@ -13,13 +17,17 @@ import {
isCustomMascotGifUrl,
type MascotVoiceGender,
selectCustomMascotGifUrl,
selectCustomPrimaryColor,
selectCustomSecondaryColor,
selectEffectiveMascotVoiceId,
selectMascotColor,
selectMascotVoiceGender,
selectMascotVoiceId,
selectMascotVoiceUseLocaleDefault,
selectSelectedMascotId,
setCustomMascotGifUrl,
setCustomPrimaryColor,
setCustomSecondaryColor,
setMascotColor,
setMascotVoiceGender,
setMascotVoiceId,
Expand Down Expand Up @@ -47,14 +55,16 @@ const COLOR_OPTIONS: ColorOption[] = [
{ id: 'burgundy', labelKey: 'settings.mascot.colorBurgundy' },
{ id: 'black', labelKey: 'settings.mascot.colorBlack' },
{ id: 'navy', labelKey: 'settings.mascot.colorNavy' },
{ id: 'green', labelKey: 'settings.mascot.colorGreen' },
{ id: 'custom', labelKey: 'settings.mascot.colorCustom' },
];

const MascotPanel = () => {
const { t, locale } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const dispatch = useAppDispatch();
const storedColor = useAppSelector(selectMascotColor);
const customPrimary = useAppSelector(selectCustomPrimaryColor);
const customSecondary = useAppSelector(selectCustomSecondaryColor);
const selectedMascotId = useAppSelector(selectSelectedMascotId);
const customMascotGifUrl = useAppSelector(selectCustomMascotGifUrl);
const storedVoiceId = useAppSelector(selectMascotVoiceId);
Expand Down Expand Up @@ -280,6 +290,16 @@ const MascotPanel = () => {
const visibleActiveDetail = selectedMascotId ? activeDetail : null;
const visibleDetailError = selectedMascotId ? detailError : null;

const activePalette = getMascotPalette(activeColor);
const primaryColorArgb = useMemo(
() => hexToArgbInt(activeColor === 'custom' ? customPrimary : activePalette.bodyFill),
[activeColor, customPrimary, activePalette]
);
const secondaryColorArgb = useMemo(
() => hexToArgbInt(activeColor === 'custom' ? customSecondary : activePalette.neckShadowColor),
[activeColor, customSecondary, activePalette]
);

return (
<div>
<SettingsHeader
Expand All @@ -290,6 +310,17 @@ const MascotPanel = () => {
/>

<div className="p-4 space-y-4">
<div className="flex justify-center">
<div style={{ width: 180, height: 180 }}>
<RiveMascot
face="idle"
size={180}
primaryColor={primaryColorArgb}
secondaryColor={secondaryColorArgb}
/>
</div>
</div>

<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-2 px-1">
{t('settings.mascot.colorHeading')}
Expand Down Expand Up @@ -328,7 +359,13 @@ const MascotPanel = () => {
? 'border-primary-500 shadow-soft'
: 'border-stone-200 dark:border-neutral-800'
}`}
style={{ backgroundColor: palette.bodyFill }}
style={
opt.id === 'custom'
? {
background: `linear-gradient(135deg, ${customPrimary} 50%, ${customSecondary} 50%)`,
}
: { backgroundColor: palette.bodyFill }
}
/>
<span className="text-xs text-stone-700 dark:text-neutral-200">{label}</span>
</button>
Expand All @@ -337,6 +374,38 @@ const MascotPanel = () => {
</div>
)}
</div>
{activeColor === 'custom' && (
<div className="mt-3 bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 p-4 space-y-3">
<label className="flex items-center gap-3">
<input
type="color"
value={customPrimary}
onChange={e => dispatch(setCustomPrimaryColor(e.target.value))}
className="w-8 h-8 rounded-md border border-stone-200 dark:border-neutral-700 cursor-pointer p-0"
/>
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.mascot.primaryColor')}
</span>
<code className="ml-auto text-[11px] font-mono text-stone-400 dark:text-neutral-500">
{customPrimary}
</code>
</label>
<label className="flex items-center gap-3">
<input
type="color"
value={customSecondary}
onChange={e => dispatch(setCustomSecondaryColor(e.target.value))}
className="w-8 h-8 rounded-md border border-stone-200 dark:border-neutral-700 cursor-pointer p-0"
/>
<span className="text-sm text-stone-700 dark:text-neutral-200">
{t('settings.mascot.secondaryColor')}
</span>
<code className="ml-auto text-[11px] font-mono text-stone-400 dark:text-neutral-500">
{customSecondary}
</code>
</label>
</div>
)}
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed px-1 mt-2">
{t('settings.mascot.colorDesc')}
</p>
Expand Down
25 changes: 18 additions & 7 deletions app/src/components/settings/panels/__tests__/MascotPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ vi.mock('../../../../services/mascotService', () => ({
getCachedMascotDetail: (...args: unknown[]) => getCachedMascotDetailMock(...args),
}));

vi.mock('../../../../features/human/Mascot', async importOriginal => {
const actual = await importOriginal<typeof import('../../../../features/human/Mascot')>();
return {
...actual,
RiveMascot: () => <div data-testid="rive-mascot-preview" />,
CustomGifMascot: ({ src }: { src: string }) => (
<img data-testid="custom-gif-mascot" src={src} alt="" />
),
};
});

vi.mock('../../../../features/human/Mascot/backend/BackendMascot', () => ({
BackendMascot: ({ mascot }: { mascot: { id: string } }) => (
<div data-testid={`backend-mascot-preview-${mascot.id}`} />
Expand Down Expand Up @@ -64,7 +75,7 @@ describe('MascotPanel', () => {
it('renders a radio swatch for each supported color', () => {
renderPanel();
expect(screen.getByRole('radiogroup', { name: 'OpenHuman color' })).toBeInTheDocument();
for (const label of ['Yellow', 'Burgundy', 'Black', 'Navy', 'Green']) {
for (const label of ['Yellow', 'Burgundy', 'Black', 'Navy', 'Custom']) {
expect(screen.getByRole('radio', { name: label })).toBeInTheDocument();
}
});
Expand All @@ -85,13 +96,13 @@ describe('MascotPanel', () => {

it('is a no-op when clicking the already-selected color', () => {
const store = buildStore();
store.dispatch(setMascotColor('green'));
store.dispatch(setMascotColor('custom'));
const dispatchSpy = vi.spyOn(store, 'dispatch');
renderPanel(store);
fireEvent.click(screen.getByRole('radio', { name: 'Green' }));
fireEvent.click(screen.getByRole('radio', { name: 'Custom' }));
// No additional dispatches beyond what React-Redux did to subscribe.
expect(dispatchSpy).not.toHaveBeenCalled();
expect(store.getState().mascot.color).toBe('green');
expect(store.getState().mascot.color).toBe('custom');
});

it('invokes navigateBack from the header back button', () => {
Expand Down Expand Up @@ -130,22 +141,22 @@ describe('MascotPanel — mascotSlice rehydrate guard', () => {
it('ignores REHYDRATE actions for other slice keys', () => {
const store = configureStore({ reducer: { mascot: mascotReducer } });
store.dispatch(setMascotColor('navy'));
store.dispatch({ type: REHYDRATE, key: 'someOtherSlice', payload: { color: 'green' } });
store.dispatch({ type: REHYDRATE, key: 'someOtherSlice', payload: { color: 'custom' } });
// Should remain navy — we only handle key === 'mascot'.
expect(store.getState().mascot.color).toBe('navy');
});

it('renders the rehydrated color as selected in the panel', () => {
const store = configureStore({ reducer: { mascot: mascotReducer } });
store.dispatch({ type: REHYDRATE, key: 'mascot', payload: { color: 'green' } });
store.dispatch({ type: REHYDRATE, key: 'mascot', payload: { color: 'custom' } });
render(
<Provider store={store}>
<MemoryRouter>
<MascotPanel />
</MemoryRouter>
</Provider>
);
expect(screen.getByRole('radio', { name: 'Green' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('radio', { name: 'Custom' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('radio', { name: 'Yellow' })).toHaveAttribute('aria-checked', 'false');
});

Expand Down
64 changes: 15 additions & 49 deletions app/src/features/human/HumanPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import chatRuntimeReducer, { setToolTimelineForThread } from '../../store/chatRuntimeSlice';
import chatRuntimeReducer from '../../store/chatRuntimeSlice';
import mascotReducer, { setCustomMascotGifUrl } from '../../store/mascotSlice';
import threadReducer, { setSelectedThread } from '../../store/threadSlice';
import threadReducer from '../../store/threadSlice';
// ── Static import (after mocks are hoisted) ──────────────────────────────
import HumanPage from './HumanPage';

Expand All @@ -23,15 +23,19 @@ vi.mock('../../pages/Conversations', () => ({
default: () => <div data-testid="conversations-stub" />,
}));

vi.mock('./Mascot', () => ({
YellowMascot: () => <div data-testid="mascot-stub" />,
CustomGifMascot: ({ src, face }: { src: string; face?: string }) => (
<img data-testid="custom-gif-mascot" data-face={face} src={src} alt="" />
),
Ghosty: ({ face, bodyColor }: { face?: string; bodyColor?: string }) => (
<div data-testid="ghosty-submascot" data-face={face} data-body-color={bodyColor} />
),
}));
vi.mock('./Mascot', async importOriginal => {
const actual = await importOriginal<typeof import('./Mascot')>();
return {
...actual,
RiveMascot: () => <div data-testid="mascot-stub" />,
CustomGifMascot: ({ src, face }: { src: string; face?: string }) => (
<img data-testid="custom-gif-mascot" data-face={face} src={src} alt="" />
),
Ghosty: ({ face, bodyColor }: { face?: string; bodyColor?: string }) => (
<div data-testid="ghosty-submascot" data-face={face} data-body-color={bodyColor} />
),
};
});

vi.mock('./useHumanMascot', () => ({ useHumanMascot: () => ({ face: 'idle', visemes: [] }) }));

Expand Down Expand Up @@ -105,44 +109,6 @@ describe('HumanPage — speak-replies localStorage persistence', () => {
expect(checkbox).toBeChecked();
});

it('renders sub-mascots for the selected thread subagent timeline', () => {
const store = buildMinimalStore();
store.dispatch(setSelectedThread('thread-subagents'));
store.dispatch(
setToolTimelineForThread({
threadId: 'thread-subagents',
entries: [
{
id: 'thread-subagents:subagent:sub-1:researcher',
name: 'subagent:researcher',
round: 1,
status: 'running',
detail: 'Research the latest docs and report back.',
subagent: {
taskId: 'sub-1',
agentId: 'researcher',
childIteration: 1,
childMaxIterations: 3,
toolCalls: [],
},
},
],
})
);

renderHumanPage(store);

expect(screen.getByTestId('sub-mascot-layer')).toBeInTheDocument();
expect(
screen.getByRole('status', { name: /researcher subagent running/i })
).toBeInTheDocument();
// The bubble renders only the label; activity moved to the title tooltip.
expect(screen.getByText('Researcher')).toBeInTheDocument();
// Activity is in the title attribute of the bubble, not visible body text.
const bubble = screen.getByTestId('sub-mascot-bubble');
expect(bubble).toHaveAttribute('title', expect.stringContaining('Iteration 1/3'));
});

it('renders a custom GIF mascot when one is configured', () => {
const store = buildMinimalStore();
store.dispatch(setCustomMascotGifUrl('https://example.com/avatar.gif'));
Expand Down
40 changes: 20 additions & 20 deletions app/src/features/human/HumanPage.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';

import { useT } from '../../lib/i18n/I18nContext';
import Conversations from '../../pages/Conversations';
import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice';
import { useAppSelector } from '../../store/hooks';
import { selectCustomMascotGifUrl, selectMascotColor } from '../../store/mascotSlice';
import { CustomGifMascot, YellowMascot } from './Mascot';
import { SubMascotLayer } from './SubMascotLayer';
import {
selectCustomMascotGifUrl,
selectCustomPrimaryColor,
selectCustomSecondaryColor,
selectMascotColor,
} from '../../store/mascotSlice';
import { CustomGifMascot, getMascotPalette, hexToArgbInt, RiveMascot } from './Mascot';
import { useHumanMascot } from './useHumanMascot';

const SPEAK_REPLIES_KEY = 'human.speakReplies';

// Stable empty reference so useAppSelector's === equality doesn't force a re-render
// of SubMascotLayer on every store update when no subagent timeline is active.
const EMPTY_TIMELINE: ToolTimelineEntry[] = [];

const HumanPage = () => {
const { t } = useT();
const [speakReplies, setSpeakReplies] = useState<boolean>(() => {
Expand All @@ -26,19 +25,21 @@ const HumanPage = () => {
window.localStorage.setItem(SPEAK_REPLIES_KEY, speakReplies ? '1' : '0');
}, [speakReplies]);

// Visemes are intentionally unused — the YellowMascot has its own talking lipsync.
const { face } = useHumanMascot({ speakReplies });
Comment thread
senamakel marked this conversation as resolved.
const mascotColor = useAppSelector(selectMascotColor);
const customPrimary = useAppSelector(selectCustomPrimaryColor);
const customSecondary = useAppSelector(selectCustomSecondaryColor);
const customMascotGifUrl = useAppSelector(selectCustomMascotGifUrl);
const subMascotTimeline = useAppSelector(state => {
const threadId = state.thread.selectedThreadId ?? state.thread.activeThreadId;
return threadId
? (state.chatRuntime.toolTimelineByThread[threadId] ?? EMPTY_TIMELINE)
: EMPTY_TIMELINE;
});
const palette = getMascotPalette(mascotColor);
const primaryColor = useMemo(
() => hexToArgbInt(mascotColor === 'custom' ? customPrimary : palette.bodyFill),
[mascotColor, customPrimary, palette]
);
const secondaryColor = useMemo(
() => hexToArgbInt(mascotColor === 'custom' ? customSecondary : palette.neckShadowColor),
[mascotColor, customSecondary, palette]
);

// Sidebar reserves ~436px (420px panel + 16px gutter) on the right; the
// mascot stage takes the remaining width so the two never overlap.
return (
<div className="absolute inset-0 bg-stone-100 dark:bg-neutral-950 overflow-hidden">
<div
Expand All @@ -54,9 +55,8 @@ const HumanPage = () => {
{customMascotGifUrl ? (
<CustomGifMascot src={customMascotGifUrl} face={face} />
) : (
<YellowMascot face={face} mascotColor={mascotColor} />
<RiveMascot face={face} primaryColor={primaryColor} secondaryColor={secondaryColor} />
)}
<SubMascotLayer entries={subMascotTimeline} />
</div>
</div>

Expand Down
Loading
Loading