diff --git a/app/src/components/PrivacyStatusIndicator.tsx b/app/src/components/PrivacyStatusIndicator.tsx new file mode 100644 index 0000000000..e96817968e --- /dev/null +++ b/app/src/components/PrivacyStatusIndicator.tsx @@ -0,0 +1,94 @@ +import debug from 'debug'; +import { useEffect } from 'react'; + +import { privacyModeLabelKey } from '../features/privacy/disclosureLabels'; +import { useT } from '../lib/i18n/I18nContext'; +import { useAppSelector } from '../store/hooks'; + +const pillLog = debug('privacy:pill'); + +interface PrivacyStatusIndicatorProps { + className?: string; +} + +/** + * Persistent privacy-status pill (#4437 / S3). Mirrors {@link ConnectionIndicator} + * — an inline-flex chip with a coloured dot + tiny label. Shows the current + * Privacy Mode plus whether the *active task* is staying on-device or sending + * externally. + * + * The off-device sub-state is driven by `privacy.activeExternalByThread` — a + * flag set when an `external_transfer_pending` disclosure arrives and cleared + * on the turn boundary by ChatRuntimeProvider. It is deliberately NOT derived + * from the dismissible disclosure ledger: reading the ledger let a "Got it" + * dismissal flip the pill on-device mid-transfer, and let a stale historical + * entry pin it off-device during later local turns. "On-device" is therefore + * the ABSENCE of a live external transfer, never a positive "local" signal. + * Renders nothing (a self-nulling leading separator + chip) until the mode is + * hydrated so the pill is never misleading. + */ +const PrivacyStatusIndicator = ({ className = '' }: PrivacyStatusIndicatorProps) => { + const { t } = useT(); + // Optional-chain the `privacy` slice — narrow test stores may omit it. + const privacyMode = useAppSelector(state => state.privacy?.privacyMode ?? null); + const selectedThreadId = useAppSelector(state => state.thread?.selectedThreadId ?? null); + const activeExternalByThread = useAppSelector(state => state.privacy?.activeExternalByThread); + + // Local-only mode blocks external model calls (enforced core-side by S7), so + // the active task is always on-device there regardless of any stale flag. + const hasActiveExternalTransfer = selectedThreadId + ? (activeExternalByThread?.[selectedThreadId] ?? false) + : false; + const localOnlyOverride = privacyMode === 'local_only'; + const isExternal = !localOnlyOverride && hasActiveExternalTransfer; + + // Diagnostics for the privacy-state flow (grep `privacy:pill`). Log only the + // derived booleans/status transitions — never provider payloads, disclosure + // contents, or user PII. Fires on transitions of the resolved state. + useEffect(() => { + pillLog( + '[privacy:pill] derive hydrated=%s mode=%s hasThread=%s localOnlyOverride=%s activeExternal=%s isExternal=%s', + String(privacyMode != null), + privacyMode ?? 'none', + String(selectedThreadId != null), + String(localOnlyOverride), + String(hasActiveExternalTransfer), + String(isExternal) + ); + }, [privacyMode, selectedThreadId, localOnlyOverride, hasActiveExternalTransfer, isExternal]); + + if (!privacyMode) return null; + + const modeLabel = t(privacyModeLabelKey(privacyMode)); + const stateLabel = isExternal ? t('privacy.status.external') : t('privacy.status.local'); + const dotColor = isExternal ? 'bg-amber-500' : 'bg-sage-500'; + const textColor = isExternal ? 'text-amber-500' : 'text-sage-500'; + + // The leading separator travels WITH the pill so the sidebar footer never + // renders a dangling `· ·` while the pill is un-hydrated (returns null). See + // AppSidebar — the version item owns the separator that follows the pill. + // Separator + chip are grouped in one inline-flex item so the footer's + // flex-wrap can never split the leading `·` onto its own line — they wrap + // together or not at all. + return ( + + +
+
+ + {modeLabel} + · + {stateLabel} + +
+ + ); +}; + +export default PrivacyStatusIndicator; diff --git a/app/src/components/__tests__/PrivacyStatusIndicator.test.tsx b/app/src/components/__tests__/PrivacyStatusIndicator.test.tsx new file mode 100644 index 0000000000..fa80b77f05 --- /dev/null +++ b/app/src/components/__tests__/PrivacyStatusIndicator.test.tsx @@ -0,0 +1,126 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { PrivacyDisclosure } from '../../store/privacySlice'; +import { renderWithProviders } from '../../test/test-utils'; +import PrivacyStatusIndicator from '../PrivacyStatusIndicator'; + +function disclosure(over?: Partial): PrivacyDisclosure { + return { + id: 'd1', + providerSlug: 'openai', + service: 'OpenAI', + isExternal: true, + reason: 'inference', + dataKinds: ['prompt'], + riskLevel: 'unknown', + riskCategories: [], + receivedAt: 0, + ...over, + }; +} + +describe('PrivacyStatusIndicator (#4437 / S3)', () => { + it('renders nothing until the privacy mode is hydrated', () => { + const { container } = renderWithProviders(, { + preloadedState: { + privacy: { privacyMode: null, disclosuresByThread: {}, activeExternalByThread: {} }, + }, + }); + expect(container.firstChild).toBeNull(); + }); + + it('shows the mode + on-device state when no external transfer is active', () => { + renderWithProviders(, { + preloadedState: { + privacy: { privacyMode: 'standard', disclosuresByThread: {}, activeExternalByThread: {} }, + thread: { selectedThreadId: 'thread-1' }, + }, + }); + const pill = screen.getByRole('status'); + expect(pill).toHaveTextContent('Standard'); + expect(pill).toHaveTextContent('On-device'); + expect(pill).toHaveAttribute('title', 'Standard · On-device'); + }); + + it('shows the off-device state when the active thread has a live external transfer', () => { + renderWithProviders(, { + preloadedState: { + privacy: { + privacyMode: 'standard', + disclosuresByThread: {}, + activeExternalByThread: { 'thread-1': true }, + }, + thread: { selectedThreadId: 'thread-1' }, + }, + }); + const pill = screen.getByRole('status'); + expect(pill).toHaveTextContent('Off-device'); + expect(pill).toHaveAttribute('title', 'Standard · Off-device'); + }); + + it('always reads on-device in local-only mode, even with a live external flag', () => { + renderWithProviders(, { + preloadedState: { + privacy: { + privacyMode: 'local_only', + disclosuresByThread: {}, + activeExternalByThread: { 'thread-1': true }, + }, + thread: { selectedThreadId: 'thread-1' }, + }, + }); + const pill = screen.getByRole('status'); + expect(pill).toHaveTextContent('Local-only'); + expect(pill).toHaveTextContent('On-device'); + }); + + it('ignores a live external transfer that belongs to a different thread', () => { + renderWithProviders(, { + preloadedState: { + privacy: { + privacyMode: 'standard', + disclosuresByThread: {}, + activeExternalByThread: { 'other-thread': true }, + }, + thread: { selectedThreadId: 'thread-1' }, + }, + }); + expect(screen.getByRole('status')).toHaveTextContent('On-device'); + }); + + // Regression (#4437 finding 1a): the pill's off-device state is driven by the + // live transfer flag, NOT the dismissible disclosure ledger — so it reads + // off-device even when the ledger has been emptied (e.g. the card dismissed) + // while the transfer is still active. + it('reads off-device from the live flag even when the disclosure ledger is empty', () => { + renderWithProviders(, { + preloadedState: { + privacy: { + privacyMode: 'standard', + disclosuresByThread: {}, + activeExternalByThread: { 'thread-1': true }, + }, + thread: { selectedThreadId: 'thread-1' }, + }, + }); + expect(screen.getByRole('status')).toHaveTextContent('Off-device'); + }); + + // Regression (#4437 finding 1b): a stale, un-dismissed ledger entry from an + // earlier turn must NOT keep the pill off-device once the turn boundary + // cleared the live flag. The pill ignores the ledger entirely. + it('stays on-device with a stale ledger entry once the live flag is cleared', () => { + renderWithProviders(, { + preloadedState: { + privacy: { + privacyMode: 'standard', + disclosuresByThread: { 'thread-1': [disclosure()] }, + activeExternalByThread: {}, + }, + thread: { selectedThreadId: 'thread-1' }, + }, + }); + expect(screen.getByRole('status')).toHaveTextContent('On-device'); + }); +}); diff --git a/app/src/components/chat/ExternalTransferDisclosureCard.tsx b/app/src/components/chat/ExternalTransferDisclosureCard.tsx new file mode 100644 index 0000000000..d6c0bf01f4 --- /dev/null +++ b/app/src/components/chat/ExternalTransferDisclosureCard.tsx @@ -0,0 +1,82 @@ +import type React from 'react'; + +import { dataKindLabelKey, reasonLabelKey } from '../../features/privacy/disclosureLabels'; +import { useT } from '../../lib/i18n/I18nContext'; +import { useAppDispatch } from '../../store/hooks'; +import { dismissDisclosureForThread, type PrivacyDisclosure } from '../../store/privacySlice'; +import Button from '../ui/Button'; + +interface Props { + threadId: string; + disclosure: PrivacyDisclosure; +} + +/** + * In-chat disclosure card for a pending external transfer (#4437 / S3). + * + * DISCLOSURE ONLY — it tells the user, at the moment of use, exactly what data + * is leaving the device, where to, and why (epic #4256 AC1). There is NO + * approve/deny arm; the only action is dismissal (that gate is S4 #4438). The + * card mirrors {@link ApprovalRequestCard} / `PlanReviewCard`: rendered above + * the composer for the active thread, off the privacy slice. + */ +export const ExternalTransferDisclosureCard: React.FC = ({ threadId, disclosure }) => { + const { t } = useT(); + const dispatch = useAppDispatch(); + + // Friendly, comma-joined data-kind labels (never the raw enum). An empty + // list (metadata-only transfer) falls back to a generic "data" label so the + // sentence never reads "… send to …". + const kinds = + disclosure.dataKinds.length > 0 + ? disclosure.dataKinds + .map(kind => t(dataKindLabelKey(kind))) + .join(t('privacy.disclosure.kindSeparator')) + : t('privacy.disclosure.kind.unknown'); + + // Destination = human service name + the public provider slug for precision. + const destination = `${disclosure.service} (${disclosure.providerSlug})`; + const reason = t(reasonLabelKey(disclosure.reason)); + + // Single translatable sentence with {placeholders} — the I18n layer has no + // interpolation, so fill them here (keeps word order translatable per-locale). + const body = t('privacy.disclosure.body') + .replace('{kinds}', kinds) + .replace('{destination}', destination) + .replace('{reason}', reason); + + const onDismiss = () => { + dispatch(dismissDisclosureForThread({ threadId, id: disclosure.id })); + }; + + return ( +
+
+ + 🛜 + +
+

+ {t('privacy.disclosure.title')} +

+

{body}

+ +
+ +
+
+
+
+ ); +}; + +export default ExternalTransferDisclosureCard; diff --git a/app/src/components/chat/__tests__/ExternalTransferDisclosureCard.test.tsx b/app/src/components/chat/__tests__/ExternalTransferDisclosureCard.test.tsx new file mode 100644 index 0000000000..457ae1355a --- /dev/null +++ b/app/src/components/chat/__tests__/ExternalTransferDisclosureCard.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { PrivacyDisclosure } from '../../../store/privacySlice'; +import { renderWithProviders } from '../../../test/test-utils'; +import { ExternalTransferDisclosureCard } from '../ExternalTransferDisclosureCard'; + +function disclosure(over?: Partial): PrivacyDisclosure { + return { + id: 'd1', + providerSlug: 'openai', + service: 'OpenAI', + isExternal: true, + reason: 'inference', + dataKinds: ['prompt'], + riskLevel: 'unknown', + riskCategories: [], + receivedAt: 0, + ...over, + }; +} + +function renderCard(d: PrivacyDisclosure) { + return renderWithProviders( + , + { + preloadedState: { + privacy: { privacyMode: 'standard', disclosuresByThread: { 'thread-1': [d] } }, + }, + } + ); +} + +describe('ExternalTransferDisclosureCard (#4437 / S3)', () => { + it('renders the title and a friendly what/where/why sentence', () => { + renderCard(disclosure()); + const card = screen.getByRole('status'); + expect(card).toHaveTextContent('Leaving your device'); + expect(card).toHaveTextContent( + 'This will send your message to OpenAI (openai) because the AI model needs to process it.' + ); + }); + + it('joins multiple data kinds with friendly labels (never raw enums)', () => { + renderCard(disclosure({ dataKinds: ['prompt', 'metadata'] })); + const card = screen.getByRole('status'); + expect(card).toHaveTextContent('your message, request metadata'); + expect(card).not.toHaveTextContent('tool_arguments'); + }); + + it('falls back to a generic label when data kinds are empty', () => { + renderCard(disclosure({ dataKinds: [] })); + expect(screen.getByRole('status')).toHaveTextContent('This will send data to OpenAI (openai)'); + }); + + it('maps each reason to friendly copy', () => { + renderCard(disclosure({ reason: 'network_fetch' })); + expect(screen.getByRole('status')).toHaveTextContent('because a web request needs it'); + }); + + it('is disclosure-only — no approve/deny buttons', () => { + renderCard(disclosure()); + expect(screen.queryByRole('button', { name: /approve/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /deny/i })).toBeNull(); + expect(screen.getByRole('button', { name: 'Got it' })).toBeInTheDocument(); + }); + + it('dismisses the disclosure from the store on "Got it"', () => { + const { store } = renderCard(disclosure()); + fireEvent.click(screen.getByRole('button', { name: 'Got it' })); + expect(store.getState().privacy.disclosuresByThread['thread-1']).toBeUndefined(); + }); +}); diff --git a/app/src/components/layout/shell/AppSidebar.tsx b/app/src/components/layout/shell/AppSidebar.tsx index c07b6e6ece..1c8e9e3403 100644 --- a/app/src/components/layout/shell/AppSidebar.tsx +++ b/app/src/components/layout/shell/AppSidebar.tsx @@ -4,6 +4,7 @@ import { useT } from '../../../lib/i18n/I18nContext'; import { trackEvent } from '../../../services/analytics'; import { APP_VERSION } from '../../../utils/config'; import ConnectionIndicator from '../../ConnectionIndicator'; +import PrivacyStatusIndicator from '../../PrivacyStatusIndicator'; import { NavIcon } from './navIcons'; import SidebarAppRail from './SidebarAppRail'; import SidebarHeader from './SidebarHeader'; @@ -84,9 +85,14 @@ export default function AppSidebar() { {t('nav.feedback')} {/* App-wide footer: connectivity status + build/version, pinned to the - bottom of the sidebar. */} -
+ bottom of the sidebar. The privacy pill is un-hydrated (renders null) + on cold boot, so its leading separator travels WITH the pill (owned by + PrivacyStatusIndicator) rather than sitting here — otherwise a null + pill would leave a dangling `Connection · · version`. The version + keeps the separator that precedes IT, which is always present. */} +
+ · {t('settings.betaBuild').replace('{version}', APP_VERSION)} diff --git a/app/src/components/settings/panels/PrivacyModeSection.test.tsx b/app/src/components/settings/panels/PrivacyModeSection.test.tsx index a38d8ca6c5..39064c3336 100644 --- a/app/src/components/settings/panels/PrivacyModeSection.test.tsx +++ b/app/src/components/settings/panels/PrivacyModeSection.test.tsx @@ -1,6 +1,7 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderWithProviders } from '../../../test/test-utils'; import PrivacyModeSection from './PrivacyModeSection'; const callCoreRpc = vi.fn(); @@ -23,7 +24,7 @@ beforeEach(() => { describe('PrivacyModeSection', () => { it('renders the three privacy mode options', async () => { - render(); + renderWithProviders(); await waitFor(() => expect(screen.getByTestId('privacy-mode-option-standard')).toBeInTheDocument() ); @@ -33,7 +34,7 @@ describe('PrivacyModeSection', () => { }); it('marks the loaded mode as selected', async () => { - render(); + renderWithProviders(); await waitFor(() => expect(screen.getByTestId('privacy-mode-option-standard')).toHaveAttribute( 'aria-checked', @@ -47,7 +48,7 @@ describe('PrivacyModeSection', () => { }); it('calls the set RPC with the chosen mode on selection', async () => { - render(); + renderWithProviders(); await waitFor(() => expect(screen.getByTestId('privacy-mode-option-local_only')).toBeInTheDocument() ); @@ -70,8 +71,26 @@ describe('PrivacyModeSection', () => { ); }); + // Finding 2 (#4437): saving a new mode must also push it into the Redux + // privacy slice so the persistent status pill updates live, instead of + // showing the stale mode until the next app reload. + it('dispatches the new mode into the privacy slice on a successful save', async () => { + const { store } = renderWithProviders(); + await waitFor(() => + expect(screen.getByTestId('privacy-mode-option-local_only')).toBeInTheDocument() + ); + + fireEvent.click(screen.getByTestId('privacy-mode-option-local_only')); + + await waitFor(() => + expect( + (store.getState() as { privacy: { privacyMode: string | null } }).privacy.privacyMode + ).toBe('local_only') + ); + }); + it('does not re-issue the set RPC when the current mode is clicked', async () => { - render(); + renderWithProviders(); await waitFor(() => expect(screen.getByTestId('privacy-mode-option-standard')).toHaveAttribute( 'aria-checked', diff --git a/app/src/components/settings/panels/PrivacyModeSection.tsx b/app/src/components/settings/panels/PrivacyModeSection.tsx index 3eb01ef83c..0721cd6f2d 100644 --- a/app/src/components/settings/panels/PrivacyModeSection.tsx +++ b/app/src/components/settings/panels/PrivacyModeSection.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { callCoreRpc } from '../../../services/coreRpcClient'; import { CORE_RPC_METHODS } from '../../../services/rpcMethods'; +import { useAppDispatch } from '../../../store/hooks'; +import { setPrivacyMode } from '../../../store/privacySlice'; import { SettingsSection, SettingsStatusLine } from '../controls'; const log = debug('privacy-mode'); @@ -35,6 +37,7 @@ const MODES: { value: PrivacyMode; labelKey: string; descKey: string }[] = [ */ const PrivacyModeSection = () => { const { t } = useT(); + const dispatch = useAppDispatch(); const [mode, setMode] = useState(null); const [status, setStatus] = useState('loading'); const [error, setError] = useState(null); @@ -75,6 +78,11 @@ const PrivacyModeSection = () => { params: { mode: next }, }); setMode(resp.result.mode); + // Keep the Redux privacy slice (and therefore the persistent status + // pill) in lock-step with the setting. Without this the pill shows the + // stale mode until the next app reload — the provider only hydrates the + // slice once on mount (#4437). + dispatch(setPrivacyMode(resp.result.mode)); setStatus('saved'); setTimeout(() => setStatus('idle'), 2000); } catch (err) { @@ -83,7 +91,7 @@ const PrivacyModeSection = () => { setStatus('error'); } }, - [mode] + [mode, dispatch] ); return ( diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 1ed85f2441..569a8d1f32 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -11,6 +11,7 @@ import ChatComposer from '../../components/chat/ChatComposer'; import ChatFilesChip from '../../components/chat/ChatFilesChip'; import ChatNewWindowHero from '../../components/chat/ChatNewWindowHero'; import ComposerTokenStats from '../../components/chat/ComposerTokenStats'; +import { ExternalTransferDisclosureCard } from '../../components/chat/ExternalTransferDisclosureCard'; import { FlowApprovalRequestCard } from '../../components/chat/FlowApprovalRequestCard'; import IntegrationConnectCard from '../../components/chat/IntegrationConnectCard'; import QueuedFollowups from '../../components/chat/QueuedFollowups'; @@ -113,6 +114,7 @@ import { type ToolTimelineEntry, } from '../../store/chatRuntimeSlice'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import type { PrivacyDisclosure } from '../../store/privacySlice'; import { selectSocketStatus } from '../../store/socketSelectors'; import { addMessageLocal, @@ -230,6 +232,11 @@ interface ConversationsProps { // avoiding spurious re-renders. const EMPTY_ACTIVE_THREADS: Record = {}; +// Stable empty reference for the privacy disclosure map — the `privacy` slice +// may be absent from narrow test stores, so default to this shared object +// identity instead of throwing / re-rendering. +const EMPTY_DISCLOSURES: Record = {}; + // Stable empty reference for the queued-follow-ups map, so the selector keeps // the same identity when the slice field is absent (narrow test stores). const EMPTY_QUEUED_FOLLOWUPS: Record = {}; @@ -405,6 +412,12 @@ const Conversations = ({ const pendingApprovalByThread = useAppSelector( state => state.chatRuntime.pendingApprovalByThread ); + // External-transfer disclosures per thread (#4437 / S3). Read-only surface — + // the card discloses what's leaving the device; dismissal is the only action. + // Optional-chain + default: narrow test stores may omit the `privacy` slice. + const disclosuresByThread = useAppSelector( + state => state.privacy?.disclosuresByThread ?? EMPTY_DISCLOSURES + ); // Flow-approval surface (chat): a paused tinyflows run's gate, pushed via // the `flow_approval_request` socket event. Not thread-scoped — the // payload carries no `thread_id` — so it's tracked independently of the @@ -2797,6 +2810,28 @@ const Conversations = ({ ); })()} + {(() => { + // External-transfer disclosure (#4437 / S3). Surface the most recent + // pending disclosure for the shown thread just above the composer, + // mirroring the approval-card placement so it's visible without + // scrolling. DISCLOSURE ONLY — dismissal is the only action. + const disclosureThreadId = selectedThreadId ?? firstActiveThreadId; + const disclosures = disclosureThreadId + ? disclosuresByThread[disclosureThreadId] + : undefined; + const latest = disclosures?.[disclosures.length - 1]; + if (!latest || !disclosureThreadId) return null; + return ( +
+ +
+ ); + })()} + {/* Flow-approval surface (chat): actionable banner(s) for paused tinyflows runs, pushed via the `flow_approval_request` socket event (issue: flow-approval surfacing). Not gated on the selected diff --git a/app/src/features/privacy/__tests__/disclosureLabels.test.ts b/app/src/features/privacy/__tests__/disclosureLabels.test.ts new file mode 100644 index 0000000000..5d452496c4 --- /dev/null +++ b/app/src/features/privacy/__tests__/disclosureLabels.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import type { EgressDataKind, EgressReason } from '../../../services/chatService'; +import type { PrivacyMode } from '../../../store/privacySlice'; +import { dataKindLabelKey, privacyModeLabelKey, reasonLabelKey } from '../disclosureLabels'; + +describe('disclosureLabels', () => { + it('maps every data kind to a distinct friendly i18n key', () => { + const kinds: EgressDataKind[] = [ + 'prompt', + 'tool_arguments', + 'embedding_input', + 'file_content', + 'url', + 'metadata', + ]; + const keys = kinds.map(dataKindLabelKey); + expect(keys).toEqual([ + 'privacy.disclosure.kind.prompt', + 'privacy.disclosure.kind.toolArguments', + 'privacy.disclosure.kind.embeddingInput', + 'privacy.disclosure.kind.fileContent', + 'privacy.disclosure.kind.url', + 'privacy.disclosure.kind.metadata', + ]); + // No collisions. + expect(new Set(keys).size).toBe(keys.length); + }); + + it('falls back to the generic kind key for an unknown value', () => { + expect(dataKindLabelKey('some_future_kind')).toBe('privacy.disclosure.kind.unknown'); + }); + + it('maps every reason to a distinct friendly i18n key', () => { + const reasons: EgressReason[] = [ + 'inference', + 'tool_call', + 'integration', + 'embedding', + 'network_fetch', + ]; + const keys = reasons.map(reasonLabelKey); + expect(keys).toEqual([ + 'privacy.disclosure.reason.inference', + 'privacy.disclosure.reason.toolCall', + 'privacy.disclosure.reason.integration', + 'privacy.disclosure.reason.embedding', + 'privacy.disclosure.reason.networkFetch', + ]); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('falls back to the generic reason key for an unknown value', () => { + expect(reasonLabelKey('some_future_reason')).toBe('privacy.disclosure.reason.unknown'); + }); + + it('maps every privacy mode to its label key', () => { + const modes: PrivacyMode[] = ['local_only', 'standard', 'sensitive']; + expect(modes.map(privacyModeLabelKey)).toEqual([ + 'privacy.mode.localOnly', + 'privacy.mode.standard', + 'privacy.mode.sensitive', + ]); + }); + + it('falls back to the standard mode key for an unexpected mode value', () => { + // Guards the always-visible pill against `t(undefined)` if the core ever + // emits a mode string the client does not yet know (#4437 finding 5). + expect(privacyModeLabelKey('some_future_mode')).toBe('privacy.mode.standard'); + }); +}); diff --git a/app/src/features/privacy/disclosureLabels.ts b/app/src/features/privacy/disclosureLabels.ts new file mode 100644 index 0000000000..3b4926b621 --- /dev/null +++ b/app/src/features/privacy/disclosureLabels.ts @@ -0,0 +1,61 @@ +import type { EgressDataKind, EgressReason } from '../../services/chatService'; +import type { PrivacyMode } from '../../store/privacySlice'; + +/** + * Map the snake_case egress enums to friendly, human-readable i18n keys so the + * disclosure surface never renders raw wire strings (#4437 / S3). Each mapper + * tolerates unknown/future values by falling back to a generic key. + */ + +export function dataKindLabelKey(kind: EgressDataKind | string): string { + switch (kind) { + case 'prompt': + return 'privacy.disclosure.kind.prompt'; + case 'tool_arguments': + return 'privacy.disclosure.kind.toolArguments'; + case 'embedding_input': + return 'privacy.disclosure.kind.embeddingInput'; + case 'file_content': + return 'privacy.disclosure.kind.fileContent'; + case 'url': + return 'privacy.disclosure.kind.url'; + case 'metadata': + return 'privacy.disclosure.kind.metadata'; + default: + return 'privacy.disclosure.kind.unknown'; + } +} + +export function reasonLabelKey(reason: EgressReason | string): string { + switch (reason) { + case 'inference': + return 'privacy.disclosure.reason.inference'; + case 'tool_call': + return 'privacy.disclosure.reason.toolCall'; + case 'integration': + return 'privacy.disclosure.reason.integration'; + case 'embedding': + return 'privacy.disclosure.reason.embedding'; + case 'network_fetch': + return 'privacy.disclosure.reason.networkFetch'; + default: + return 'privacy.disclosure.reason.unknown'; + } +} + +export function privacyModeLabelKey(mode: PrivacyMode | string): string { + switch (mode) { + case 'local_only': + return 'privacy.mode.localOnly'; + case 'standard': + return 'privacy.mode.standard'; + case 'sensitive': + return 'privacy.mode.sensitive'; + // The pill is always-visible, so an unexpected/future mode string must + // still resolve to a real i18n key — never `undefined`, which `t()` would + // choke on. Fall back to the neutral "Standard" label, matching the sibling + // dataKind/reason mappers. + default: + return 'privacy.mode.standard'; + } +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 5d7bafd3b3..0d3de8b358 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7067,6 +7067,29 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'حذف', 'flows.delete.deleting': 'جارٍ الحذف…', 'flows.canvas.renameLabel': 'إعادة تسمية سير العمل', + + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': 'حالة الخصوصية', + 'privacy.status.external': 'خارج الجهاز', + 'privacy.status.local': 'على الجهاز', + 'privacy.disclosure.title': 'يغادر جهازك', + 'privacy.disclosure.body': 'سيؤدي هذا إلى إرسال {kinds} إلى {destination} لأن {reason}.', + 'privacy.disclosure.dismiss': 'حسنًا', + 'privacy.disclosure.ariaLabel': 'إفصاح عن البيانات الخارجية', + 'privacy.disclosure.kindSeparator': '، ', + 'privacy.disclosure.kind.prompt': 'رسالتك', + 'privacy.disclosure.kind.toolArguments': 'مدخلات الأداة', + 'privacy.disclosure.kind.embeddingInput': 'نص للفهرسة', + 'privacy.disclosure.kind.fileContent': 'محتويات الملف', + 'privacy.disclosure.kind.url': 'عنوان ويب', + 'privacy.disclosure.kind.metadata': 'بيانات وصفية للطلب', + 'privacy.disclosure.kind.unknown': 'بيانات', + 'privacy.disclosure.reason.inference': 'نموذج الذكاء الاصطناعي يحتاج إلى معالجته', + 'privacy.disclosure.reason.toolCall': 'أداة تحتاج إليه', + 'privacy.disclosure.reason.integration': 'تكامل متصل يحتاج إليه', + 'privacy.disclosure.reason.embedding': 'يجب فهرسته للبحث', + 'privacy.disclosure.reason.networkFetch': 'طلب ويب يحتاج إليه', + 'privacy.disclosure.reason.unknown': 'إنه مطلوب لهذا الإجراء', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 75f87bd5d6..9f553e272f 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7231,6 +7231,29 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'মুছুন', 'flows.delete.deleting': 'মুছে ফেলা হচ্ছে…', 'flows.canvas.renameLabel': 'ওয়ার্কফ্লো পুনঃনামকরণ করুন', + + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': 'গোপনীয়তা স্থিতি', + 'privacy.status.external': 'ডিভাইসের বাইরে', + 'privacy.status.local': 'ডিভাইসে', + 'privacy.disclosure.title': 'আপনার ডিভাইস ছেড়ে যাচ্ছে', + 'privacy.disclosure.body': 'এটি {kinds} কে {destination}-এ পাঠাবে কারণ {reason}।', + 'privacy.disclosure.dismiss': 'বুঝেছি', + 'privacy.disclosure.ariaLabel': 'বাহ্যিক ডেটা প্রকাশ', + 'privacy.disclosure.kindSeparator': ', ', + 'privacy.disclosure.kind.prompt': 'আপনার বার্তা', + 'privacy.disclosure.kind.toolArguments': 'টুল ইনপুট', + 'privacy.disclosure.kind.embeddingInput': 'সূচিবদ্ধ করার পাঠ্য', + 'privacy.disclosure.kind.fileContent': 'ফাইলের বিষয়বস্তু', + 'privacy.disclosure.kind.url': 'একটি ওয়েব ঠিকানা', + 'privacy.disclosure.kind.metadata': 'অনুরোধের মেটাডেটা', + 'privacy.disclosure.kind.unknown': 'ডেটা', + 'privacy.disclosure.reason.inference': 'AI মডেলটিকে এটি প্রক্রিয়া করতে হবে', + 'privacy.disclosure.reason.toolCall': 'একটি টুলের এটি প্রয়োজন', + 'privacy.disclosure.reason.integration': 'একটি সংযুক্ত ইন্টিগ্রেশনের এটি প্রয়োজন', + 'privacy.disclosure.reason.embedding': 'এটি অনুসন্ধানের জন্য সূচিবদ্ধ করা প্রয়োজন', + 'privacy.disclosure.reason.networkFetch': 'একটি ওয়েব অনুরোধের এটি প্রয়োজন', + 'privacy.disclosure.reason.unknown': 'এটি এই ক্রিয়ার জন্য প্রয়োজন', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 886236acdd..4abe7b019e 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7446,6 +7446,29 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Löschen', 'flows.delete.deleting': 'Wird gelöscht…', 'flows.canvas.renameLabel': 'Workflow umbenennen', + + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': 'Datenschutzstatus', + 'privacy.status.external': 'Außerhalb des Geräts', + 'privacy.status.local': 'Auf dem Gerät', + 'privacy.disclosure.title': 'Verlässt Ihr Gerät', + 'privacy.disclosure.body': 'Dadurch werden {kinds} an {destination} gesendet, weil {reason}.', + 'privacy.disclosure.dismiss': 'Verstanden', + 'privacy.disclosure.ariaLabel': 'Offenlegung externer Daten', + 'privacy.disclosure.kindSeparator': ', ', + 'privacy.disclosure.kind.prompt': 'Ihre Nachricht', + 'privacy.disclosure.kind.toolArguments': 'Werkzeug-Eingaben', + 'privacy.disclosure.kind.embeddingInput': 'zu indexierender Text', + 'privacy.disclosure.kind.fileContent': 'Dateiinhalte', + 'privacy.disclosure.kind.url': 'eine Webadresse', + 'privacy.disclosure.kind.metadata': 'Anfrage-Metadaten', + 'privacy.disclosure.kind.unknown': 'Daten', + 'privacy.disclosure.reason.inference': 'das KI-Modell es verarbeiten muss', + 'privacy.disclosure.reason.toolCall': 'ein Werkzeug es benötigt', + 'privacy.disclosure.reason.integration': 'eine verbundene Integration es benötigt', + 'privacy.disclosure.reason.embedding': 'es für die Suche indexiert werden muss', + 'privacy.disclosure.reason.networkFetch': 'eine Webanfrage es benötigt', + 'privacy.disclosure.reason.unknown': 'es für diese Aktion erforderlich ist', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 9b3a66d417..d22191c78a 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1713,6 +1713,29 @@ const en: TranslationMap = { 'privacy.mode.saved': 'Saved', 'privacy.mode.saveError': 'Could not update privacy mode.', + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': 'Privacy status', + 'privacy.status.external': 'Off-device', + 'privacy.status.local': 'On-device', + 'privacy.disclosure.title': 'Leaving your device', + 'privacy.disclosure.body': 'This will send {kinds} to {destination} because {reason}.', + 'privacy.disclosure.dismiss': 'Got it', + 'privacy.disclosure.ariaLabel': 'External data disclosure', + 'privacy.disclosure.kindSeparator': ', ', + 'privacy.disclosure.kind.prompt': 'your message', + 'privacy.disclosure.kind.toolArguments': 'tool inputs', + 'privacy.disclosure.kind.embeddingInput': 'text to index', + 'privacy.disclosure.kind.fileContent': 'file contents', + 'privacy.disclosure.kind.url': 'a web address', + 'privacy.disclosure.kind.metadata': 'request metadata', + 'privacy.disclosure.kind.unknown': 'data', + 'privacy.disclosure.reason.inference': 'the AI model needs to process it', + 'privacy.disclosure.reason.toolCall': 'a tool needs it', + 'privacy.disclosure.reason.integration': 'a connected integration needs it', + 'privacy.disclosure.reason.embedding': 'it needs to be indexed for search', + 'privacy.disclosure.reason.networkFetch': 'a web request needs it', + 'privacy.disclosure.reason.unknown': 'it is required for this action', + // Settings: About 'settings.about.version': 'Version', 'settings.about.updateAvailable': 'is available', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index d9e981ff33..926891b588 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7380,6 +7380,29 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Eliminar', 'flows.delete.deleting': 'Eliminando…', 'flows.canvas.renameLabel': 'Cambiar el nombre del flujo de trabajo', + + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': 'Estado de privacidad', + 'privacy.status.external': 'Fuera del dispositivo', + 'privacy.status.local': 'En el dispositivo', + 'privacy.disclosure.title': 'Sale de tu dispositivo', + 'privacy.disclosure.body': 'Esto enviará {kinds} a {destination} porque {reason}.', + 'privacy.disclosure.dismiss': 'Entendido', + 'privacy.disclosure.ariaLabel': 'Divulgación de datos externos', + 'privacy.disclosure.kindSeparator': ', ', + 'privacy.disclosure.kind.prompt': 'tu mensaje', + 'privacy.disclosure.kind.toolArguments': 'entradas de la herramienta', + 'privacy.disclosure.kind.embeddingInput': 'texto para indexar', + 'privacy.disclosure.kind.fileContent': 'contenido del archivo', + 'privacy.disclosure.kind.url': 'una dirección web', + 'privacy.disclosure.kind.metadata': 'metadatos de la solicitud', + 'privacy.disclosure.kind.unknown': 'datos', + 'privacy.disclosure.reason.inference': 'el modelo de IA necesita procesarlo', + 'privacy.disclosure.reason.toolCall': 'una herramienta lo necesita', + 'privacy.disclosure.reason.integration': 'una integración conectada lo necesita', + 'privacy.disclosure.reason.embedding': 'necesita indexarse para la búsqueda', + 'privacy.disclosure.reason.networkFetch': 'una solicitud web lo necesita', + 'privacy.disclosure.reason.unknown': 'es necesario para esta acción', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 3daf295423..72b09608c3 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7414,6 +7414,29 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Supprimer', 'flows.delete.deleting': 'Suppression…', 'flows.canvas.renameLabel': 'Renommer le workflow', + + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': 'État de confidentialité', + 'privacy.status.external': 'Hors de l’appareil', + 'privacy.status.local': 'Sur l’appareil', + 'privacy.disclosure.title': 'Quitte votre appareil', + 'privacy.disclosure.body': 'Ceci enverra {kinds} vers {destination} car {reason}.', + 'privacy.disclosure.dismiss': 'Compris', + 'privacy.disclosure.ariaLabel': 'Divulgation de données externes', + 'privacy.disclosure.kindSeparator': ', ', + 'privacy.disclosure.kind.prompt': 'votre message', + 'privacy.disclosure.kind.toolArguments': 'les entrées de l’outil', + 'privacy.disclosure.kind.embeddingInput': 'le texte à indexer', + 'privacy.disclosure.kind.fileContent': 'le contenu du fichier', + 'privacy.disclosure.kind.url': 'une adresse web', + 'privacy.disclosure.kind.metadata': 'les métadonnées de la requête', + 'privacy.disclosure.kind.unknown': 'des données', + 'privacy.disclosure.reason.inference': 'le modèle d’IA doit le traiter', + 'privacy.disclosure.reason.toolCall': 'un outil en a besoin', + 'privacy.disclosure.reason.integration': 'une intégration connectée en a besoin', + 'privacy.disclosure.reason.embedding': 'il doit être indexé pour la recherche', + 'privacy.disclosure.reason.networkFetch': 'une requête web en a besoin', + 'privacy.disclosure.reason.unknown': 'il est requis pour cette action', }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index dd7fe5d902..054cf1c60f 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7229,6 +7229,29 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'हटाएं', 'flows.delete.deleting': 'हटाया जा रहा है…', 'flows.canvas.renameLabel': 'वर्कफ़्लो का नाम बदलें', + + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': 'गोपनीयता स्थिति', + 'privacy.status.external': 'डिवाइस के बाहर', + 'privacy.status.local': 'डिवाइस पर', + 'privacy.disclosure.title': 'आपके डिवाइस से बाहर जा रहा है', + 'privacy.disclosure.body': 'यह {kinds} को {destination} पर भेजेगा क्योंकि {reason}।', + 'privacy.disclosure.dismiss': 'समझ गया', + 'privacy.disclosure.ariaLabel': 'बाहरी डेटा प्रकटीकरण', + 'privacy.disclosure.kindSeparator': ', ', + 'privacy.disclosure.kind.prompt': 'आपका संदेश', + 'privacy.disclosure.kind.toolArguments': 'टूल इनपुट', + 'privacy.disclosure.kind.embeddingInput': 'अनुक्रमित करने के लिए पाठ', + 'privacy.disclosure.kind.fileContent': 'फ़ाइल सामग्री', + 'privacy.disclosure.kind.url': 'एक वेब पता', + 'privacy.disclosure.kind.metadata': 'अनुरोध मेटाडेटा', + 'privacy.disclosure.kind.unknown': 'डेटा', + 'privacy.disclosure.reason.inference': 'AI मॉडल को इसे संसाधित करना है', + 'privacy.disclosure.reason.toolCall': 'एक टूल को इसकी ज़रूरत है', + 'privacy.disclosure.reason.integration': 'एक कनेक्टेड इंटीग्रेशन को इसकी ज़रूरत है', + 'privacy.disclosure.reason.embedding': 'इसे खोज के लिए अनुक्रमित करना आवश्यक है', + 'privacy.disclosure.reason.networkFetch': 'एक वेब अनुरोध को इसकी ज़रूरत है', + 'privacy.disclosure.reason.unknown': 'यह इस क्रिया के लिए आवश्यक है', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 4052a84d26..63bcd01a72 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7263,6 +7263,29 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Hapus', 'flows.delete.deleting': 'Menghapus…', 'flows.canvas.renameLabel': 'Ganti nama alur kerja', + + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': 'Status privasi', + 'privacy.status.external': 'Di luar perangkat', + 'privacy.status.local': 'Di perangkat', + 'privacy.disclosure.title': 'Meninggalkan perangkat Anda', + 'privacy.disclosure.body': 'Ini akan mengirim {kinds} ke {destination} karena {reason}.', + 'privacy.disclosure.dismiss': 'Mengerti', + 'privacy.disclosure.ariaLabel': 'Pengungkapan data eksternal', + 'privacy.disclosure.kindSeparator': ', ', + 'privacy.disclosure.kind.prompt': 'pesan Anda', + 'privacy.disclosure.kind.toolArguments': 'masukan alat', + 'privacy.disclosure.kind.embeddingInput': 'teks untuk diindeks', + 'privacy.disclosure.kind.fileContent': 'isi berkas', + 'privacy.disclosure.kind.url': 'alamat web', + 'privacy.disclosure.kind.metadata': 'metadata permintaan', + 'privacy.disclosure.kind.unknown': 'data', + 'privacy.disclosure.reason.inference': 'model AI perlu memprosesnya', + 'privacy.disclosure.reason.toolCall': 'sebuah alat memerlukannya', + 'privacy.disclosure.reason.integration': 'integrasi yang terhubung memerlukannya', + 'privacy.disclosure.reason.embedding': 'perlu diindeks untuk pencarian', + 'privacy.disclosure.reason.networkFetch': 'permintaan web memerlukannya', + 'privacy.disclosure.reason.unknown': 'diperlukan untuk tindakan ini', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 06828119a3..5eb0ea29d7 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7370,6 +7370,29 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Elimina', 'flows.delete.deleting': 'Eliminazione…', 'flows.canvas.renameLabel': 'Rinomina flusso di lavoro', + + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': 'Stato privacy', + 'privacy.status.external': 'Fuori dal dispositivo', + 'privacy.status.local': 'Sul dispositivo', + 'privacy.disclosure.title': 'Sta lasciando il tuo dispositivo', + 'privacy.disclosure.body': 'Questo invierà {kinds} a {destination} perché {reason}.', + 'privacy.disclosure.dismiss': 'Ho capito', + 'privacy.disclosure.ariaLabel': 'Informativa sul trasferimento di dati esterni', + 'privacy.disclosure.kindSeparator': ', ', + 'privacy.disclosure.kind.prompt': 'il tuo messaggio', + 'privacy.disclosure.kind.toolArguments': 'input dello strumento', + 'privacy.disclosure.kind.embeddingInput': 'testo da indicizzare', + 'privacy.disclosure.kind.fileContent': 'contenuto del file', + 'privacy.disclosure.kind.url': 'un indirizzo web', + 'privacy.disclosure.kind.metadata': 'metadati della richiesta', + 'privacy.disclosure.kind.unknown': 'dati', + 'privacy.disclosure.reason.inference': 'il modello di IA deve elaborare i dati', + 'privacy.disclosure.reason.toolCall': 'uno strumento ne ha bisogno', + 'privacy.disclosure.reason.integration': 'un’integrazione collegata ne ha bisogno', + 'privacy.disclosure.reason.embedding': 'i dati devono essere indicizzati per la ricerca', + 'privacy.disclosure.reason.networkFetch': 'una richiesta web ne ha bisogno', + 'privacy.disclosure.reason.unknown': 'è necessario per questa azione', }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index fed6143361..1425f954de 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -7149,6 +7149,30 @@ const messages: TranslationMap = { 'flows.delete.confirm': '삭제', 'flows.delete.deleting': '삭제 중…', 'flows.canvas.renameLabel': '워크플로 이름 바꾸기', + + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': '개인정보 상태', + 'privacy.status.external': '기기 외', + 'privacy.status.local': '기기 내', + 'privacy.disclosure.title': '기기에서 나가는 중', + 'privacy.disclosure.body': + '이 작업은 {destination}(으)로 다음 데이터를 보냅니다: {kinds}. ({reason})', + 'privacy.disclosure.dismiss': '확인', + 'privacy.disclosure.ariaLabel': '외부 데이터 공개', + 'privacy.disclosure.kindSeparator': ', ', + 'privacy.disclosure.kind.prompt': '메시지', + 'privacy.disclosure.kind.toolArguments': '도구 입력값', + 'privacy.disclosure.kind.embeddingInput': '색인할 텍스트', + 'privacy.disclosure.kind.fileContent': '파일 내용', + 'privacy.disclosure.kind.url': '웹 주소', + 'privacy.disclosure.kind.metadata': '요청 메타데이터', + 'privacy.disclosure.kind.unknown': '데이터', + 'privacy.disclosure.reason.inference': 'AI 모델이 이를 처리해야 하기 때문에', + 'privacy.disclosure.reason.toolCall': '도구가 이를 필요로 하기 때문에', + 'privacy.disclosure.reason.integration': '연결된 통합 기능이 이를 필요로 하기 때문에', + 'privacy.disclosure.reason.embedding': '검색을 위해 색인해야 하기 때문에', + 'privacy.disclosure.reason.networkFetch': '웹 요청이 이를 필요로 하기 때문에', + 'privacy.disclosure.reason.unknown': '이 작업에 필요하기 때문에', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index ce57182802..6a7f3e0d82 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7339,6 +7339,30 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Usuń', 'flows.delete.deleting': 'Usuwanie…', 'flows.canvas.renameLabel': 'Zmień nazwę przepływu pracy', + + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': 'Stan prywatności', + 'privacy.status.external': 'Poza urządzeniem', + 'privacy.status.local': 'Na urządzeniu', + 'privacy.disclosure.title': 'Opuszcza Twoje urządzenie', + 'privacy.disclosure.body': + 'Spowoduje to wysłanie następujących danych do {destination}: {kinds}. Powód: {reason}.', + 'privacy.disclosure.dismiss': 'Rozumiem', + 'privacy.disclosure.ariaLabel': 'Ujawnienie danych zewnętrznych', + 'privacy.disclosure.kindSeparator': ', ', + 'privacy.disclosure.kind.prompt': 'Twoja wiadomość', + 'privacy.disclosure.kind.toolArguments': 'dane wejściowe narzędzia', + 'privacy.disclosure.kind.embeddingInput': 'tekst do zindeksowania', + 'privacy.disclosure.kind.fileContent': 'zawartość pliku', + 'privacy.disclosure.kind.url': 'adres internetowy', + 'privacy.disclosure.kind.metadata': 'metadane żądania', + 'privacy.disclosure.kind.unknown': 'dane', + 'privacy.disclosure.reason.inference': 'model AI musi przetworzyć te dane', + 'privacy.disclosure.reason.toolCall': 'narzędzie potrzebuje tych danych', + 'privacy.disclosure.reason.integration': 'połączona integracja potrzebuje tych danych', + 'privacy.disclosure.reason.embedding': 'te dane muszą zostać zindeksowane do wyszukiwania', + 'privacy.disclosure.reason.networkFetch': 'żądanie sieciowe potrzebuje tych danych', + 'privacy.disclosure.reason.unknown': 'jest to wymagane do tej akcji', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 3d89a22546..8fc59bf70e 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7353,6 +7353,29 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Excluir', 'flows.delete.deleting': 'Excluindo…', 'flows.canvas.renameLabel': 'Renomear fluxo de trabalho', + + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': 'Estado de privacidade', + 'privacy.status.external': 'Fora do dispositivo', + 'privacy.status.local': 'No dispositivo', + 'privacy.disclosure.title': 'A sair do seu dispositivo', + 'privacy.disclosure.body': 'Isto irá enviar {kinds} para {destination} porque {reason}.', + 'privacy.disclosure.dismiss': 'Entendido', + 'privacy.disclosure.ariaLabel': 'Divulgação de dados externos', + 'privacy.disclosure.kindSeparator': ', ', + 'privacy.disclosure.kind.prompt': 'a sua mensagem', + 'privacy.disclosure.kind.toolArguments': 'entradas da ferramenta', + 'privacy.disclosure.kind.embeddingInput': 'texto para indexar', + 'privacy.disclosure.kind.fileContent': 'conteúdo do ficheiro', + 'privacy.disclosure.kind.url': 'um endereço web', + 'privacy.disclosure.kind.metadata': 'metadados do pedido', + 'privacy.disclosure.kind.unknown': 'dados', + 'privacy.disclosure.reason.inference': 'o modelo de IA precisa de processar estes dados', + 'privacy.disclosure.reason.toolCall': 'uma ferramenta precisa destes dados', + 'privacy.disclosure.reason.integration': 'uma integração ligada precisa destes dados', + 'privacy.disclosure.reason.embedding': 'estes dados precisam de ser indexados para pesquisa', + 'privacy.disclosure.reason.networkFetch': 'um pedido web precisa destes dados', + 'privacy.disclosure.reason.unknown': 'é necessário para esta ação', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 992715873d..42fd11abd1 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7309,6 +7309,29 @@ const messages: TranslationMap = { 'flows.delete.confirm': 'Удалить', 'flows.delete.deleting': 'Удаление…', 'flows.canvas.renameLabel': 'Переименовать рабочий процесс', + + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': 'Состояние конфиденциальности', + 'privacy.status.external': 'Вне устройства', + 'privacy.status.local': 'На устройстве', + 'privacy.disclosure.title': 'Покидает ваше устройство', + 'privacy.disclosure.body': 'Это отправит {kinds} на {destination}, потому что {reason}.', + 'privacy.disclosure.dismiss': 'Понятно', + 'privacy.disclosure.ariaLabel': 'Раскрытие внешних данных', + 'privacy.disclosure.kindSeparator': ', ', + 'privacy.disclosure.kind.prompt': 'ваше сообщение', + 'privacy.disclosure.kind.toolArguments': 'входные данные инструмента', + 'privacy.disclosure.kind.embeddingInput': 'текст для индексации', + 'privacy.disclosure.kind.fileContent': 'содержимое файла', + 'privacy.disclosure.kind.url': 'веб-адрес', + 'privacy.disclosure.kind.metadata': 'метаданные запроса', + 'privacy.disclosure.kind.unknown': 'данные', + 'privacy.disclosure.reason.inference': 'модели ИИ нужно обработать эти данные', + 'privacy.disclosure.reason.toolCall': 'инструменту это нужно', + 'privacy.disclosure.reason.integration': 'подключённой интеграции это нужно', + 'privacy.disclosure.reason.embedding': 'эти данные нужно проиндексировать для поиска', + 'privacy.disclosure.reason.networkFetch': 'веб-запросу это нужно', + 'privacy.disclosure.reason.unknown': 'это требуется для данного действия', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 5345a471cf..5cf28b0ced 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6840,6 +6840,29 @@ const messages: TranslationMap = { 'flows.delete.confirm': '删除', 'flows.delete.deleting': '正在删除…', 'flows.canvas.renameLabel': '重命名工作流', + + // Privacy status pill + per-action egress disclosure (#4437 / S3) + 'privacy.status.ariaLabel': '隐私状态', + 'privacy.status.external': '设备外', + 'privacy.status.local': '本地设备', + 'privacy.disclosure.title': '正在离开你的设备', + 'privacy.disclosure.body': '这将把 {kinds} 发送到 {destination},因为{reason}。', + 'privacy.disclosure.dismiss': '知道了', + 'privacy.disclosure.ariaLabel': '外部数据披露', + 'privacy.disclosure.kindSeparator': '、', + 'privacy.disclosure.kind.prompt': '你的消息', + 'privacy.disclosure.kind.toolArguments': '工具输入', + 'privacy.disclosure.kind.embeddingInput': '待索引的文本', + 'privacy.disclosure.kind.fileContent': '文件内容', + 'privacy.disclosure.kind.url': '一个网址', + 'privacy.disclosure.kind.metadata': '请求元数据', + 'privacy.disclosure.kind.unknown': '数据', + 'privacy.disclosure.reason.inference': 'AI 模型需要处理它', + 'privacy.disclosure.reason.toolCall': '某个工具需要它', + 'privacy.disclosure.reason.integration': '已连接的集成需要它', + 'privacy.disclosure.reason.embedding': '它需要被索引以供搜索', + 'privacy.disclosure.reason.networkFetch': '网络请求需要它', + 'privacy.disclosure.reason.unknown': '此操作需要它', }; export default messages; diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index 285a2e399f..62428a0167 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -31,6 +31,7 @@ import chatRuntimeReducer, { setTurnTimelinesForThread, } from '../../store/chatRuntimeSlice'; import layoutReducer from '../../store/layoutSlice'; +import privacyReducer, { type PrivacyDisclosure } from '../../store/privacySlice'; import socketReducer from '../../store/socketSlice'; import themeReducer from '../../store/themeSlice'; import threadReducer, { setSelectedThread } from '../../store/threadSlice'; @@ -202,6 +203,7 @@ function buildStore(preload: Record = {}) { chatRuntime: chatRuntimeReducer, agentProfiles: agentProfileReducer, theme: themeReducer, + privacy: privacyReducer, }), preloadedState: preload as never, }); @@ -2712,3 +2714,105 @@ describe('Conversations — message list reserves room for the floating composer } }); }); + +// External-transfer disclosure surface (#4437 / S3). The card's own unit test +// covers the component in isolation; these drive the SELECTION path in +// Conversations — latest-per-thread, `firstActiveThreadId` fallback, and +// clear-after-dismissal — which the isolated card test cannot reach. +describe('Conversations — external-transfer disclosure surface (#4437 / S3)', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + mockGetThreads.mockResolvedValue({ threads: [], count: 0 }); + mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 }); + }); + + function disclosure(over: Partial = {}): PrivacyDisclosure { + return { + id: 'd1', + providerSlug: 'openai', + service: 'OpenAI', + isExternal: true, + reason: 'inference', + dataKinds: ['prompt'], + riskLevel: 'unknown', + riskCategories: [], + receivedAt: 0, + ...over, + }; + } + + it('surfaces the latest disclosure for the selected thread', async () => { + const thread = makeThread({ id: 't-sel' }); + mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 }); + await act(async () => { + await renderConversations({ + thread: selectedThreadState(thread), + socket: socketState('connected'), + privacy: { + privacyMode: 'standard', + disclosuresByThread: { + 't-sel': [ + disclosure({ id: 'old', service: 'OldService' }), + disclosure({ id: 'new', service: 'OpenAI' }), + ], + }, + }, + }); + }); + + const title = await screen.findByText('Leaving your device'); + const card = title.closest('[role="status"]'); + expect(card).not.toBeNull(); + // Latest wins — the newest disclosure's destination, not the older one. + expect(card).toHaveTextContent('OpenAI'); + expect(card).not.toHaveTextContent('OldService'); + }); + + it('falls back to firstActiveThreadId when no thread is selected', async () => { + const thread = makeThread({ id: 't-act' }); + // Keep thread loading pending so the mount flow's auto-select can't fire. With a + // resolved load the sole thread `t-act` would be auto-selected, and the assertion + // would then pass through the normal selected-thread path instead of the intended + // `selectedThreadId ?? firstActiveThreadId` fallback (CR #4849). A pending load + // leaves `selectedThreadId` null, so the fallback is the ONLY way the card shows. + mockGetThreads.mockReturnValue(new Promise(() => {})); + let store: Awaited>; + await act(async () => { + store = await renderConversations({ + // No selectedThreadId — only an active thread present. + thread: { ...emptyThreadState, threads: [thread], activeThreadIds: { 't-act': true } }, + socket: socketState('connected'), + privacy: { + privacyMode: 'standard', + disclosuresByThread: { 't-act': [disclosure({ service: 'Anthropic' })] }, + }, + }); + }); + + // Auto-selection must NOT have fired — the fallback branch is what surfaces the + // card, not the normal selected-thread path. + expect(store!.getState().thread.selectedThreadId).toBeNull(); + const title = await screen.findByText('Leaving your device'); + expect(title.closest('[role="status"]')).toHaveTextContent('Anthropic'); + }); + + it('clears the card after dismissal', async () => { + const thread = makeThread({ id: 't-sel' }); + mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 }); + await act(async () => { + await renderConversations({ + thread: selectedThreadState(thread), + socket: socketState('connected'), + privacy: { privacyMode: 'standard', disclosuresByThread: { 't-sel': [disclosure()] } }, + }); + }); + + const title = await screen.findByText('Leaving your device'); + const card = title.closest('[role="status"]') as HTMLElement; + await act(async () => { + fireEvent.click(within(card).getByRole('button', { name: 'Got it' })); + }); + expect(screen.queryByText('Leaving your device')).toBeNull(); + }); +}); diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index e93b985077..d8c1a4c99d 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -23,6 +23,7 @@ import { type ChatTaskBoardUpdatedEvent, type ChatToolCallEvent, type ChatToolResultEvent, + type ExternalTransferPendingEvent, type ProactiveMessageEvent, segmentText, subscribeChatEvents, @@ -66,6 +67,12 @@ import { type WorkflowProposal, } from '../store/chatRuntimeSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + clearActiveExternalForThread, + disclosureFromEvent, + hydratePrivacyMode, + pushDisclosureForThread, +} from '../store/privacySlice'; import { selectSocketStatus } from '../store/socketSelectors'; import { addInferenceResponse, @@ -356,6 +363,13 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { // only — it never gates or cancels a turn. const skillLatencyRef = useRef(createSkillToolChainLatencyTracker()); + // Hydrate the current Privacy Mode once on mount so the persistent status + // pill can show the posture immediately (#4437 / S3). Failures resolve to + // null inside the thunk — the pill degrades gracefully. + useEffect(() => { + void dispatch(hydratePrivacyMode()); + }, [dispatch]); + useEffect(() => { toolTimelineRef.current = toolTimelineByThread; }, [toolTimelineByThread]); @@ -1057,6 +1071,25 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { }) ); }, + onExternalTransferPending: (event: ExternalTransferPendingEvent) => { + // #4437 / S3 — DISCLOSURE ONLY. Project the egress descriptor onto the + // thread's privacy ledger so the in-chat card + status pill can render + // what/where/why. No approve/deny here (that's S4 #4438). + rtLog('external_transfer_pending', { + thread: event.thread_id, + provider: event.provider_slug, + service: event.service, + reason: event.reason, + kinds: event.data_kinds.length, + external: String(event.is_external), + }); + dispatch( + pushDisclosureForThread({ + threadId: event.thread_id, + disclosure: disclosureFromEvent(event), + }) + ); + }, onApprovalRequest: (event: ChatApprovalRequestEvent) => { rtLog('approval_request', { thread: event.thread_id, @@ -1188,6 +1221,10 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { dispatch(clearStreamingAssistantForThread({ threadId: event.thread_id })); dispatch(clearPendingApprovalForThread({ threadId: event.thread_id })); dispatch(clearPendingPlanReviewForThread({ threadId: event.thread_id })); + // Turn boundary: this turn's external transfers are done, so return the + // privacy pill to on-device. The (un-dismissed) disclosure ledger stays + // for the in-chat card's history — only the live flag clears (#4437). + dispatch(clearActiveExternalForThread({ threadId: event.thread_id })); const existing = store.getState().chatRuntime.toolTimelineByThread[event.thread_id] ?? []; if (existing.length > 0) { @@ -1340,6 +1377,9 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { dispatch(clearStreamingAssistantForThread({ threadId: event.thread_id })); dispatch(clearPendingApprovalForThread({ threadId: event.thread_id })); dispatch(clearPendingPlanReviewForThread({ threadId: event.thread_id })); + // Turn boundary (error path): clear the live external-transfer flag so + // the privacy pill resets to on-device, mirroring the done path (#4437). + dispatch(clearActiveExternalForThread({ threadId: event.thread_id })); const existing = store.getState().chatRuntime.toolTimelineByThread[event.thread_id] ?? []; if (existing.length > 0) { @@ -1427,6 +1467,10 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { // can't complete. dispatch(clearPendingApprovalForThread({ threadId })); dispatch(clearPendingPlanReviewForThread({ threadId })); + // A disconnect tears the turn down without a done/error event, so clear + // the live external-transfer flag here too — otherwise the privacy pill + // would stay off-device for a turn that can never complete (#4437). + dispatch(clearActiveExternalForThread({ threadId })); dispatch(endInferenceTurn({ threadId })); } // A disconnect kills every in-flight turn on the dead session, so clear all diff --git a/app/src/services/__tests__/chatService.externalTransfer.test.ts b/app/src/services/__tests__/chatService.externalTransfer.test.ts new file mode 100644 index 0000000000..312e6fae2a --- /dev/null +++ b/app/src/services/__tests__/chatService.externalTransfer.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { subscribeChatEvents } from '../chatService'; +import { socketService } from '../socketService'; + +vi.mock('../socketService', () => ({ + socketService: { getSocket: vi.fn(), on: vi.fn(), off: vi.fn() }, +})); +vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() })); + +type Handler = (...args: unknown[]) => void; + +function createMockSocket() { + const handlers = new Map(); + const on = vi.fn((event: string, cb: Handler) => { + const existing = handlers.get(event) ?? []; + existing.push(cb); + handlers.set(event, existing); + }); + const off = vi.fn((event: string, cb: Handler) => { + const existing = handlers.get(event) ?? []; + handlers.set( + event, + existing.filter(handler => handler !== cb) + ); + }); + const emit = (event: string, payload: unknown) => { + for (const handler of handlers.get(event) ?? []) handler(payload); + }; + return { id: 'socket-1', on, off, emit }; +} + +function bindMockSocket(socket: ReturnType) { + vi.mocked(socketService.getSocket).mockReturnValue(socket as never); + vi.mocked(socketService.on).mockImplementation((event, cb) => socket.on(event, cb as Handler)); + vi.mocked(socketService.off).mockImplementation((event, cb) => socket.off(event, cb as Handler)); +} + +describe('chatService — external_transfer_pending handler (#4437 / S3)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('subscribes under the canonical snake_case event name', () => { + const socket = createMockSocket(); + bindMockSocket(socket); + + subscribeChatEvents({ onExternalTransferPending: () => {} }); + + const events = socket.on.mock.calls.map(call => call[0]); + expect(events).toEqual(['external_transfer_pending']); + }); + + it('flattens the wire envelope into a typed ExternalTransferPendingEvent', () => { + const socket = createMockSocket(); + bindMockSocket(socket); + const onExternalTransferPending = vi.fn(); + + subscribeChatEvents({ onExternalTransferPending }); + + socket.emit('external_transfer_pending', { + thread_id: 'thread-1', + client_id: 'web-x', + args: { + provider_slug: 'openai', + service: 'OpenAI', + is_external: true, + reason: 'inference', + data_kinds: ['prompt', 'tool_arguments'], + risk_level: 'unknown', + risk_categories: [], + }, + }); + + expect(onExternalTransferPending).toHaveBeenCalledTimes(1); + expect(onExternalTransferPending).toHaveBeenCalledWith({ + thread_id: 'thread-1', + client_id: 'web-x', + provider_slug: 'openai', + service: 'OpenAI', + is_external: true, + reason: 'inference', + data_kinds: ['prompt', 'tool_arguments'], + risk_level: 'unknown', + risk_categories: [], + }); + }); + + it('accepts an empty data_kinds array (metadata-only transfer)', () => { + const socket = createMockSocket(); + bindMockSocket(socket); + const onExternalTransferPending = vi.fn(); + + subscribeChatEvents({ onExternalTransferPending }); + + socket.emit('external_transfer_pending', { + thread_id: 'thread-1', + args: { + provider_slug: 'composio', + service: 'Gmail', + is_external: true, + reason: 'integration', + data_kinds: [], + risk_level: 'unknown', + risk_categories: [], + }, + }); + + expect(onExternalTransferPending).toHaveBeenCalledTimes(1); + expect(onExternalTransferPending.mock.calls[0]![0]).toMatchObject({ + service: 'Gmail', + data_kinds: [], + }); + }); + + it('defaults is_external to true and risk_level to unknown when absent/invalid', () => { + const socket = createMockSocket(); + bindMockSocket(socket); + const onExternalTransferPending = vi.fn(); + + subscribeChatEvents({ onExternalTransferPending }); + + socket.emit('external_transfer_pending', { + thread_id: 'thread-1', + args: { + provider_slug: 'openai', + service: 'OpenAI', + // is_external omitted + reason: 'inference', + data_kinds: ['prompt'], + risk_level: 'not-a-real-level', + // risk_categories omitted + }, + }); + + const event = onExternalTransferPending.mock.calls[0]![0] as { + is_external: boolean; + risk_level: string; + risk_categories: string[]; + }; + expect(event.is_external).toBe(true); + expect(event.risk_level).toBe('unknown'); + expect(event.risk_categories).toEqual([]); + }); + + it('drops payloads missing load-bearing fields', () => { + const socket = createMockSocket(); + bindMockSocket(socket); + const onExternalTransferPending = vi.fn(); + + subscribeChatEvents({ onExternalTransferPending }); + + // No args → bad envelope. + socket.emit('external_transfer_pending', { thread_id: 'thread-1' }); + // Missing provider_slug. + socket.emit('external_transfer_pending', { + thread_id: 'thread-1', + args: { service: 'OpenAI', reason: 'inference', data_kinds: [] }, + }); + // Missing service. + socket.emit('external_transfer_pending', { + thread_id: 'thread-1', + args: { provider_slug: 'openai', reason: 'inference', data_kinds: [] }, + }); + // Missing reason. + socket.emit('external_transfer_pending', { + thread_id: 'thread-1', + args: { provider_slug: 'openai', service: 'OpenAI', data_kinds: [] }, + }); + // data_kinds not an array. + socket.emit('external_transfer_pending', { + thread_id: 'thread-1', + args: { + provider_slug: 'openai', + service: 'OpenAI', + reason: 'inference', + data_kinds: 'prompt', + }, + }); + // Missing thread_id → bad envelope. + socket.emit('external_transfer_pending', { + args: { provider_slug: 'openai', service: 'OpenAI', reason: 'inference', data_kinds: [] }, + }); + + expect(onExternalTransferPending).not.toHaveBeenCalled(); + }); + + it('removes the handler on cleanup', () => { + const socket = createMockSocket(); + bindMockSocket(socket); + + const cleanup = subscribeChatEvents({ onExternalTransferPending: () => {} }); + cleanup(); + + const offEvents = socket.off.mock.calls.map(call => call[0]); + expect(offEvents).toEqual(['external_transfer_pending']); + }); +}); diff --git a/app/src/services/chatService.ts b/app/src/services/chatService.ts index 6fb128bb5c..f4677a8c8f 100644 --- a/app/src/services/chatService.ts +++ b/app/src/services/chatService.ts @@ -291,6 +291,71 @@ export interface ArtifactPendingEvent { path: string; } +/** + * Reason an external transfer is happening — the "because" clause of the + * disclosure. Mirrors the Rust `EgressReason` serialized (snake_case) on the + * S2 egress spine's `external_transfer_pending` event descriptor. Unknown + * future variants are tolerated by the UI (mapped to a generic fallback label). + */ +export type EgressReason = + | 'inference' + | 'tool_call' + | 'integration' + | 'embedding' + | 'network_fetch'; + +/** + * A category of user data leaving the device on an external transfer. Mirrors + * the Rust `EgressDataKind` (snake_case). Rendered as friendly labels — never + * the raw enum string. + */ +export type EgressDataKind = + | 'prompt' + | 'tool_arguments' + | 'embedding_input' + | 'file_content' + | 'url' + | 'metadata'; + +/** + * Risk grade the core assigned to the transfer. Always `"unknown"` today + * (the S2 spine does not classify yet); typed for forward-compatibility so the + * S3 disclosure UI can surface a badge once the core starts grading. + */ +export type EgressRiskLevel = 'unknown' | 'none' | 'low' | 'medium' | 'high'; + +/** + * Emitted by the S2 egress spine (`external_transfer_pending` socket event) + * immediately before an external transfer that is routed through a chat turn + * (the bridge only emits when BOTH `thread_id` and `client_id` are present — + * background egress never surfaces). The Rust side de-dupes per turn so a + * repeated destination fires once per turn. + * + * This slice is DISCLOSURE ONLY (epic #4256 AC1 / issue #4437 / S3) — the S4 + * approve/deny arm (#4438) is a separate follow-up. The descriptor is packed + * into the generic `args` field of the web-channel wire envelope, exactly like + * the artifact-lifecycle events; the `subscribeChatEvents` handler flattens it + * back into this typed shape. + */ +export interface ExternalTransferPendingEvent { + thread_id: string; + client_id?: string; + /** Provider identifier (e.g. `openai`, `composio`). Public, not PII. */ + provider_slug: string; + /** Human-facing destination service name (e.g. `OpenAI`, `Gmail`). */ + service: string; + /** Whether this transfer actually leaves the device (always true in practice). */ + is_external: boolean; + /** Why the transfer is happening — drives the "because …" clause. */ + reason: EgressReason; + /** Categories of user data being sent. */ + data_kinds: EgressDataKind[]; + /** Core-assigned risk grade. `"unknown"` today. */ + risk_level: EgressRiskLevel; + /** Named risk categories. Empty today. */ + risk_categories: string[]; +} + /** Emitted when the agent turn begins (before the first LLM call). */ export interface ChatInferenceStartEvent { thread_id: string; @@ -558,6 +623,7 @@ export interface ChatEventListeners { onArtifactPending?: (event: ArtifactPendingEvent) => void; onArtifactReady?: (event: ArtifactReadyEvent) => void; onArtifactFailed?: (event: ArtifactFailedEvent) => void; + onExternalTransferPending?: (event: ExternalTransferPendingEvent) => void; onDone?: (event: ChatDoneEvent) => void; onError?: (event: ChatErrorEvent) => void; } @@ -605,6 +671,7 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void { artifactPending: 'artifact_pending', artifactReady: 'artifact_ready', artifactFailed: 'artifact_failed', + externalTransferPending: 'external_transfer_pending', done: 'chat_done', error: 'chat_error', } as const; @@ -1142,6 +1209,79 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void { handlers.push([EVENTS.artifactFailed, cb]); } + // External-transfer disclosure (#4437 / S3). Same envelope shape as the + // artifact events — the S2 egress spine packs the `EgressDescriptor` into + // the generic `args` field. Flatten it back into the typed + // `ExternalTransferPendingEvent` after narrowing every field, so a + // malformed / partial payload can never reach the disclosure card. + const isStringArray = (v: unknown): v is string[] => + Array.isArray(v) && v.every(item => typeof item === 'string'); + const validRiskLevels: ReadonlySet = new Set([ + 'unknown', + 'none', + 'low', + 'medium', + 'high', + ]); + const readRiskLevel = (v: unknown): EgressRiskLevel => + typeof v === 'string' && validRiskLevels.has(v as EgressRiskLevel) + ? (v as EgressRiskLevel) + : 'unknown'; + + if (listeners.onExternalTransferPending) { + const cb = (payload: unknown) => { + const env = readEnvelope(payload); + if (!env) { + chatLog('%s — skipping malformed payload (bad envelope)', EVENTS.externalTransferPending); + return; + } + const { args } = env; + // `provider_slug`, `service` and `reason` are the load-bearing display + // fields; without them the card can't say what/where/why, so drop the + // event rather than render blanks. `data_kinds` may legitimately be empty + // for a metadata-only transfer, so only require it to be an array. + if ( + !isNonEmptyString(args.provider_slug) || + !isNonEmptyString(args.service) || + !isNonEmptyString(args.reason) || + !isStringArray(args.data_kinds) + ) { + chatLog( + '%s thread_id=%s — skipping malformed payload (bad args)', + EVENTS.externalTransferPending, + env.thread_id + ); + return; + } + const event: ExternalTransferPendingEvent = { + thread_id: env.thread_id, + client_id: env.client_id, + provider_slug: args.provider_slug, + service: args.service, + // Defaults to true — the spine only emits for genuinely-external + // transfers, but tolerate a missing/non-boolean flag. + is_external: typeof args.is_external === 'boolean' ? args.is_external : true, + reason: args.reason as EgressReason, + data_kinds: args.data_kinds as EgressDataKind[], + risk_level: readRiskLevel(args.risk_level), + risk_categories: isStringArray(args.risk_categories) ? args.risk_categories : [], + }; + chatLog( + '%s thread_id=%s provider=%s service=%s reason=%s kinds=%d external=%s', + EVENTS.externalTransferPending, + event.thread_id, + event.provider_slug, + event.service, + event.reason, + event.data_kinds.length, + event.is_external + ); + listeners.onExternalTransferPending?.(event); + }; + socket.on(EVENTS.externalTransferPending, cb); + handlers.push([EVENTS.externalTransferPending, cb]); + } + if (listeners.onTaskBoardUpdated) { const cb = (payload: unknown) => { const e = payload as ChatTaskBoardUpdatedEvent; diff --git a/app/src/store/__tests__/privacySlice.test.ts b/app/src/store/__tests__/privacySlice.test.ts new file mode 100644 index 0000000000..b62b397a7e --- /dev/null +++ b/app/src/store/__tests__/privacySlice.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExternalTransferPendingEvent } from '../../services/chatService'; +import { callCoreRpc } from '../../services/coreRpcClient'; +import privacyReducer, { + clearActiveExternalForThread, + clearDisclosuresForThread, + disclosureFromEvent, + dismissDisclosureForThread, + hydratePrivacyMode, + type PrivacyDisclosure, + pushDisclosureForThread, + setPrivacyMode, +} from '../privacySlice'; +import { resetUserScopedState } from '../resetActions'; + +vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); + +const EVENT: ExternalTransferPendingEvent = { + thread_id: 'thread-1', + provider_slug: 'openai', + service: 'OpenAI', + is_external: true, + reason: 'inference', + data_kinds: ['prompt'], + risk_level: 'unknown', + risk_categories: [], +}; + +function initial() { + return privacyReducer(undefined, { type: '@@INIT' }); +} + +describe('privacySlice — reducers', () => { + it('has the expected initial state', () => { + expect(initial()).toEqual({ + privacyMode: null, + disclosuresByThread: {}, + activeExternalByThread: {}, + }); + }); + + it('setPrivacyMode updates the mode', () => { + const state = privacyReducer(initial(), setPrivacyMode('local_only')); + expect(state.privacyMode).toBe('local_only'); + }); + + it('pushDisclosureForThread appends per thread', () => { + const d = disclosureFromEvent(EVENT); + const state = privacyReducer( + initial(), + pushDisclosureForThread({ threadId: 'thread-1', disclosure: d }) + ); + expect(state.disclosuresByThread['thread-1']).toHaveLength(1); + expect(state.disclosuresByThread['thread-1'][0]).toMatchObject({ + providerSlug: 'openai', + service: 'OpenAI', + reason: 'inference', + dataKinds: ['prompt'], + isExternal: true, + }); + }); + + it('caps the per-thread ledger at 20 entries (drops oldest)', () => { + let state = initial(); + let firstId = ''; + for (let i = 0; i < 25; i += 1) { + const d = disclosureFromEvent(EVENT); + if (i === 0) firstId = d.id; + state = privacyReducer(state, pushDisclosureForThread({ threadId: 't', disclosure: d })); + } + const list = state.disclosuresByThread['t']; + expect(list).toHaveLength(20); + // The very first (oldest) disclosure was evicted. + expect(list.some(entry => entry.id === firstId)).toBe(false); + }); + + it('dismissDisclosureForThread removes one entry by id', () => { + const a = disclosureFromEvent(EVENT); + const b = disclosureFromEvent(EVENT); + let state = privacyReducer( + initial(), + pushDisclosureForThread({ threadId: 'thread-1', disclosure: a }) + ); + state = privacyReducer(state, pushDisclosureForThread({ threadId: 'thread-1', disclosure: b })); + state = privacyReducer(state, dismissDisclosureForThread({ threadId: 'thread-1', id: a.id })); + expect(state.disclosuresByThread['thread-1']).toHaveLength(1); + expect(state.disclosuresByThread['thread-1'][0].id).toBe(b.id); + }); + + it('dismissing the last entry removes the thread key entirely', () => { + const a = disclosureFromEvent(EVENT); + let state = privacyReducer( + initial(), + pushDisclosureForThread({ threadId: 'thread-1', disclosure: a }) + ); + state = privacyReducer(state, dismissDisclosureForThread({ threadId: 'thread-1', id: a.id })); + expect(state.disclosuresByThread['thread-1']).toBeUndefined(); + }); + + it('clearDisclosuresForThread drops all disclosures for a thread', () => { + const a = disclosureFromEvent(EVENT); + let state = privacyReducer( + initial(), + pushDisclosureForThread({ threadId: 'thread-1', disclosure: a }) + ); + state = privacyReducer(state, clearDisclosuresForThread({ threadId: 'thread-1' })); + expect(state.disclosuresByThread['thread-1']).toBeUndefined(); + }); + + it('resetUserScopedState wipes disclosures, mode, and the active-external flags', () => { + let state = privacyReducer(initial(), setPrivacyMode('standard')); + state = privacyReducer( + state, + pushDisclosureForThread({ threadId: 't', disclosure: disclosureFromEvent(EVENT) }) + ); + expect(state.activeExternalByThread['t']).toBe(true); + state = privacyReducer(state, resetUserScopedState()); + expect(state).toEqual({ + privacyMode: null, + disclosuresByThread: {}, + activeExternalByThread: {}, + }); + }); +}); + +describe('privacySlice — active external-transfer flag (#4437 finding 1)', () => { + it('pushing an external disclosure marks the thread active-external', () => { + const state = privacyReducer( + initial(), + pushDisclosureForThread({ threadId: 'thread-1', disclosure: disclosureFromEvent(EVENT) }) + ); + expect(state.activeExternalByThread['thread-1']).toBe(true); + }); + + it('a non-external disclosure does NOT mark the thread active-external', () => { + const localEvent: ExternalTransferPendingEvent = { ...EVENT, is_external: false }; + const state = privacyReducer( + initial(), + pushDisclosureForThread({ threadId: 'thread-1', disclosure: disclosureFromEvent(localEvent) }) + ); + expect(state.activeExternalByThread['thread-1']).toBeUndefined(); + }); + + // Finding 1a: dismissing the card must NOT flip the pill off while the + // transfer is still active — the active flag survives dismissal. + it('dismissing the disclosure card leaves the active-external flag set', () => { + const d = disclosureFromEvent(EVENT); + let state = privacyReducer( + initial(), + pushDisclosureForThread({ threadId: 'thread-1', disclosure: d }) + ); + state = privacyReducer(state, dismissDisclosureForThread({ threadId: 'thread-1', id: d.id })); + // Ledger entry gone… + expect(state.disclosuresByThread['thread-1']).toBeUndefined(); + // …but the live transfer flag remains until the turn boundary clears it. + expect(state.activeExternalByThread['thread-1']).toBe(true); + }); + + // Finding 1b: the turn boundary clears the active flag even though the + // (un-dismissed) ledger entry is still there for the card's history. + it('clearActiveExternalForThread clears the flag but keeps the ledger', () => { + const d = disclosureFromEvent(EVENT); + let state = privacyReducer( + initial(), + pushDisclosureForThread({ threadId: 'thread-1', disclosure: d }) + ); + state = privacyReducer(state, clearActiveExternalForThread({ threadId: 'thread-1' })); + expect(state.activeExternalByThread['thread-1']).toBeUndefined(); + // The disclosure history is untouched — only the live flag was cleared. + expect(state.disclosuresByThread['thread-1']).toHaveLength(1); + }); + + it('clearActiveExternalForThread is a no-op for an unknown thread', () => { + const state = privacyReducer(initial(), clearActiveExternalForThread({ threadId: 'nope' })); + expect(state.activeExternalByThread).toEqual({}); + }); +}); + +describe('privacySlice — disclosureFromEvent', () => { + it('maps wire snake_case fields onto the disclosure and assigns unique ids', () => { + const a = disclosureFromEvent(EVENT); + const b = disclosureFromEvent(EVENT); + expect(a.id).not.toBe(b.id); + expect(a).toMatchObject({ + providerSlug: 'openai', + service: 'OpenAI', + isExternal: true, + reason: 'inference', + dataKinds: ['prompt'], + riskLevel: 'unknown', + riskCategories: [], + }); + expect(typeof a.receivedAt).toBe('number'); + }); +}); + +describe('privacySlice — hydratePrivacyMode thunk', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sets the mode from the double-wrapped RPC result on success', async () => { + vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { mode: 'sensitive' } } as never); + const action = await hydratePrivacyMode()(vi.fn(), vi.fn(), undefined); + expect(action.payload).toBe('sensitive'); + + const state = privacyReducer(initial(), action as never); + expect(state.privacyMode).toBe('sensitive'); + }); + + it('resolves to null (and leaves mode untouched) on RPC failure', async () => { + vi.mocked(callCoreRpc).mockRejectedValueOnce(new Error('core down')); + const action = await hydratePrivacyMode()(vi.fn(), vi.fn(), undefined); + expect(action.payload).toBeNull(); + + const seeded = privacyReducer(initial(), setPrivacyMode('standard')); + const state = privacyReducer(seeded, action as never); + // Null payload must not clobber an already-known mode. + expect(state.privacyMode).toBe('standard'); + }); +}); + +// Type guard: exported PrivacyDisclosure stays structurally stable. +const _typecheck: PrivacyDisclosure = disclosureFromEvent(EVENT); +void _typecheck; diff --git a/app/src/store/index.ts b/app/src/store/index.ts index b41171943a..2dcedf935a 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -32,6 +32,7 @@ import localeReducer from './localeSlice'; import mascotReducer from './mascotSlice'; import notificationReducer from './notificationSlice'; import personaReducer from './personaSlice'; +import privacyReducer from './privacySlice'; import providerSurfacesReducer from './providerSurfaceSlice'; import { pttReducer } from './pttSlice'; import socketReducer from './socketSlice'; @@ -237,6 +238,10 @@ export const store = configureStore({ locale: persistedLocaleReducer, mascot: persistedMascotReducer, persona: persistedPersonaReducer, + // Privacy disclosure surface (#4437 / S3). In-memory only: disclosures are + // ephemeral per-turn signals and the mode is re-hydrated from the core on + // boot, so nothing here should survive a restart. + privacy: privacyReducer, theme: persistedThemeReducer, ptt: persistedPttReducer, announcement: persistedAnnouncementReducer, diff --git a/app/src/store/privacySlice.ts b/app/src/store/privacySlice.ts new file mode 100644 index 0000000000..eb8f91e934 --- /dev/null +++ b/app/src/store/privacySlice.ts @@ -0,0 +1,227 @@ +import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import debug from 'debug'; + +import type { + EgressDataKind, + EgressReason, + EgressRiskLevel, + ExternalTransferPendingEvent, +} from '../services/chatService'; +import { callCoreRpc } from '../services/coreRpcClient'; +import { CORE_RPC_METHODS } from '../services/rpcMethods'; +import { resetUserScopedState } from './resetActions'; + +const privacyLog = debug('privacy:slice'); + +/** + * Privacy Mode values as serialized by the Rust core (snake_case). Mirrors the + * `PrivacyMode` type in {@link ../components/settings/panels/PrivacyModeSection}. + * The disclosure surface reads this to show the *current posture* alongside the + * per-action egress state — it does NOT own the setting (that stays in + * PrivacyModeSection); the slice is hydrated on boot and kept loosely in sync. + */ +export type PrivacyMode = 'local_only' | 'standard' | 'sensitive'; + +/** + * One external-transfer disclosure projected onto a thread (#4437 / S3). Built + * from an `external_transfer_pending` socket event. DISCLOSURE ONLY — there is + * no approve/deny decision here (that is S4 #4438); the only user action is + * dismissal. + */ +export interface PrivacyDisclosure { + /** Client-generated id — the dismissal handle and React key. */ + id: string; + /** Provider identifier (e.g. `openai`). Public, not PII. */ + providerSlug: string; + /** Human-facing destination service (e.g. `OpenAI`, `Gmail`). */ + service: string; + /** Whether the transfer leaves the device (always true in practice). */ + isExternal: boolean; + /** Why the transfer is happening. */ + reason: EgressReason; + /** Categories of user data being sent. */ + dataKinds: EgressDataKind[]; + /** Core-assigned risk grade. `"unknown"` today. */ + riskLevel: EgressRiskLevel; + /** Named risk categories. Empty today. */ + riskCategories: string[]; + /** When the disclosure was received, milliseconds since epoch. */ + receivedAt: number; +} + +interface PrivacyState { + /** + * Current data-egress posture. `null` until hydrated from the core (or if the + * RPC fails). Kept for the persistent status pill; not authoritative — the + * setting lives in the core and is edited via PrivacyModeSection. + */ + privacyMode: PrivacyMode | null; + /** + * Per-thread disclosure ledger, newest last. The disclosure card renders the + * most recent entry for the active thread; dismissal removes one entry by id. + * + * IMPORTANT: this ledger is user-DISMISSIBLE history for the in-chat card + * only. It MUST NOT drive the status pill's on/off-device sub-state — a + * dismissal removing the last entry would otherwise flip the pill to + * "on-device" while the transfer is still in flight, and an un-dismissed + * historical entry would keep it "off-device" during later purely-local + * turns. The pill reads {@link activeExternalByThread} instead. + */ + disclosuresByThread: Record; + /** + * Per-thread "an external transfer is active on the current turn" flag. Set + * true when an external disclosure is pushed, and CLEARED on the turn + * boundary (chat_done / chat_error / socket-disconnect reconcile) by + * ChatRuntimeProvider — the same turn-completion signals the approval/plan + * flows use. This is the SOLE source of truth for the pill's off-device + * state, kept deliberately separate from the dismissible ledger above so the + * pill reflects the live transfer, not the card's history. + */ + activeExternalByThread: Record; +} + +const initialState: PrivacyState = { + privacyMode: null, + disclosuresByThread: {}, + activeExternalByThread: {}, +}; + +/** Cap the per-thread ledger so a chatty turn can't grow it unbounded. */ +const MAX_DISCLOSURES_PER_THREAD = 20; + +let disclosureSeq = 0; + +/** + * Build a {@link PrivacyDisclosure} from a socket event. Exported so the store + * wiring in {@link ../providers/ChatRuntimeProvider} and unit tests share one + * mapping. `id` is a monotonically-increasing client id (stable within a + * session, never sent anywhere). + */ +export function disclosureFromEvent(event: ExternalTransferPendingEvent): PrivacyDisclosure { + disclosureSeq += 1; + return { + id: `disclosure-${disclosureSeq}`, + providerSlug: event.provider_slug, + service: event.service, + isExternal: event.is_external, + reason: event.reason, + dataKinds: event.data_kinds, + riskLevel: event.risk_level, + riskCategories: event.risk_categories, + receivedAt: Date.now(), + }; +} + +/** + * Hydrate the current Privacy Mode from the core on boot. Mirrors the RPC + * PrivacyModeSection uses (`config_get_privacy_mode`) whose result is the + * double-wrapped `{ result: { mode } }` shape. Failures resolve to `null` so + * the pill degrades gracefully rather than throwing. + */ +export const hydratePrivacyMode = createAsyncThunk( + 'privacy/hydratePrivacyMode', + async () => { + try { + const resp = await callCoreRpc<{ result: { mode: PrivacyMode } }>({ + method: CORE_RPC_METHODS.configGetPrivacyMode, + params: {}, + }); + privacyLog('[privacy] hydrated mode=%s', resp.result.mode); + return resp.result.mode; + } catch (err) { + privacyLog('[privacy] failed to hydrate privacy mode: %o', err); + return null; + } + } +); + +const privacySlice = createSlice({ + name: 'privacy', + initialState, + reducers: { + /** Set the current Privacy Mode (from boot hydration or a settings change). */ + setPrivacyMode: (state, action: PayloadAction) => { + privacyLog('[privacy] setPrivacyMode %s', action.payload); + state.privacyMode = action.payload; + }, + /** Append a disclosure for a thread, capping the ledger length. */ + pushDisclosureForThread: ( + state, + action: PayloadAction<{ threadId: string; disclosure: PrivacyDisclosure }> + ) => { + const { threadId, disclosure } = action.payload; + const list = (state.disclosuresByThread[threadId] ??= []); + list.push(disclosure); + if (list.length > MAX_DISCLOSURES_PER_THREAD) { + list.splice(0, list.length - MAX_DISCLOSURES_PER_THREAD); + } + // Mark the thread as having a live external transfer so the status pill + // flips off-device. This is independent of the dismissible ledger above: + // dismissing the card does NOT clear it (the transfer is still active) — + // only the turn-boundary `clearActiveExternalForThread` does. + if (disclosure.isExternal) { + state.activeExternalByThread[threadId] = true; + } + privacyLog( + '[privacy] pushDisclosureForThread thread=%s service=%s external=%s depth=%d', + threadId, + disclosure.service, + String(disclosure.isExternal), + list.length + ); + }, + /** Dismiss a single disclosure by id (the only user action in S3). */ + dismissDisclosureForThread: ( + state, + action: PayloadAction<{ threadId: string; id: string }> + ) => { + const { threadId, id } = action.payload; + const list = state.disclosuresByThread[threadId]; + if (!list) return; + const next = list.filter(d => d.id !== id); + if (next.length === 0) { + delete state.disclosuresByThread[threadId]; + } else { + state.disclosuresByThread[threadId] = next; + } + privacyLog('[privacy] dismissDisclosureForThread thread=%s id=%s', threadId, id); + }, + /** Clear all disclosures for a thread (e.g. on turn start / thread reset). */ + clearDisclosuresForThread: (state, action: PayloadAction<{ threadId: string }>) => { + delete state.disclosuresByThread[action.payload.threadId]; + privacyLog('[privacy] clearDisclosuresForThread thread=%s', action.payload.threadId); + }, + /** + * Clear the live external-transfer flag for a thread. Dispatched by + * ChatRuntimeProvider on the turn boundary (chat_done / chat_error / + * disconnect reconcile) so the pill returns to on-device once the turn's + * external activity is over — even though the (un-dismissed) ledger entries + * remain for the in-chat card's history. + */ + clearActiveExternalForThread: (state, action: PayloadAction<{ threadId: string }>) => { + if (state.activeExternalByThread[action.payload.threadId]) { + delete state.activeExternalByThread[action.payload.threadId]; + privacyLog('[privacy] clearActiveExternalForThread thread=%s', action.payload.threadId); + } + }, + }, + extraReducers: builder => { + // On identity flip / sign-out, drop per-user disclosure history. The + // privacy mode is a core-side setting re-hydrated on the next boot, so it + // is safe to reset here too. + builder.addCase(resetUserScopedState, () => initialState); + builder.addCase(hydratePrivacyMode.fulfilled, (state, action) => { + if (action.payload) state.privacyMode = action.payload; + }); + }, +}); + +export const { + setPrivacyMode, + pushDisclosureForThread, + dismissDisclosureForThread, + clearDisclosuresForThread, + clearActiveExternalForThread, +} = privacySlice.actions; + +export default privacySlice.reducer; diff --git a/app/src/test/test-utils.tsx b/app/src/test/test-utils.tsx index beda189643..391e0162a7 100644 --- a/app/src/test/test-utils.tsx +++ b/app/src/test/test-utils.tsx @@ -24,6 +24,7 @@ import localeReducer from '../store/localeSlice'; import mascotReducer from '../store/mascotSlice'; import notificationReducer from '../store/notificationSlice'; import personaReducer from '../store/personaSlice'; +import privacyReducer from '../store/privacySlice'; import { pttReducer } from '../store/pttSlice'; import socketReducer from '../store/socketSlice'; import themeReducer from '../store/themeSlice'; @@ -53,6 +54,7 @@ const testRootReducer = combineReducers({ mascot: mascotReducer, notifications: notificationReducer, persona: personaReducer, + privacy: privacyReducer, ptt: pttReducer, socket: socketReducer, theme: themeReducer,