+ 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,