mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
refactor: rename tabs, flatten composer, polish UI (#3611)
This commit is contained in:
+19
-3
@@ -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
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 l’instant — 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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': 'Активно',
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
);
|
||||
|
||||
@@ -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'] },
|
||||
];
|
||||
|
||||
|
||||
@@ -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/],
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user