refactor: rename tabs, flatten composer, polish UI (#3611)

This commit is contained in:
Steven Enamakel
2026-06-12 01:05:22 -07:00
committed by GitHub
parent dca3897127
commit 5bf7daeb32
66 changed files with 1077 additions and 1529 deletions
+19 -3
View File
@@ -1,7 +1,12 @@
import * as Sentry from '@sentry/react';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { Provider } from 'react-redux';
import { HashRouter as Router, useLocation, useNavigate } from 'react-router-dom';
import {
HashRouter as Router,
useLocation,
useNavigate,
useNavigationType,
} from 'react-router-dom';
import { PersistGate } from 'redux-persist/integration/react';
import AppRoutes from './AppRoutes';
@@ -200,11 +205,22 @@ function AppShellDesktop() {
// the core is ready (once per boot). Extracted to a hook so it's testable.
useNotchBootSync(isBootstrapping);
const scrollRef = useRef<HTMLDivElement>(null);
const navType = useNavigationType();
useEffect(() => {
if (navType !== 'POP') {
scrollRef.current?.scrollTo(0, 0);
}
}, [location.pathname, navType]);
return (
<div className="relative h-screen flex flex-col overflow-hidden">
<AppBackground />
<div className="relative z-10 flex-1 flex flex-col overflow-hidden">
<div className={`flex-1 overflow-y-auto ${fullscreen || onOnboardingRoute ? '' : 'pb-16'}`}>
<div
ref={scrollRef}
className={`flex-1 overflow-y-auto ${fullscreen || onOnboardingRoute ? '' : 'pb-16'}`}>
<GlobalUpsellBanner />
<AppRoutes />
</div>
+5 -19
View File
@@ -7,7 +7,6 @@ import PublicRoute from './components/PublicRoute';
import HumanPage from './features/human/HumanPage';
import { getIsMobile } from './lib/platform';
import Accounts from './pages/Accounts';
import Activity from './pages/Activity';
import Brain from './pages/Brain';
import AgentInsightsPreview from './pages/dev/AgentInsightsPreview';
import Home from './pages/Home';
@@ -88,20 +87,9 @@ const AppRoutes = () => {
}
/>
{/* Primary Activity surface — replaces /intelligence (Phase 3). */}
<Route
path="/activity"
element={
<ProtectedRoute requireAuth={true}>
<Activity />
</ProtectedRoute>
}
/>
{/* Back-compat: /intelligence → /activity (preserves ?tab= deep links).
Deep links such as ?tab=memory or ?tab=agents still resolve but fall
back to the tasks tab in prod (dev-only tabs are gated inside Activity). */}
<Route path="/intelligence" element={<Navigate to="/activity" replace />} />
{/* Back-compat: /activity and /intelligence → settings notifications hub. */}
<Route path="/activity" element={<Navigate to="/settings/notifications-hub" replace />} />
<Route path="/intelligence" element={<Navigate to="/settings/notifications-hub" replace />} />
{/* Connections page lives at /connections (Phase 2 rename from /skills).
The old /skills path is kept as a back-compat redirect so bookmarks
@@ -177,7 +165,7 @@ const AppRoutes = () => {
{/* Back-compat: /routines was an orphaned dead page (superseded by the
Cron Jobs settings panel). Redirect to Activity → Automations so
any surviving deep links land somewhere sensible. */}
<Route path="/routines" element={<Navigate to="/activity?tab=automations" replace />} />
<Route path="/routines" element={<Navigate to="/settings/automations" replace />} />
<Route
path="/rewards"
@@ -188,9 +176,7 @@ const AppRoutes = () => {
}
/>
{/* Workflows moved onto the Activity page (Automations tab). Keep the
old /workflows path working as a deep link into that tab. */}
<Route path="/workflows" element={<Navigate to="/activity?tab=automations" replace />} />
<Route path="/workflows" element={<Navigate to="/settings/automations" replace />} />
<Route path="/webhooks" element={<Navigate to="/settings/webhooks-triggers" replace />} />
+7 -47
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AVATAR_MENU_ITEMS, CENTER_TAB, NAV_TABS } from '../config/navConfig';
import { AVATAR_MENU_ITEMS, NAV_TABS } from '../config/navConfig';
import { useT } from '../lib/i18n/I18nContext';
import { useCoreState } from '../providers/CoreStateProvider';
import { trackEvent } from '../services/analytics';
@@ -16,10 +16,8 @@ import { resolveUserName } from '../utils/userName';
// ── SVG icons, keyed by tab id ────────────────────────────────────────────────
function TabIcon({ id, large = false }: { id: string; large?: boolean }) {
// Regular pill tabs render small (w-4); the raised center FAB renders large
// (w-6) so its glyph reads as the centerpiece.
const cls = large ? 'w-6 h-6' : 'w-4 h-4';
function TabIcon({ id }: { id: string }) {
const cls = 'w-4 h-4';
switch (id) {
case 'home':
return (
@@ -95,8 +93,7 @@ function TabIcon({ id, large = false }: { id: string; large?: boolean }) {
</svg>
);
case 'brain':
// Two symmetric lobes — reads clearly as a brain. Rendered larger and
// white inside the raised center circle.
// Two symmetric lobes — reads clearly as a brain.
return (
<svg className={cls} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -282,43 +279,8 @@ const BottomTabBar = () => {
);
};
// The Assistant — a raised circular button rising out of the center of the
// bar. The bg-colored ring fakes a notch cut into the pill's top edge.
// `center-fab` marks the button (test/identification hook); it renders a
// static glow when active — no pulse.
//
// `-my-3` collapses the button's 48px (h-12) layout footprint so it no longer
// forces the nav row taller than the ~32px pill tabs — the bar height is
// driven by the tabs, while `-translate-y-4` still lifts the circle above the
// top edge. Without it the lower half of the raised circle left a dead band
// of empty bar height beneath the tabs.
const renderCenterButton = () => {
const active = isActive(CENTER_TAB.path);
const centerTab = { ...CENTER_TAB, label: t(CENTER_TAB.labelKey) };
return (
<button
key={CENTER_TAB.id}
type="button"
data-walkthrough={CENTER_TAB.walkthroughAttr}
onClick={() => handleTabClick(centerTab, active)}
aria-label={centerTab.label}
title={centerTab.label}
className={`center-fab group relative mx-1 flex h-12 w-12 -my-3 -translate-y-4 items-center justify-center rounded-full text-white shadow-soft ring-4 ring-stone-200 transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer dark:ring-neutral-900 ${
active
? 'bg-primary-600 shadow-[0_0_16px_rgba(74,131,221,0.55)] scale-105'
: 'bg-primary-500 hover:bg-primary-600 hover:scale-105'
}`}>
<TabIcon id={CENTER_TAB.id} large />
</button>
);
};
// Home is a normal pill tab now (no longer pinned/icon-only). The regular
// tabs split evenly around the centered Assistant button; only the avatar
// stays pinned to the far-right behind a divider:
// | home · human · brain ( 💬 ) connections · activity · settings | [ avatar ]
const leftTabs = tabs.slice(0, 3);
const rightTabs = tabs.slice(3);
// All tabs render as uniform pill buttons in a single row; the avatar
// stays pinned to the far-right behind a divider.
return (
// pointer-events-none on the full-width shell so transparent areas (e.g.
@@ -344,9 +306,7 @@ const BottomTabBar = () => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setRevealed(false);
}}>
<nav className="pointer-events-auto inline-flex items-center gap-1 rounded-sm border border-stone-300 dark:border-neutral-700 bg-stone-200 dark:bg-neutral-900 shadow-soft px-1 py-1">
{leftTabs.map(tab => renderTab(tab))}
{renderCenterButton()}
{rightTabs.map(tab => renderTab(tab))}
{tabs.map(tab => renderTab(tab))}
<div
className="relative ml-1 border-l border-stone-300 pl-1 dark:border-neutral-700"
ref={profileMenuRef}>
@@ -1,14 +1,11 @@
/**
* Tests for BottomTabBar — verifies that:
* - 6 tabs are rendered (no Rewards tab; Human restored), Activity label is present
* - Assistant tab is present (was "Chat", id stays 'chat', label now 'Assistant')
* - Walkthrough attributes reflect the new ids (tab-connections, tab-activity)
* - 6 tabs are rendered (no Rewards or Activity tab; Human restored; Chat is a regular tab)
* - Chat tab is present (id 'chat', label 'Chat')
* - Walkthrough attributes reflect the new ids (tab-connections)
* - Avatar menu opens and shows Account / Billing / Rewards / Invites / Wallet
* - Clicking an avatar menu item navigates or opens URL
* - The bar is hidden on '/' and '/login' paths
*
* Human tab restored as a first-class entry (after the IA Phase 6 merge into
* Assistant); Chat keeps its "Assistant" label.
*/
import { configureStore } from '@reduxjs/toolkit';
import { fireEvent, render, screen } from '@testing-library/react';
@@ -167,14 +164,10 @@ describe('BottomTabBar', () => {
agentProfilesApiMock.select.mockResolvedValue(testProfiles);
});
it('renders exactly 6 regular tab buttons (Assistant is rendered separately)', async () => {
it('renders exactly 6 regular tab buttons (Chat is a regular tab)', async () => {
await renderBottomTabBar('/home');
// Query only the regular pill tabs inside <nav>: exclude the avatar button
// (aria-haspopup) and the special raised Assistant center button (tab-chat).
const nav = document.querySelector('nav');
const navButtons = nav?.querySelectorAll(
'button:not([aria-haspopup]):not([data-walkthrough="tab-chat"])'
);
const navButtons = nav?.querySelectorAll('button:not([aria-haspopup])');
expect(navButtons).toHaveLength(6);
});
@@ -188,19 +181,18 @@ describe('BottomTabBar', () => {
expect(humanBtn.querySelector('.truncate')).not.toBeNull();
});
it('renders the raised Assistant center button with data-walkthrough="tab-chat"', async () => {
it('renders the Chat tab with data-walkthrough="tab-chat"', async () => {
await renderBottomTabBar('/home');
const assistantBtn = screen.getByRole('button', { name: 'Assistant' });
expect(assistantBtn).toBeInTheDocument();
expect(assistantBtn).toHaveAttribute('data-walkthrough', 'tab-chat');
expect(assistantBtn).toHaveClass('center-fab');
const chatBtn = screen.getByRole('button', { name: 'Chat' });
expect(chatBtn).toBeInTheDocument();
expect(chatBtn).toHaveAttribute('data-walkthrough', 'tab-chat');
});
it('navigates to /chat and tracks the change when the Assistant center button is clicked', async () => {
it('navigates to /chat and tracks the change when the Chat tab is clicked', async () => {
const { trackEvent } = await import('../../services/analytics');
await renderBottomTabBar('/home');
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
fireEvent.click(screen.getByRole('button', { name: 'Chat' }));
expect(trackEvent).toHaveBeenCalledWith('tab_bar_change', {
from_tab: 'home',
@@ -236,9 +228,9 @@ describe('BottomTabBar', () => {
});
});
it('renders the Activity tab', async () => {
it('does NOT render an Activity tab', async () => {
await renderBottomTabBar('/home');
expect(screen.getByRole('button', { name: 'Activity' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Activity' })).toBeNull();
});
it('renders the Brain tab in the regular row with data-walkthrough="tab-brain"', async () => {
@@ -246,8 +238,6 @@ describe('BottomTabBar', () => {
const brainBtn = screen.getByRole('button', { name: 'Brain' });
expect(brainBtn).toBeInTheDocument();
expect(brainBtn).toHaveAttribute('data-walkthrough', 'tab-brain');
// It's a regular pill tab now, not the raised center FAB.
expect(brainBtn).not.toHaveClass('center-fab');
});
it('renders the Connections tab with data-walkthrough="tab-connections"', async () => {
@@ -257,12 +247,6 @@ describe('BottomTabBar', () => {
expect(connectionsBtn).toHaveAttribute('data-walkthrough', 'tab-connections');
});
it('renders Activity tab with data-walkthrough="tab-activity"', async () => {
await renderBottomTabBar('/home');
const activityBtn = screen.getByRole('button', { name: 'Activity' });
expect(activityBtn).toHaveAttribute('data-walkthrough', 'tab-activity');
});
it('renders Settings tab with data-walkthrough="tab-settings"', async () => {
await renderBottomTabBar('/home');
const settingsBtn = screen.getByRole('button', { name: 'Settings' });
+111 -119
View File
@@ -4,10 +4,9 @@ import type { ChatSendError } from '../../chat/chatSendError';
import type { Attachment } from '../../lib/attachments';
import { useT } from '../../lib/i18n/I18nContext';
import AttachmentPreview from './AttachmentPreview';
import CycleUsagePill from './CycleUsagePill';
/** Max composer height ≈ 4 lines of text-sm + padding. */
const COMPOSER_MAX_HEIGHT = 96;
/** Max composer height ≈ 8 lines of text-sm + padding. */
const COMPOSER_MAX_HEIGHT = 192;
export interface ChatComposerProps {
inputValue: string;
@@ -35,11 +34,9 @@ export interface ChatComposerProps {
}
/**
* Two-row chat composer:
* Row 1 — full-width textarea with inline ghost completion
* Row 2 — toolbar: [+] · CycleUsagePill on left | voice · send on right
*
* All buttons live inside the rounded container — no external pill buttons.
* Single-row chat composer: [+] textarea [mic] [send]
* Buttons sit at the bottom-end of the row so they stay anchored when the
* textarea grows with multiline input.
*/
export default function ChatComposer({
inputValue,
@@ -107,128 +104,123 @@ export default function ChatComposer({
</div>
)}
{/* Row 1: Textarea with inline ghost completion */}
<div className="relative flex items-center">
{/* Ghost overlay for inline completion suffix */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words px-4 py-2.5 text-sm leading-normal font-sans">
<span className="invisible">{inputValue}</span>
<span className="text-stone-500 dark:text-neutral-400/50">{inlineCompletionSuffix}</span>
</div>
<textarea
ref={textInputRef}
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onCompositionStart={() => {
isComposingTextRef.current = true;
}}
onCompositionEnd={() => {
isComposingTextRef.current = false;
}}
onKeyDown={handleInputKeyDown}
placeholder={t('chat.typeMessage')}
rows={1}
disabled={composerInteractionBlocked || isSending}
className="relative z-10 w-full resize-none border-0 bg-transparent px-4 py-2.5 text-sm leading-normal whitespace-pre-wrap break-words font-sans text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 overflow-hidden disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
{/* Row 2: Toolbar */}
<div className="flex items-center justify-between px-3 pb-2.5 pt-0.5">
{/* Left: attachment + button, then usage pill */}
<div className="flex items-center gap-2">
{attachmentsEnabled && (
<button
type="button"
data-analytics-id="chat-composer-attach-file"
aria-label={t('composer.attachFile')}
title={t('composer.attachFile')}
onClick={() => fileInputRef.current?.click()}
disabled={
composerInteractionBlocked || isSending || attachments.length >= maxAttachments
}
className="flex items-center justify-center text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M12 5v14m-7-7h14"
/>
</svg>
</button>
)}
<CycleUsagePill />
</div>
{/* Right: voice mode + send */}
<div className="flex items-center gap-2">
{/* Voice mode — switches to mic-cloud mode */}
{/* Single row: [+] textarea [mic] [send] */}
<div className="flex items-center gap-2 p-3">
{/* Attach button */}
{attachmentsEnabled && (
<button
type="button"
data-analytics-id="chat-composer-voice-mode"
aria-label={t('composer.voiceMode')}
title={t('composer.voiceMode')}
onClick={onSwitchToMicCloud}
disabled={composerInteractionBlocked || isSending}
className="flex items-center justify-center text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
data-analytics-id="chat-composer-attach-file"
aria-label={t('composer.attachFile')}
title={t('composer.attachFile')}
onClick={() => fileInputRef.current?.click()}
disabled={
composerInteractionBlocked || isSending || attachments.length >= maxAttachments
}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 10v2a7 7 0 01-14 0v-2M12 19v4m-4 0h8"
strokeWidth={1.8}
d="M12 5v14m-7-7h14"
/>
</svg>
</button>
)}
{/* Send button — always visible */}
<button
type="button"
data-analytics-id="chat-composer-send"
data-testid="send-message-button"
aria-label={t('chat.send')}
title={t('chat.send')}
onClick={() => {
void onSend();
{/* Textarea with ghost completion */}
<div className="relative flex-1 align-middle flex min-w-0">
<div
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words py-0.5 text-sm leading-5 font-sans">
<span className="invisible">{inputValue}</span>
<span className="text-stone-500 dark:text-neutral-400/50">
{inlineCompletionSuffix}
</span>
</div>
<textarea
ref={textInputRef}
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onCompositionStart={() => {
isComposingTextRef.current = true;
}}
disabled={!hasContent || composerInteractionBlocked || isSending}
className="flex items-center justify-center w-7 h-7 rounded-full bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
{isSending ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
)}
</button>
onCompositionEnd={() => {
isComposingTextRef.current = false;
}}
onKeyDown={handleInputKeyDown}
placeholder={t('chat.typeMessage')}
rows={1}
disabled={composerInteractionBlocked || isSending}
className="relative z-10 w-full resize-none border-0 bg-transparent py-0.5 px-0.5 text-sm leading-5 whitespace-pre-wrap break-words font-sans text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 overflow-hidden disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
{/* Voice mode */}
<button
type="button"
data-analytics-id="chat-composer-voice-mode"
aria-label={t('composer.voiceMode')}
title={t('composer.voiceMode')}
onClick={onSwitchToMicCloud}
disabled={composerInteractionBlocked || isSending}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 10v2a7 7 0 01-14 0v-2M12 19v4m-4 0h8"
/>
</svg>
</button>
{/* Send button */}
<button
type="button"
data-analytics-id="chat-composer-send"
data-testid="send-message-button"
aria-label={t('chat.send')}
title={t('chat.send')}
onClick={() => {
void onSend();
}}
disabled={!hasContent || composerInteractionBlocked || isSending}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
{isSending ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
)}
</button>
</div>
</div>
);
@@ -0,0 +1,84 @@
import { useT } from '../../lib/i18n/I18nContext';
import { useAppSelector } from '../../store/hooks';
const DEFAULT_CONTEXT_WINDOW = 200_000;
function fmt(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '0';
if (n < 1000) return String(Math.round(n));
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}K`;
return `${(n / 1_000_000).toFixed(1)}M`;
}
function ok(n: number): boolean {
return Number.isFinite(n) && n > 0;
}
function dot() {
return <span className="text-stone-300 dark:text-neutral-700">·</span>;
}
export default function ComposerTokenStats() {
const { t } = useT();
const usage = useAppSelector(state => state.chatRuntime.sessionTokenUsage);
const inTok = usage.inputTokens || 0;
const outTok = usage.outputTokens || 0;
const turns = usage.turns || 0;
const lastIn = usage.lastTurnInputTokens || 0;
const lastOut = usage.lastTurnOutputTokens || 0;
if (turns === 0) return null;
const showIn = ok(inTok);
const showOut = ok(outTok);
const contextUsed = lastIn + lastOut;
const showCtx = ok(contextUsed);
const contextPct = showCtx
? Math.min(100, Math.round((contextUsed / DEFAULT_CONTEXT_WINDOW) * 100))
: 0;
const parts: React.ReactNode[] = [];
if (showIn) {
parts.push(
<span key="in" title={t('token.inputTokens')}>
{t('token.inLabel')} {fmt(inTok)}
</span>
);
}
if (showOut) {
parts.push(
<span key="out" title={t('token.outputTokens')}>
{t('token.outLabel')} {fmt(outTok)}
</span>
);
}
if (turns > 0) {
parts.push(
<span key="turns" title={t('token.turnsCount')}>
{turns} {turns === 1 ? t('token.turn') : t('token.turns')}
</span>
);
}
if (showCtx) {
parts.push(
<span key="ctx" title={t('token.contextWindow')}>
{t('token.ctxLabel')} {contextPct}% ({fmt(contextUsed)}/{fmt(DEFAULT_CONTEXT_WINDOW)})
</span>
);
}
if (parts.length === 0) return null;
return (
<div className="flex items-center gap-2.5 mt-1.5 text-[10px] font-mono text-stone-400 dark:text-neutral-500 select-none">
{parts.map((part, i) => (
<span key={i} className="contents">
{i > 0 && dot()}
{part}
</span>
))}
</div>
);
}
@@ -1,9 +1,18 @@
import { configureStore } from '@reduxjs/toolkit';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { PropsWithChildren } from 'react';
import { Provider } from 'react-redux';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import themeReducer from '../../store/themeSlice';
import type { GraphEdge, GraphNode } from '../../utils/tauriCommands';
import { MemoryGraph } from './MemoryGraph';
function ReduxWrapper({ children }: PropsWithChildren) {
const store = configureStore({ reducer: { theme: themeReducer } });
return <Provider store={store}>{children}</Provider>;
}
const mocks = vi.hoisted(() => ({
openUrl: vi.fn(),
openWorkspacePath: vi.fn(),
@@ -64,7 +73,7 @@ describe('<MemoryGraph />', () => {
});
it('renders the empty state when there are no nodes', () => {
render(<MemoryGraph nodes={[]} edges={[]} mode="tree" />);
render(<MemoryGraph nodes={[]} edges={[]} mode="tree" />, { wrapper: ReduxWrapper });
expect(screen.getByTestId('memory-graph-empty')).toBeInTheDocument();
});
@@ -74,13 +83,17 @@ describe('<MemoryGraph />', () => {
makeSummaryNode({ id: 'root', level: 0, parent_id: null }),
makeSummaryNode({ id: 'child', level: 1, parent_id: 'root' }),
];
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" onReady={onReady} />);
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" onReady={onReady} />, {
wrapper: ReduxWrapper,
});
await waitFor(() => expect(onReady).toHaveBeenCalledTimes(1));
});
it('does not fire onReady for an empty graph (nothing to lay out)', () => {
const onReady = vi.fn();
render(<MemoryGraph nodes={[]} edges={[]} mode="tree" onReady={onReady} />);
render(<MemoryGraph nodes={[]} edges={[]} mode="tree" onReady={onReady} />, {
wrapper: ReduxWrapper,
});
expect(onReady).not.toHaveBeenCalled();
});
@@ -89,7 +102,9 @@ describe('<MemoryGraph />', () => {
makeSummaryNode({ id: 'root', level: 0, parent_id: null }),
makeSummaryNode({ id: 'child', level: 1, parent_id: 'root' }),
];
const { container } = render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />);
const { container } = render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />, {
wrapper: ReduxWrapper,
});
expect(screen.getByTestId('memory-graph-svg')).toBeInTheDocument();
expect(container.querySelectorAll('circle').length).toBe(2);
expect(screen.getByTestId('memory-graph-node-root')).toBeInTheDocument();
@@ -102,7 +117,7 @@ describe('<MemoryGraph />', () => {
makeContactNode({ id: 'person:alice', label: 'Alice' }),
];
const edges: GraphEdge[] = [{ from: 'd1', to: 'person:alice' }];
render(<MemoryGraph nodes={nodes} edges={edges} mode="contacts" />);
render(<MemoryGraph nodes={nodes} edges={edges} mode="contacts" />, { wrapper: ReduxWrapper });
// Two legend rows render with i18n keys as fallback (graph.document/contact)
// — assert via the rendered nodes count instead, which is deterministic.
expect(screen.getAllByTestId(/memory-graph-node-/).length).toBe(2);
@@ -118,7 +133,7 @@ describe('<MemoryGraph />', () => {
file_basename: 'summary-A',
}),
];
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />);
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />, { wrapper: ReduxWrapper });
fireEvent.click(screen.getByTestId('memory-graph-node-sum-A'));
await waitFor(() => {
expect(mocks.openWorkspacePath).toHaveBeenCalledWith(
@@ -141,7 +156,7 @@ describe('<MemoryGraph />', () => {
}),
];
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />);
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />, { wrapper: ReduxWrapper });
fireEvent.click(screen.getByTestId('memory-graph-node-sum-open-fails'));
await waitFor(() => {
@@ -164,7 +179,7 @@ describe('<MemoryGraph />', () => {
file_basename: 'summary-slack',
}),
];
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />);
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />, { wrapper: ReduxWrapper });
fireEvent.click(screen.getByTestId('memory-graph-node-sum-slack'));
await waitFor(() => {
expect(mocks.openWorkspacePath).toHaveBeenCalledWith(
@@ -183,7 +198,7 @@ describe('<MemoryGraph />', () => {
file_basename: 'summary-A',
}),
];
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />);
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />, { wrapper: ReduxWrapper });
fireEvent.mouseEnter(screen.getByTestId('memory-graph-node-sum-A'));
fireEvent.click(screen.getByTestId('memory-graph-preview-sum-A'));
@@ -211,7 +226,7 @@ describe('<MemoryGraph />', () => {
}),
];
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />);
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />, { wrapper: ReduxWrapper });
fireEvent.mouseEnter(screen.getByTestId('memory-graph-node-sum-preview-fails'));
fireEvent.click(screen.getByTestId('memory-graph-preview-sum-preview-fails'));
@@ -242,7 +257,7 @@ describe('<MemoryGraph />', () => {
}),
];
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />);
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />, { wrapper: ReduxWrapper });
fireEvent.mouseEnter(screen.getByTestId('memory-graph-node-sum-truncated'));
fireEvent.click(screen.getByTestId('memory-graph-preview-sum-truncated'));
@@ -260,7 +275,7 @@ describe('<MemoryGraph />', () => {
file_basename: 'summary-A',
}),
];
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />);
render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />, { wrapper: ReduxWrapper });
const node = screen.getByTestId('memory-graph-node-sum-A');
fireEvent.mouseEnter(node);
@@ -279,7 +294,9 @@ describe('<MemoryGraph />', () => {
file_basename: 'summary-A',
}),
];
const { container } = render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />);
const { container } = render(<MemoryGraph nodes={nodes} edges={[]} mode="tree" />, {
wrapper: ReduxWrapper,
});
fireEvent.mouseEnter(screen.getByTestId('memory-graph-node-sum-A'));
expect(screen.getByTestId('memory-graph-tooltip')).toBeInTheDocument();
@@ -290,7 +307,7 @@ describe('<MemoryGraph />', () => {
it('does NOT call workspace open when a non-summary node is clicked', async () => {
const nodes = [makeChunkNode({ id: 'doc-1' })];
render(<MemoryGraph nodes={nodes} edges={[]} mode="contacts" />);
render(<MemoryGraph nodes={nodes} edges={[]} mode="contacts" />, { wrapper: ReduxWrapper });
fireEvent.click(screen.getByTestId('memory-graph-node-doc-1'));
await Promise.resolve();
expect(mocks.openWorkspacePath).not.toHaveBeenCalled();
@@ -298,7 +315,7 @@ describe('<MemoryGraph />', () => {
it('shows a tooltip footer when a node is hovered', () => {
const nodes = [makeContactNode({ id: 'person:bob', label: 'Bob' })];
render(<MemoryGraph nodes={nodes} edges={[]} mode="contacts" />);
render(<MemoryGraph nodes={nodes} edges={[]} mode="contacts" />, { wrapper: ReduxWrapper });
fireEvent.mouseEnter(screen.getByTestId('memory-graph-node-person:bob'));
expect(screen.getByTestId('memory-graph-tooltip')).toBeInTheDocument();
expect(screen.getByTestId('memory-graph-tooltip').textContent).toContain('Bob');
+23 -28
View File
@@ -38,6 +38,8 @@ import {
} from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { useAppSelector } from '../../store/hooks';
import { resolveTheme, type ThemeMode } from '../../store/themeSlice';
import { type GraphEdge, type GraphMode, type GraphNode } from '../../utils/tauriCommands';
import { openWorkspacePath, previewWorkspaceText } from '../../utils/tauriCommands/workspacePaths';
import {
@@ -194,6 +196,8 @@ function relaxLayout(nodes: SimNode[], edges: Array<[number, number]>, iteration
export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGraphProps) {
const { t } = useT();
const themeMode = useAppSelector(state => state.theme?.mode ?? 'system') as ThemeMode;
const isDark = resolveTheme(themeMode) === 'dark';
const [hovered, setHovered] = useState<GraphNode | null>(null);
// Fire `onReady` at most once across this component's lifetime. The latest
@@ -456,29 +460,12 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr
if (!s || userInteractedRef.current) return;
const ns = s.sim;
if (ns.length === 0) return;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of ns) {
if (n.x < minX) minX = n.x;
if (n.y < minY) minY = n.y;
if (n.x > maxX) maxX = n.x;
if (n.y > maxY) maxY = n.y;
}
if (!Number.isFinite(minX)) return;
const pad = 48;
const w = Math.max(1, maxX - minX);
const h = Math.max(1, maxY - minY);
const scale = Math.min(
ZOOM_MAX,
Math.max(ZOOM_MIN, Math.min((VIEWPORT_W - pad) / w, (VIEWPORT_H - pad) / h))
);
setView({
scale,
tx: VIEWPORT_W / 2 - ((minX + maxX) / 2) * scale,
ty: VIEWPORT_H / 2 - ((minY + maxY) / 2) * scale,
});
// Center on the root node at a fixed comfortable zoom.
const root = ns.find(n => n.kind === 'root');
const cx = root?.x ?? 0;
const cy = root?.y ?? 0;
const scale = 0.17;
setView({ scale, tx: VIEWPORT_W / 2 - cx * scale, ty: VIEWPORT_H / 2 - cy * scale });
}, []);
fitRef.current = fitToView;
@@ -645,7 +632,10 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr
data-testid="memory-graph-svg">
{/* Pan / zoom group — drag the background to pan, scroll to zoom. */}
<g transform={`translate(${view.tx} ${view.ty}) scale(${view.scale})`}>
<g stroke="#cbd5e1" strokeWidth={0.6} opacity={0.7}>
<g
stroke={isDark ? '#cbd5e1' : '#475569'}
strokeWidth={isDark ? 0.6 : 1.2}
opacity={isDark ? 0.7 : 0.7}>
{sim.edges.map(([ai, bi], idx) => {
// Only draw edges whose endpoints are both mounted yet.
if (ai >= svgVisible || bi >= svgVisible) return null;
@@ -684,7 +674,9 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr
cy={n.y}
r={isHover ? r + 2 : r}
fill={fill}
stroke={isHover ? '#0f172a' : '#ffffff'}
stroke={
isHover ? (isDark ? '#0f172a' : '#1e293b') : isDark ? '#ffffff' : '#e2e8f0'
}
strokeWidth={isHover ? 1.4 : 0.8}
style={{ cursor: grabbing ? 'grabbing' : 'pointer', filter: glow }}
onPointerDown={e => onNodePointerDown(e, n)}
@@ -708,7 +700,11 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr
<div
className="border-t border-stone-100 dark:border-neutral-800 bg-stone-50/70 dark:bg-neutral-900/70 px-4 py-2 text-xs text-stone-700 dark:text-neutral-200"
data-testid="memory-graph-tooltip">
{hovered.kind === 'source' ? (
{hovered.kind === 'root' ? (
<span className="font-medium text-violet-600 dark:text-violet-400">
{hovered.label}
</span>
) : hovered.kind === 'source' ? (
<span className="font-medium text-orange-600 dark:text-orange-400">
{hovered.label}
</span>
@@ -778,8 +774,7 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr
}
function tooltipFor(n: GraphNode, t: (key: string, fallback?: string) => string): string {
// NOTE: the underlying t() does not interpolate params; placeholders in the
// translated string are rendered as-is. Preserved to match prior behavior.
if (n.kind === 'root') return n.label;
if (n.kind === 'summary') return t('graph.tooltip.summary');
if (n.kind === 'contact') return t('graph.tooltip.contact');
return n.label || t('graph.document');
@@ -6,11 +6,20 @@
* `WORKER_SUPPORTED` is captured at module load, so Worker must be stubbed
* before MemoryGraph is imported — hence the dynamic import in beforeAll.
*/
import { configureStore } from '@reduxjs/toolkit';
import { act, fireEvent, render } from '@testing-library/react';
import type { PropsWithChildren } from 'react';
import { Provider } from 'react-redux';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import themeReducer from '../../store/themeSlice';
import type { GraphNode } from '../../utils/tauriCommands';
function ReduxWrapper({ children }: PropsWithChildren) {
const store = configureStore({ reducer: { theme: themeReducer } });
return <Provider store={store}>{children}</Provider>;
}
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
vi.mock('../../utils/tauriCommands/workspacePaths', () => ({
openWorkspacePath: vi.fn(),
@@ -73,7 +82,9 @@ describe('<MemoryGraph /> worker-backed SVG path', () => {
});
it('spins up a worker and applies streamed positions imperatively', () => {
const { container } = render(<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />);
const { container } = render(<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />, {
wrapper: ReduxWrapper,
});
const w = last();
expect(w).toBeTruthy();
expect(w.posted[0].type).toBe('init');
@@ -93,7 +104,9 @@ describe('<MemoryGraph /> worker-backed SVG path', () => {
});
it('frames the graph on settle (end → fit transform)', () => {
const { container } = render(<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />);
const { container } = render(<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />, {
wrapper: ReduxWrapper,
});
const w = last();
act(() =>
w.emit({
@@ -108,7 +121,9 @@ describe('<MemoryGraph /> worker-backed SVG path', () => {
});
it('freezes the worker on a drag, but NOT on a plain click', () => {
const { container } = render(<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />);
const { container } = render(<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />, {
wrapper: ReduxWrapper,
});
const svg = container.querySelector('[data-testid="memory-graph-svg"]') as Element;
withCTM(svg);
const w = last();
@@ -125,7 +140,9 @@ describe('<MemoryGraph /> worker-backed SVG path', () => {
});
it('reheats gently and carries positions over on an incremental update', () => {
const { rerender } = render(<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />);
const { rerender } = render(<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />, {
wrapper: ReduxWrapper,
});
const first = last();
act(() =>
first.emit({
@@ -144,13 +161,16 @@ describe('<MemoryGraph /> worker-backed SVG path', () => {
it('mounts a large graph progressively without throwing', () => {
expect(() =>
render(<MemoryGraph nodes={treeNodes(900)} edges={[]} mode="tree" />)
render(<MemoryGraph nodes={treeNodes(900)} edges={[]} mode="tree" />, {
wrapper: ReduxWrapper,
})
).not.toThrow();
});
it('handles node drag, wheel zoom, and reset view', () => {
const { container, getByTestId } = render(
<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />
<MemoryGraph nodes={treeNodes(3)} edges={[]} mode="tree" />,
{ wrapper: ReduxWrapper }
);
const svg = container.querySelector('[data-testid="memory-graph-svg"]') as Element;
withCTM(svg);
@@ -39,7 +39,7 @@ describe('<PixiGraph />', () => {
HTMLElement,
{ simNodes: unknown[]; links: unknown[] },
];
expect(opts.simNodes).toHaveLength(2);
expect(opts.simNodes).toHaveLength(3); // 2 data nodes + synthetic root hub
expect(opts.links).toHaveLength(1); // leaf -> root
});
@@ -68,7 +68,7 @@ describe('memoryGraphLayout', () => {
chunk({ id: 'orphan', parent_id: 'missing' }), // dangling → dropped
];
const { simNodes, links } = buildGraph(nodes, [], 'tree');
expect(simNodes).toHaveLength(4);
expect(simNodes).toHaveLength(5); // 4 data nodes + synthetic root hub
expect(simNodes.every(n => typeof n.x === 'number' && typeof n.y === 'number')).toBe(true);
const pairs = links.map(l => `${String(l.source)}->${String(l.target)}`);
expect(pairs).toContain('child->root');
@@ -83,7 +83,7 @@ describe('memoryGraphLayout', () => {
{ from: 'c1', to: 'ghost' }, // dangling endpoint → dropped
];
const { links } = buildGraph(nodes, edges, 'contacts');
expect(links).toHaveLength(1);
expect(links).toHaveLength(1); // no source nodes → no root links
expect(String(links[0].source)).toBe('c1');
expect(String(links[0].target)).toBe('p1');
});
@@ -36,6 +36,7 @@ export const LEVEL_COLOR = [
export const LEAF_COLOR = '#94A3B8'; // raw chunks / leaves (no level)
export const CONTACT_COLOR = '#A78BFA'; // person entities (contacts mode)
export const SOURCE_COLOR = '#F97316'; // synthetic source root nodes
export const ROOT_COLOR = '#8B5CF6'; // master root hub (purple)
/** Layout is computed in this fixed coordinate space; the renderer pans/zooms it. */
export const VIEWPORT_W = 1100;
@@ -56,6 +57,7 @@ export function levelColor(level: number | null | undefined): string {
}
export function nodeColor(node: GraphNode): string {
if (node.kind === 'root') return ROOT_COLOR;
if (node.kind === 'source') return SOURCE_COLOR;
if (node.kind === 'summary') return levelColor(node.level);
if (node.kind === 'contact') return CONTACT_COLOR;
@@ -63,6 +65,7 @@ export function nodeColor(node: GraphNode): string {
}
export function nodeRadius(node: GraphNode): number {
if (node.kind === 'root') return 20;
if (node.kind === 'source') return 16;
if (node.kind === 'summary') {
// Higher levels render slightly larger, but the size MUST be capped:
@@ -96,18 +99,35 @@ export type SimLink = SimulationLinkDatum<SimNode>;
* Tree mode draws an edge from each node to its `parent_id`; contacts mode
* uses the explicit `edges`. Dangling endpoints are dropped.
*/
export const ROOT_NODE_ID = '__root__';
export function buildGraph(
nodes: GraphNode[],
edges: GraphEdge[],
mode: GraphMode
): { simNodes: SimNode[]; links: SimLink[] } {
const ids = new Set(nodes.map(n => n.id));
const simNodes: SimNode[] = nodes.map((n, i) => {
// Synthetic master root at the origin — all source nodes fan out from it.
const rootNode: SimNode = { kind: 'root', id: ROOT_NODE_ID, label: 'Memory', x: 0, y: 0 };
const simNodes: SimNode[] = [rootNode];
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2;
const r = 180 + (i % 7) * 14;
return { ...n, x: Math.cos(angle) * r, y: Math.sin(angle) * r };
});
simNodes.push({ ...n, x: Math.cos(angle) * r, y: Math.sin(angle) * r });
}
const links: SimLink[] = [];
// Link every source node to the master root.
for (const n of nodes) {
if (n.kind === 'source') {
links.push({ source: n.id, target: ROOT_NODE_ID });
}
}
if (mode === 'tree') {
for (const n of nodes) {
if (!n.parent_id || !ids.has(n.parent_id) || !ids.has(n.id)) continue;
@@ -132,18 +152,37 @@ export function createSimulation(
links: SimLink[]
): Simulation<SimNode, SimLink> {
return forceSimulation(simNodes)
.force('charge', forceManyBody<SimNode>().strength(-140).distanceMax(420))
.force(
'charge',
forceManyBody<SimNode>()
.strength(n => {
if (n.kind === 'root') return -2000;
if (n.kind === 'source') return -800;
return -400;
})
.distanceMax(600)
)
.force(
'link',
forceLink<SimNode, SimLink>(links)
.id(d => d.id)
.distance(58)
.distance(link => {
const src = link.source as SimNode;
const tgt = link.target as SimNode;
if (src.kind === 'root' || tgt.kind === 'root') return 250;
if (src.kind === 'source' || tgt.kind === 'source') return 100;
return 58;
})
.strength(0.35)
)
.force('center', forceCenter(0, 0).strength(0.04))
.force(
'collide',
forceCollide<SimNode>().radius(n => nodeRadius(n) + 2)
forceCollide<SimNode>().radius(n => {
if (n.kind === 'root') return 80;
if (n.kind === 'source') return 40;
return nodeRadius(n) + 2;
})
)
.stop();
}
@@ -95,31 +95,14 @@ export async function mountPixiGraph(
// so the initial frame is zoomed out to show as much as possible.
let userInteracted = false;
/** Scale + centre the world so every node's disc fits the viewport. */
/** Centre on the root node at a fixed comfortable zoom. */
const fitToView = () => {
if (opts.simNodes.length === 0) return;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of opts.simNodes) {
const r = nodeRadius(n) + 6;
if (n.x - r < minX) minX = n.x - r;
if (n.y - r < minY) minY = n.y - r;
if (n.x + r > maxX) maxX = n.x + r;
if (n.y + r > maxY) maxY = n.y + r;
}
if (!Number.isFinite(minX)) return;
const pad = 48;
const w = Math.max(1, maxX - minX);
const h = Math.max(1, maxY - minY);
const scale = Math.min(
ZOOM_MAX,
Math.max(ZOOM_MIN, Math.min((app.screen.width - pad) / w, (app.screen.height - pad) / h))
);
const root = opts.simNodes.find(n => n.kind === 'root');
const cx = root?.x ?? 0;
const cy = root?.y ?? 0;
const scale = 0.17;
world.scale.set(scale);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
world.position.set(app.screen.width / 2 - cx * scale, app.screen.height / 2 - cy * scale);
};
+55 -260
View File
@@ -1,9 +1,3 @@
// Meeting bots entry point on the Skills "Integrations" section.
//
// Surfaces as a compact banner: clicking opens a modal that asks the
// backend to send a Recall.ai-hosted mascot bot into the meeting. The
// backend streams replies, harness requests, and the final transcript
// back through the core Socket.IO bridge.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type MascotFace, RiveMascot } from '../../features/human/Mascot';
@@ -43,24 +37,13 @@ interface Props {
}
export default function MeetingBotsCard({ onToast }: Props) {
const [open, setOpen] = useState(false);
const status = useAppSelector(selectBackendMeetStatus);
// Only switch to ActiveMeetingView once the backend has actually admitted
// the bot. The 'joining' state still leaves the user on the modal so a
// synchronous backend rejection (e.g. paid-plan gate) surfaces in the
// modal's error alert instead of flashing through ActiveMeetingView.
const showActive = status === 'active';
return (
<>
{showActive ? (
<ActiveMeetingView onToast={onToast} />
) : (
<MeetingBotsBanner onClick={() => setOpen(true)} />
)}
{open && <MeetingBotsModal onClose={() => setOpen(false)} onToast={onToast} />}
</>
return showActive ? (
<ActiveMeetingView onToast={onToast} />
) : (
<MeetingBotsInline onToast={onToast} />
);
}
@@ -190,73 +173,10 @@ function ActiveMeetingView({ onToast }: Props) {
);
}
function MeetingBotsBanner({ onClick }: { onClick: () => void }) {
const { t } = useT();
return (
<button
type="button"
onClick={onClick}
data-testid="meeting-bots-banner"
className="group relative w-full overflow-hidden rounded-2xl border border-primary-200/60 dark:border-primary-500/30 bg-gradient-to-br from-primary-50 via-white to-amber-50 dark:from-primary-500/15 dark:via-neutral-900 dark:to-amber-500/10 p-4 text-left shadow-soft transition hover:-translate-y-0.5 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 animate-fade-up">
{/* Decorative gradient orbs — purely cosmetic, hidden from a11y. */}
<span
aria-hidden="true"
className="pointer-events-none absolute -right-8 -top-8 h-32 w-32 rounded-full bg-primary-300/30 blur-2xl transition group-hover:bg-primary-300/40"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute -bottom-10 -left-6 h-24 w-24 rounded-full bg-amber-300/30 blur-2xl"
/>
<div className="relative flex items-center gap-3">
<span
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white dark:bg-neutral-900 text-base font-bold text-primary-600 shadow-soft ring-1 ring-primary-200/70">
{/* Tiny "wave" mark — three dots that animate on hover. */}
<span className="flex items-end gap-0.5">
<span className="h-2 w-1 rounded-full bg-primary-500 transition group-hover:h-3" />
<span className="h-3 w-1 rounded-full bg-primary-500 transition group-hover:h-4" />
<span className="h-2 w-1 rounded-full bg-primary-500 transition group-hover:h-3" />
</span>
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('skills.meetingBots.bannerTitle')}
</h2>
<span className="rounded-full bg-primary-100 dark:bg-primary-500/20 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-700 dark:text-primary-300">
{t('skills.meetingBots.newBadge')}
</span>
</div>
<p className="mt-0.5 line-clamp-1 text-[11px] leading-relaxed text-stone-600 dark:text-neutral-300">
{t('skills.meetingBots.bannerDesc')}
</p>
</div>
<span
aria-hidden="true"
className="ml-2 hidden text-stone-400 dark:text-neutral-500 transition group-hover:text-stone-600 dark:group-hover:text-neutral-300 sm:inline">
</span>
</div>
</button>
);
}
interface ModalProps {
onClose: () => void;
onToast?: (toast: Toast) => void;
}
export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
function MeetingBotsInline({ onToast }: Props) {
const { t } = useT();
const dispatch = useAppDispatch();
const [meetUrl, setMeetUrl] = useState('');
// PASSIVE MODE: the respondTo state is retained for the
// joinMeetViaBackendBot payload (always passed undefined now since the
// backend ignores it) but the setter is unused while the input field
// is hidden. Restore `[respondTo, setRespondTo]` if the field returns.
const [respondTo] = useState('');
const personaDisplayName = useAppSelector(selectPersonaDisplayName);
const personaDescription = useAppSelector(selectPersonaDescription);
@@ -268,15 +188,7 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
const [error, setError] = useState<string | null>(null);
const meetStatus = useAppSelector(selectBackendMeetStatus);
const meetError = useAppSelector(selectBackendMeetError);
// True once the user has clicked Join in this modal session — guards the
// status-watching effect against stale redux state from a prior attempt.
// Held as a ref (not state) so toggling it inside the effect doesn't
// trigger a re-render cascade — see react-hooks/set-state-in-effect.
const hasSubmittedRef = useRef(false);
// Recent-calls history loaded from core when the modal opens.
// `null` means "not yet fetched"; `[]` means "fetched, no rows".
// Separating the two lets the UI render a "Loading…" hint on
// first open without flashing a misleading empty state.
const [recentCalls, setRecentCalls] = useState<MeetCallRecord[] | null>(null);
const [recentError, setRecentError] = useState<string | null>(null);
@@ -294,8 +206,6 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
}, []);
useEffect(() => {
// Fire-and-forget on mount; the modal is short-lived (closes on
// submit or Cancel) so a slow RPC here can't pile up.
void refreshRecentCalls();
}, [refreshRecentCalls]);
@@ -308,27 +218,6 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
? { primaryColor: customPrimaryColor, secondaryColor: customSecondaryColor }
: undefined;
// The modal blocks dismissal while a join request is in flight so the
// backend's admit/reject verdict (success toast + close, or inline
// error) isn't skipped by an early Escape / backdrop click / X / Cancel.
const canDismiss = !submitting;
// Esc closes the modal — matches the OpenhumanLinkModal pattern.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && canDismiss) onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [canDismiss, onClose]);
// After Join is clicked, watch the backend meet status. The RPC returns
// as soon as the join request reaches the core, but the actual admit /
// reject from the bot service arrives asynchronously over the socket.
// - 'active' → bot was admitted; surface success toast and close.
// - 'error' → bot was rejected (paid-plan gate, capacity, etc); leave
// the modal open with the backend's message in the alert
// so the user is blocked from joining and sees why.
useEffect(() => {
if (!hasSubmittedRef.current) return;
if (meetStatus === 'active') {
@@ -339,7 +228,6 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
message: t('skills.meetingBots.joiningMessage'),
});
setMeetUrl('');
onClose();
return;
}
if (meetStatus === 'error') {
@@ -349,7 +237,7 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
setSubmitting(false);
onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message });
}
}, [meetStatus, meetError, onClose, onToast, t]);
}, [meetStatus, meetError, onToast, t]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -357,18 +245,8 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
setSubmitting(true);
hasSubmittedRef.current = true;
try {
// Generate a correlation ID so every backend event for this session
// can be tied back to this meeting.
const meetingId = crypto.randomUUID();
// Mark the meet slice as "joining" so the rest of the app reflects
// the in-flight state. The modal stays open until the backend either
// admits the bot (status → 'active', useEffect closes the modal) or
// rejects it (status → 'error', useEffect surfaces the message).
dispatch(setBackendMeetJoining({ meetUrl: meetUrl.trim(), meetingId }));
// Backend Recall.ai bot: sends the mascot into the meeting via
// the backend's Recall.ai integration. The backend joins as a
// participant, renders the mascot as the bot's camera feed, and
// streams transcript events back over Socket.IO.
await joinMeetViaBackendBot({
meetUrl,
displayName: agentName,
@@ -380,8 +258,6 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
correlationId: meetingId,
respondToParticipant: respondTo.trim() || undefined,
});
// Don't close the modal here — wait for status === 'active' or 'error'
// via the watcher useEffect above.
} catch (err) {
const message = err instanceof Error ? err.message : t('skills.meetingBots.failedToStart');
setError(message);
@@ -392,127 +268,60 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
};
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('skills.meetingBots.modalAriaLabel')}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={() => {
if (canDismiss) onClose();
}}>
<div
className="w-full max-w-md overflow-hidden rounded-2xl bg-white dark:bg-neutral-900 shadow-xl"
onClick={e => e.stopPropagation()}>
{/* Header band — same fun gradient as the banner so the modal feels like
a continuation of the click, not a context switch. */}
<div className="relative bg-gradient-to-br from-primary-50 via-white to-amber-50 dark:from-primary-500/15 dark:via-neutral-900 dark:to-amber-500/10 px-5 py-4">
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
disabled={!canDismiss}
className="absolute right-3 top-3 rounded-full p-1 text-stone-500 dark:text-neutral-400 hover:bg-white/80 dark:hover:bg-neutral-800/60 hover:text-stone-800 dark:hover:text-neutral-100 disabled:cursor-not-allowed disabled:opacity-40">
</button>
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('skills.meetingBots.modalTitle')}
</h2>
<p className="mt-1 text-xs leading-relaxed text-stone-600 dark:text-neutral-300">
{t('skills.meetingBots.modalDesc')}
</p>
</div>
<div className="space-y-4 p-5">
<form onSubmit={handleSubmit} className="space-y-3">
<label className="block">
<span className="text-[10px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('skills.meetingBots.meetingLink')}
</span>
<input
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
value={meetUrl}
onChange={e => setMeetUrl(e.target.value)}
placeholder={t('skills.meetingBots.platformHints.gmeet')}
disabled={submitting}
autoFocus
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-stone-50 dark:disabled:bg-neutral-800/60"
required
/>
</label>
{/* PASSIVE MODE: the bot doesn't listen for a wake phrase or
respond to a single participant — it just transcribes. The
"Your Name in This Meeting" field is hidden so users aren't
prompted for input that no longer affects behavior. Restore
this block if the responsive bot is ever re-enabled. */}
{/* <label className="block">
<span className="text-[10px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('skills.meetingBots.respondToParticipant')}
</span>
<input
type="text"
autoComplete="off"
spellCheck={false}
value={respondTo}
onChange={e => setRespondTo(e.target.value)}
placeholder={t('skills.meetingBots.respondToParticipantHint')}
disabled={submitting}
required
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-stone-50 dark:disabled:bg-neutral-800/60"
/>
<p className="mt-1 text-[10px] text-stone-400 dark:text-neutral-500">
{t('skills.meetingBots.respondToParticipantDesc')}
</p>
</label> */}
{error && (
<div
role="alert"
className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{error}
</div>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
disabled={!canDismiss}
className="rounded-xl px-3 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-40">
{t('common.cancel')}
</button>
<button
type="submit"
disabled={submitting || !meetUrl.trim()}
className="rounded-xl bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 disabled:cursor-not-allowed disabled:bg-stone-200 dark:disabled:bg-neutral-700 disabled:text-stone-400 dark:disabled:text-neutral-500">
{submitting
? t('skills.meetingBots.starting')
: t('skills.meetingBots.sendTo').replace('{label}', selectedLabel)}
</button>
</div>
</form>
<RecentCallsSection rows={recentCalls} error={recentError} />
</div>
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 shadow-soft animate-fade-up">
<div className="mb-4">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('skills.meetingBots.modalTitle')}
</h2>
<p className="mt-1 text-xs leading-relaxed text-stone-600 dark:text-neutral-300">
{t('skills.meetingBots.modalDesc')}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<label className="block">
<span className="text-[10px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('skills.meetingBots.meetingLink')}
</span>
<input
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
value={meetUrl}
onChange={e => setMeetUrl(e.target.value)}
placeholder={t('skills.meetingBots.platformHints.gmeet')}
disabled={submitting}
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-stone-50 dark:disabled:bg-neutral-800/60"
required
/>
</label>
{error && (
<div
role="alert"
className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{error}
</div>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="submit"
disabled={submitting || !meetUrl.trim()}
className="rounded-xl bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 disabled:cursor-not-allowed disabled:bg-stone-200 dark:disabled:bg-neutral-700 disabled:text-stone-400 dark:disabled:text-neutral-500">
{submitting
? t('skills.meetingBots.starting')
: t('skills.meetingBots.sendTo').replace('{label}', selectedLabel)}
</button>
</div>
</form>
<RecentCallsSection rows={recentCalls} error={recentError} />
</div>
);
}
/**
* Recent calls list rendered below the join form inside the same
* modal — same surface where the user launches a call, so they see
* their history without navigating away. Three states:
* - `rows === null` → still loading (small spinner-y hint).
* - `rows === []` → no calls yet (gentle empty state).
* - `rows.length > 0` → render a compact list, newest first.
*
* `error` is shown inline above the list when the fetch failed but
* doesn't block the form — the join path is independent.
*/
function RecentCallsSection({
rows,
error,
@@ -537,11 +346,6 @@ function RecentCallsSection({
</div>
{error && (
// Plain status text rather than role="alert" — the join form
// already owns the alert role for the modal's primary error
// surface. A failure to fetch history is informational, not
// actionable, and shouldn't collide with the form's a11y
// announcement.
<p className="mt-2 text-[11px] text-coral-600 dark:text-coral-400">{error}</p>
)}
@@ -565,9 +369,6 @@ function RecentCallsSection({
}
function RecentCallRow({ call }: { call: MeetCallRecord }) {
// Show the trailing meeting code (`abc-defg-hij`) rather than the
// full URL — the URL prefix is always `https://meet.google.com/`
// and would just waste row width.
const meetingCode = (() => {
try {
const parsed = new URL(call.meet_url);
@@ -598,12 +399,6 @@ function RecentCallRow({ call }: { call: MeetCallRecord }) {
);
}
/**
* Compact "12 min ago" / "yesterday" / "May 14" style stamp. Browser
* `Intl.RelativeTimeFormat` would be nicer but pulls a much larger
* locale data path; the targets here are short labels in a single
* surface, not a full i18n investment.
*/
function formatRelativeTime(ms: number): string {
if (!ms) return '—';
const diff = Date.now() - ms;
@@ -7,7 +7,7 @@ import {
setBackendMeetJoined,
} from '../../../store/backendMeetSlice';
import { renderWithProviders } from '../../../test/test-utils';
import MeetingBotsCard, { MeetingBotsModal } from '../MeetingBotsCard';
import MeetingBotsCard from '../MeetingBotsCard';
const joinMock = vi.fn();
const listMock = vi.fn();
@@ -29,35 +29,13 @@ describe('MeetingBotsCard', () => {
beforeEach(() => {
joinMock.mockReset();
listMock.mockReset();
// Default: resolve with empty list so modal renders without flashing errors.
listMock.mockResolvedValue([]);
});
afterEach(() => cleanup());
it('renders the banner and hides the modal by default', () => {
it('renders the inline form directly (no banner/modal)', () => {
renderWithProviders(<MeetingBotsCard />);
expect(screen.getByTestId('meeting-bots-banner')).toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('opens the modal when the banner is clicked', () => {
renderWithProviders(<MeetingBotsCard />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('closes the modal on Cancel', () => {
renderWithProviders(<MeetingBotsCard />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('closes the modal on Escape', () => {
renderWithProviders(<MeetingBotsCard />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.getByLabelText(/meeting link/i)).toBeInTheDocument();
});
it('submits to joinMeetViaBackendBot and fires a success toast', async () => {
@@ -68,11 +46,10 @@ describe('MeetingBotsCard', () => {
const onToast = vi.fn();
const { store } = renderWithProviders(<MeetingBotsCard onToast={onToast} />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
fireEvent.change(screen.getByLabelText(/meeting link/i), {
target: { value: 'https://meet.google.com/abc-defg-hij' },
});
const form = screen.getByRole('dialog').querySelector('form')!;
const form = document.querySelector('form')!;
fireEvent.submit(form);
await vi.waitFor(() => {
@@ -85,9 +62,6 @@ describe('MeetingBotsCard', () => {
})
);
});
// The modal now waits for the backend's admit signal — simulate it by
// dispatching the same slice action the socket layer fires on
// bot:joined / agent_meetings:joined.
store.dispatch(
setBackendMeetJoined({ meetUrl: 'https://meet.google.com/abc-defg-hij' })
);
@@ -96,10 +70,6 @@ describe('MeetingBotsCard', () => {
expect.objectContaining({ type: 'success', title: expect.stringMatching(/joining/i) })
);
});
// Modal closes after the backend admits the bot.
await vi.waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('uses the saved persona and mascot profile when joining', async () => {
@@ -124,11 +94,10 @@ describe('MeetingBotsCard', () => {
},
});
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
fireEvent.change(screen.getByLabelText(/meeting link/i), {
target: { value: 'https://meet.google.com/abc-defg-hij' },
});
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!);
fireEvent.submit(document.querySelector('form')!);
await vi.waitFor(() => {
expect(joinMock).toHaveBeenCalledWith(
@@ -149,11 +118,10 @@ describe('MeetingBotsCard', () => {
const onToast = vi.fn();
renderWithProviders(<MeetingBotsCard onToast={onToast} />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
fireEvent.change(screen.getByLabelText(/meeting link/i), {
target: { value: 'https://meet.google.com/x' },
});
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!);
fireEvent.submit(document.querySelector('form')!);
await vi.waitFor(() => {
expect(onToast).toHaveBeenCalledWith(
@@ -163,7 +131,7 @@ describe('MeetingBotsCard', () => {
expect(screen.getByRole('alert')).toHaveTextContent('Bad URL');
});
it('keeps the modal open with the backend message when the bot is rejected', async () => {
it('surfaces the backend rejection error inline', async () => {
joinMock.mockResolvedValueOnce({
meetUrl: 'https://meet.google.com/abc-defg-hij',
platform: 'gmeet',
@@ -171,14 +139,12 @@ describe('MeetingBotsCard', () => {
const onToast = vi.fn();
const { store } = renderWithProviders(<MeetingBotsCard onToast={onToast} />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
fireEvent.change(screen.getByLabelText(/meeting link/i), {
target: { value: 'https://meet.google.com/abc-defg-hij' },
});
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!);
fireEvent.submit(document.querySelector('form')!);
await vi.waitFor(() => expect(joinMock).toHaveBeenCalled());
// Simulate the backend rejecting the bot (paid-plan gate, capacity, etc).
store.dispatch(
setBackendMeetError({ error: 'Meeting bot is a paid-plan feature.' })
);
@@ -188,9 +154,6 @@ describe('MeetingBotsCard', () => {
'Meeting bot is a paid-plan feature.'
);
});
// Modal stays open so the user is blocked rather than being dropped into
// an ActiveMeetingView that immediately collapses.
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(onToast).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
@@ -199,56 +162,19 @@ describe('MeetingBotsCard', () => {
);
});
it('does not show meeting platform choices in the Google Meet CTA', () => {
it('shows the Google Meet CTA button', () => {
renderWithProviders(<MeetingBotsCard />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
expect(screen.queryByRole('button', { name: /Zoom/ })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Microsoft Teams/ })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /send to google meet/i })).toBeInTheDocument();
});
it('blocks Escape / Cancel / X / backdrop dismissals while a join is in flight', async () => {
// Make the join RPC hang so we stay in the in-flight state.
let resolveJoin: ((v: unknown) => void) | undefined;
joinMock.mockImplementationOnce(() => new Promise(r => (resolveJoin = r)));
renderWithProviders(<MeetingBotsCard />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
fireEvent.change(screen.getByLabelText(/meeting link/i), {
target: { value: 'https://meet.google.com/abc-defg-hij' },
});
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!);
await waitFor(() => expect(joinMock).toHaveBeenCalled());
// Cancel + X are visually disabled while in flight.
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /close/i })).toBeDisabled();
// Escape, backdrop click, Cancel click — modal stays open.
fireEvent.keyDown(window, { key: 'Escape' });
fireEvent.click(screen.getByRole('dialog'));
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Release the RPC so other tests' state doesn't leak.
resolveJoin?.({ meetUrl: 'https://meet.google.com/abc-defg-hij', platform: 'gmeet' });
});
it('only asks for the meeting link in passive mode', () => {
renderWithProviders(<MeetingBotsCard />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
expect(screen.getByLabelText(/meeting link/i)).toBeInTheDocument();
// PASSIVE MODE: the "Your Name in This Meeting" (respondTo) field is
// hidden because the bot no longer listens for a wake phrase or
// targets a specific speaker — it just transcribes.
expect(screen.queryByLabelText(/your name in this meeting/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/^display name$/i)).not.toBeInTheDocument();
expect(screen.queryByText(/^wake phrase$/i)).not.toBeInTheDocument();
});
});
// ── ActiveMeetingView tests ───────────────────────────────────────────────────
// Exercises the live-meeting banner rendered when Redux status is active/joining.
const activeMeetState = {
backendMeet: {
@@ -270,9 +196,7 @@ describe('MeetingBotsCard — ActiveMeetingView', () => {
it('shows the LIVE badge and meeting code when status is active', () => {
renderWithProviders(<MeetingBotsCard />, { preloadedState: activeMeetState });
// Both "Live" (badge) and "Live in meeting" (status text) are present
expect(screen.getAllByText(/live/i).length).toBeGreaterThan(0);
// Pathname stripped: shows "abc-defg-hij" not the full URL
expect(screen.getByText('abc-defg-hij')).toBeInTheDocument();
});
@@ -288,7 +212,6 @@ describe('MeetingBotsCard — ActiveMeetingView', () => {
});
it('Leave button is disabled during in-flight leave call', async () => {
// Hang the leave call so we can inspect intermediate disabled state
leaveMock.mockReturnValue(new Promise(() => {}));
renderWithProviders(<MeetingBotsCard />, { preloadedState: activeMeetState });
const btn = screen.getByRole('button', { name: /leave/i });
@@ -308,27 +231,21 @@ describe('MeetingBotsCard — ActiveMeetingView', () => {
expect(screen.getByText(/hi there/i)).toBeInTheDocument();
});
it('keeps the banner (not ActiveMeetingView) while status is joining', () => {
// The 'joining' status no longer flips to ActiveMeetingView — the
// modal stays open over the banner until the backend either admits
// (status → 'active') or rejects (status → 'error'). When the modal
// is closed and status is 'joining', the banner remains.
it('shows the inline form (not ActiveMeetingView) while status is joining', () => {
renderWithProviders(<MeetingBotsCard />, {
preloadedState: {
backendMeet: { ...activeMeetState.backendMeet, status: 'joining' as const },
},
});
expect(screen.getByTestId('meeting-bots-banner')).toBeInTheDocument();
expect(screen.getByLabelText(/meeting link/i)).toBeInTheDocument();
expect(screen.queryByText(/live in meeting/i)).not.toBeInTheDocument();
});
it('shows banner (not ActiveMeetingView) when status is ended', () => {
// MeetingBotsCard only shows ActiveMeetingView for active/joining.
// When ended the banner is rendered so the user can start a new call.
it('shows the inline form (not ActiveMeetingView) when status is ended', () => {
renderWithProviders(<MeetingBotsCard />, {
preloadedState: { backendMeet: { ...activeMeetState.backendMeet, status: 'ended' as const } },
});
expect(screen.getByTestId('meeting-bots-banner')).toBeInTheDocument();
expect(screen.getByLabelText(/meeting link/i)).toBeInTheDocument();
expect(screen.queryByText(/live in meeting/i)).not.toBeInTheDocument();
});
@@ -344,8 +261,6 @@ describe('MeetingBotsCard — ActiveMeetingView', () => {
});
// ── RecentCallsSection / RecentCallRow tests ──────────────────────────────────
// These exercise the listMeetCalls integration inside MeetingBotsModal:
// loading state, empty state, error state, and populated list.
function makeCallRecord(overrides: Partial<MeetCallRecord> = {}): MeetCallRecord {
return {
@@ -353,7 +268,7 @@ function makeCallRecord(overrides: Partial<MeetCallRecord> = {}): MeetCallRecord
meet_url: 'https://meet.google.com/abc-defg-hij',
bot_display_name: 'OpenHuman',
owner_display_name: 'Alice',
started_at_ms: Date.now() - 5 * 60 * 1000, // 5 minutes ago
started_at_ms: Date.now() - 5 * 60 * 1000,
ended_at_ms: Date.now() - 4 * 60 * 1000,
listened_seconds: 30,
spoken_seconds: 30,
@@ -362,23 +277,18 @@ function makeCallRecord(overrides: Partial<MeetCallRecord> = {}): MeetCallRecord
};
}
describe('MeetingBotsModal — recent calls section', () => {
describe('MeetingBotsCard — recent calls section', () => {
afterEach(() => cleanup());
it('shows a loading hint while listMeetCalls is pending', () => {
// Never resolves during this test — simulates a slow fetch.
listMock.mockReturnValue(new Promise(() => {}));
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
expect(screen.getByText(/loading…/i)).toBeInTheDocument();
});
it('shows an empty-state message when listMeetCalls returns an empty array', async () => {
listMock.mockResolvedValueOnce([]);
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
expect(screen.getByText(/no previous calls yet/i)).toBeInTheDocument();
});
@@ -398,38 +308,29 @@ describe('MeetingBotsModal — recent calls section', () => {
}),
];
listMock.mockResolvedValueOnce(records);
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
expect(screen.getByText('aaa-bbbb-ccc')).toBeInTheDocument();
expect(screen.getByText('ddd-eeee-fff')).toBeInTheDocument();
});
// turn counts shown in the row detail line
expect(screen.getByText(/2 turns/i)).toBeInTheDocument();
expect(screen.getByText(/5 turns/i)).toBeInTheDocument();
});
it('shows the count badge when there is at least one record', async () => {
listMock.mockResolvedValueOnce([makeCallRecord()]);
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
// The "(1)" count badge next to the "Recent calls" heading.
expect(screen.getByText('(1)')).toBeInTheDocument();
});
});
it('shows an error hint and an empty list when listMeetCalls rejects', async () => {
listMock.mockRejectedValueOnce(new Error('Network timeout'));
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
expect(screen.getByText(/network timeout/i)).toBeInTheDocument();
});
// After the error the rows state falls back to [] — no loading hint.
expect(screen.queryByText(/loading…/i)).not.toBeInTheDocument();
});
@@ -437,32 +338,24 @@ describe('MeetingBotsModal — recent calls section', () => {
listMock.mockResolvedValueOnce([
makeCallRecord({ meet_url: 'https://meet.google.com/xyz-1234-abc' }),
]);
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
expect(screen.getByText('xyz-1234-abc')).toBeInTheDocument();
});
// Full URL should NOT be visible — only the code portion.
expect(screen.queryByText('https://meet.google.com/xyz-1234-abc')).not.toBeInTheDocument();
});
it('shows duration as combined spoken + listened seconds', async () => {
listMock.mockResolvedValueOnce([makeCallRecord({ spoken_seconds: 40, listened_seconds: 20 })]);
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
expect(screen.getByText(/60s on call/i)).toBeInTheDocument();
});
});
it('shows a relative timestamp for recent calls', async () => {
// started 5 minutes ago
listMock.mockResolvedValueOnce([makeCallRecord({ started_at_ms: Date.now() - 5 * 60 * 1000 })]);
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
expect(screen.getByText(/\dm ago/)).toBeInTheDocument();
});
@@ -470,21 +363,15 @@ describe('MeetingBotsModal — recent calls section', () => {
it('shows "—" for a zero started_at_ms timestamp', async () => {
listMock.mockResolvedValueOnce([makeCallRecord({ started_at_ms: 0 })]);
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
expect(screen.getByText('—')).toBeInTheDocument();
});
});
// ── Extra coverage for RecentCallRow / formatRelativeTime branches ──────────
it('shows singular "turn" (not "turns") when turn_count is 1', async () => {
listMock.mockResolvedValueOnce([makeCallRecord({ turn_count: 1 })]);
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
expect(screen.getByText(/1 turn$/)).toBeInTheDocument();
});
@@ -493,9 +380,7 @@ describe('MeetingBotsModal — recent calls section', () => {
it('falls back to the raw URL when it cannot be parsed', async () => {
listMock.mockResolvedValueOnce([makeCallRecord({ meet_url: 'not-a-valid-url' })]);
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
expect(screen.getByText('not-a-valid-url')).toBeInTheDocument();
});
@@ -505,9 +390,7 @@ describe('MeetingBotsModal — recent calls section', () => {
listMock.mockResolvedValueOnce([
makeCallRecord({ started_at_ms: Date.now() - 3 * 60 * 60 * 1000 }),
]);
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
expect(screen.getByText(/3h ago/)).toBeInTheDocument();
});
@@ -517,9 +400,7 @@ describe('MeetingBotsModal — recent calls section', () => {
listMock.mockResolvedValueOnce([
makeCallRecord({ started_at_ms: Date.now() - 25 * 60 * 60 * 1000 }),
]);
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
expect(screen.getByText('yesterday')).toBeInTheDocument();
});
@@ -529,9 +410,7 @@ describe('MeetingBotsModal — recent calls section', () => {
listMock.mockResolvedValueOnce([
makeCallRecord({ started_at_ms: Date.now() - 3 * 24 * 60 * 60 * 1000 }),
]);
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
expect(screen.getByText(/3d ago/)).toBeInTheDocument();
});
@@ -541,11 +420,8 @@ describe('MeetingBotsModal — recent calls section', () => {
listMock.mockResolvedValueOnce([
makeCallRecord({ started_at_ms: Date.now() - 10 * 24 * 60 * 60 * 1000 }),
]);
renderWithProviders(<MeetingBotsModal onClose={() => {}} />);
renderWithProviders(<MeetingBotsCard />);
await waitFor(() => {
// toLocaleDateString returns "Month Day" — just check it's not a relative label.
const timestamp = screen.queryByText(/ago|yesterday|\dm|\dh/);
expect(timestamp).not.toBeInTheDocument();
});
+23
View File
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
interface BetaBannerProps {
/** Override the default "This feature is in beta" message. */
children?: ReactNode;
className?: string;
}
export default function BetaBanner({ children, className }: BetaBannerProps) {
const { t } = useT();
return (
<div
className={`flex items-start gap-2.5 rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-800 dark:text-amber-300 ${className ?? ''}`}
role="status">
<span className="mt-px shrink-0 rounded bg-amber-200 dark:bg-amber-500/30 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-amber-700 dark:text-amber-300">
{t('common.beta')}
</span>
<span>{children ?? t('common.betaDisclaimer')}</span>
</div>
);
}
+9 -67
View File
@@ -1,28 +1,19 @@
/**
* Tests for navConfig — verifies the shape, count, and key values of NAV_TABS
* and AVATAR_MENU_ITEMS so regressions are caught early.
*
* Human tab restored as a first-class entry (after the IA Phase 6 merge into
* Assistant), so the regular row is back to 6 tabs. The Assistant (id 'chat',
* labelKey 'nav.assistant', walkthroughAttr 'tab-chat') is no longer in the
* row — it's the raised center FAB (`CENTER_TAB`); the Brain sits in the row.
*/
import { describe, expect, it } from 'vitest';
import { AVATAR_MENU_ITEMS, CENTER_TAB, NAV_TABS } from '../navConfig';
import { AVATAR_MENU_ITEMS, NAV_TABS } from '../navConfig';
describe('NAV_TABS', () => {
it('has exactly 6 entries (Human restored as a first-class tab)', () => {
it('has exactly 6 entries', () => {
expect(NAV_TABS).toHaveLength(6);
});
it('has the correct ids in order (Brain in the row, Assistant is the center FAB)', () => {
it('has the correct ids in order', () => {
expect(NAV_TABS.map(t => t.id)).toEqual([
'home',
'chat',
'human',
'brain',
'connections',
'activity',
'settings',
]);
});
@@ -30,10 +21,10 @@ describe('NAV_TABS', () => {
it('has the correct paths', () => {
expect(NAV_TABS.map(t => t.path)).toEqual([
'/home',
'/chat',
'/human',
'/brain',
'/connections',
'/activity',
'/settings',
]);
});
@@ -41,10 +32,10 @@ describe('NAV_TABS', () => {
it('has the correct labelKeys', () => {
expect(NAV_TABS.map(t => t.labelKey)).toEqual([
'nav.home',
'nav.chat',
'nav.human',
'nav.brain',
'nav.connections',
'nav.activity',
'nav.settings',
]);
});
@@ -52,20 +43,16 @@ describe('NAV_TABS', () => {
it('has the correct walkthroughAttrs', () => {
expect(NAV_TABS.map(t => t.walkthroughAttr)).toEqual([
'tab-home',
'tab-chat',
'tab-human',
'tab-brain',
'tab-connections',
'tab-activity',
'tab-settings',
]);
});
it('contains a Human tab pointing at /human', () => {
const humanTab = NAV_TABS.find(t => t.id === 'human');
expect(humanTab).toBeDefined();
expect(humanTab?.path).toBe('/human');
expect(humanTab?.labelKey).toBe('nav.human');
expect(humanTab?.walkthroughAttr).toBe('tab-human');
it('does not contain an activity tab', () => {
expect(NAV_TABS.find(t => t.id === 'activity')).toBeUndefined();
});
it('does not contain a rewards tab', () => {
@@ -76,29 +63,6 @@ describe('NAV_TABS', () => {
expect(NAV_TABS.find(t => t.id === 'intelligence')).toBeUndefined();
expect(NAV_TABS.find(t => t.id === 'skills')).toBeUndefined();
});
it('does not contain the Assistant/chat tab (rendered specially as the center FAB)', () => {
expect(NAV_TABS.find(t => t.id === 'chat')).toBeUndefined();
});
it('Brain tab sits in the regular row with nav.brain label and tab-brain walkthrough attr', () => {
const brainTab = NAV_TABS.find(t => t.id === 'brain');
expect(brainTab).toBeDefined();
expect(brainTab?.labelKey).toBe('nav.brain');
expect(brainTab?.walkthroughAttr).toBe('tab-brain');
expect(brainTab?.path).toBe('/brain');
});
});
describe('CENTER_TAB', () => {
it('is the Assistant — expected shape (id, path, labelKey, walkthroughAttr)', () => {
expect(CENTER_TAB).toEqual({
id: 'chat',
labelKey: 'nav.assistant',
path: '/chat',
walkthroughAttr: 'tab-chat',
});
});
});
describe('AVATAR_MENU_ITEMS', () => {
@@ -116,35 +80,13 @@ describe('AVATAR_MENU_ITEMS', () => {
]);
});
it('has the correct labelKeys', () => {
expect(AVATAR_MENU_ITEMS.map(i => i.labelKey)).toEqual([
'nav.avatarMenu.account',
'nav.avatarMenu.billing',
'nav.avatarMenu.rewards',
'nav.avatarMenu.invites',
'nav.avatarMenu.wallet',
]);
});
it('billing, rewards, and invites are cloudOnly; account and wallet are not', () => {
const cloudOnly = AVATAR_MENU_ITEMS.filter(i => i.cloudOnly).map(i => i.id);
expect(cloudOnly).toEqual(['billing', 'rewards', 'invites']);
const notCloudOnly = AVATAR_MENU_ITEMS.filter(i => !i.cloudOnly).map(i => i.id);
expect(notCloudOnly).toEqual(['account', 'wallet']);
});
it('billing uses openUrl; all others use navigate', () => {
const openUrlItems = AVATAR_MENU_ITEMS.filter(i => i.kind === 'openUrl').map(i => i.id);
expect(openUrlItems).toEqual(['billing']);
const navigateItems = AVATAR_MENU_ITEMS.filter(i => i.kind === 'navigate').map(i => i.id);
expect(navigateItems).toEqual(['account', 'rewards', 'invites', 'wallet']);
});
it('each item has a non-empty target', () => {
for (const item of AVATAR_MENU_ITEMS) {
expect(item.target.length).toBeGreaterThan(0);
}
});
});
+6 -23
View File
@@ -21,17 +21,17 @@ export interface NavTab {
/**
* Ordered list of bottom-bar tabs. Six entries:
* home → human → brain → connections → activity → settings
* home → chat → human → brain → connections → settings
*
* The Human tab is a first-class destination again (it was briefly merged into
* Assistant in IA Phase 6, then restored): `/human` renders the Human page on
* desktop. The Assistant is NOT in this row — it's the raised center FAB (see
* `CENTER_TAB`); the Brain sits in the regular row where the Assistant briefly
* lived. Ids/paths/walkthroughAttrs travel with each tab, so analytics and the
* Chat is a regular pill tab (second after home). The Human tab is a
* first-class destination again (it was briefly merged into Assistant in
* IA Phase 6, then restored): `/human` renders the Human page on desktop.
* Ids/paths/walkthroughAttrs travel with each tab, so analytics and the
* walkthrough tour stay attached to the right feature regardless of position.
*/
export const NAV_TABS: NavTab[] = [
{ id: 'home', labelKey: 'nav.home', path: '/home', walkthroughAttr: 'tab-home' },
{ id: 'chat', labelKey: 'nav.chat', path: '/chat', walkthroughAttr: 'tab-chat' },
{ id: 'human', labelKey: 'nav.human', path: '/human', walkthroughAttr: 'tab-human' },
{ id: 'brain', labelKey: 'nav.brain', path: '/brain', walkthroughAttr: 'tab-brain' },
{
@@ -40,26 +40,9 @@ export const NAV_TABS: NavTab[] = [
path: '/connections',
walkthroughAttr: 'tab-connections',
},
{ id: 'activity', labelKey: 'nav.activity', path: '/activity', walkthroughAttr: 'tab-activity' },
{ id: 'settings', labelKey: 'nav.settings', path: '/settings', walkthroughAttr: 'tab-settings' },
];
/**
* The "Assistant" — the app's centerpiece chat surface. Rendered specially by
* BottomTabBar.tsx as a raised circular button in the dead center of the bar
* (a notch the button rises out of), NOT as part of the regular `NAV_TABS`
* row. Kept separate so its special, elevated nature is explicit and the
* 6-tab invariants for the regular row stay intact. The tab id stays `chat`
* and walkthroughAttr stays `tab-chat` for back-compat with analytics and the
* walkthrough tour.
*/
export const CENTER_TAB: NavTab = {
id: 'chat',
labelKey: 'nav.assistant',
path: '/chat',
walkthroughAttr: 'tab-chat',
};
// ── Avatar / account menu ─────────────────────────────────────────────────────
/**
-43
View File
@@ -23,16 +23,6 @@ vi.mock('../../pages/Conversations', () => ({
default: () => <div data-testid="conversations-stub" />,
}));
vi.mock('../../components/skills/MeetingBotsCard', () => ({
MeetingBotsModal: ({ onClose }: { onClose: () => void }) => (
<div role="dialog" aria-label="meeting-bots-modal">
<button type="button" onClick={onClose}>
Close modal
</button>
</div>
),
}));
vi.mock('./Mascot', async importOriginal => {
const actual = await importOriginal<typeof import('./Mascot')>();
return {
@@ -132,36 +122,3 @@ describe('HumanPage — speak-replies localStorage persistence', () => {
expect(screen.queryByTestId('mascot-stub')).not.toBeInTheDocument();
});
});
describe('HumanPage — join meeting pill', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('renders the join-meeting pill button', () => {
renderHumanPage();
expect(screen.getByTestId('human-join-meeting-pill')).toBeInTheDocument();
});
it('opens the MeetingBotsModal when the join-meeting pill is clicked', async () => {
renderHumanPage();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('human-join-meeting-pill'));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('closes the MeetingBotsModal when onClose is called', async () => {
renderHumanPage();
fireEvent.click(screen.getByTestId('human-join-meeting-pill'));
expect(screen.getByRole('dialog')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /close modal/i }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
-20
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { MeetingBotsModal } from '../../components/skills/MeetingBotsCard';
import { useT } from '../../lib/i18n/I18nContext';
import Conversations from '../../pages/Conversations';
import { useAppSelector } from '../../store/hooks';
@@ -10,7 +9,6 @@ import {
selectCustomSecondaryColor,
selectMascotColor,
} from '../../store/mascotSlice';
import { IS_DEV } from '../../utils/config';
import { CustomGifMascot, getMascotPalette, hexToArgbInt, RiveMascot } from './Mascot';
import { useHumanMascot } from './useHumanMascot';
@@ -22,7 +20,6 @@ const HumanPage = () => {
const raw = window.localStorage.getItem(SPEAK_REPLIES_KEY);
return raw === null ? true : raw === '1';
});
const [joinMeetingOpen, setJoinMeetingOpen] = useState(false);
useEffect(() => {
window.localStorage.setItem(SPEAK_REPLIES_KEY, speakReplies ? '1' : '0');
@@ -79,23 +76,6 @@ const HumanPage = () => {
{t('voice.pushToTalk')}
</label>
{/* "Send OpenHuman to a meeting" — dev-only; opens the Flow A modal
which spawns an off-screen CEF webview pointed at the Meet URL with
the mascot canvas as the outbound camera and synthesized speech as
the outbound mic. The user's OS mic is never wired to the meeting. */}
{IS_DEV && (
<button
type="button"
onClick={() => setJoinMeetingOpen(true)}
data-testid="human-join-meeting-pill"
className="absolute top-4 left-44 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-primary-500 text-white text-xs font-medium shadow-soft hover:bg-primary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-300">
<span aria-hidden="true">📞</span>
{t('skills.meetingBots.modalTitle')}
</button>
)}
{joinMeetingOpen && <MeetingBotsModal onClose={() => setJoinMeetingOpen(false)} />}
{/* Chat sidebar — vertically centered above the BottomTabBar (~80px). */}
<div className="absolute right-4 top-0 bottom-20 z-10 flex items-center">
<aside className="w-[420px] h-[min(720px,calc(100vh-160px))] rounded-2xl border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-soft flex flex-col overflow-hidden">
+19 -6
View File
@@ -30,7 +30,8 @@ const messages: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'خريطة معرفتك ومصادر الذاكرة وعناصر التحكم.',
'brain.loading': 'يتم جمع ذكرياتك…',
'brain.tabs.memory': 'الذاكرة',
'brain.tabs.subconscious': 'اللاوعي',
'brain.empty': 'دماغك فارغ في الوقت الحالي — قم بربط مصدر لبدء بناء الذاكرة.',
'brain.error': 'تعذّر تحميل دماغك. يرجى المحاولة مرة أخرى.',
'common.cancel': 'إلغاء',
@@ -76,6 +77,9 @@ const messages: TranslationMap = {
'common.continue': 'متابعة',
'common.comingSoon': 'قريبًا',
'common.breadcrumb': 'مسار التنقل',
'common.beta': 'تجريبي',
'common.betaDisclaimer':
'هذه الميزة في مرحلة تجريبية. قد تتغير أو تحتوي على بعض المشاكل — ملاحظاتك تساعدنا على تحسينها.',
'settings.general': 'عام',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'الحساب',
@@ -372,11 +376,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'المهارات',
'skills.tabs.meetings': 'اجتماعات Google Meet',
'skills.tabs.mcp': 'MCP الخوادم',
'connections.tabs.apps': 'التطبيقات',
'connections.tabs.messaging': 'المراسلة',
'connections.tabs.tools': 'الأدوات',
'connections.tabs.explorer': 'المستكشف',
'connections.tabs.talents': 'المواهب',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': 'القنوات',
'connections.tabs.mcp': 'خوادم MCP',
'connections.tabs.skills': 'المهارات',
'connections.tabs.meetings': 'الاجتماعات',
'memory.title': 'الذاكرة',
'memory.search': 'البحث في الذكريات...',
'memory.noResults': 'لم يتم العثور على ذكريات',
@@ -1973,6 +1977,15 @@ const messages: TranslationMap = {
'token.planClickForDetails': 'الخطة - انقر للتفاصيل',
'token.sessionTokens': 'وارد: {in} | صادر: {out} | الدورات: {turns}',
'token.limit': 'تم الوصول إلى الحد',
'token.inLabel': 'وارد',
'token.outLabel': 'صادر',
'token.inputTokens': 'الرموز الواردة في هذه الجلسة',
'token.outputTokens': 'الرموز الصادرة في هذه الجلسة',
'token.turnsCount': 'دورات الاستدلال في هذه الجلسة',
'token.turn': 'دورة',
'token.turns': 'دورات',
'token.ctxLabel': 'سياق',
'token.contextWindow': 'استخدام نافذة السياق (آخر دورة)',
'catalog.noCapabilityBinding': 'لا يوجد ربط بالإمكانات',
'catalog.downloadFailed': 'فشل التنزيل',
'catalog.active': 'نشط',
+19 -6
View File
@@ -30,7 +30,8 @@ const messages: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'আপনার জ্ঞান গ্রাফ, মেমরি উৎস এবং নিয়ন্ত্রণ।',
'brain.loading': 'আপনার স্মৃতি সংগ্রহ করা হচ্ছে…',
'brain.tabs.memory': 'স্মৃতি',
'brain.tabs.subconscious': 'অবচেতন',
'brain.empty': 'আপনার ব্রেইন এখন খালি — মেমরি তৈরি শুরু করতে একটি উৎস সংযুক্ত করুন।',
'brain.error': 'আপনার ব্রেইন লোড করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।',
'common.cancel': 'বাতিল',
@@ -76,6 +77,9 @@ const messages: TranslationMap = {
'common.continue': 'চালিয়ে যান',
'common.comingSoon': 'শীঘ্রই আসছে',
'common.breadcrumb': 'ব্রেডক্রাম্ব',
'common.beta': 'বেটা',
'common.betaDisclaimer':
'এই বৈশিষ্ট্যটি বেটাতে আছে। এটি পরিবর্তন হতে পারে বা কিছু সমস্যা থাকতে পারে — আপনার মতামত আমাদের উন্নতিতে সাহায্য করে।',
'settings.general': 'সাধারণ',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'অ্যাকাউন্ট',
@@ -377,11 +381,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'স্কিল',
'skills.tabs.meetings': 'Google Meet মিটিং',
'skills.tabs.mcp': 'MCP সার্ভার',
'connections.tabs.apps': 'অ্যাপস',
'connections.tabs.messaging': 'বার্তা পাঠানো',
'connections.tabs.tools': 'সরঞ্জাম',
'connections.tabs.explorer': 'ক্সপ্লোরার',
'connections.tabs.talents': 'ট্যালেন্ট',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': 'চ্যানেল',
'connections.tabs.mcp': 'MCP সার্ভার',
'connections.tabs.skills': 'ক্ষতা',
'connections.tabs.meetings': 'মিটিং',
'memory.title': 'মেমোরি',
'memory.search': 'মেমোরি খুঁজুন...',
'memory.noResults': 'কোনো মেমোরি পাওয়া যায়নি',
@@ -2017,6 +2021,15 @@ const messages: TranslationMap = {
'token.planClickForDetails': 'প্ল্যান - বিস্তারিত জানতে ক্লিক করুন',
'token.sessionTokens': 'ইন: {in} | আউট: {out} | টার্ন: {turns}',
'token.limit': 'সীমা পৌঁছেছে',
'token.inLabel': 'ইন',
'token.outLabel': 'আউট',
'token.inputTokens': 'এই সেশনে ইনপুট টোকেন',
'token.outputTokens': 'এই সেশনে আউটপুট টোকেন',
'token.turnsCount': 'এই সেশনে ইনফারেন্স টার্ন',
'token.turn': 'টার্ন',
'token.turns': 'টার্ন',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'কনটেক্সট উইন্ডো ব্যবহার (শেষ টার্ন)',
'catalog.noCapabilityBinding': 'কোনো ক্যাপাবিলিটি বাইন্ডিং নেই',
'catalog.downloadFailed': 'ডাউনলোড ব্যর্থ',
'catalog.active': 'সক্রিয়',
+19 -6
View File
@@ -30,7 +30,8 @@ const messages: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Dein Wissensgraph, deine Speicherquellen und Steuerelemente.',
'brain.loading': 'Deine Erinnerungen werden gesammelt…',
'brain.tabs.memory': 'Gedächtnis',
'brain.tabs.subconscious': 'Unterbewusstsein',
'brain.empty': 'Dein Gehirn ist noch leer verbinde eine Quelle, um Speicher aufzubauen.',
'brain.error': 'Dein Gehirn konnte nicht geladen werden. Bitte versuche es erneut.',
'common.cancel': 'Abbrechen',
@@ -76,6 +77,9 @@ const messages: TranslationMap = {
'common.continue': 'Weiter',
'common.comingSoon': 'Demnächst verfügbar',
'common.breadcrumb': 'Breadcrumb',
'common.beta': 'Beta',
'common.betaDisclaimer':
'Diese Funktion ist in der Beta-Phase. Sie kann sich ändern oder Ecken und Kanten haben — Ihr Feedback hilft uns, sie zu verbessern.',
'settings.general': 'Allgemein',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Konto',
@@ -388,11 +392,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skills',
'skills.tabs.meetings': 'Google Meet-Besprechungen',
'skills.tabs.mcp': 'MCP Server',
'connections.tabs.apps': 'Apps',
'connections.tabs.messaging': 'Nachrichten',
'connections.tabs.tools': 'Werkzeuge',
'connections.tabs.explorer': 'Erkunden',
'connections.tabs.talents': 'Talente',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': 'Kanäle',
'connections.tabs.mcp': 'MCP-Server',
'connections.tabs.skills': 'Fähigkeiten',
'connections.tabs.meetings': 'Meetings',
'memory.title': 'Erinnerung',
'memory.search': 'Erinnerungen suchen...',
'memory.noResults': 'Keine Erinnerungen gefunden',
@@ -2068,6 +2072,15 @@ const messages: TranslationMap = {
'token.planClickForDetails': 'Plan klicke für Details',
'token.sessionTokens': 'In: {in} | Aus: {out} | Turns: {turns}',
'token.limit': 'Limit erreicht',
'token.inLabel': 'EIN',
'token.outLabel': 'AUS',
'token.inputTokens': 'Eingabe-Tokens in dieser Sitzung',
'token.outputTokens': 'Ausgabe-Tokens in dieser Sitzung',
'token.turnsCount': 'Inferenzrunden in dieser Sitzung',
'token.turn': 'Runde',
'token.turns': 'Runden',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Kontextfenster-Nutzung (letzte Runde)',
'catalog.noCapabilityBinding': 'Keine Fähigkeitsbindung',
'catalog.downloadFailed': 'Der Download ist fehlgeschlagen',
'catalog.active': 'Aktiv',
+19 -6
View File
@@ -31,7 +31,8 @@ const en: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Your knowledge graph, memory sources, and controls.',
'brain.loading': 'Gathering your memories…',
'brain.tabs.memory': 'Memory',
'brain.tabs.subconscious': 'Subconscious',
'brain.empty': 'Your brain is empty for now — connect a source to start building memory.',
'brain.error': "Couldn't load your brain. Please try again.",
@@ -79,6 +80,9 @@ const en: TranslationMap = {
'common.continue': 'Continue',
'common.comingSoon': 'Coming Soon',
'common.breadcrumb': 'Breadcrumb',
'common.beta': 'Beta',
'common.betaDisclaimer':
'This feature is in beta. It may change or have rough edges — your feedback helps us improve it.',
// Settings Home
'settings.general': 'General',
@@ -417,11 +421,11 @@ const en: TranslationMap = {
'skills.tabs.meetings': 'Google Meet',
'skills.tabs.mcp': 'MCP Servers',
// Connections page tabs (Phase 2 rename)
'connections.tabs.apps': 'Apps',
'connections.tabs.messaging': 'Messaging',
'connections.tabs.tools': 'Tools',
'connections.tabs.explorer': 'Explorer',
'connections.tabs.talents': 'Talents',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': 'Channels',
'connections.tabs.mcp': 'MCP Servers',
'connections.tabs.skills': 'Skills',
'connections.tabs.meetings': 'Meetings',
// Intelligence / Memory
'memory.title': 'Memory',
'memory.search': 'Search memories...',
@@ -2420,6 +2424,15 @@ const en: TranslationMap = {
'token.planClickForDetails': 'plan - click for details',
'token.sessionTokens': 'In: {in} | Out: {out} | Turns: {turns}',
'token.limit': 'Limit Reached',
'token.inLabel': 'IN',
'token.outLabel': 'OUT',
'token.inputTokens': 'Input tokens this session',
'token.outputTokens': 'Output tokens this session',
'token.turnsCount': 'Inference turns this session',
'token.turn': 'turn',
'token.turns': 'turns',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Context window usage (last turn)',
// Catalog
'catalog.noCapabilityBinding': 'No capability binding',
+19 -6
View File
@@ -30,7 +30,8 @@ const messages: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Tu grafo de conocimiento, fuentes de memoria y controles.',
'brain.loading': 'Recopilando tus recuerdos…',
'brain.tabs.memory': 'Memoria',
'brain.tabs.subconscious': 'Subconsciente',
'brain.empty':
'Tu cerebro está vacío por ahora: conecta una fuente para empezar a construir tu memoria.',
'brain.error': 'No se pudo cargar tu cerebro. Inténtalo de nuevo.',
@@ -77,6 +78,9 @@ const messages: TranslationMap = {
'common.continue': 'Continuar',
'common.comingSoon': 'Próximamente',
'common.breadcrumb': 'Miga de pan',
'common.beta': 'Beta',
'common.betaDisclaimer':
'Esta función está en beta. Puede cambiar o tener aspectos por pulir — tus comentarios nos ayudan a mejorarla.',
'settings.general': 'generales',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Cuenta',
@@ -390,11 +394,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skills',
'skills.tabs.meetings': 'Reuniones de Google Meet',
'skills.tabs.mcp': 'MCP Servidores',
'connections.tabs.apps': 'Aplicaciones',
'connections.tabs.messaging': 'Mensajería',
'connections.tabs.tools': 'Herramientas',
'connections.tabs.explorer': 'Explorar',
'connections.tabs.talents': 'Talentos',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': 'Canales',
'connections.tabs.mcp': 'Servidores MCP',
'connections.tabs.skills': 'Habilidades',
'connections.tabs.meetings': 'Reuniones',
'memory.title': 'Memoria',
'memory.search': 'Buscar recuerdos...',
'memory.noResults': 'No se encontraron recuerdos',
@@ -2060,6 +2064,15 @@ const messages: TranslationMap = {
'token.planClickForDetails': 'plan - toca para ver detalles',
'token.sessionTokens': 'Ent: {in} | Sal: {out} | Turnos: {turns}',
'token.limit': 'Límite alcanzado',
'token.inLabel': 'ENT',
'token.outLabel': 'SAL',
'token.inputTokens': 'Tokens de entrada en esta sesión',
'token.outputTokens': 'Tokens de salida en esta sesión',
'token.turnsCount': 'Turnos de inferencia en esta sesión',
'token.turn': 'turno',
'token.turns': 'turnos',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Uso de ventana de contexto (último turno)',
'catalog.noCapabilityBinding': 'Sin vinculación de capacidad',
'catalog.downloadFailed': 'Descarga fallida',
'catalog.active': 'Activo',
+19 -6
View File
@@ -30,7 +30,8 @@ const messages: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Votre graphe de connaissances, vos sources de mémoire et vos commandes.',
'brain.loading': 'Collecte de vos souvenirs…',
'brain.tabs.memory': 'Mémoire',
'brain.tabs.subconscious': 'Subconscient',
'brain.empty':
'Votre cerveau est vide pour linstant — connectez une source pour commencer à constituer votre mémoire.',
'brain.error': 'Impossible de charger votre cerveau. Veuillez réessayer.',
@@ -77,6 +78,9 @@ const messages: TranslationMap = {
'common.continue': 'Continuer',
'common.comingSoon': 'Bientôt disponible',
'common.breadcrumb': "Fil d'Ariane",
'common.beta': 'Bêta',
'common.betaDisclaimer':
"Cette fonctionnalité est en bêta. Elle peut changer ou présenter des imperfections — vos retours nous aident à l'améliorer.",
'settings.general': 'Général',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Compte',
@@ -389,11 +393,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skills',
'skills.tabs.meetings': 'Réunions Google Meet',
'skills.tabs.mcp': 'MCP Serveurs',
'connections.tabs.apps': 'Applications',
'connections.tabs.messaging': 'Messagerie',
'connections.tabs.tools': 'Outils',
'connections.tabs.explorer': 'Explorateur',
'connections.tabs.talents': 'Talents',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': 'Canaux',
'connections.tabs.mcp': 'Serveurs MCP',
'connections.tabs.skills': 'Compétences',
'connections.tabs.meetings': 'Réunions',
'memory.title': 'Mémoire',
'memory.search': 'Rechercher dans la mémoire…',
'memory.noResults': 'Aucun souvenir trouvé',
@@ -2069,6 +2073,15 @@ const messages: TranslationMap = {
'token.planClickForDetails': 'plan - clique pour les détails',
'token.sessionTokens': 'Entrée : {in} | Sortie : {out} | Tours : {turns}',
'token.limit': 'Limite atteinte',
'token.inLabel': 'ENT',
'token.outLabel': 'SOR',
'token.inputTokens': 'Tokens en entrée cette session',
'token.outputTokens': 'Tokens en sortie cette session',
'token.turnsCount': "Tours d'inférence cette session",
'token.turn': 'tour',
'token.turns': 'tours',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Utilisation de la fenêtre de contexte (dernier tour)',
'catalog.noCapabilityBinding': 'Aucune liaison de fonctionnalité',
'catalog.downloadFailed': 'Échec du téléchargement',
'catalog.active': 'Actif',
+19 -6
View File
@@ -30,7 +30,8 @@ const messages: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'आपका नॉलेज ग्राफ, मेमोरी स्रोत और नियंत्रण।',
'brain.loading': 'आपकी यादें इकट्ठा की जा रही हैं…',
'brain.tabs.memory': 'स्मृति',
'brain.tabs.subconscious': 'अवचेतन',
'brain.empty': 'आपका ब्रेन अभी खाली है — मेमोरी बनाना शुरू करने के लिए कोई स्रोत कनेक्ट करें।',
'brain.error': 'आपका ब्रेन लोड नहीं हो सका। कृपया फिर से प्रयास करें।',
'common.cancel': 'रद्द करें',
@@ -76,6 +77,9 @@ const messages: TranslationMap = {
'common.continue': 'जारी रखें',
'common.comingSoon': 'जल्द आ रहा है',
'common.breadcrumb': 'ब्रेडक्रंब',
'common.beta': 'बीटा',
'common.betaDisclaimer':
'यह सुविधा बीटा में है। यह बदल सकती है या इसमें कुछ खामियाँ हो सकती हैं — आपकी प्रतिक्रिया इसे बेहतर बनाने में मदद करती है।',
'settings.general': 'सामान्य',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'खाता',
@@ -376,11 +380,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'स्किल',
'skills.tabs.meetings': 'Google Meet बैठकें',
'skills.tabs.mcp': 'MCP सर्वर',
'connections.tabs.apps': 'ऐप्स',
'connections.tabs.messaging': 'मैसेजिंग',
'connections.tabs.tools': 'टूल्स',
'connections.tabs.explorer': 'एक्सप्लोरर',
'connections.tabs.talents': 'टैलेंट',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': 'चैनल',
'connections.tabs.mcp': 'MCP सर्वर',
'connections.tabs.skills': 'कौशल',
'connections.tabs.meetings': 'मीटिंग',
'memory.title': 'मेमोरी',
'memory.search': 'मेमोरी सर्च करें...',
'memory.noResults': 'कोई मेमोरी नहीं मिली',
@@ -2015,6 +2019,15 @@ const messages: TranslationMap = {
'token.planClickForDetails': 'प्लान - डिटेल्स के लिए क्लिक करें',
'token.sessionTokens': 'इन: {in} | आउट: {out} | टर्न: {turns}',
'token.limit': 'सीमा पहुँच गई',
'token.inLabel': 'इन',
'token.outLabel': 'आउट',
'token.inputTokens': 'इस सत्र में इनपुट टोकन',
'token.outputTokens': 'इस सत्र में आउटपुट टोकन',
'token.turnsCount': 'इस सत्र में अनुमान चक्र',
'token.turn': 'चक्र',
'token.turns': 'चक्र',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'संदर्भ विंडो उपयोग (अंतिम चक्र)',
'catalog.noCapabilityBinding': 'कोई कैपेबिलिटी बाइंडिंग नहीं',
'catalog.downloadFailed': 'डाउनलोड विफल',
'catalog.active': 'एक्टिव',
+19 -6
View File
@@ -30,7 +30,8 @@ const messages: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Grafik pengetahuan, sumber memori, dan kontrol Anda.',
'brain.loading': 'Mengumpulkan memori Anda…',
'brain.tabs.memory': 'Memori',
'brain.tabs.subconscious': 'Alam Bawah Sadar',
'brain.empty': 'Otak Anda masih kosong — hubungkan sumber untuk mulai membangun memori.',
'brain.error': 'Tidak dapat memuat otak Anda. Silakan coba lagi.',
'common.cancel': 'Batal',
@@ -76,6 +77,9 @@ const messages: TranslationMap = {
'common.continue': 'Lanjutkan',
'common.comingSoon': 'Segera Hadir',
'common.breadcrumb': 'Breadcrumb',
'common.beta': 'Beta',
'common.betaDisclaimer':
'Fitur ini dalam tahap beta. Mungkin berubah atau memiliki kekurangan — masukan Anda membantu kami memperbaikinya.',
'settings.general': 'Umum',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Akun',
@@ -380,11 +384,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skill',
'skills.tabs.meetings': 'Rapat Google Meet',
'skills.tabs.mcp': 'MCP Server',
'connections.tabs.apps': 'Aplikasi',
'connections.tabs.messaging': 'Pesan',
'connections.tabs.tools': 'Alat',
'connections.tabs.explorer': 'Penjelajah',
'connections.tabs.talents': 'Talenta',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': 'Saluran',
'connections.tabs.mcp': 'Server MCP',
'connections.tabs.skills': 'Keterampilan',
'connections.tabs.meetings': 'Rapat',
'memory.title': 'Memori',
'memory.search': 'Cari memori...',
'memory.noResults': 'Memori tidak ditemukan',
@@ -2020,6 +2024,15 @@ const messages: TranslationMap = {
'token.planClickForDetails': 'paket - klik untuk detail',
'token.sessionTokens': 'Masuk: {in} | Keluar: {out} | Giliran: {turns}',
'token.limit': 'Batas Tercapai',
'token.inLabel': 'MSK',
'token.outLabel': 'KLR',
'token.inputTokens': 'Token masuk sesi ini',
'token.outputTokens': 'Token keluar sesi ini',
'token.turnsCount': 'Putaran inferensi sesi ini',
'token.turn': 'putaran',
'token.turns': 'putaran',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Penggunaan jendela konteks (putaran terakhir)',
'catalog.noCapabilityBinding': 'Tidak ada binding kemampuan',
'catalog.downloadFailed': 'Unduhan gagal',
'catalog.active': 'Aktif',
+19 -6
View File
@@ -30,7 +30,8 @@ const messages: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Il tuo grafo della conoscenza, le fonti di memoria e i controlli.',
'brain.loading': 'Raccolta dei tuoi ricordi…',
'brain.tabs.memory': 'Memoria',
'brain.tabs.subconscious': 'Subconscio',
'brain.empty':
'Il tuo cervello è ancora vuoto: collega una fonte per iniziare a costruire la memoria.',
'brain.error': 'Impossibile caricare il tuo cervello. Riprova.',
@@ -77,6 +78,9 @@ const messages: TranslationMap = {
'common.continue': 'Continua',
'common.comingSoon': 'Prossimamente',
'common.breadcrumb': 'breadcrumb',
'common.beta': 'Beta',
'common.betaDisclaimer':
'Questa funzione è in beta. Potrebbe cambiare o avere qualche imperfezione — il tuo feedback ci aiuta a migliorarla.',
'settings.general': 'Generale',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Account',
@@ -385,11 +389,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skill',
'skills.tabs.meetings': 'Riunioni Google Meet',
'skills.tabs.mcp': 'MCP Server',
'connections.tabs.apps': 'App',
'connections.tabs.messaging': 'Messaggistica',
'connections.tabs.tools': 'Strumenti',
'connections.tabs.explorer': 'Esplora',
'connections.tabs.talents': 'Talenti',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': 'Canali',
'connections.tabs.mcp': 'Server MCP',
'connections.tabs.skills': 'Competenze',
'connections.tabs.meetings': 'Riunioni',
'memory.title': 'Memoria',
'memory.search': 'Cerca memorie...',
'memory.noResults': 'Nessuna memoria trovata',
@@ -2051,6 +2055,15 @@ const messages: TranslationMap = {
'token.planClickForDetails': 'piano - clicca per dettagli',
'token.sessionTokens': 'Ingresso: {in} | Uscita: {out} | Turni: {turns}',
'token.limit': 'Limite raggiunto',
'token.inLabel': 'ENT',
'token.outLabel': 'USC',
'token.inputTokens': 'Token in ingresso questa sessione',
'token.outputTokens': 'Token in uscita questa sessione',
'token.turnsCount': 'Turni di inferenza questa sessione',
'token.turn': 'turno',
'token.turns': 'turni',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Utilizzo finestra di contesto (ultimo turno)',
'catalog.noCapabilityBinding': 'Nessun binding di capacità',
'catalog.downloadFailed': 'Download fallito',
'catalog.active': 'Attivo',
+19 -6
View File
@@ -30,7 +30,8 @@ const messages: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': '지식 그래프, 메모리 소스 및 컨트롤.',
'brain.loading': '기억을 모으는 중…',
'brain.tabs.memory': '기억',
'brain.tabs.subconscious': '잠재의식',
'brain.empty': '아직 브레인이 비어 있습니다 — 소스를 연결하여 메모리를 만들어 보세요.',
'brain.error': '브레인을 불러올 수 없습니다. 다시 시도해 주세요.',
'common.cancel': '취소',
@@ -76,6 +77,9 @@ const messages: TranslationMap = {
'common.continue': '계속',
'common.comingSoon': '출시 예정',
'common.breadcrumb': '탐색경로',
'common.beta': '베타',
'common.betaDisclaimer':
'이 기능은 베타 버전입니다. 변경되거나 불완전할 수 있습니다 — 피드백이 개선에 도움이 됩니다.',
'settings.general': '일반',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': '계정',
@@ -377,11 +381,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': '스킬',
'skills.tabs.meetings': 'Google Meet 회의',
'skills.tabs.mcp': 'MCP 서버',
'connections.tabs.apps': '앱',
'connections.tabs.messaging': '메시징',
'connections.tabs.tools': '도구',
'connections.tabs.explorer': '탐색기',
'connections.tabs.talents': '재능',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': '채널',
'connections.tabs.mcp': 'MCP 서버',
'connections.tabs.skills': '스킬',
'connections.tabs.meetings': '미팅',
'memory.title': '메모리',
'memory.search': '메모리 검색...',
'memory.noResults': '메모리를 찾을 수 없습니다',
@@ -1996,6 +2000,15 @@ const messages: TranslationMap = {
'token.planClickForDetails': '플랜 - 자세한 내용을 보려면 클릭',
'token.sessionTokens': '입력: {in} | 출력: {out} | 턴: {turns}',
'token.limit': '한도 도달',
'token.inLabel': '입력',
'token.outLabel': '출력',
'token.inputTokens': '이 세션의 입력 토큰',
'token.outputTokens': '이 세션의 출력 토큰',
'token.turnsCount': '이 세션의 추론 턴',
'token.turn': '턴',
'token.turns': '턴',
'token.ctxLabel': 'CTX',
'token.contextWindow': '컨텍스트 윈도우 사용량 (마지막 턴)',
'catalog.noCapabilityBinding': '기능 바인딩 없음',
'catalog.downloadFailed': '다운로드 실패',
'catalog.active': '활성',
+19 -6
View File
@@ -30,7 +30,8 @@ const messages: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Twój graf wiedzy, źródła pamięci i ustawienia.',
'brain.loading': 'Zbieranie Twoich wspomnień…',
'brain.tabs.memory': 'Pamięć',
'brain.tabs.subconscious': 'Podświadomość',
'brain.empty': 'Twój mózg jest na razie pusty — połącz źródło, aby zacząć budować pamięć.',
'brain.error': 'Nie udało się załadować Twojego mózgu. Spróbuj ponownie.',
'common.cancel': 'Anuluj',
@@ -76,6 +77,9 @@ const messages: TranslationMap = {
'common.continue': 'Kontynuuj',
'common.comingSoon': 'Wkrótce',
'common.breadcrumb': 'Ścieżka nawigacji',
'common.beta': 'Beta',
'common.betaDisclaimer':
'Ta funkcja jest w fazie beta. Może się zmienić lub mieć niedociągnięcia — Twoja opinia pomaga nam ją ulepszyć.',
'settings.general': 'Ogólne',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Konto',
@@ -382,11 +386,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skille',
'skills.tabs.meetings': 'Spotkania Google Meet',
'skills.tabs.mcp': 'Serwery MCP',
'connections.tabs.apps': 'Aplikacje',
'connections.tabs.messaging': 'Wiadomości',
'connections.tabs.tools': 'Narzędzia',
'connections.tabs.explorer': 'Eksplorator',
'connections.tabs.talents': 'Talenty',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': 'Kanały',
'connections.tabs.mcp': 'Serwery MCP',
'connections.tabs.skills': 'Umiejętności',
'connections.tabs.meetings': 'Spotkania',
'memory.title': 'Pamięć',
'memory.search': 'Szukaj w pamięci...',
'memory.noResults': 'Nie znaleziono wspomnień',
@@ -2038,6 +2042,15 @@ const messages: TranslationMap = {
'token.planClickForDetails': 'plan — kliknij, aby zobaczyć szczegóły',
'token.sessionTokens': 'Wej: {in} | Wyj: {out} | Tury: {turns}',
'token.limit': 'Limit osiągnięty',
'token.inLabel': 'WEJ',
'token.outLabel': 'WYJ',
'token.inputTokens': 'Tokeny wejściowe w tej sesji',
'token.outputTokens': 'Tokeny wyjściowe w tej sesji',
'token.turnsCount': 'Tury wnioskowania w tej sesji',
'token.turn': 'tura',
'token.turns': 'tury',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Użycie okna kontekstu (ostatnia tura)',
'catalog.noCapabilityBinding': 'Brak powiązanej możliwości',
'catalog.downloadFailed': 'Pobieranie nie powiodło się',
'catalog.active': 'Aktywny',
+19 -6
View File
@@ -30,7 +30,8 @@ const messages: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Seu grafo de conhecimento, fontes de memória e controles.',
'brain.loading': 'Reunindo suas memórias…',
'brain.tabs.memory': 'Memória',
'brain.tabs.subconscious': 'Subconsciente',
'brain.empty':
'Seu cérebro está vazio por enquanto — conecte uma fonte para começar a construir a memória.',
'brain.error': 'Não foi possível carregar seu cérebro. Tente novamente.',
@@ -77,6 +78,9 @@ const messages: TranslationMap = {
'common.continue': 'Continuar',
'common.comingSoon': 'Em breve',
'common.breadcrumb': 'Breadcrumb',
'common.beta': 'Beta',
'common.betaDisclaimer':
'Este recurso está em beta. Pode mudar ou ter imperfeições — seu feedback nos ajuda a melhorá-lo.',
'settings.general': 'Geral',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Conta',
@@ -389,11 +393,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skills',
'skills.tabs.meetings': 'Reuniões do Google Meet',
'skills.tabs.mcp': 'MCP Servidores',
'connections.tabs.apps': 'Aplicativos',
'connections.tabs.messaging': 'Mensagens',
'connections.tabs.tools': 'Ferramentas',
'connections.tabs.explorer': 'Explorar',
'connections.tabs.talents': 'Talentos',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': 'Canais',
'connections.tabs.mcp': 'Servidores MCP',
'connections.tabs.skills': 'Habilidades',
'connections.tabs.meetings': 'Reuniões',
'memory.title': 'Memória',
'memory.search': 'Pesquisar memórias...',
'memory.noResults': 'Nenhuma memória encontrada',
@@ -2059,6 +2063,15 @@ const messages: TranslationMap = {
'token.planClickForDetails': 'plano - clique para detalhes',
'token.sessionTokens': 'Ent: {in} | Saí: {out} | Turnos: {turns}',
'token.limit': 'Limite Atingido',
'token.inLabel': 'ENT',
'token.outLabel': 'SAÍ',
'token.inputTokens': 'Tokens de entrada nesta sessão',
'token.outputTokens': 'Tokens de saída nesta sessão',
'token.turnsCount': 'Turnos de inferência nesta sessão',
'token.turn': 'turno',
'token.turns': 'turnos',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Uso da janela de contexto (último turno)',
'catalog.noCapabilityBinding': 'Sem vinculação de funcionalidade',
'catalog.downloadFailed': 'Falha no download',
'catalog.active': 'Ativo',
+19 -6
View File
@@ -30,7 +30,8 @@ const messages: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': 'Ваш граф знаний, источники памяти и элементы управления.',
'brain.loading': 'Собираем ваши воспоминания…',
'brain.tabs.memory': 'Память',
'brain.tabs.subconscious': 'Подсознание',
'brain.empty': 'Ваш мозг пока пуст — подключите источник, чтобы начать формировать память.',
'brain.error': 'Не удалось загрузить ваш мозг. Пожалуйста, попробуйте ещё раз.',
'common.cancel': 'Отмена',
@@ -76,6 +77,9 @@ const messages: TranslationMap = {
'common.continue': 'Продолжить',
'common.comingSoon': 'Скоро',
'common.breadcrumb': 'Breadcrumb',
'common.beta': 'Бета',
'common.betaDisclaimer':
'Эта функция находится в бета-версии. Она может измениться или иметь недоработки — ваши отзывы помогают нам её улучшить.',
'settings.general': 'Общие',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Аккаунт',
@@ -382,11 +386,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Навыки',
'skills.tabs.meetings': 'Встречи Google Meet',
'skills.tabs.mcp': 'MCP Серверы',
'connections.tabs.apps': 'Приложения',
'connections.tabs.messaging': 'Сообщения',
'connections.tabs.tools': 'Инструменты',
'connections.tabs.explorer': 'Обозреватель',
'connections.tabs.talents': 'Таланты',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': 'Каналы',
'connections.tabs.mcp': 'MCP-серверы',
'connections.tabs.skills': 'Навыки',
'connections.tabs.meetings': 'Встречи',
'memory.title': 'Память',
'memory.search': 'Поиск воспоминаний...',
'memory.noResults': 'Воспоминания не найдены',
@@ -2034,6 +2038,15 @@ const messages: TranslationMap = {
'token.planClickForDetails': 'план — нажми для подробностей',
'token.sessionTokens': 'Вход: {in} | Выход: {out} | Ходов: {turns}',
'token.limit': 'Лимит достигнут',
'token.inLabel': 'ВХ',
'token.outLabel': 'ВЫХ',
'token.inputTokens': 'Входные токены за сессию',
'token.outputTokens': 'Выходные токены за сессию',
'token.turnsCount': 'Циклы вывода за сессию',
'token.turn': 'цикл',
'token.turns': 'циклов',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Использование окна контекста (последний цикл)',
'catalog.noCapabilityBinding': 'Нет привязки возможностей',
'catalog.downloadFailed': 'Загрузка не удалась',
'catalog.active': 'Активно',
+18 -6
View File
@@ -30,7 +30,8 @@ const messages: TranslationMap = {
// Brain — full-page memory knowledge-graph surface
'brain.subtitle': '你的知识图谱、记忆来源与控制项。',
'brain.loading': '正在收集你的记忆',
'brain.tabs.memory': '记忆',
'brain.tabs.subconscious': '潜意识',
'brain.empty': '你的大脑暂时是空的——连接一个来源即可开始构建记忆。',
'brain.error': '无法加载你的大脑,请重试。',
'common.cancel': '取消',
@@ -76,6 +77,8 @@ const messages: TranslationMap = {
'common.continue': '继续',
'common.comingSoon': '即将推出',
'common.breadcrumb': '面包屑',
'common.beta': '测试版',
'common.betaDisclaimer': '此功能处于测试阶段。它可能会更改或存在不足——您的反馈有助于我们改进。',
'settings.general': '通用',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': '账户',
@@ -361,11 +364,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': '技能',
'skills.tabs.meetings': 'Google Meet 会议',
'skills.tabs.mcp': 'MCP 服务器',
'connections.tabs.apps': '应用',
'connections.tabs.messaging': '消息',
'connections.tabs.tools': '工具',
'connections.tabs.explorer': '探索',
'connections.tabs.talents': '才能',
'connections.tabs.composio': 'Composio',
'connections.tabs.channels': '频道',
'connections.tabs.mcp': 'MCP 服务器',
'connections.tabs.skills': '技能',
'connections.tabs.meetings': '会议',
'memory.title': '记忆',
'memory.search': '搜索记忆...',
'memory.noResults': '未找到记忆',
@@ -1910,6 +1913,15 @@ const messages: TranslationMap = {
'token.planClickForDetails': '套餐 - 点击查看详情',
'token.sessionTokens': '输入: {in} | 输出: {out} | 轮次: {turns}',
'token.limit': '已达限制',
'token.inLabel': '输入',
'token.outLabel': '输出',
'token.inputTokens': '本次会话输入令牌',
'token.outputTokens': '本次会话输出令牌',
'token.turnsCount': '本次会话推理轮次',
'token.turn': '轮',
'token.turns': '轮',
'token.ctxLabel': '上下文',
'token.contextWindow': '上下文窗口使用量(上一轮)',
'catalog.noCapabilityBinding': '无能力绑定',
'catalog.downloadFailed': '下载失败',
'catalog.active': '活跃',
+7 -50
View File
@@ -7,7 +7,6 @@ import {
CustomGifMascot,
getMascotPalette,
hexToArgbInt,
MascotChipAvatar,
RiveMascot,
} from '../features/human/Mascot';
import { useHumanMascot } from '../features/human/useHumanMascot';
@@ -200,37 +199,21 @@ const Accounts = () => {
const order = useAppSelector(state => state.accounts.order);
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
const unreadByAccount = useAppSelector(state => state.accounts.unread);
// Mascot identity — drives the avatar on the "Talk to Tiny" chip so the
// button advertises the user's actual character (colour / custom GIF) rather
// than a generic emoji.
const mascotColor = useAppSelector(selectMascotColor);
const customPrimary = useAppSelector(selectCustomPrimaryColor);
const customMascotGifUrl = useAppSelector(selectCustomMascotGifUrl);
const [addOpen, setAddOpen] = useState(false);
const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null);
// Face-mode toggle — persists across sessions. Face mode only affects the
// agent-chat surface (external webview accounts ignore it).
const [faceMode, setFaceMode] = useState<boolean>(() => {
const [faceMode] = useState<boolean>(() => {
try {
return window.localStorage.getItem(FACE_MODE_KEY) === '1';
const stored = window.localStorage.getItem(FACE_MODE_KEY);
if (stored === '1') {
window.localStorage.removeItem(FACE_MODE_KEY);
}
return false;
} catch {
return false;
}
});
const toggleFaceMode = () => {
setFaceMode(prev => {
const next = !prev;
try {
window.localStorage.setItem(FACE_MODE_KEY, next ? '1' : '0');
} catch {
// Swallow storage errors.
}
return next;
});
};
useEffect(() => {
startWebviewAccountService();
}, []);
@@ -405,33 +388,7 @@ const Accounts = () => {
</button>
</aside>
{/* Floating "Talk to Tiny" face-mode toggle. Kept out of the layout flow
(absolute) so it never steals vertical space from the chat composer —
the previous in-flow header strip pushed the input below the viewport. */}
{isAgentSelected && (
<button
type="button"
onClick={toggleFaceMode}
data-testid="face-toggle-button"
aria-pressed={faceMode}
className={`group absolute right-4 top-4 z-40 inline-flex items-center gap-2 rounded-full border py-1.5 pl-1.5 pr-3 text-xs font-medium shadow-soft backdrop-blur-sm transition-colors ${
faceMode
? 'border-primary-300 bg-primary-50/90 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200'
: 'border-stone-300/80 bg-white/90 text-stone-600 hover:border-primary-300 hover:text-primary-600 dark:border-neutral-700/80 dark:bg-neutral-900/90 dark:text-neutral-300 dark:hover:text-primary-300'
}`}
aria-label={faceMode ? t('assistant.faceMode.turnOff') : t('assistant.faceMode.turnOn')}>
{/* The mascot wakes up (wiggles) on hover/focus; static at rest. */}
<span className="origin-bottom transition-transform group-hover:animate-wiggle group-focus-visible:animate-wiggle">
<MascotChipAvatar
color={mascotColor}
customPrimary={customPrimary}
gifUrl={customMascotGifUrl}
size={22}
/>
</span>
{faceMode ? t('assistant.faceMode.on') : t('assistant.faceMode.off')}
</button>
)}
{/* "Talk to Tiny" face-mode toggle — hidden (kept for potential re-enable). */}
{/* Main pane
In face mode (agent selected), the layout is a horizontal split:
+62 -187
View File
@@ -1,32 +1,21 @@
/**
* Brain — the centerpiece memory surface.
* Brain — the centerpiece memory + subconscious surface.
*
* Reached from the raised center button in the bottom bar. On open it plays a
* short "brain collecting information" Lottie flourish (a minimum display
* window so it always reads as intentional), then cross-fades into the live
* knowledge graph once its force layout has settled. Below the graph it
* surfaces the full memory workspace — controls, tree status, and connected
* sources — framed as a clean, single-column dashboard.
*
* Readiness gate — the loading overlay leaves only when ALL of:
* 1. the minimum animation window has elapsed (skipped under reduced motion),
* 2. the graph data has been fetched, and
* 3. the graph layout has settled (`MemoryGraph` `onReady`, or there's nothing
* to lay out) — OR a hard max-timeout fires so a stuck layout can never
* trap the user behind the overlay, OR the fetch errored.
*
* Everything here is light: one existing RPC for the graph (the status/sources
* panels own their polling), the existing Pixi/worker graph pipeline, and a
* runtime-fetched (cached) Lottie asset.
* Two sub-tabs:
* - **Memory**: knowledge graph, tree status, and connected sources.
* - **Subconscious**: background thinking engine controls.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
import { MemoryControls } from '../components/intelligence/MemoryControls';
import { MemoryGraph } from '../components/intelligence/MemoryGraph';
import { MemorySourcesRegistry } from '../components/intelligence/MemorySourcesRegistry';
import { MemoryTreeStatusPanel } from '../components/intelligence/MemoryTreeStatusPanel';
import { ToastContainer } from '../components/intelligence/Toast';
import LottieAnimation from '../components/LottieAnimation';
import PillTabBar from '../components/PillTabBar';
import BetaBanner from '../components/ui/BetaBanner';
import { useSubconscious } from '../hooks/useSubconscious';
import { useT } from '../lib/i18n/I18nContext';
import type { ToastNotification } from '../types/intelligence';
import {
@@ -35,35 +24,18 @@ import {
memoryTreeGraphExport,
} from '../utils/tauriCommands';
/** Minimum time the loading flourish stays up so it never just flashes. */
const MIN_ANIMATION_MS = 1200;
/** Hard ceiling: reveal the graph regardless after this, even if not settled. */
const MAX_WAIT_MS = 8000;
const BRAIN_LOTTIE_SRC = '/lottie/brain-collecting.json';
/** Honour the OS reduced-motion preference — skip the flourish entirely. */
const prefersReducedMotion = (): boolean =>
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
type BrainTab = 'memory' | 'subconscious';
export default function Brain() {
const { t } = useT();
const [activeTab, setActiveTab] = useState<BrainTab>('memory');
const [graph, setGraph] = useState<GraphExportResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Graph layout has settled (or there is nothing to lay out).
const [graphReady, setGraphReady] = useState(false);
// Minimum animation window has elapsed.
const [minElapsed, setMinElapsed] = useState(false);
// Hard ceiling reached — reveal regardless.
const [timedOut, setTimedOut] = useState(false);
// Graph view mode — driven by the toolbar's Trees / Contacts toggle.
const [mode, setMode] = useState<GraphMode>('tree');
// Bumped to force a graph re-pull (Refresh + post-mutation refresh).
const [refreshKey, setRefreshKey] = useState(0);
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const reduceMotion = useRef(prefersReducedMotion());
const sub = useSubconscious();
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]);
@@ -73,13 +45,10 @@ export default function Brain() {
}, []);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
// ── Fetch the graph on mount / mode change / refresh, and refresh when the
// tree finishes building.
useEffect(() => {
let cancelled = false;
const load = async () => {
console.debug('[brain] graph fetch: entry mode=%s', mode);
// Clear any prior error so a successful retry isn't masked by a stale one.
setError(null);
try {
const resp = await memoryTreeGraphExport(mode);
@@ -90,9 +59,6 @@ export default function Brain() {
resp.edges.length
);
setGraph(resp);
// Nothing to lay out → ready immediately (MemoryGraph won't fire onReady
// for an empty graph since it short-circuits to the empty placeholder).
if (resp.nodes.length === 0) setGraphReady(true);
} catch (err) {
if (cancelled) return;
console.error('[brain] graph fetch failed', err);
@@ -111,43 +77,12 @@ export default function Brain() {
};
}, [mode, refreshKey]);
// ── Minimum-display timer (skipped under reduced motion).
useEffect(() => {
if (reduceMotion.current) {
setMinElapsed(true);
return;
}
const id = window.setTimeout(() => setMinElapsed(true), MIN_ANIMATION_MS);
return () => window.clearTimeout(id);
}, []);
// ── Safety ceiling so the overlay can't trap the user behind a stuck layout.
useEffect(() => {
const id = window.setTimeout(() => {
console.debug('[brain] readiness max-timeout reached');
setTimedOut(true);
}, MAX_WAIT_MS);
return () => window.clearTimeout(id);
}, []);
const handleGraphReady = useCallback(() => {
console.debug('[brain] graph onReady');
setGraphReady(true);
}, []);
const dataLoaded = graph !== null || error !== null;
const overlayDone = (minElapsed && dataLoaded && graphReady) || error !== null || timedOut;
// Matches the MemorySourcesRegistry card so the dashboard reads as one set.
const cardClass =
'rounded-lg border border-stone-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900';
return (
// Standard page shell — matches Activity/other pages. `relative` anchors the
// loading overlay and the toast stack.
<div className="relative min-h-full p-4 pt-6">
<div className="mx-auto max-w-4xl space-y-5">
{/* Page header */}
<header className="min-w-0">
<h1 className="text-xl font-bold text-stone-900 dark:text-neutral-100">
{t('nav.brain')}
@@ -155,129 +90,69 @@ export default function Brain() {
<p className="mt-1 text-sm text-stone-500 dark:text-neutral-400">{t('brain.subtitle')}</p>
</header>
{/* Graph toolbar — mode toggle + actions. */}
<MemoryControls
mode={mode}
onModeChange={setMode}
onRefresh={refresh}
onToast={addToast}
contentRootAbs={graph?.content_root_abs}
<PillTabBar<BrainTab>
selected={activeTab}
onChange={setActiveTab}
items={[
{ value: 'memory', label: t('brain.tabs.memory') },
{ value: 'subconscious', label: t('brain.tabs.subconscious') },
]}
/>
{/* Hero graph — mounted as soon as data arrives (even while the overlay
covers it) so its force layout settles and fires onReady underneath. */}
<div
className={`transition-opacity duration-700 ${overlayDone ? 'opacity-100' : 'opacity-0'}`}
aria-hidden={!overlayDone}>
{/* A loaded graph wins over a transient error so a failed background
refetch never clobbers an already-rendered graph. */}
{graph ? (
<div className="animate-fade-in">
{activeTab === 'memory' && (
<div className="space-y-5 animate-fade-up">
<MemoryControls
mode={mode}
onModeChange={setMode}
onRefresh={refresh}
onToast={addToast}
contentRootAbs={graph?.content_root_abs}
/>
{graph ? (
<MemoryGraph
nodes={graph.nodes}
edges={graph.edges}
mode={mode}
emptyHint={t('brain.empty')}
onReady={handleGraphReady}
/>
) : error ? (
<div
className={`${cardClass} text-sm text-coral-600 dark:text-coral-400`}
role="alert">
{t('brain.error')}
</div>
) : null}
<div className="space-y-5">
<div className={cardClass}>
<MemoryTreeStatusPanel onToast={addToast} />
</div>
<MemorySourcesRegistry onToast={addToast} />
</div>
</div>
)}
{activeTab === 'subconscious' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<div className={cardClass}>
<IntelligenceSubconsciousTab
status={sub.status}
mode={sub.mode}
intervalMinutes={sub.intervalMinutes}
triggerTick={sub.triggerTick}
triggering={sub.triggering}
settingMode={sub.settingMode}
setMode={sub.setMode}
setIntervalMinutes={sub.setIntervalMinutes}
/>
</div>
) : error ? (
<div className={`${cardClass} text-sm text-coral-600 dark:text-coral-400`} role="alert">
{t('brain.error')}
</div>
) : null}
</div>
{/* Memory workspace — revealed once the graph is in. The panels own
their own polling, so deferring their mount keeps the load light. */}
{overlayDone ? (
<div className="animate-fade-in space-y-5">
<div className={cardClass}>
<MemoryTreeStatusPanel onToast={addToast} />
</div>
<MemorySourcesRegistry onToast={addToast} />
</div>
) : null}
)}
</div>
{/* Loading overlay — the "brain collecting information" flourish, dressed
as an ambient ocean-glow scene: a radial gradient wash, a soft pulsing
halo, and the brain floating above it. Opaque and viewport-filling so
the whole page (header, toolbar, graph, and the panels below) stays
hidden until the graph is ready. `fixed` keeps it centered regardless
of page height; z-40 sits below the bottom nav bar (z-50) so navigation
stays available. All motion layers are dropped under reduced motion —
which falls back to a static knowledge-node glyph instead of the
looping Lottie. */}
{!overlayDone && (
<div
className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-7 overflow-hidden bg-stone-50 animate-fade-in dark:bg-neutral-950"
data-testid="brain-loading"
role="status"
aria-live="polite">
{/* Ambient ocean-glow wash filling the viewport behind everything. */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-gradient-radial from-primary-500/15 via-transparent to-transparent dark:from-primary-500/20"
/>
{/* Brain over a soft pulsing halo. The halo breathes (glow-pulse) and
the brain floats; both are stilled under reduced motion. */}
<div className="relative flex h-60 w-60 items-center justify-center">
<div
aria-hidden
className={`absolute h-48 w-48 rounded-full bg-primary-400/30 blur-3xl dark:bg-primary-500/25 ${
reduceMotion.current ? '' : 'animate-glow-pulse'
}`}
/>
{reduceMotion.current ? (
<BrainGlyph />
) : (
<div className="relative animate-float">
<LottieAnimation src={BRAIN_LOTTIE_SRC} height={220} width={220} />
</div>
)}
</div>
<p className="relative text-sm font-medium tracking-wide text-stone-500 dark:text-neutral-400">
{t('brain.loading')}
</p>
</div>
)}
<ToastContainer notifications={toasts} onRemove={removeToast} />
</div>
);
}
/**
* Static knowledge-node glyph shown in the loading overlay under reduced
* motion — a central hub linked to satellite nodes, echoing the graph the
* page is about to reveal, with no animation.
*/
function BrainGlyph() {
return (
<svg
width={120}
height={120}
viewBox="0 0 120 120"
fill="none"
className="relative text-primary-500 dark:text-primary-400"
data-testid="brain-glyph"
aria-hidden>
<g stroke="currentColor" strokeWidth={2} opacity={0.45}>
<line x1="60" y1="60" x2="26" y2="34" />
<line x1="60" y1="60" x2="96" y2="40" />
<line x1="60" y1="60" x2="34" y2="92" />
<line x1="60" y1="60" x2="92" y2="88" />
</g>
<g fill="currentColor">
<circle cx="60" cy="60" r="11" />
<circle cx="26" cy="34" r="6" opacity={0.85} />
<circle cx="96" cy="40" r="6" opacity={0.85} />
<circle cx="34" cy="92" r="6" opacity={0.85} />
<circle cx="92" cy="88" r="6" opacity={0.85} />
</g>
</svg>
);
}
+8 -30
View File
@@ -9,6 +9,7 @@ import ApprovalRequestCard from '../components/chat/ApprovalRequestCard';
import ArtifactCard from '../components/chat/ArtifactCard';
import ChatComposer from '../components/chat/ChatComposer';
import ChatFilesChip from '../components/chat/ChatFilesChip';
import ComposerTokenStats from '../components/chat/ComposerTokenStats';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import PillTabBar from '../components/PillTabBar';
import UpsellBanner from '../components/upsell/UpsellBanner';
@@ -103,7 +104,6 @@ import {
import {
GENERAL_TAB_VALUE,
isThreadVisibleInTab,
MEETINGS_TAB_VALUE,
SUBCONSCIOUS_TAB_VALUE,
TASKS_TAB_VALUE,
} from './conversations/utils/threadFilter';
@@ -416,11 +416,9 @@ const Conversations = ({
setSelectedLabel(
isThreadVisibleInTab(openThread, TASKS_TAB_VALUE)
? TASKS_TAB_VALUE
: isThreadVisibleInTab(openThread, MEETINGS_TAB_VALUE)
? MEETINGS_TAB_VALUE
: isThreadVisibleInTab(openThread, SUBCONSCIOUS_TAB_VALUE)
? SUBCONSCIOUS_TAB_VALUE
: GENERAL_TAB_VALUE
: isThreadVisibleInTab(openThread, SUBCONSCIOUS_TAB_VALUE)
? SUBCONSCIOUS_TAB_VALUE
: GENERAL_TAB_VALUE
);
dispatch(setSelectedThread(openThread.id));
void dispatch(loadThreadMessages(openThread.id));
@@ -1270,7 +1268,6 @@ const Conversations = ({
// filter state remains unambiguous regardless of what threads exist.
const labelTabs = [
{ label: t('chat.filter.general'), value: GENERAL_TAB_VALUE },
{ label: t('chat.filter.meetings'), value: MEETINGS_TAB_VALUE },
{ label: t('chat.filter.subconscious'), value: SUBCONSCIOUS_TAB_VALUE },
{ label: t('chat.filter.tasks'), value: TASKS_TAB_VALUE },
];
@@ -1319,27 +1316,7 @@ const Conversations = ({
is clamped to true) so the single onboarding thread is always visible. */}
{!isSidebar && effectiveShowSidebar && (
<div className="w-64 flex-shrink-0 flex flex-col bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-stone-100 dark:border-neutral-800">
<h2 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('chat.threads')}
</h2>
<button
data-testid="new-thread-sidebar-button"
data-analytics-id="chat-sidebar-new-thread"
onClick={() => void handleCreateNewThread()}
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60 text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 dark:hover:text-neutral-200 transition-colors"
title={t('chat.newThread')}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
<div className="px-4 py-2 border-b border-stone-50 dark:border-neutral-800">
<div className="px-2 py-2 border-b border-stone-50 dark:border-neutral-800">
<PillTabBar
items={labelTabs}
selected={selectedLabel}
@@ -1373,14 +1350,14 @@ const Conversations = ({
void dispatch(loadThreadMessages(thread.id));
}
}}
className={`w-full text-left px-4 py-3 border-b border-stone-50 dark:border-neutral-800 transition-colors group cursor-pointer ${
className={`w-full text-left px-3 py-1.5 border-b border-stone-100/60 dark:border-neutral-800/60 transition-colors group cursor-pointer ${
selectedThreadId === thread.id
? 'bg-primary-50 dark:bg-primary-900/30 border-l-2 border-l-primary-500'
: 'hover:bg-stone-50 dark:hover:bg-neutral-800/60'
}`}>
<div className="flex items-center justify-between">
<p
className={`text-sm truncate flex-1 ${
className={`text-xs truncate flex-1 ${
selectedThreadId === thread.id
? 'font-medium text-primary-700 dark:text-primary-200'
: 'text-stone-700 dark:text-neutral-200'
@@ -2344,6 +2321,7 @@ const Conversations = ({
</p>
</div>
)}
<ComposerTokenStats />
</div>
</div>
<ConfirmationModal
+12 -2
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { Navigate, Route, Routes, useNavigate } from 'react-router-dom';
import CostDashboardPanel from '../components/dashboard/CostDashboardPanel';
import WorkflowsTab from '../components/intelligence/WorkflowsTab';
import LogoutAndClearActions from '../components/settings/LogoutAndClearActions';
import AboutPanel from '../components/settings/panels/AboutPanel';
import AgentAccessPanel from '../components/settings/panels/AgentAccessPanel';
@@ -282,12 +283,17 @@ const Settings = () => {
// Notifications hub (lives under Advanced) — gathers the Alerts inbox and the
// notification preferences/routing panel under one section page.
const notificationsHubItems = [
{
id: 'automations',
title: t('activity.tabs.automations'),
description: t('activity.tabs.automationsDescription'),
route: 'automations',
icon: NotificationsIcon,
},
{
id: 'alerts',
title: t('nav.alerts'),
description: t('settings.alertsDesc'),
// Alerts is the top-level inbox at `/notifications`, outside the settings
// tree, so navigate explicitly instead of via `navigateToSettings`.
onClick: () => navigate('/notifications'),
icon: NotificationsIcon,
},
@@ -599,6 +605,10 @@ const Settings = () => {
<Route path="cron-jobs" element={wrapSettingsPage(<CronJobsPanel />)} />
<Route path="task-sources" element={wrapSettingsPage(<TaskSourcesPanel />)} />
<Route path="tasks" element={wrapSettingsPage(<TasksPanel />)} />
<Route
path="automations"
element={wrapSettingsPage(<WorkflowsTab />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route path="dev-workflow" element={wrapSettingsPage(<DevWorkflowPanel />)} />
<Route path="skills-runner" element={wrapSettingsPage(<WorkflowRunnerPanel />)} />
<Route
+52 -36
View File
@@ -26,6 +26,7 @@ import {
import SkillSearchBar from '../components/skills/SkillSearchBar';
import SkillsExplorerTab from '../components/skills/SkillsExplorerTab';
import VoiceSetupModal from '../components/skills/VoiceSetupModal';
import BetaBanner from '../components/ui/BetaBanner';
import { useAutocompleteSkillStatus } from '../features/autocomplete/useAutocompleteSkillStatus';
import { useScreenIntelligenceSkillStatus } from '../features/screen-intelligence/useScreenIntelligenceSkillStatus';
import { useVoiceSkillStatus } from '../features/voice/useVoiceSkillStatus';
@@ -349,13 +350,13 @@ interface SkillItem {
* Phase 2 rename mapping (old → new):
* composio → apps
* channels → messaging
* mcp → tools (meetings content folded in under Tools)
* skills → explorer (kept secondary)
* mcp → mcp
* skills → skills (kept secondary)
*
* Back-compat: the old ?tab= values (composio, channels, mcp, meetings) are
* normalised to the new values so existing deep links continue to work.
*/
type ConnectionsTab = 'apps' | 'messaging' | 'tools' | 'explorer' | 'talents';
type ConnectionsTab = 'composio' | 'channels' | 'mcp' | 'skills' | 'meetings';
export default function Skills() {
const { t } = useT();
@@ -363,30 +364,38 @@ export default function Skills() {
const location = useLocation();
const navigate = useNavigate();
const isLocalSession = isLocalSessionToken(getCoreStateSnapshot().snapshot.sessionToken);
// Honour `?tab=<apps|messaging|tools|explorer>` so deep links land on the
// Honour `?tab=<apps|messaging|mcp|skills>` so deep links land on the
// right sub-tab. Also normalise legacy tab names from the old /skills route
// so that e.g. `/skills?tab=composio` still works after the redirect.
const initialTab: ConnectionsTab = (() => {
const activeTab = useMemo<ConnectionsTab>(() => {
const params = new URLSearchParams(location.search);
const raw = params.get('tab');
// New canonical values
if (
raw === 'apps' ||
raw === 'messaging' ||
raw === 'tools' ||
raw === 'explorer' ||
raw === 'talents'
raw === 'composio' ||
raw === 'channels' ||
raw === 'mcp' ||
raw === 'skills' ||
raw === 'meetings'
)
return raw;
// Legacy back-compat aliases
if (raw === 'composio') return 'apps';
if (raw === 'channels') return 'messaging';
if (raw === 'mcp') return 'tools';
if (raw === 'meetings') return 'talents';
if (raw === 'skills') return 'explorer';
return 'apps';
})();
const [activeTab, setActiveTab] = useState<ConnectionsTab>(initialTab);
if (raw === 'apps') return 'composio';
if (raw === 'messaging') return 'channels';
if (raw === 'tools') return 'mcp';
if (raw === 'talents') return 'meetings';
if (raw === 'explorer') return 'skills';
return 'composio';
}, [location.search]);
const handleTabChange = useCallback(
(tab: ConnectionsTab) => {
const params = new URLSearchParams(location.search);
params.set('tab', tab);
navigate({ pathname: location.pathname, search: `?${params.toString()}` });
},
[location.pathname, location.search, navigate]
);
const dispatch = useAppDispatch();
const [defaultChannelBusy, setDefaultChannelBusy] = useState<ChannelType | null>(null);
const handleSetDefaultChannel = useCallback(
@@ -815,18 +824,18 @@ export default function Skills() {
<PillTabBar<ConnectionsTab>
selected={activeTab}
onChange={setActiveTab}
onChange={handleTabChange}
items={[
{ value: 'apps', label: t('connections.tabs.apps') },
{ value: 'messaging', label: t('connections.tabs.messaging') },
{ value: 'tools', label: t('connections.tabs.tools') },
{ value: 'explorer', label: t('connections.tabs.explorer') },
{ value: 'talents', label: t('connections.tabs.talents') },
{ value: 'composio', label: t('connections.tabs.composio') },
{ value: 'channels', label: t('connections.tabs.channels') },
{ value: 'mcp', label: t('connections.tabs.mcp') },
{ value: 'skills', label: t('connections.tabs.skills') },
{ value: 'meetings', label: t('connections.tabs.meetings') },
]}
/>
{
<>
{activeTab === 'messaging' && channelsGroup && (
{activeTab === 'channels' && channelsGroup && (
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up">
<div className="px-1 pb-3 pt-1">
<h2
@@ -888,7 +897,7 @@ export default function Skills() {
</div>
)}
{activeTab === 'apps' && (
{activeTab === 'composio' && (
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up">
<div className="px-1 pb-3 pt-1">
<div className="flex items-center gap-2">
@@ -964,20 +973,27 @@ export default function Skills() {
</div>
)}
{activeTab === 'apps' && otherGroups.map(group => renderGroup(group))}
{activeTab === 'composio' && otherGroups.map(group => renderGroup(group))}
{activeTab === 'explorer' && <SkillsExplorerTab onToast={addToast} />}
{activeTab === 'tools' && (
<div className="space-y-4 animate-fade-up">
{/* MCP Servers */}
<McpServersTab />
{activeTab === 'skills' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<SkillsExplorerTab onToast={addToast} />
</div>
)}
{activeTab === 'talents' && (
<div className="space-y-4 animate-fade-up">
{/* Meeting bots live under Talents (moved out of Tools) */}
{activeTab === 'mcp' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 shadow-soft">
<McpServersTab />
</div>
</div>
)}
{activeTab === 'meetings' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<MeetingBotsCard onToast={addToast} />
</div>
)}
+3 -3
View File
@@ -12,9 +12,9 @@
* meaningful. We considered /workflows/run?workflow=<new-id>, but
* new skills aren't auto-scheduled and the runner picker pre-select
* only makes sense once the user has filled in inputs. The
* Connections page (defaulting to Apps tab) provides a clear "here
* are your connections" signal. Use ?tab=explorer to deep-link to
* the Explorer tab if needed.
* Connections page (defaulting to the Composio tab) provides a clear "here
* are your connections" signal. Use ?tab=skills to deep-link to
* the Skills tab if needed.
* - Cancel → /connections.
*/
import { useCallback, useState } from 'react';
+42 -77
View File
@@ -1,42 +1,47 @@
/**
* Tests for the Brain page orchestration: it shows the loading overlay, then
* reveals the memory graph once data is fetched, the layout has settled
* (mocked MemoryGraph fires onReady), and the minimum animation window passes.
* Heavy children (the Pixi/SVG graph and the Lottie player) and the RPC are
* mocked so this stays a fast unit test of the gate logic.
*/
import { act, render, screen } from '@testing-library/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Brain from '../Brain';
const graphExportMock = vi.hoisted(() => vi.fn());
vi.mock('../../utils/tauriCommands', () => ({ memoryTreeGraphExport: graphExportMock }));
vi.mock('../../utils/tauriCommands', () => ({
memoryTreeGraphExport: graphExportMock,
isTauri: () => false,
}));
// Mocked graph: fires onReady on mount to simulate the layout settling.
vi.mock('../../components/intelligence/MemoryGraph', async () => {
const React = await import('react');
return {
MemoryGraph: ({ nodes, onReady }: { nodes: unknown[]; onReady?: () => void }) => {
React.useEffect(() => {
onReady?.();
}, [onReady]);
return React.createElement('div', { 'data-testid': 'memory-graph' }, `nodes:${nodes.length}`);
},
MemoryGraph: ({ nodes }: { nodes: unknown[] }) =>
React.createElement('div', { 'data-testid': 'memory-graph' }, `nodes:${nodes.length}`),
};
});
vi.mock('../../components/LottieAnimation', async () => {
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('../../hooks/useSubconscious', () => ({
useSubconscious: () => ({
status: null,
mode: 'off',
refresh: vi.fn(),
triggerTick: vi.fn(),
setMode: vi.fn(),
}),
}));
vi.mock('../../components/intelligence/IntelligenceSubconsciousTab', () => ({
default: () => null,
}));
vi.mock('../../components/PillTabBar', async () => {
const React = await import('react');
return {
default: ({ src }: { src: string }) =>
React.createElement('div', { 'data-testid': 'brain-lottie', 'data-src': src }),
default: ({ children }: { children?: React.ReactNode }) =>
React.createElement('div', null, children),
};
});
vi.mock('../../components/ui/BetaBanner', () => ({ default: () => null }));
// The memory workspace panels own their own polling/RPCs — stub them so this
// stays an isolated test of the Brain page's loading/reveal orchestration.
vi.mock('../../components/intelligence/MemoryControls', () => ({ MemoryControls: () => null }));
vi.mock('../../components/intelligence/MemoryTreeStatusPanel', () => ({
MemoryTreeStatusPanel: () => null,
@@ -55,79 +60,39 @@ const makeGraph = (n: number) => ({
describe('Brain page', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// jsdom has no matchMedia — default to "motion allowed".
window.matchMedia = vi
.fn()
.mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as unknown as typeof window.matchMedia;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('shows the loading overlay first, then reveals the graph once ready', async () => {
it('renders the graph once data is fetched', async () => {
graphExportMock.mockResolvedValue(makeGraph(3));
render(<Brain />);
// Overlay (with the Lottie flourish) is visible immediately.
expect(screen.getByTestId('brain-loading')).toBeInTheDocument();
expect(screen.getByTestId('brain-lottie')).toHaveAttribute(
'data-src',
'/lottie/brain-collecting.json'
);
// Flush the fetch + the minimum-display timer.
await act(async () => {
await vi.advanceTimersByTimeAsync(1300);
render(<Brain />);
});
await waitFor(() => {
expect(screen.getByTestId('memory-graph')).toHaveTextContent('nodes:3');
});
expect(screen.queryByTestId('brain-loading')).toBeNull();
expect(screen.getByTestId('memory-graph')).toHaveTextContent('nodes:3');
});
it('reveals immediately (empty state) when there is no memory yet', async () => {
it('renders empty-state graph when there are no nodes', async () => {
graphExportMock.mockResolvedValue(makeGraph(0));
render(<Brain />);
await act(async () => {
await vi.advanceTimersByTimeAsync(1300);
render(<Brain />);
});
await waitFor(() => {
expect(screen.getByTestId('memory-graph')).toHaveTextContent('nodes:0');
});
expect(screen.queryByTestId('brain-loading')).toBeNull();
expect(screen.getByTestId('memory-graph')).toHaveTextContent('nodes:0');
});
it('uses the static knowledge-node glyph (no Lottie) under reduced motion', async () => {
// Reduced motion → the floating Lottie is replaced by the still glyph.
window.matchMedia = vi
.fn()
.mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as unknown as typeof window.matchMedia;
graphExportMock.mockResolvedValue(makeGraph(2));
render(<Brain />);
expect(screen.getByTestId('brain-loading')).toBeInTheDocument();
expect(screen.getByTestId('brain-glyph')).toBeInTheDocument();
expect(screen.queryByTestId('brain-lottie')).toBeNull();
});
it('surfaces an error and dismisses the overlay when the fetch fails', async () => {
it('surfaces an error alert when the fetch fails', async () => {
graphExportMock.mockRejectedValue(new Error('boom'));
render(<Brain />);
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
render(<Brain />);
});
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
expect(screen.queryByTestId('brain-loading')).toBeNull();
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
@@ -317,16 +317,14 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
// Covers line 906: const effectiveShowSidebar = threadSidebarVisible;
// Covers line 941: <div className="flex-1 overflow-y-auto"> (always rendered in page mode)
it('renders the Threads sidebar header in page mode', async () => {
it('renders the sidebar pill tabs in page mode', async () => {
await act(async () => {
await renderConversations({ thread: emptyThreadState });
});
// Sidebar is hidden by default — open it first.
await openSidebar();
// The "Threads" header is always rendered in page mode (sidebar guard removed)
expect(screen.getByText('Threads')).toBeInTheDocument();
expect(screen.getByText('General')).toBeInTheDocument();
});
it('restores and updates the persisted thread sidebar visibility', async () => {
@@ -337,14 +335,14 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
});
});
expect(screen.getByText('Threads')).toBeInTheDocument();
expect(screen.getByText('General')).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByTitle('Hide sidebar'));
});
await waitFor(() => {
expect(screen.queryByText('Threads')).not.toBeInTheDocument();
expect(screen.queryByText('General')).not.toBeInTheDocument();
});
expect(renderedStore?.getState().thread.threadSidebarVisible).toBe(false);
});
@@ -616,25 +614,8 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
});
});
// Covers line 919: onClick={() => void handleCreateNewThread()} — sidebar "New thread" button
// Covers line 1061: onClick={() => void handleCreateNewThread()} — header "+ New" button
it('clicking "New thread" sidebar button calls handleCreateNewThread', async () => {
await act(async () => {
await renderConversations({ thread: emptyThreadState });
});
// Sidebar is hidden by default — open it first.
await openSidebar();
// The sidebar "New thread" button has title="New thread"
const newThreadBtn = screen.getByTitle('New thread');
await act(async () => {
fireEvent.click(newThreadBtn);
});
// createNewThread was called — verifies line 919 callback executed
expect(threadApi.createNewThread).toHaveBeenCalled();
});
// Sidebar "New thread" button was removed in the composer flattening refactor.
// The "+ New" header button (tested below) is the remaining create-thread entry point.
it('clicking "+ New" header button calls handleCreateNewThread', async () => {
// Need a selected thread so the header renders
@@ -69,8 +69,8 @@ describe('Skills page — Channels grid', () => {
it('renders configured channels as tiles in a dedicated card and opens the setup modal on click', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
// Switch to the Messaging tab to make the Channels card visible.
fireEvent.click(screen.getByRole('tab', { name: 'Messaging' }));
// Switch to the Channels tab to make the Channels card visible.
fireEvent.click(screen.getByRole('tab', { name: 'Channels' }));
const channelsHeading = screen.getByRole('heading', { name: 'Messaging' });
expect(channelsHeading).toBeInTheDocument();
@@ -135,8 +135,8 @@ describe('Skills page — Channels grid', () => {
};
renderWithProviders(<Skills />, { initialEntries: ['/connections'], preloadedState });
// Switch to the Messaging tab so the Channels card is visible.
fireEvent.click(screen.getByRole('tab', { name: 'Messaging' }));
// Switch to the Channels tab so the Channels card is visible.
fireEvent.click(screen.getByRole('tab', { name: 'Channels' }));
const channelsCard = screen
.getByRole('heading', { name: 'Messaging' })
.closest('.rounded-2xl');
@@ -149,9 +149,9 @@ describe('Skills page — Channels grid', () => {
it('does not surface a Channels chip in the category filter inside the Integrations card', () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
fireEvent.click(screen.getByRole('tab', { name: 'Apps' }));
fireEvent.click(screen.getByRole('tab', { name: 'Composio' }));
// The Apps tab owns the Integrations category filter.
// The Composio tab owns the Integrations category filter.
const integrationsHeading = screen.getByRole('heading', { name: 'Composio Integrations' });
const integrationsCard = integrationsHeading.closest('.rounded-2xl');
expect(integrationsCard).not.toBeNull();
@@ -79,7 +79,7 @@ describe('Skills page — Composio catalog fallback', () => {
});
function openAppsTab() {
fireEvent.click(screen.getByRole('tab', { name: 'Apps' }));
fireEvent.click(screen.getByRole('tab', { name: 'Composio' }));
}
it('shows known composio integrations in the integrations icon grid when the live toolkit list is empty', () => {
@@ -49,11 +49,11 @@ vi.mock('../../services/api/mcpClientsApi', () => ({
},
}));
describe('Skills page — Tools tab (MCP + Meeting bots, Phase 2)', () => {
it('renders the MCP servers table in the Tools tab', async () => {
describe('Skills page — MCP Servers tab (MCP + Meeting bots)', () => {
it('renders the MCP servers table in the MCP Servers tab', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
fireEvent.click(screen.getByRole('tab', { name: 'Tools' }));
fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' }));
// The Tools tab shows filter chips (All / Installed / Registry) and a search input
await waitFor(() => {
@@ -63,10 +63,10 @@ describe('Skills page — Tools tab (MCP + Meeting bots, Phase 2)', () => {
expect(screen.getByRole('button', { name: 'Registry' })).toBeInTheDocument();
});
it('shows the table header columns on the Tools tab', async () => {
it('shows the table header columns on the MCP Servers tab', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
fireEvent.click(screen.getByRole('tab', { name: 'Tools' }));
fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' }));
// Wait for initial load to complete
await waitFor(() => {
@@ -81,7 +81,7 @@ describe('Skills page — Tools tab (MCP + Meeting bots, Phase 2)', () => {
it('shows empty-installed state when Installed chip is clicked', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
fireEvent.click(screen.getByRole('tab', { name: 'Tools' }));
fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /Installed/i })).toBeInTheDocument();
@@ -93,10 +93,13 @@ describe('Skills page — Tools tab (MCP + Meeting bots, Phase 2)', () => {
});
});
it('supports direct links via legacy ?tab=mcp (normalised to tools)', async () => {
it('supports direct links via legacy ?tab=mcp (normalised to mcp-servers)', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections?tab=mcp'] });
expect(screen.getByRole('tab', { name: 'Tools' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'MCP Servers' })).toHaveAttribute(
'aria-selected',
'true'
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'All' })).toBeInTheDocument();
});
@@ -38,33 +38,33 @@ vi.mock('../../lib/composio/hooks', () => ({
}),
}));
describe('Skills page — Talents tab (meeting bots)', () => {
it('shows the meeting bot CTA inside the Talents tab (not Tools)', () => {
describe('Skills page — Meetings tab (meeting bots)', () => {
it('shows the meeting bot CTA inside the Meetings tab (not MCP Servers)', () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
expect(screen.queryByTestId('meeting-bots-card')).not.toBeInTheDocument();
// Tools no longer hosts the meeting bot CTA.
fireEvent.click(screen.getByRole('tab', { name: 'Tools' }));
// MCP Servers no longer hosts the meeting bot CTA.
fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' }));
expect(screen.queryByTestId('meeting-bots-card')).not.toBeInTheDocument();
// Talents does.
fireEvent.click(screen.getByRole('tab', { name: 'Talents' }));
// Meetings does.
fireEvent.click(screen.getByRole('tab', { name: 'Meetings' }));
expect(screen.getByTestId('meeting-bots-card')).toBeInTheDocument();
});
it('supports direct links via legacy ?tab=meetings (normalised to talents)', () => {
// The old ?tab=meetings alias now maps to the new "Talents" tab.
// The old ?tab=meetings alias now maps to the new "Meetings" tab.
renderWithProviders(<Skills />, { initialEntries: ['/connections?tab=meetings'] });
expect(screen.getByRole('tab', { name: 'Talents' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Meetings' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByTestId('meeting-bots-card')).toBeInTheDocument();
});
it('supports direct links via ?tab=talents', () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections?tab=talents'] });
expect(screen.getByRole('tab', { name: 'Talents' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Meetings' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByTestId('meeting-bots-card')).toBeInTheDocument();
});
});
@@ -41,7 +41,7 @@ vi.mock('../../lib/composio/hooks', () => ({
describe('Skills page — Gmail composio integration', () => {
it('renders Gmail as a connected composio integration and opens its management modal', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
fireEvent.click(screen.getByRole('tab', { name: 'Apps' }));
fireEvent.click(screen.getByRole('tab', { name: 'Composio' }));
const integrationsSection = screen
.getByRole('heading', { name: 'Composio Integrations' })
@@ -37,7 +37,7 @@ vi.mock('../../lib/composio/hooks', () => ({
describe('Skills page — Notion composio integration', () => {
it('renders Notion as a disconnected composio integration and opens its connect modal', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
fireEvent.click(screen.getByRole('tab', { name: 'Apps' }));
fireEvent.click(screen.getByRole('tab', { name: 'Composio' }));
expect(screen.getByRole('heading', { name: 'Composio Integrations' })).toBeInTheDocument();
const notionTile = screen.getByRole('button', { name: /Notion.*Connect/i });
@@ -3,9 +3,7 @@ import { describe, expect, it } from 'vitest';
import type { Thread } from '../../../types/thread';
import {
GENERAL_TAB_VALUE,
isMeetingThread,
isThreadVisibleInTab,
MEETINGS_TAB_VALUE,
SUBCONSCIOUS_TAB_VALUE,
TASKS_TAB_VALUE,
} from './threadFilter';
@@ -56,10 +54,8 @@ describe('isThreadVisibleInTab', () => {
);
});
it('excludes meeting threads from the General bucket', () => {
expect(
isThreadVisibleInTab(thread({ labels: [MEETINGS_TAB_VALUE] }), GENERAL_TAB_VALUE)
).toBe(false);
it('excludes meeting threads from the General bucket (folded into Tasks)', () => {
expect(isThreadVisibleInTab(thread({ labels: ['meetings'] }), GENERAL_TAB_VALUE)).toBe(false);
expect(isThreadVisibleInTab(thread({ labels: ['Meetings'] }), GENERAL_TAB_VALUE)).toBe(false);
});
});
@@ -105,6 +101,11 @@ describe('isThreadVisibleInTab', () => {
).toBe(true);
});
it('keeps meeting-labeled threads (meetings folded into tasks)', () => {
expect(isThreadVisibleInTab(thread({ labels: ['meetings'] }), TASKS_TAB_VALUE)).toBe(true);
expect(isThreadVisibleInTab(thread({ labels: ['Meetings'] }), TASKS_TAB_VALUE)).toBe(true);
});
it('excludes ordinary and subconscious threads', () => {
expect(isThreadVisibleInTab(thread({ labels: [GENERAL_TAB_VALUE] }), TASKS_TAB_VALUE)).toBe(
false
@@ -114,38 +115,4 @@ describe('isThreadVisibleInTab', () => {
).toBe(false);
});
});
describe('Meetings bucket', () => {
it('keeps threads with canonical "meetings" label', () => {
expect(
isThreadVisibleInTab(thread({ labels: [MEETINGS_TAB_VALUE] }), MEETINGS_TAB_VALUE)
).toBe(true);
});
it('keeps threads with Rust-generated "Meetings" (capitalized) label', () => {
expect(isThreadVisibleInTab(thread({ labels: ['Meetings'] }), MEETINGS_TAB_VALUE)).toBe(true);
});
it('excludes ordinary and task threads', () => {
expect(
isThreadVisibleInTab(thread({ labels: [GENERAL_TAB_VALUE] }), MEETINGS_TAB_VALUE)
).toBe(false);
expect(isThreadVisibleInTab(thread({ labels: [TASKS_TAB_VALUE] }), MEETINGS_TAB_VALUE)).toBe(
false
);
expect(isThreadVisibleInTab(thread({ labels: [] }), MEETINGS_TAB_VALUE)).toBe(false);
});
});
describe('isMeetingThread helper', () => {
it('recognizes canonical and capitalized meeting labels', () => {
expect(isMeetingThread(thread({ labels: [MEETINGS_TAB_VALUE] }))).toBe(true);
expect(isMeetingThread(thread({ labels: ['Meetings'] }))).toBe(true);
});
it('rejects non-meeting threads', () => {
expect(isMeetingThread(thread({ labels: [] }))).toBe(false);
expect(isMeetingThread(thread({ labels: [GENERAL_TAB_VALUE] }))).toBe(false);
});
});
});
@@ -3,12 +3,11 @@ import type { Thread } from '../../../types/thread';
export const GENERAL_TAB_VALUE = 'general';
export const SUBCONSCIOUS_TAB_VALUE = 'subconscious';
export const TASKS_TAB_VALUE = 'tasks';
export const MEETINGS_TAB_VALUE = 'meetings';
export const LEGACY_GENERAL_LABEL = 'work';
export const LEGACY_SUBCONSCIOUS_LABELS = ['from_reflection', 'subconscious_tick'];
export const LEGACY_TASK_LABELS = ['agent-task', 'worker'];
/** Canonical label applied to meeting transcript threads by the Rust core. */
const MEETINGS_LABEL = 'Meetings';
/** Labels that identify meeting transcript threads (now folded into Tasks). */
const MEETINGS_LABELS = ['meetings', 'Meetings'];
function hasAnyLabel(thread: Thread, labels: readonly string[]): boolean {
return Boolean(thread.labels?.some(label => labels.includes(label)));
@@ -20,36 +19,29 @@ function isSubconsciousThread(thread: Thread): boolean {
export function isTaskThread(thread: Thread): boolean {
return Boolean(
thread.parentThreadId || hasAnyLabel(thread, [TASKS_TAB_VALUE, ...LEGACY_TASK_LABELS])
thread.parentThreadId ||
hasAnyLabel(thread, [TASKS_TAB_VALUE, ...LEGACY_TASK_LABELS, ...MEETINGS_LABELS])
);
}
export function isMeetingThread(thread: Thread): boolean {
return hasAnyLabel(thread, [MEETINGS_TAB_VALUE, MEETINGS_LABEL]);
}
/**
* Pure, side-effect-free thread filter shared between
* `Conversations.tsx` (which renders the sidebar list) and the test
* suite. Keeping it free of React state means a future change to the
* filter rule lands in one place with explicit unit coverage instead
* of a buried `useMemo` body.
* suite.
*
* Rules:
* - Tasks includes task-board threads plus legacy worker/sub-agent threads.
* - Tasks includes task-board threads, legacy worker/sub-agent threads,
* and meeting transcript threads.
* - Subconscious includes new and legacy reflection/tick-generated threads.
* - General is the fallback bucket for everything else, including legacy
* `work`, unknown labels, and unlabeled historical threads.
* - General is the fallback bucket for everything else.
*/
export function isThreadVisibleInTab(thread: Thread, selectedLabel: string): boolean {
const isSubconscious = isSubconsciousThread(thread);
const isTask = isTaskThread(thread);
const isMeeting = isMeetingThread(thread);
if (selectedLabel === SUBCONSCIOUS_TAB_VALUE) return isSubconscious;
if (selectedLabel === TASKS_TAB_VALUE) return isTask;
if (selectedLabel === MEETINGS_TAB_VALUE) return isMeeting;
if (selectedLabel === GENERAL_TAB_VALUE) {
return !isSubconscious && !isTask && !isMeeting;
return !isSubconscious && !isTask;
}
return Boolean(thread.labels?.includes(selectedLabel));
}
+28 -4
View File
@@ -189,6 +189,8 @@ export interface SessionTokenUsage {
outputTokens: number;
turns: number;
lastUpdated: number;
lastTurnInputTokens: number;
lastTurnOutputTokens: number;
}
/**
@@ -286,7 +288,14 @@ const initialState: ChatRuntimeState = {
inferenceTurnLifecycleByThread: {},
pendingApprovalByThread: {},
artifactsByThread: {},
sessionTokenUsage: { inputTokens: 0, outputTokens: 0, turns: 0, lastUpdated: 0 },
sessionTokenUsage: {
inputTokens: 0,
outputTokens: 0,
turns: 0,
lastUpdated: 0,
lastTurnInputTokens: 0,
lastTurnOutputTokens: 0,
},
queueStatusByThread: {},
};
@@ -718,13 +727,28 @@ const chatRuntimeSlice = createSlice({
state,
action: PayloadAction<{ inputTokens: number; outputTokens: number }>
) => {
state.sessionTokenUsage.inputTokens += Math.max(0, action.payload.inputTokens);
state.sessionTokenUsage.outputTokens += Math.max(0, action.payload.outputTokens);
const inTok = Number.isFinite(action.payload.inputTokens)
? Math.max(0, action.payload.inputTokens)
: 0;
const outTok = Number.isFinite(action.payload.outputTokens)
? Math.max(0, action.payload.outputTokens)
: 0;
state.sessionTokenUsage.inputTokens += inTok;
state.sessionTokenUsage.outputTokens += outTok;
state.sessionTokenUsage.turns += 1;
state.sessionTokenUsage.lastUpdated = Date.now();
state.sessionTokenUsage.lastTurnInputTokens = inTok;
state.sessionTokenUsage.lastTurnOutputTokens = outTok;
},
resetSessionTokenUsage: state => {
state.sessionTokenUsage = { inputTokens: 0, outputTokens: 0, turns: 0, lastUpdated: 0 };
state.sessionTokenUsage = {
inputTokens: 0,
outputTokens: 0,
turns: 0,
lastUpdated: 0,
lastTurnInputTokens: 0,
lastTurnOutputTokens: 0,
};
},
/**
* Apply a persisted [TurnState] snapshot from the Rust core to the
+1 -1
View File
@@ -424,7 +424,7 @@ export async function memoryTreeSetLlm(
* node (Tree mode); `"chunk"` is a raw memory chunk and `"contact"`
* is a person entity (Contacts mode).
*/
export type GraphNodeKind = 'source' | 'summary' | 'chunk' | 'contact';
export type GraphNodeKind = 'root' | 'source' | 'summary' | 'chunk' | 'contact';
/**
* One node in the graph export. Optional fields are populated only
@@ -82,8 +82,8 @@ async function bootSkillsPage(page: Page, userId: string) {
.toContain('/connections');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
// Navigate to the Composio tab
await page.getByRole('tab', { name: 'Composio' }).click();
// Heading reads "Composio Integrations" (skills.integrations); the tab is "Apps"
await expect(
page.getByRole('heading', { name: 'Composio Integrations', exact: true })
@@ -187,7 +187,7 @@ test.describe('Composio triggers flow', () => {
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
// Tab is "Apps"; heading reads "Composio Integrations"
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByRole('tab', { name: 'Composio' }).click();
await expect(
page.getByRole('heading', { name: 'Composio Integrations', exact: true })
).toBeVisible({ timeout: 20_000 });
@@ -75,8 +75,8 @@ async function bootSkillsPage(page: Page, userId: string) {
.toContain('/connections');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
// Navigate to the Composio tab
await page.getByRole('tab', { name: 'Composio' }).click();
const heading = page.getByRole('heading', { name: 'Composio Integrations', exact: true });
if (!(await heading.isVisible().catch(() => false))) {
const connectionsButton = page.getByRole('button', { name: 'Connections' });
@@ -99,7 +99,7 @@ async function reloadSkills(page: Page) {
}
async function ensureComposioSurface(page: Page) {
// Phase 2: /skills → /connections, "Composio" tab renamed to "Apps"
// Navigate to /connections and click the Composio tab
const heading = page.getByRole('heading', { name: 'Composio Integrations', exact: true });
for (let attempt = 0; attempt < 3; attempt++) {
await page.evaluate(() => {
@@ -110,7 +110,7 @@ async function ensureComposioSurface(page: Page) {
.toContain('/connections');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByRole('tab', { name: 'Composio' }).click();
if (await heading.isVisible().catch(() => false)) {
return;
}
@@ -119,7 +119,7 @@ async function ensureComposioSurface(page: Page) {
await connectionsButton.click({ force: true });
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByRole('tab', { name: 'Composio' }).click();
if (await heading.isVisible().catch(() => false)) {
return;
}
@@ -73,7 +73,7 @@ async function bootSkills(page: Page, userId: string): Promise<void> {
await page.goto('/#/connections');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByRole('tab', { name: 'Composio' }).click();
await expect(
page.getByRole('heading', { name: 'Composio Integrations', exact: true })
).toBeVisible({ timeout: 20_000 });
@@ -146,7 +146,7 @@ test.describe('Connector session guard matrix', () => {
await page.reload();
await waitForAppReady(page);
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByRole('tab', { name: 'Composio' }).click();
await page.getByTestId('skill-install-composio-jira').click();
const dialog = page.getByRole('dialog', { name: /Jira/i });
await expect(dialog).toBeVisible();
@@ -160,7 +160,7 @@ test.describe('Connector session guard matrix', () => {
await page.reload();
await waitForAppReady(page);
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByRole('tab', { name: 'Composio' }).click();
await expect(page.getByTestId('skill-install-composio-discord')).toContainText('Discord');
await assertSessionAlive(page);
@@ -168,7 +168,7 @@ test.describe('Connector session guard matrix', () => {
await page.reload();
await waitForAppReady(page);
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByRole('tab', { name: 'Composio' }).click();
await expect(page.getByTestId('skill-install-composio-github')).toContainText(
/Reconnect|GitHub/
);
+5 -5
View File
@@ -71,7 +71,7 @@ async function bootSkillsPage(page: Page, userId: string) {
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
// Tab is "Apps"; the h2 heading reads "Composio Integrations" (skills.integrations)
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByRole('tab', { name: 'Composio' }).click();
// Wait for the Apps tab grid to be visible — the h2 heading text is "Composio Integrations"
const heading = page.getByRole('heading', { name: 'Composio Integrations', exact: true });
await expect(heading).toBeVisible({ timeout: 20_000 });
@@ -99,7 +99,7 @@ test.describe('Gmail Integration Flows', () => {
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByRole('tab', { name: 'Composio' }).click();
await page.getByTestId('skill-install-composio-gmail').click();
await expect(page.getByRole('dialog', { name: /Connect Gmail/i })).toBeVisible();
@@ -126,14 +126,14 @@ test.describe('Gmail Integration Flows', () => {
await page.reload();
await waitForAppReady(page);
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByRole('tab', { name: 'Composio' }).click();
await expect(page.getByTestId('skill-install-composio-gmail')).toContainText(CONNECTOR_NAME);
await seedConnector('EXPIRED');
await page.reload();
await waitForAppReady(page);
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByRole('tab', { name: 'Composio' }).click();
await expect(page.getByTestId(`skill-install-composio-${TOOLKIT_SLUG}`)).toContainText(
/Auth expired|Reconnect/i
);
@@ -142,7 +142,7 @@ test.describe('Gmail Integration Flows', () => {
test('execute and disconnect routes do not blank the skills page', async ({ page }) => {
await callCoreRpc('openhuman.composio_execute', { tool: ACTION, arguments: {} });
// Tab is "Apps"; the h2 heading reads "Composio Integrations" (skills.integrations)
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByRole('tab', { name: 'Composio' }).click();
await expect(
page.getByRole('heading', { name: 'Composio Integrations', exact: true })
).toBeVisible();
@@ -8,39 +8,26 @@ import {
test.describe('Google Meet Connections tab', () => {
test.beforeEach(async ({ page }) => {
// Phase 2: /skills → /connections; Phase 2b: MeetingBotsCard moved from
// Tools tab to the new Talents tab (?tab=talents). Back-compat: ?tab=meetings
// alias still works and resolves to talents.
await bootAuthenticatedPage(page, 'pw-gmeet-connections-tab-user', '/connections?tab=talents');
await bootAuthenticatedPage(page, 'pw-gmeet-connections-tab-user', '/connections?tab=meetings');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
});
test('opens the Talents tab and shows the meeting link modal', async ({ page }) => {
test('opens the Meetings tab and shows the inline join form', async ({ page }) => {
await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 })
.toContain('/connections');
// Phase 2b: MeetingBotsCard moved to the Talents tab; there is no longer a
// "Google Meet" sub-tab. The PillTabBar "Talents" pill carries the
// aria-selected=true attribute when the Talents surface is active.
await expect(page.getByRole('tab', { name: 'Talents', exact: true })).toHaveAttribute(
await expect(page.getByRole('tab', { name: 'Meetings', exact: true })).toHaveAttribute(
'aria-selected',
'true'
);
await page.getByTestId('meeting-bots-banner').click();
// PASSIVE MODE: the modal asks for the Meeting link only. The
// "Your Name in This Meeting" (respondTo) text field added in #3555
// is hidden because the backend bot no longer listens for a wake
// phrase. It stays Google-Meet only — no other platforms.
const dialog = page.getByRole('dialog', { name: 'Send OpenHuman to a meeting' });
await expect(dialog).toBeVisible();
await expect(dialog.getByLabel('Meeting link')).toBeVisible();
await expect(dialog.locator('input[type="url"]')).toHaveCount(1);
await expect(dialog.locator('input[type="text"]')).toHaveCount(0);
await expect(dialog.getByText('Zoom')).toHaveCount(0);
await expect(dialog.getByText('Microsoft Teams')).toHaveCount(0);
// The join form renders inline on the Meetings tab (no banner/modal).
await expect(page.getByText('Send OpenHuman to a meeting')).toBeVisible();
await expect(page.getByText('Meeting link')).toBeVisible();
await expect(page.locator('input[type="url"]')).toHaveCount(1);
await expect(page.getByText('Zoom')).toHaveCount(0);
await expect(page.getByText('Microsoft Teams')).toHaveCount(0);
});
});
@@ -10,8 +10,7 @@ interface PanelCheck {
const panels: PanelCheck[] = [
{ hash: '/settings', markers: ['Settings', 'Appearance', 'Notifications'] },
{ hash: '/settings/memory-data', markers: ['Memory', 'Data', 'Storage'] },
// Phase 3: /intelligence → /activity; Memory tab is dev-gated, test only always-visible content
{ hash: '/activity', markers: ['Tasks', 'Automations', 'Activity'] },
{ hash: '/settings/notifications-hub', markers: ['Notifications'] },
{ hash: '/settings/developer-options', markers: ['Developer', 'Debug', 'Advanced'] },
{
hash: '/settings/billing',
@@ -7,22 +7,15 @@ interface RouteCheck {
markers: string[];
}
// Phase 2/3/6 IA revamp:
// /skills → /connections (redirects; test new canonical route)
// /intelligence → /activity (redirects; test new canonical route)
// /human → /chat (redirects; already covered by /chat entry)
const routes: RouteCheck[] = [
{ hash: '/chat', markers: ['Threads', 'Chat', 'Message', 'New'] },
// Connections page (was /skills) — tabs: Apps, Messaging, Tools, Explorer
{ hash: '/connections', markers: ['Apps', 'Messaging', 'Tools', 'Explorer'] },
{ hash: '/connections', markers: ['Composio', 'Channels', 'MCP Servers', 'Skills'] },
{ hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected'] },
// /channels now redirects to /connections?tab=messaging
{ hash: '/channels', markers: ['Messaging', 'Connections', 'Telegram', 'Discord'] },
{ hash: '/channels', markers: ['Channels', 'Connections', 'Telegram', 'Discord'] },
{ hash: '/notifications', markers: ['Notifications', 'Alerts', 'No alerts yet'] },
{ hash: '/rewards', markers: ['Rewards', 'Referral', 'Credits', 'Invite'] },
{ hash: '/settings', markers: ['Settings', 'Account', 'Billing', 'Advanced'] },
// Activity page (was /intelligence) — tabs: Tasks, Automations, Subconscious
{ hash: '/activity', markers: ['Tasks', 'Automations', 'Subconscious'] },
{ hash: '/settings/notifications-hub', markers: ['Notifications'] },
{ hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected'] },
];
+4 -3
View File
@@ -12,15 +12,16 @@ interface RouteEntry {
// Back-compat redirects are included so the router redirect itself is tested.
// /human → renders the Human surface (first-class route, restored)
// /skills → /connections (Phase 2)
// /intelligence → /activity (Phase 3)
// /activity → /settings/notifications-hub (Phase 6)
// /intelligence → /settings/notifications-hub (Phase 6)
const ROUTES: RouteEntry[] = [
{ route: '/home' },
{ route: '/human' }, // first-class route again (no longer redirects to /chat)
{ route: '/chat' },
{ route: '/connections' },
{ route: '/skills', expectedHash: '/connections' }, // back-compat redirect
{ route: '/activity' },
{ route: '/intelligence', expectedHash: '/activity' }, // back-compat redirect
{ route: '/activity', expectedHash: '/settings/notifications-hub' }, // back-compat redirect
{ route: '/intelligence', expectedHash: '/settings/notifications-hub' }, // back-compat redirect
{ route: '/rewards' },
{ route: '/settings' },
];
@@ -32,7 +32,7 @@ test.describe('Settings - Channels & Permissions', () => {
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
const messagingTab = page.getByRole('tab', { name: 'Messaging', exact: true });
const messagingTab = page.getByRole('tab', { name: 'Channels', exact: true });
if (await messagingTab.isVisible().catch(() => false)) {
await messagingTab.click();
}
@@ -103,7 +103,7 @@ test.describe('Settings - Feature Preferences', () => {
// Phase 2: default messaging channel moved to /connections (Messaging tab)
await openAuthenticatedRoute(page, 'pw-settings-default-channel', '/connections?tab=messaging');
const messagingTab = page.getByRole('tab', { name: 'Messaging', exact: true });
const messagingTab = page.getByRole('tab', { name: 'Channels', exact: true });
if (await messagingTab.isVisible().catch(() => false)) {
await messagingTab.click();
}
@@ -33,7 +33,7 @@ test.describe('Skill discovery (UI + core RPC)', () => {
const text = await page.locator('#root').innerText();
expect(
['Composio Integrations', 'Apps', 'Messaging', 'Gmail', 'Notion', 'GitHub'].some(marker =>
['Composio Integrations', 'Composio', 'Channels', 'Gmail', 'Notion', 'GitHub'].some(marker =>
text.includes(marker)
)
).toBe(true);
@@ -16,9 +16,10 @@ test.describe('Skill lifecycle smoke', () => {
.toContain('/connections');
const text = await page.locator('#root').innerText();
// Connections page tab labels (IA revamp): Apps/Messaging/Tools/Explorer/Talents.
expect(
['Apps', 'Messaging', 'Tools', 'Explorer', 'Talents'].some(marker => text.includes(marker))
['Composio', 'Channels', 'MCP Servers', 'Skills', 'Meetings'].some(marker =>
text.includes(marker)
)
).toBe(true);
const rpcResult = await callCoreRpc<unknown>('openhuman.workflows_list', {});
@@ -33,14 +33,10 @@ test.describe('Skills registry flow', () => {
});
test('navigates to /connections and renders the current tabs', async ({ page }) => {
// Phase 2: tabs renamed — Apps (was Composio), Messaging (was Channels), Tools (was MCP).
// `exact: true` on Tools avoids colliding with the "Tools & Automation" skill
// category pill (also role="tab").
await expect(page.getByRole('tab', { name: 'Apps', exact: true })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Messaging', exact: true })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Tools', exact: true })).toBeVisible();
await page.getByRole('tab', { name: 'Apps', exact: true }).click();
// Heading reads "Composio Integrations" (skills.integrations); the tab is "Apps"
await expect(page.getByRole('tab', { name: 'Composio', exact: true })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Channels', exact: true })).toBeVisible();
await expect(page.getByRole('tab', { name: 'MCP Servers', exact: true })).toBeVisible();
await page.getByRole('tab', { name: 'Composio', exact: true }).click();
await expect(
page.getByRole('heading', { name: 'Composio Integrations', exact: true })
).toBeVisible();
@@ -55,17 +51,13 @@ test.describe('Skills registry flow', () => {
).toBeVisible();
});
test('messaging tab renders messaging connectors', async ({ page }) => {
// Phase 2: "Channels" tab renamed to "Messaging". The standalone "Channels"
// heading is gone; the connector cards are the meaningful content.
await page.getByRole('tab', { name: 'Messaging', exact: true }).click();
test('channels tab renders messaging connectors', async ({ page }) => {
await page.getByRole('tab', { name: 'Channels', exact: true }).click();
await expect(page.getByText(/Telegram|Discord|Slack/).first()).toBeVisible();
});
test('tools tab renders the server table', async ({ page }) => {
// Phase 2: "MCP Servers" tab renamed to "Tools" (exact to avoid the
// "Tools & Automation" category pill).
await page.getByRole('tab', { name: 'Tools', exact: true }).click();
test('MCP Servers tab renders the server table', async ({ page }) => {
await page.getByRole('tab', { name: 'MCP Servers', exact: true }).click();
await expect(
page
.getByRole('searchbox')
@@ -93,17 +93,11 @@ test.describe('Top-level functional flows', () => {
test('major top-level pages render actionable UI without blanking', async ({ page }) => {
await bootAuthenticatedPage(page, 'pw-top-level-ui', '/home');
// Phase 2/3 IA revamp:
// /skills → /connections (back-compat redirect tested; use new canonical route here)
// /intelligence → /activity (back-compat redirect; use new canonical route here)
// Connections tabs renamed: Composio→Apps, Channels→Messaging, MCP→Tools
// Activity tabs: Tasks, Automations, Subconscious
// Memory/Agents/Council are dev-gated; test against the always-visible tabs only
const routes: Array<[string, RegExp]> = [
['/home', /Ask your assistant anything|Start/],
['/connections', /Composio Integrations|Apps|Messaging|Tools/],
['/connections', /Composio Integrations|Composio|Channels|MCP Servers/],
['/chat', /How can I help you today|No messages yet|Threads/],
['/activity', /Tasks|Automations|Subconscious/],
['/settings/notifications-hub', /Notifications/],
['/notifications', /Notifications|System Events/],
['/rewards', /Rewards|Referrals|Redeem/],
];