diff --git a/app/src/components/chat/SuperContextToggle.tsx b/app/src/components/chat/SuperContextToggle.tsx new file mode 100644 index 000000000..773458822 --- /dev/null +++ b/app/src/components/chat/SuperContextToggle.tsx @@ -0,0 +1,138 @@ +import debugFactory from 'debug'; +import { useCallback, useEffect, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { isTauri } from '../../utils/tauriCommands/common'; +import { + openhumanGetSuperContextEnabled, + openhumanSetSuperContextEnabled, +} from '../../utils/tauriCommands/config'; +import SettingsSwitch from '../settings/controls/SettingsSwitch'; +import { trackSuperContextWrite } from './superContextWrite'; + +const log = debugFactory('chat:super-context-toggle'); + +/** + * "Super context" toggle, rendered directly below the chat composer. + * + * Flips the persistent `context.super_context_enabled` core config flag. When + * on, the harness runs a read-only context-collection pass on the **first turn + * of a new thread** — before the orchestrator LLM runs — and folds the result + * into the user message. This is harness-driven (deterministic), unlike the + * `agent_prepare_context` tool the model may call on its own. + * + * Because the flag is read at thread construction, toggling it only affects + * threads started afterwards — surfaced to the user via the helper hint. + */ +const SuperContextToggle = () => { + const { t } = useT(); + const [enabled, setEnabled] = useState(false); + // Until the first read resolves we don't know the real value; keep the + // switch disabled so a stray click can't write a stale default back. Outside + // Tauri (Storybook/web preview) there's no core to read, so treat the control + // as loaded immediately in its default-off state without hitting the RPC. + const [loaded, setLoaded] = useState(() => !isTauri()); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!isTauri()) { + return; + } + let cancelled = false; + void (async () => { + try { + const res = await openhumanGetSuperContextEnabled(); + if (!cancelled) { + setEnabled(Boolean(res.result)); + log('loaded super_context_enabled=%o', res.result); + } + } catch (err) { + // Best-effort: a read failure leaves the toggle in its default-off + // state. The user can still flip it; the write path surfaces errors. + log('failed to load super_context_enabled: %o', err); + } finally { + if (!cancelled) setLoaded(true); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const handleChange = useCallback( + (next: boolean) => { + if (busy) return; + const previous = enabled; + // Optimistic: reflect the choice immediately, roll back on failure. + setEnabled(next); + setBusy(true); + log('set super_context_enabled -> %o', next); + const write = openhumanSetSuperContextEnabled(next); + // Register the write so a flip-then-immediately-Send awaits it before the + // new thread's session reads the persisted flag (avoids a stale first turn). + trackSuperContextWrite(write); + void (async () => { + try { + const res = await write; + setEnabled(Boolean(res.result)); + } catch (err) { + log('failed to persist super_context_enabled, rolling back: %o', err); + setEnabled(previous); + } finally { + setBusy(false); + } + })(); + }, + [busy, enabled] + ); + + return ( +
+ + + {t('chat.superContext.label')} + + {/* Self-contained wrapping tooltip (the shared is single-line + nowrap and can't fit this paragraph). Anchored bottom-full + right-0 + so it grows up-and-left into the app interior — the toggle only shows + on a fresh thread, where that space is empty, so it never clips. */} + + + + {t('chat.superContext.hint')} + + +
+ ); +}; + +export default SuperContextToggle; diff --git a/app/src/components/chat/__tests__/SuperContextToggle.test.tsx b/app/src/components/chat/__tests__/SuperContextToggle.test.tsx new file mode 100644 index 000000000..11557e465 --- /dev/null +++ b/app/src/components/chat/__tests__/SuperContextToggle.test.tsx @@ -0,0 +1,81 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import SuperContextToggle from '../SuperContextToggle'; + +// `vi.mock` factories are hoisted above the static imports, so any module-level +// mock fns they reference must be created with `vi.hoisted` (hoisted alongside +// them) — a plain `const` would be in the TDZ when the factory runs and could +// throw on import. See CodeRabbit/Codex review on PR #4085. +const { isTauriMock, getMock, setMock } = vi.hoisted(() => ({ + isTauriMock: vi.fn(() => true), + getMock: vi.fn(), + setMock: vi.fn(), +})); + +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); +vi.mock('../../../utils/tauriCommands/common', () => ({ isTauri: () => isTauriMock() })); +vi.mock('../../../utils/tauriCommands/config', () => ({ + openhumanGetSuperContextEnabled: () => getMock(), + openhumanSetSuperContextEnabled: (value: boolean) => setMock(value), +})); + +beforeEach(() => { + isTauriMock.mockReturnValue(true); + getMock.mockReset(); + setMock.mockReset(); +}); + +describe('', () => { + it('loads the persisted flag and reflects it as the switch state', async () => { + getMock.mockResolvedValue({ result: true, logs: [] }); + + render(); + + const sw = screen.getByTestId('super-context-toggle'); + await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'true')); + expect(getMock).toHaveBeenCalledTimes(1); + }); + + it('persists the new value and optimistically updates on toggle', async () => { + getMock.mockResolvedValue({ result: false, logs: [] }); + setMock.mockResolvedValue({ result: true, logs: [] }); + + render(); + const sw = screen.getByTestId('super-context-toggle'); + // Wait for the initial read to enable the switch. + await waitFor(() => expect(sw).not.toBeDisabled()); + + fireEvent.click(sw); + + // Optimistic flip is immediate; persistence is called with the new value. + await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'true')); + expect(setMock).toHaveBeenCalledWith(true); + }); + + it('rolls back the optimistic flip when persistence fails', async () => { + getMock.mockResolvedValue({ result: false, logs: [] }); + setMock.mockRejectedValue(new Error('rpc down')); + + render(); + const sw = screen.getByTestId('super-context-toggle'); + await waitFor(() => expect(sw).not.toBeDisabled()); + + fireEvent.click(sw); + + // Ends back at false after the rejected write. + await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'false')); + expect(setMock).toHaveBeenCalledWith(true); + }); + + it('does not call the core RPC when running outside Tauri', () => { + isTauriMock.mockReturnValue(false); + + render(); + + expect(getMock).not.toHaveBeenCalled(); + const sw = screen.getByTestId('super-context-toggle'); + // Treated as loaded (enabled) immediately, default-off. + expect(sw).toHaveAttribute('aria-checked', 'false'); + }); +}); diff --git a/app/src/components/chat/__tests__/superContextWrite.test.ts b/app/src/components/chat/__tests__/superContextWrite.test.ts new file mode 100644 index 000000000..9fa5355a9 --- /dev/null +++ b/app/src/components/chat/__tests__/superContextWrite.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { trackSuperContextWrite, whenSuperContextWriteSettled } from '../superContextWrite'; + +describe('superContextWrite gate', () => { + it('resolves immediately when nothing is pending', async () => { + // Completing the await without throwing is the assertion. + await whenSuperContextWriteSettled(); + }); + + it('waits for a tracked write to settle before resolving', async () => { + let settled = false; + const write = new Promise(resolve => + setTimeout(() => { + settled = true; + resolve('ok'); + }, 5) + ); + trackSuperContextWrite(write); + + await whenSuperContextWriteSettled(); + expect(settled).toBe(true); + }); + + it('still settles when a tracked write rejects', async () => { + trackSuperContextWrite(Promise.reject(new Error('rpc down'))); + // Must not throw — the send path only needs the write to have settled. + await whenSuperContextWriteSettled(); + }); +}); diff --git a/app/src/components/chat/superContextWrite.ts b/app/src/components/chat/superContextWrite.ts new file mode 100644 index 000000000..f35df1b99 --- /dev/null +++ b/app/src/components/chat/superContextWrite.ts @@ -0,0 +1,25 @@ +// Tracks the most recent in-flight "super context" config write so the chat +// send path can await it before a new thread's session is built. +// +// The flag is read by the core when the chat task constructs its session, so a +// flip-then-immediately-Send could otherwise race: the fire-and-forget write +// from the composer toggle may still be in flight, and the first turn would run +// with the *previous* persisted value while the UI already shows the new one. +// `SuperContextToggle` registers its write here; `handleSendMessage` awaits +// `whenSuperContextWriteSettled()` before sending. + +let pending: Promise = Promise.resolve(); + +/** Register an in-flight super-context write. Failures are swallowed — the + * send path only needs the write to have *settled*, not to have succeeded. */ +export function trackSuperContextWrite(write: Promise): void { + // Chain so concurrent flips all settle before the gate resolves. + const prior = pending; + pending = Promise.allSettled([prior, write]); +} + +/** Resolves once every registered super-context write has settled. Cheap to + * await when nothing is pending (resolved promise). */ +export function whenSuperContextWriteSettled(): Promise { + return pending; +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 0541b9ab4..c53254001 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -2142,6 +2142,9 @@ const messages: TranslationMap = { 'chat.left': 'متبقٍ', 'chat.setup': 'إعداد', 'chat.switchToText': 'التبديل إلى النص', + 'chat.superContext.label': 'سياق فائق', + 'chat.superContext.hint': + 'يتيح «سياق فائق» لـ OpenHuman جمع وتحضير السياق من جميع البيانات التي يمكنه الوصول إليها لتقديم إجابة وثيقة الصلة للغاية. «سياق فائق» في مرحلة تجريبية مبكرة.', 'chat.transcribing': 'جارٍ النسخ...', 'chat.stopAndSend': 'إيقاف وإرسال', 'chat.startTalking': 'ابدأ الحديث', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 941b30393..b2db3299c 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -2193,6 +2193,9 @@ const messages: TranslationMap = { 'chat.left': 'বাকি', 'chat.setup': 'সেটআপ করুন', 'chat.switchToText': 'টেক্সটে পরিবর্তন করুন', + 'chat.superContext.label': 'সুপার কনটেক্সট', + 'chat.superContext.hint': + 'সুপার কনটেক্সট OpenHuman-কে তার অ্যাক্সেসযোগ্য সমস্ত ডেটা থেকে প্রসঙ্গ সংগ্রহ ও প্রস্তুত করতে দেয় যাতে এটি অত্যন্ত প্রাসঙ্গিক উত্তর দিতে পারে। সুপার কনটেক্সট প্রাথমিক বিটা পর্যায়ে রয়েছে।', 'chat.transcribing': 'ট্রান্সক্রাইব হচ্ছে...', 'chat.stopAndSend': 'থামুন ও পাঠান', 'chat.startTalking': 'কথা বলা শুরু করুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 781f4c040..6845e43e6 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -2244,6 +2244,9 @@ const messages: TranslationMap = { 'chat.left': 'links', 'chat.setup': 'Einrichten', 'chat.switchToText': 'Wechsle zu Text', + 'chat.superContext.label': 'Super-Kontext', + 'chat.superContext.hint': + 'Super-Kontext ermöglicht OpenHuman, Kontext aus allen verfügbaren Daten zu sammeln und aufzubereiten, um eine hochrelevante Antwort zu liefern. Super-Kontext befindet sich in einer frühen Beta-Phase.', 'chat.transcribing': 'Transkribieren...', 'chat.stopAndSend': 'Stoppen und senden', 'chat.startTalking': 'Sprich los', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 089a93126..d8e9b68a4 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -2590,6 +2590,9 @@ const en: TranslationMap = { 'chat.left': 'left', 'chat.setup': 'Set up', 'chat.switchToText': 'Switch to text', + 'chat.superContext.label': 'Super Context', + 'chat.superContext.hint': + 'Super Context allows OpenHuman to gather and prepare context from all the data it has access to so that it can deliver a highly relevant answer. Super Context is in early beta.', 'chat.transcribing': 'Transcribing...', 'chat.stopAndSend': 'Stop and send', 'chat.startTalking': 'Start talking', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 427ea7c81..78c577556 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -2232,6 +2232,9 @@ const messages: TranslationMap = { 'chat.left': 'restante', 'chat.setup': 'Configurar', 'chat.switchToText': 'Cambiar a texto', + 'chat.superContext.label': 'Súper contexto', + 'chat.superContext.hint': + 'Súper contexto permite a OpenHuman reunir y preparar contexto a partir de todos los datos a los que tiene acceso para ofrecer una respuesta muy relevante. Súper contexto está en fase beta inicial.', 'chat.transcribing': 'Transcribiendo...', 'chat.stopAndSend': 'Detener y enviar', 'chat.startTalking': 'Empieza a hablar', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 6c699b27a..6027edac5 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -2243,6 +2243,9 @@ const messages: TranslationMap = { 'chat.left': 'restant', 'chat.setup': 'Configurer', 'chat.switchToText': 'Passer au texte', + 'chat.superContext.label': 'Super contexte', + 'chat.superContext.hint': + 'Super contexte permet à OpenHuman de rassembler et de préparer le contexte à partir de toutes les données auxquelles il a accès afin de fournir une réponse très pertinente. Super contexte est en bêta précoce.', 'chat.transcribing': 'Transcription…', 'chat.stopAndSend': 'Arrêter et envoyer', 'chat.startTalking': 'Commence à parler', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 8420419f8..4edb63ddb 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -2189,6 +2189,9 @@ const messages: TranslationMap = { 'chat.left': 'बचा हुआ', 'chat.setup': 'सेट अप करें', 'chat.switchToText': 'टेक्स्ट पर स्विच करें', + 'chat.superContext.label': 'सुपर कॉन्टेक्स्ट', + 'chat.superContext.hint': + 'सुपर कॉन्टेक्स्ट OpenHuman को उन सभी डेटा से संदर्भ इकट्ठा और तैयार करने देता है जिन तक उसकी पहुँच है, ताकि वह अत्यधिक प्रासंगिक उत्तर दे सके। सुपर कॉन्टेक्स्ट प्रारंभिक बीटा में है।', 'chat.transcribing': 'ट्रांसक्राइब हो रहा है...', 'chat.stopAndSend': 'रोकें और भेजें', 'chat.startTalking': 'बोलना शुरू करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 0c844ef73..4786a9980 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -2192,6 +2192,9 @@ const messages: TranslationMap = { 'chat.left': 'tersisa', 'chat.setup': 'Atur', 'chat.switchToText': 'Beralih ke teks', + 'chat.superContext.label': 'Super konteks', + 'chat.superContext.hint': + 'Super konteks memungkinkan OpenHuman mengumpulkan dan menyiapkan konteks dari semua data yang dapat diaksesnya agar dapat memberikan jawaban yang sangat relevan. Super konteks masih dalam tahap beta awal.', 'chat.transcribing': 'Mentranskripsi...', 'chat.stopAndSend': 'Berhenti dan kirim', 'chat.startTalking': 'Mulai bicara', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index be39d88e2..ae088c5c5 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -2224,6 +2224,9 @@ const messages: TranslationMap = { 'chat.left': 'rimasti', 'chat.setup': 'Configura', 'chat.switchToText': 'Passa al testo', + 'chat.superContext.label': 'Super contesto', + 'chat.superContext.hint': + 'Super contesto consente a OpenHuman di raccogliere e preparare il contesto da tutti i dati a cui ha accesso per fornire una risposta altamente pertinente. Super contesto è in beta iniziale.', 'chat.transcribing': 'Trascrizione...', 'chat.stopAndSend': 'Ferma e invia', 'chat.startTalking': 'Inizia a parlare', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 861917969..f6452bfbe 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -2169,6 +2169,9 @@ const messages: TranslationMap = { 'chat.left': '남음', 'chat.setup': '설정', 'chat.switchToText': '텍스트로 전환', + 'chat.superContext.label': '슈퍼 컨텍스트', + 'chat.superContext.hint': + '슈퍼 컨텍스트를 사용하면 OpenHuman이 접근할 수 있는 모든 데이터에서 컨텍스트를 수집하고 준비하여 매우 관련성 높은 답변을 제공할 수 있습니다. 슈퍼 컨텍스트는 초기 베타 단계입니다.', 'chat.transcribing': '전사 중...', 'chat.stopAndSend': '중지하고 보내기', 'chat.startTalking': '말하기 시작', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 583817dc8..8ff14225b 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -2212,6 +2212,9 @@ const messages: TranslationMap = { 'chat.left': 'pozostało', 'chat.setup': 'Skonfiguruj', 'chat.switchToText': 'Przełącz na tekst', + 'chat.superContext.label': 'Superkontekst', + 'chat.superContext.hint': + 'Superkontekst pozwala OpenHuman zebrać i przygotować kontekst ze wszystkich dostępnych danych, aby udzielić wysoce trafnej odpowiedzi. Superkontekst jest we wczesnej wersji beta.', 'chat.transcribing': 'Transkrypcja...', 'chat.stopAndSend': 'Zatrzymaj i wyślij', 'chat.startTalking': 'Zacznij mówić', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 80694700f..2d8626533 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -2233,6 +2233,9 @@ const messages: TranslationMap = { 'chat.left': 'restante', 'chat.setup': 'Configurar', 'chat.switchToText': 'Mudar para texto', + 'chat.superContext.label': 'Supercontexto', + 'chat.superContext.hint': + 'O Supercontexto permite que o OpenHuman reúna e prepare contexto a partir de todos os dados a que tem acesso para oferecer uma resposta altamente relevante. O Supercontexto está em beta inicial.', 'chat.transcribing': 'Transcrevendo...', 'chat.stopAndSend': 'Parar e enviar', 'chat.startTalking': 'Comece a falar', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index c6776f0b9..e94ca0274 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -2208,6 +2208,9 @@ const messages: TranslationMap = { 'chat.left': 'осталось', 'chat.setup': 'Настроить', 'chat.switchToText': 'Переключиться на текст', + 'chat.superContext.label': 'Супер-контекст', + 'chat.superContext.hint': + 'Супер-контекст позволяет OpenHuman собирать и подготавливать контекст из всех доступных ему данных, чтобы давать максимально релевантный ответ. Супер-контекст находится на стадии раннего бета-тестирования.', 'chat.transcribing': 'Транскрипция...', 'chat.stopAndSend': 'Остановить и отправить', 'chat.startTalking': 'Начни говорить', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 33aee0891..44fb49d30 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2077,6 +2077,9 @@ const messages: TranslationMap = { 'chat.left': '剩余', 'chat.setup': '设置', 'chat.switchToText': '切换到文本', + 'chat.superContext.label': '超级上下文', + 'chat.superContext.hint': + '超级上下文让 OpenHuman 能够从其可访问的所有数据中收集并准备上下文,从而给出高度相关的回答。超级上下文目前处于早期测试阶段。', 'chat.transcribing': '转录中...', 'chat.stopAndSend': '停止并发送', 'chat.startTalking': '开始说话', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 17da04465..805b5528f 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -13,6 +13,8 @@ import ChatNewWindowHero from '../components/chat/ChatNewWindowHero'; import ComposerTokenStats from '../components/chat/ComposerTokenStats'; import IntegrationConnectCard from '../components/chat/IntegrationConnectCard'; import QueuedFollowups from '../components/chat/QueuedFollowups'; +import SuperContextToggle from '../components/chat/SuperContextToggle'; +import { whenSuperContextWriteSettled } from '../components/chat/superContextWrite'; import { ConfirmationModal } from '../components/intelligence/ConfirmationModal'; import { SidebarContent } from '../components/layout/shell/SidebarSlot'; import { settingsNavState } from '../components/settings/modal/settingsOverlay'; @@ -892,6 +894,12 @@ const Conversations = ({ // may proceed concurrently. if (selectedThreadId && pendingSendsRef.current.has(selectedThreadId)) return; + // If the user just flipped the Super Context toggle, make sure that config + // write has landed before the core builds this thread's session (which + // reads `context.super_context_enabled`). Resolves instantly when nothing + // is pending. + await whenSuperContextWriteSettled(); + const normalized = text ?? inputValue; const trimmedInput = normalized.trim(); @@ -2788,6 +2796,12 @@ const Conversations = ({ {t('chat.agentProfile.reasoning')} + {/* Super context is read at thread construction, so it only + affects NEW threads. Hide the toggle once the thread has ANY + activity — use the raw `messages` (not `hasVisibleMessages`, + which ignores hidden transcript entries) so an already-started + thread never looks "fresh" here. */} + {messages.length === 0 && } {selectedThreadId && (