From 66859e78a59b17fc75a6b60482ae71bb1232e744 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 13:04:08 +0530 Subject: [PATCH 01/17] feat(privacy): consume external_transfer_pending socket event (#4437) --- app/src/services/chatService.ts | 140 ++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) 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; From 23dac5320b753be6ff7be582332bd9cc81f38d67 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 13:04:09 +0530 Subject: [PATCH 02/17] feat(privacy): add privacy disclosure redux slice (#4437) --- app/src/store/index.ts | 5 + app/src/store/privacySlice.ts | 184 ++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 app/src/store/privacySlice.ts 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..876b1ffcfc --- /dev/null +++ b/app/src/store/privacySlice.ts @@ -0,0 +1,184 @@ +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. + */ + disclosuresByThread: Record; +} + +const initialState: PrivacyState = { privacyMode: null, disclosuresByThread: {} }; + +/** 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); + } + privacyLog( + '[privacy] pushDisclosureForThread thread=%s service=%s depth=%d', + threadId, + disclosure.service, + 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); + }, + }, + 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, +} = privacySlice.actions; + +export default privacySlice.reducer; From 3a21aff21287925a2594b6ac53fc998fcf1bba2d Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 13:04:22 +0530 Subject: [PATCH 03/17] feat(privacy): wire external-transfer disclosures + mode hydration (#4437) --- app/src/providers/ChatRuntimeProvider.tsx | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index e93b985077..696093fa49 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,11 @@ import { type WorkflowProposal, } from '../store/chatRuntimeSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + disclosureFromEvent, + hydratePrivacyMode, + pushDisclosureForThread, +} from '../store/privacySlice'; import { selectSocketStatus } from '../store/socketSelectors'; import { addInferenceResponse, @@ -356,6 +362,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 +1070,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, From 1a9b12a61ce7a69c5702662f19b32f783bf4b6f1 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 13:04:22 +0530 Subject: [PATCH 04/17] feat(privacy): add persistent privacy status pill (#4437) --- app/src/components/PrivacyStatusIndicator.tsx | 57 +++++++++++++++++++ .../components/layout/shell/AppSidebar.tsx | 5 +- app/src/features/privacy/disclosureLabels.ts | 55 ++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 app/src/components/PrivacyStatusIndicator.tsx create mode 100644 app/src/features/privacy/disclosureLabels.ts diff --git a/app/src/components/PrivacyStatusIndicator.tsx b/app/src/components/PrivacyStatusIndicator.tsx new file mode 100644 index 0000000000..f69bb81419 --- /dev/null +++ b/app/src/components/PrivacyStatusIndicator.tsx @@ -0,0 +1,57 @@ +import { privacyModeLabelKey } from '../features/privacy/disclosureLabels'; +import { useT } from '../lib/i18n/I18nContext'; +import { useAppSelector } from '../store/hooks'; + +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 `external_transfer_pending` event only fires on an EXTERNAL transfer, so + * "on-device" is inferred from the mode + the ABSENCE of a recent external + * disclosure for the active thread — never from a positive "local" signal. + * Renders nothing 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 disclosuresByThread = useAppSelector(state => state.privacy?.disclosuresByThread); + + if (!privacyMode) return null; + + // 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 event. + const hasExternalDisclosure = selectedThreadId + ? (disclosuresByThread?.[selectedThreadId]?.some(d => d.isExternal) ?? false) + : false; + const isExternal = privacyMode !== 'local_only' && hasExternalDisclosure; + + 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'; + + return ( +
+
+ + {modeLabel} + · + {stateLabel} + +
+ ); +}; + +export default PrivacyStatusIndicator; diff --git a/app/src/components/layout/shell/AppSidebar.tsx b/app/src/components/layout/shell/AppSidebar.tsx index c07b6e6ece..efa6667b92 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'; @@ -85,9 +86,11 @@ export default function AppSidebar() { {/* App-wide footer: connectivity status + build/version, pinned to the bottom of the sidebar. */} -
+
· + + · {t('settings.betaBuild').replace('{version}', APP_VERSION)} diff --git a/app/src/features/privacy/disclosureLabels.ts b/app/src/features/privacy/disclosureLabels.ts new file mode 100644 index 0000000000..af57a64044 --- /dev/null +++ b/app/src/features/privacy/disclosureLabels.ts @@ -0,0 +1,55 @@ +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 { + switch (mode) { + case 'local_only': + return 'privacy.mode.localOnly'; + case 'standard': + return 'privacy.mode.standard'; + case 'sensitive': + return 'privacy.mode.sensitive'; + } +} From 1fbea1e1885d4502a34de403361ce377968b0683 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 13:04:22 +0530 Subject: [PATCH 05/17] feat(privacy): add in-chat external-transfer disclosure card (#4437) --- .../chat/ExternalTransferDisclosureCard.tsx | 82 +++++++++++++++++++ .../features/conversations/Conversations.tsx | 35 ++++++++ 2 files changed, 117 insertions(+) create mode 100644 app/src/components/chat/ExternalTransferDisclosureCard.tsx 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/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 From 8b872375f543483553eac3c5cf15e3dede230de0 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 13:04:33 +0530 Subject: [PATCH 06/17] feat(privacy): add i18n copy for disclosure surface across all locales (#4437) --- app/src/lib/i18n/ar.ts | 23 +++++++++++++++++++++++ app/src/lib/i18n/bn.ts | 23 +++++++++++++++++++++++ app/src/lib/i18n/de.ts | 23 +++++++++++++++++++++++ app/src/lib/i18n/en.ts | 23 +++++++++++++++++++++++ app/src/lib/i18n/es.ts | 23 +++++++++++++++++++++++ app/src/lib/i18n/fr.ts | 23 +++++++++++++++++++++++ app/src/lib/i18n/hi.ts | 23 +++++++++++++++++++++++ app/src/lib/i18n/id.ts | 23 +++++++++++++++++++++++ app/src/lib/i18n/it.ts | 23 +++++++++++++++++++++++ app/src/lib/i18n/ko.ts | 23 +++++++++++++++++++++++ app/src/lib/i18n/pl.ts | 23 +++++++++++++++++++++++ app/src/lib/i18n/pt.ts | 23 +++++++++++++++++++++++ app/src/lib/i18n/ru.ts | 23 +++++++++++++++++++++++ app/src/lib/i18n/zh-CN.ts | 23 +++++++++++++++++++++++ 14 files changed, 322 insertions(+) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index a6eec0ddbe..b891e6a74d 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -6896,6 +6896,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 32e3b67d27..e69d7f412e 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7051,6 +7051,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 3f02e545fc..ad1b225af2 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7248,6 +7248,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': 'Wird extern gesendet', + '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 408e0f832a..808449cfe3 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1549,6 +1549,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': 'Sending externally', + '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 d8e85ffbbe..859df2e662 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7193,6 +7193,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': 'Enviando al exterior', + '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 0a9dff0dac..b98cb961c6 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7216,6 +7216,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': 'Envoi vers l’extérieur', + '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 d1fb34f466..9a99882fbd 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7049,6 +7049,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 bb6875e433..170e3580d6 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7078,6 +7078,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': 'Mengirim ke luar', + '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 07acaaab25..9161321eb8 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7180,6 +7180,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': 'Invio all’esterno', + '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': 'Divulgazione 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 elaborarlo', + 'privacy.disclosure.reason.toolCall': 'uno strumento ne ha bisogno', + 'privacy.disclosure.reason.integration': 'un’integrazione collegata ne ha bisogno', + 'privacy.disclosure.reason.embedding': 'deve essere indicizzato 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 809ddbf7ab..d489048527 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -6972,6 +6972,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': '이 작업은 {reason} {kinds}을(를) {destination}(으)로 보냅니다.', + '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 d86755829b..c08d270edd 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7155,6 +7155,29 @@ 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': 'Wysyłanie na zewnątrz', + 'privacy.status.local': 'Na urządzeniu', + 'privacy.disclosure.title': 'Opuszcza Twoje urządzenie', + 'privacy.disclosure.body': 'Spowoduje to wysłanie {kinds} do {destination}, ponieważ {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 je przetworzyć', + 'privacy.disclosure.reason.toolCall': 'potrzebuje go narzędzie', + 'privacy.disclosure.reason.integration': 'potrzebuje go połączona integracja', + 'privacy.disclosure.reason.embedding': 'musi zostać zindeksowane do wyszukiwania', + 'privacy.disclosure.reason.networkFetch': 'potrzebuje go żądanie sieciowe', + '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 4267c09616..ef569fd1b8 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7169,6 +7169,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': 'A enviar para o exterior', + '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 o processar', + 'privacy.disclosure.reason.toolCall': 'uma ferramenta precisa dele', + 'privacy.disclosure.reason.integration': 'uma integração ligada precisa dele', + 'privacy.disclosure.reason.embedding': 'precisa de ser indexado para pesquisa', + 'privacy.disclosure.reason.networkFetch': 'um pedido web precisa dele', + '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 5d9e084454..5d6a3ad9b3 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7129,6 +7129,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 606a73166f..3b02466519 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6671,6 +6671,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; From cc084e35ed029124c7ccb9caa76b1f0b5b35f27b Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 13:04:34 +0530 Subject: [PATCH 07/17] test(privacy): cover disclosure slice, socket handler, pill, card, labels (#4437) --- .../__tests__/PrivacyStatusIndicator.test.tsx | 80 +++++++ .../ExternalTransferDisclosureCard.test.tsx | 73 +++++++ .../__tests__/disclosureLabels.test.ts | 65 ++++++ .../chatService.externalTransfer.test.ts | 198 ++++++++++++++++++ app/src/store/__tests__/privacySlice.test.ts | 163 ++++++++++++++ app/src/test/test-utils.tsx | 2 + 6 files changed, 581 insertions(+) create mode 100644 app/src/components/__tests__/PrivacyStatusIndicator.test.tsx create mode 100644 app/src/components/chat/__tests__/ExternalTransferDisclosureCard.test.tsx create mode 100644 app/src/features/privacy/__tests__/disclosureLabels.test.ts create mode 100644 app/src/services/__tests__/chatService.externalTransfer.test.ts create mode 100644 app/src/store/__tests__/privacySlice.test.ts diff --git a/app/src/components/__tests__/PrivacyStatusIndicator.test.tsx b/app/src/components/__tests__/PrivacyStatusIndicator.test.tsx new file mode 100644 index 0000000000..42e00ff2a2 --- /dev/null +++ b/app/src/components/__tests__/PrivacyStatusIndicator.test.tsx @@ -0,0 +1,80 @@ +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: {} } }, + }); + expect(container.firstChild).toBeNull(); + }); + + it('shows the mode + on-device state when no external disclosure exists', () => { + renderWithProviders(, { + preloadedState: { + privacy: { privacyMode: 'standard', disclosuresByThread: {} }, + 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 external state when the active thread has an external disclosure', () => { + renderWithProviders(, { + preloadedState: { + privacy: { privacyMode: 'standard', disclosuresByThread: { 'thread-1': [disclosure()] } }, + thread: { selectedThreadId: 'thread-1' }, + }, + }); + const pill = screen.getByRole('status'); + expect(pill).toHaveTextContent('Sending externally'); + expect(pill).toHaveAttribute('title', 'Standard · Sending externally'); + }); + + it('always reads on-device in local-only mode, even with a stale external disclosure', () => { + renderWithProviders(, { + preloadedState: { + privacy: { privacyMode: 'local_only', disclosuresByThread: { 'thread-1': [disclosure()] } }, + thread: { selectedThreadId: 'thread-1' }, + }, + }); + const pill = screen.getByRole('status'); + expect(pill).toHaveTextContent('Local-only'); + expect(pill).toHaveTextContent('On-device'); + }); + + it('ignores an external disclosure that belongs to a different thread', () => { + renderWithProviders(, { + preloadedState: { + privacy: { + privacyMode: 'standard', + disclosuresByThread: { 'other-thread': [disclosure()] }, + }, + thread: { selectedThreadId: 'thread-1' }, + }, + }); + expect(screen.getByRole('status')).toHaveTextContent('On-device'); + }); +}); 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/features/privacy/__tests__/disclosureLabels.test.ts b/app/src/features/privacy/__tests__/disclosureLabels.test.ts new file mode 100644 index 0000000000..38e070c2c7 --- /dev/null +++ b/app/src/features/privacy/__tests__/disclosureLabels.test.ts @@ -0,0 +1,65 @@ +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', + ]); + }); +}); 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/store/__tests__/privacySlice.test.ts b/app/src/store/__tests__/privacySlice.test.ts new file mode 100644 index 0000000000..cf29df2b48 --- /dev/null +++ b/app/src/store/__tests__/privacySlice.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExternalTransferPendingEvent } from '../../services/chatService'; +import { callCoreRpc } from '../../services/coreRpcClient'; +import privacyReducer, { + 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: {} }); + }); + + 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 and mode', () => { + let state = privacyReducer(initial(), setPrivacyMode('standard')); + state = privacyReducer( + state, + pushDisclosureForThread({ threadId: 't', disclosure: disclosureFromEvent(EVENT) }) + ); + state = privacyReducer(state, resetUserScopedState()); + expect(state).toEqual({ privacyMode: null, disclosuresByThread: {} }); + }); +}); + +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/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, From 085723fc8ddd446cbcf163f0e33aa2aacabadc98 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 15:10:59 +0530 Subject: [PATCH 08/17] fix(privacy): drive status pill from live transfer state, not the dismissible ledger (#4437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill derived its on/off-device sub-state from disclosuresByThread, so dismissing the in-chat card (which removes the ledger entry) flipped the pill to on-device while the external transfer was still in flight, and an un-dismissed historical entry pinned it off-device during later purely-local turns. Track the live transfer separately in privacy.activeExternalByThread: set on an external disclosure, cleared on the turn boundary (chat_done / chat_error / socket-disconnect reconcile) by ChatRuntimeProvider — the same signals the approval/plan flows use. The ledger now feeds only the dismissible card's history. Also adds grep-friendly privacy:pill diagnostics for the hydration / local-only-override / thread-lookup / external-vs-local branches (booleans/status only, no payloads), and moves the pill's leading separator into the component so the sidebar footer never renders a dangling "Connection · · version" while the pill is un-hydrated. --- app/src/components/PrivacyStatusIndicator.tsx | 78 +++++++++++++------ .../components/layout/shell/AppSidebar.tsx | 7 +- app/src/providers/ChatRuntimeProvider.tsx | 12 +++ app/src/store/privacySlice.ts | 47 ++++++++++- 4 files changed, 118 insertions(+), 26 deletions(-) diff --git a/app/src/components/PrivacyStatusIndicator.tsx b/app/src/components/PrivacyStatusIndicator.tsx index f69bb81419..3a9dcb419c 100644 --- a/app/src/components/PrivacyStatusIndicator.tsx +++ b/app/src/components/PrivacyStatusIndicator.tsx @@ -1,7 +1,12 @@ +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; } @@ -12,45 +17,74 @@ interface PrivacyStatusIndicatorProps { * Privacy Mode plus whether the *active task* is staying on-device or sending * externally. * - * The `external_transfer_pending` event only fires on an EXTERNAL transfer, so - * "on-device" is inferred from the mode + the ABSENCE of a recent external - * disclosure for the active thread — never from a positive "local" signal. - * Renders nothing until the mode is hydrated so the pill is never misleading. + * 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 disclosuresByThread = useAppSelector(state => state.privacy?.disclosuresByThread); - - if (!privacyMode) return 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 event. - const hasExternalDisclosure = selectedThreadId - ? (disclosuresByThread?.[selectedThreadId]?.some(d => d.isExternal) ?? false) + // the active task is always on-device there regardless of any stale flag. + const hasActiveExternalTransfer = selectedThreadId + ? (activeExternalByThread?.[selectedThreadId] ?? false) : false; - const isExternal = privacyMode !== 'local_only' && hasExternalDisclosure; + 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. return ( -
-
- - {modeLabel} - · - {stateLabel} + <> + -
+
+
+ + {modeLabel} + · + {stateLabel} + +
+ ); }; diff --git a/app/src/components/layout/shell/AppSidebar.tsx b/app/src/components/layout/shell/AppSidebar.tsx index efa6667b92..1c8e9e3403 100644 --- a/app/src/components/layout/shell/AppSidebar.tsx +++ b/app/src/components/layout/shell/AppSidebar.tsx @@ -85,10 +85,13 @@ 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. */}
- · · diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index 696093fa49..d8c1a4c99d 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -68,6 +68,7 @@ import { } from '../store/chatRuntimeSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { + clearActiveExternalForThread, disclosureFromEvent, hydratePrivacyMode, pushDisclosureForThread, @@ -1220,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) { @@ -1372,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) { @@ -1459,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/store/privacySlice.ts b/app/src/store/privacySlice.ts index 876b1ffcfc..eb8f91e934 100644 --- a/app/src/store/privacySlice.ts +++ b/app/src/store/privacySlice.ts @@ -59,11 +59,32 @@ interface PrivacyState { /** * 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: {} }; +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; @@ -134,10 +155,18 @@ const privacySlice = createSlice({ 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 depth=%d', + '[privacy] pushDisclosureForThread thread=%s service=%s external=%s depth=%d', threadId, disclosure.service, + String(disclosure.isExternal), list.length ); }, @@ -162,6 +191,19 @@ const privacySlice = createSlice({ 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 @@ -179,6 +221,7 @@ export const { pushDisclosureForThread, dismissDisclosureForThread, clearDisclosuresForThread, + clearActiveExternalForThread, } = privacySlice.actions; export default privacySlice.reducer; From 2d446abf300db83c4a13f5cd3b19247199f11277 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 15:11:09 +0530 Subject: [PATCH 09/17] fix(privacy): sync privacy-mode changes from settings into the store (#4437) PrivacyModeSection updated only its local state after config_set_privacy_mode, so the persistent status pill kept showing the old mode until reload (the provider hydrates the slice only once on mount). Dispatch setPrivacyMode after a successful save so the pill updates live. --- .../components/settings/panels/PrivacyModeSection.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 ( From 5f8312a68ac69958088148984de88572bc4253cc Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 15:11:09 +0530 Subject: [PATCH 10/17] fix(privacy): add default fallback to privacyModeLabelKey (#4437) Unlike the sibling dataKind/reason mappers, privacyModeLabelKey had no default arm, so an unexpected/future mode string returned undefined and would break the always-visible pill via t(undefined). Fall back to the neutral standard label. --- app/src/features/privacy/disclosureLabels.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/features/privacy/disclosureLabels.ts b/app/src/features/privacy/disclosureLabels.ts index af57a64044..3b4926b621 100644 --- a/app/src/features/privacy/disclosureLabels.ts +++ b/app/src/features/privacy/disclosureLabels.ts @@ -43,7 +43,7 @@ export function reasonLabelKey(reason: EgressReason | string): string { } } -export function privacyModeLabelKey(mode: PrivacyMode): string { +export function privacyModeLabelKey(mode: PrivacyMode | string): string { switch (mode) { case 'local_only': return 'privacy.mode.localOnly'; @@ -51,5 +51,11 @@ export function privacyModeLabelKey(mode: PrivacyMode): string { 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'; } } From d26d6408ac6188bc358ffc64bb158582d37b0cc9 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 15:11:23 +0530 Subject: [PATCH 11/17] i18n(privacy): make disclosure copy state-parallel and agreement-safe (#4437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - privacy.status.external is a persistent STATE, not an in-progress action: make it parallel to the on-device label ("Off-device" vs "On-device") in en + all 14 locales (fixes zh-CN 正在外发, it/pl/pt action phrasings, etc.). - Plural/agreement safety: rephrase reason/{kinds} interpolations that assumed one value — it (elaborarlo/indicizzarlo), ru (masc-sing его), pt (dele/o), pl (genitive after wysłanie) — to number-neutral constructions; restructure the ko and pl bodies so no case particle/inflection attaches to {kinds}. - zh-CN: use the CJK enumeration separator 、 between data-kind labels. - it: reword the disclosure a11y label to name the disclosure itself. All keys stay present in every locale (coverage parity preserved). --- app/src/lib/i18n/ar.ts | 2 +- app/src/lib/i18n/bn.ts | 2 +- app/src/lib/i18n/de.ts | 2 +- app/src/lib/i18n/en.ts | 2 +- app/src/lib/i18n/es.ts | 2 +- app/src/lib/i18n/fr.ts | 2 +- app/src/lib/i18n/hi.ts | 2 +- app/src/lib/i18n/id.ts | 2 +- app/src/lib/i18n/it.ts | 8 ++++---- app/src/lib/i18n/ko.ts | 5 +++-- app/src/lib/i18n/pl.ts | 15 ++++++++------- app/src/lib/i18n/pt.ts | 12 ++++++------ app/src/lib/i18n/ru.ts | 6 +++--- app/src/lib/i18n/zh-CN.ts | 4 ++-- 14 files changed, 34 insertions(+), 32 deletions(-) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index b891e6a74d..62552acd9f 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -6899,7 +6899,7 @@ const messages: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'حالة الخصوصية', - 'privacy.status.external': 'جارٍ الإرسال خارجيًا', + 'privacy.status.external': 'خارج الجهاز', 'privacy.status.local': 'على الجهاز', 'privacy.disclosure.title': 'يغادر جهازك', 'privacy.disclosure.body': 'سيؤدي هذا إلى إرسال {kinds} إلى {destination} لأن {reason}.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index e69d7f412e..eae3198aa6 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -7054,7 +7054,7 @@ const messages: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'গোপনীয়তা স্থিতি', - 'privacy.status.external': 'বাইরে পাঠানো হচ্ছে', + 'privacy.status.external': 'ডিভাইসের বাইরে', 'privacy.status.local': 'ডিভাইসে', 'privacy.disclosure.title': 'আপনার ডিভাইস ছেড়ে যাচ্ছে', 'privacy.disclosure.body': 'এটি {kinds} কে {destination}-এ পাঠাবে কারণ {reason}।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index ad1b225af2..af3ed2cb8f 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7251,7 +7251,7 @@ const messages: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Datenschutzstatus', - 'privacy.status.external': 'Wird extern gesendet', + '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}.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 808449cfe3..30e997552a 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1551,7 +1551,7 @@ const en: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Privacy status', - 'privacy.status.external': 'Sending externally', + '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}.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 859df2e662..2de889c25d 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -7196,7 +7196,7 @@ const messages: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Estado de privacidad', - 'privacy.status.external': 'Enviando al exterior', + '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}.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index b98cb961c6..ebfd84c77f 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -7219,7 +7219,7 @@ const messages: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'État de confidentialité', - 'privacy.status.external': 'Envoi vers l’extérieur', + '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}.', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 9a99882fbd..0b38f0f4cf 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7052,7 +7052,7 @@ const messages: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'गोपनीयता स्थिति', - 'privacy.status.external': 'बाहर भेजा जा रहा है', + 'privacy.status.external': 'डिवाइस के बाहर', 'privacy.status.local': 'डिवाइस पर', 'privacy.disclosure.title': 'आपके डिवाइस से बाहर जा रहा है', 'privacy.disclosure.body': 'यह {kinds} को {destination} पर भेजेगा क्योंकि {reason}।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 170e3580d6..5ec7fe3d73 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -7081,7 +7081,7 @@ const messages: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Status privasi', - 'privacy.status.external': 'Mengirim ke luar', + '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}.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 9161321eb8..e331722df8 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -7183,12 +7183,12 @@ const messages: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Stato privacy', - 'privacy.status.external': 'Invio all’esterno', + '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': 'Divulgazione dati esterni', + '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', @@ -7197,10 +7197,10 @@ const messages: TranslationMap = { '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 elaborarlo', + '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': 'deve essere indicizzato per la ricerca', + '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', }; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index d489048527..44f12e7a48 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -6975,10 +6975,11 @@ const messages: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': '개인정보 상태', - 'privacy.status.external': '외부로 전송 중', + 'privacy.status.external': '기기 외', 'privacy.status.local': '기기 내', 'privacy.disclosure.title': '기기에서 나가는 중', - 'privacy.disclosure.body': '이 작업은 {reason} {kinds}을(를) {destination}(으)로 보냅니다.', + 'privacy.disclosure.body': + '이 작업은 {destination}(으)로 다음 데이터를 보냅니다: {kinds}. ({reason})', 'privacy.disclosure.dismiss': '확인', 'privacy.disclosure.ariaLabel': '외부 데이터 공개', 'privacy.disclosure.kindSeparator': ', ', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index c08d270edd..589db29314 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -7158,10 +7158,11 @@ const messages: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Stan prywatności', - 'privacy.status.external': 'Wysyłanie na zewnątrz', + '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 {kinds} do {destination}, ponieważ {reason}.', + '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': ', ', @@ -7172,11 +7173,11 @@ const messages: TranslationMap = { 'privacy.disclosure.kind.url': 'adres internetowy', 'privacy.disclosure.kind.metadata': 'metadane żądania', 'privacy.disclosure.kind.unknown': 'dane', - 'privacy.disclosure.reason.inference': 'model AI musi je przetworzyć', - 'privacy.disclosure.reason.toolCall': 'potrzebuje go narzędzie', - 'privacy.disclosure.reason.integration': 'potrzebuje go połączona integracja', - 'privacy.disclosure.reason.embedding': 'musi zostać zindeksowane do wyszukiwania', - 'privacy.disclosure.reason.networkFetch': 'potrzebuje go żądanie sieciowe', + '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', }; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index ef569fd1b8..75d4626c37 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -7172,7 +7172,7 @@ const messages: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Estado de privacidade', - 'privacy.status.external': 'A enviar para o exterior', + '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}.', @@ -7186,11 +7186,11 @@ const messages: TranslationMap = { '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 o processar', - 'privacy.disclosure.reason.toolCall': 'uma ferramenta precisa dele', - 'privacy.disclosure.reason.integration': 'uma integração ligada precisa dele', - 'privacy.disclosure.reason.embedding': 'precisa de ser indexado para pesquisa', - 'privacy.disclosure.reason.networkFetch': 'um pedido web precisa dele', + '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', }; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 5d6a3ad9b3..969b02a6c4 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -7132,7 +7132,7 @@ const messages: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': 'Состояние конфиденциальности', - 'privacy.status.external': 'Отправка вовне', + 'privacy.status.external': 'Вне устройства', 'privacy.status.local': 'На устройстве', 'privacy.disclosure.title': 'Покидает ваше устройство', 'privacy.disclosure.body': 'Это отправит {kinds} на {destination}, потому что {reason}.', @@ -7146,10 +7146,10 @@ const messages: TranslationMap = { 'privacy.disclosure.kind.url': 'веб-адрес', 'privacy.disclosure.kind.metadata': 'метаданные запроса', 'privacy.disclosure.kind.unknown': 'данные', - 'privacy.disclosure.reason.inference': 'модели ИИ нужно его обработать', + 'privacy.disclosure.reason.inference': 'модели ИИ нужно обработать эти данные', 'privacy.disclosure.reason.toolCall': 'инструменту это нужно', 'privacy.disclosure.reason.integration': 'подключённой интеграции это нужно', - 'privacy.disclosure.reason.embedding': 'его нужно проиндексировать для поиска', + 'privacy.disclosure.reason.embedding': 'эти данные нужно проиндексировать для поиска', 'privacy.disclosure.reason.networkFetch': 'веб-запросу это нужно', 'privacy.disclosure.reason.unknown': 'это требуется для данного действия', }; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 3b02466519..26f5ad6479 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -6674,13 +6674,13 @@ const messages: TranslationMap = { // Privacy status pill + per-action egress disclosure (#4437 / S3) 'privacy.status.ariaLabel': '隐私状态', - 'privacy.status.external': '正在外发', + '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.kindSeparator': '、', 'privacy.disclosure.kind.prompt': '你的消息', 'privacy.disclosure.kind.toolArguments': '工具输入', 'privacy.disclosure.kind.embeddingInput': '待索引的文本', From 5ea6a51c1cd93d235f072377828a93422ab2e49c Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 15:11:32 +0530 Subject: [PATCH 12/17] test(privacy): cover active-transfer state, settings sync, and label fallback (#4437) - Slice: external disclosure sets the active flag, non-external does not, dismissal does NOT clear it (finding 1a), and the turn-boundary clear drops the flag while keeping the ledger (finding 1b). - Pill: reads off-device from the live flag even with an empty ledger, and stays on-device with a stale ledger entry once the flag is cleared. - PrivacyModeSection: wrap in the store Provider (component now dispatches) and assert the new mode lands in the privacy slice on save (finding 2). - disclosureLabels: assert the new unknown-mode fallback (finding 5). --- .../__tests__/PrivacyStatusIndicator.test.tsx | 68 +++++++++++++++--- .../panels/PrivacyModeSection.test.tsx | 29 ++++++-- .../__tests__/disclosureLabels.test.ts | 6 ++ app/src/store/__tests__/privacySlice.test.ts | 69 ++++++++++++++++++- 4 files changed, 153 insertions(+), 19 deletions(-) diff --git a/app/src/components/__tests__/PrivacyStatusIndicator.test.tsx b/app/src/components/__tests__/PrivacyStatusIndicator.test.tsx index 42e00ff2a2..fa80b77f05 100644 --- a/app/src/components/__tests__/PrivacyStatusIndicator.test.tsx +++ b/app/src/components/__tests__/PrivacyStatusIndicator.test.tsx @@ -23,15 +23,17 @@ function disclosure(over?: Partial): PrivacyDisclosure { describe('PrivacyStatusIndicator (#4437 / S3)', () => { it('renders nothing until the privacy mode is hydrated', () => { const { container } = renderWithProviders(, { - preloadedState: { privacy: { privacyMode: null, disclosuresByThread: {} } }, + preloadedState: { + privacy: { privacyMode: null, disclosuresByThread: {}, activeExternalByThread: {} }, + }, }); expect(container.firstChild).toBeNull(); }); - it('shows the mode + on-device state when no external disclosure exists', () => { + it('shows the mode + on-device state when no external transfer is active', () => { renderWithProviders(, { preloadedState: { - privacy: { privacyMode: 'standard', disclosuresByThread: {} }, + privacy: { privacyMode: 'standard', disclosuresByThread: {}, activeExternalByThread: {} }, thread: { selectedThreadId: 'thread-1' }, }, }); @@ -41,22 +43,30 @@ describe('PrivacyStatusIndicator (#4437 / S3)', () => { expect(pill).toHaveAttribute('title', 'Standard · On-device'); }); - it('shows the external state when the active thread has an external disclosure', () => { + it('shows the off-device state when the active thread has a live external transfer', () => { renderWithProviders(, { preloadedState: { - privacy: { privacyMode: 'standard', disclosuresByThread: { 'thread-1': [disclosure()] } }, + privacy: { + privacyMode: 'standard', + disclosuresByThread: {}, + activeExternalByThread: { 'thread-1': true }, + }, thread: { selectedThreadId: 'thread-1' }, }, }); const pill = screen.getByRole('status'); - expect(pill).toHaveTextContent('Sending externally'); - expect(pill).toHaveAttribute('title', 'Standard · Sending externally'); + expect(pill).toHaveTextContent('Off-device'); + expect(pill).toHaveAttribute('title', 'Standard · Off-device'); }); - it('always reads on-device in local-only mode, even with a stale external disclosure', () => { + it('always reads on-device in local-only mode, even with a live external flag', () => { renderWithProviders(, { preloadedState: { - privacy: { privacyMode: 'local_only', disclosuresByThread: { 'thread-1': [disclosure()] } }, + privacy: { + privacyMode: 'local_only', + disclosuresByThread: {}, + activeExternalByThread: { 'thread-1': true }, + }, thread: { selectedThreadId: 'thread-1' }, }, }); @@ -65,12 +75,48 @@ describe('PrivacyStatusIndicator (#4437 / S3)', () => { expect(pill).toHaveTextContent('On-device'); }); - it('ignores an external disclosure that belongs to a different thread', () => { + 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: { 'other-thread': [disclosure()] }, + disclosuresByThread: { 'thread-1': [disclosure()] }, + activeExternalByThread: {}, }, thread: { selectedThreadId: 'thread-1' }, }, 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/features/privacy/__tests__/disclosureLabels.test.ts b/app/src/features/privacy/__tests__/disclosureLabels.test.ts index 38e070c2c7..5d452496c4 100644 --- a/app/src/features/privacy/__tests__/disclosureLabels.test.ts +++ b/app/src/features/privacy/__tests__/disclosureLabels.test.ts @@ -62,4 +62,10 @@ describe('disclosureLabels', () => { '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/store/__tests__/privacySlice.test.ts b/app/src/store/__tests__/privacySlice.test.ts index cf29df2b48..b62b397a7e 100644 --- a/app/src/store/__tests__/privacySlice.test.ts +++ b/app/src/store/__tests__/privacySlice.test.ts @@ -3,6 +3,7 @@ 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, @@ -32,7 +33,11 @@ function initial() { describe('privacySlice — reducers', () => { it('has the expected initial state', () => { - expect(initial()).toEqual({ privacyMode: null, disclosuresByThread: {} }); + expect(initial()).toEqual({ + privacyMode: null, + disclosuresByThread: {}, + activeExternalByThread: {}, + }); }); it('setPrivacyMode updates the mode', () => { @@ -103,14 +108,72 @@ describe('privacySlice — reducers', () => { expect(state.disclosuresByThread['thread-1']).toBeUndefined(); }); - it('resetUserScopedState wipes disclosures and mode', () => { + 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: {} }); + 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({}); }); }); From ae400ab4db48718d078c38fee28683b02b5804bf Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 18:48:13 +0530 Subject: [PATCH 13/17] fix(privacy): keep pill separator grouped with chip to prevent wrap-split (#4437) --- app/src/components/PrivacyStatusIndicator.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/components/PrivacyStatusIndicator.tsx b/app/src/components/PrivacyStatusIndicator.tsx index 3a9dcb419c..e7b2b586ae 100644 --- a/app/src/components/PrivacyStatusIndicator.tsx +++ b/app/src/components/PrivacyStatusIndicator.tsx @@ -67,8 +67,11 @@ const PrivacyStatusIndicator = ({ className = '' }: PrivacyStatusIndicatorProps) // 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 ( - <> + @@ -84,7 +87,7 @@ const PrivacyStatusIndicator = ({ className = '' }: PrivacyStatusIndicatorProps) {stateLabel}
- + ); }; From 3e3b652c714d4e08fa2dc511e60b91012bbe0ea3 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 18:48:13 +0530 Subject: [PATCH 14/17] =?UTF-8?q?i18n(privacy):=20use=20idiomatic=20Hindi?= =?UTF-8?q?=20indexing=20term=20=E0=A4=85=E0=A4=A8=E0=A5=81=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=AE=E0=A4=BF=E0=A4=A4=20(#4437)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/lib/i18n/hi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 0b38f0f4cf..47d26c64ba 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7061,7 +7061,7 @@ const messages: TranslationMap = { 'privacy.disclosure.kindSeparator': ', ', 'privacy.disclosure.kind.prompt': 'आपका संदेश', 'privacy.disclosure.kind.toolArguments': 'टूल इनपुट', - 'privacy.disclosure.kind.embeddingInput': 'अनुक्रमणित करने के लिए पाठ', + 'privacy.disclosure.kind.embeddingInput': 'अनुक्रमित करने के लिए पाठ', 'privacy.disclosure.kind.fileContent': 'फ़ाइल सामग्री', 'privacy.disclosure.kind.url': 'एक वेब पता', 'privacy.disclosure.kind.metadata': 'अनुरोध मेटाडेटा', From 7fc5f8d0ebb4cf0672375760ee0097fc89652e77 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Tue, 14 Jul 2026 18:52:12 +0530 Subject: [PATCH 15/17] test(privacy): cover disclosure-surfacing branch in Conversations (#4437) --- .../__tests__/Conversations.render.test.tsx | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index 285a2e399f..a6a8b23635 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,96 @@ 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' }); + mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 }); + await act(async () => { + 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' })] }, + }, + }); + }); + + 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(); + }); +}); From b0cbf431baab7b3b279a7ca132d41fc9464e5b12 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 14 Jul 2026 19:59:47 +0530 Subject: [PATCH 16/17] fix(privacy): expose dynamic privacy state in the status pill accessible name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PrivacyStatusIndicator status pill exposed only the static `privacy.status.ariaLabel` ("Privacy status") to assistive tech; the live mode/transfer state (`modeLabel · stateLabel`) was in `title` (sighted-only) but not the accessible name. Fold it into `aria-label` so screen readers announce e.g. "Privacy status: Standard · On device". Addresses CodeRabbit review (PrivacyStatusIndicator accessible-name). --- app/src/components/PrivacyStatusIndicator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/PrivacyStatusIndicator.tsx b/app/src/components/PrivacyStatusIndicator.tsx index e7b2b586ae..e96817968e 100644 --- a/app/src/components/PrivacyStatusIndicator.tsx +++ b/app/src/components/PrivacyStatusIndicator.tsx @@ -78,7 +78,7 @@ const PrivacyStatusIndicator = ({ className = '' }: PrivacyStatusIndicatorProps)
From 4e7cf9516e1ff9ce86fedcacb33a9d8d88a5697f Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 14 Jul 2026 21:26:51 +0530 Subject: [PATCH 17/17] review: Arabic comma for kindSeparator + isolate disclosure fallback test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit CHANGES_REQUESTED on PR #4849: - app/src/lib/i18n/ar.ts: privacy.disclosure.kindSeparator now uses the Arabic comma U+060C ('، ') instead of the Latin ', '; the separator is interpolated into an otherwise fully-Arabic sentence, so the Latin comma read as incorrect punctuation. - app/src/pages/__tests__/Conversations.render.test.tsx: the 'falls back to firstActiveThreadId' test kept thread loading resolved, so the sole thread was auto-selected and the assertion passed through the normal selected-thread path. Keep loadThreads pending so auto-select can't fire and assert selectedThreadId stays null, isolating the selectedThreadId ?? firstActiveThreadId fallback branch. --- app/src/lib/i18n/ar.ts | 2 +- .../pages/__tests__/Conversations.render.test.tsx | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 92947d3220..0d3de8b358 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -7076,7 +7076,7 @@ const messages: TranslationMap = { 'privacy.disclosure.body': 'سيؤدي هذا إلى إرسال {kinds} إلى {destination} لأن {reason}.', 'privacy.disclosure.dismiss': 'حسنًا', 'privacy.disclosure.ariaLabel': 'إفصاح عن البيانات الخارجية', - 'privacy.disclosure.kindSeparator': ', ', + 'privacy.disclosure.kindSeparator': '، ', 'privacy.disclosure.kind.prompt': 'رسالتك', 'privacy.disclosure.kind.toolArguments': 'مدخلات الأداة', 'privacy.disclosure.kind.embeddingInput': 'نص للفهرسة', diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index a6a8b23635..62428a0167 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -2771,9 +2771,15 @@ describe('Conversations — external-transfer disclosure surface (#4437 / S3)', it('falls back to firstActiveThreadId when no thread is selected', async () => { const thread = makeThread({ id: 't-act' }); - mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 }); + // 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 () => { - await renderConversations({ + store = await renderConversations({ // No selectedThreadId — only an active thread present. thread: { ...emptyThreadState, threads: [thread], activeThreadIds: { 't-act': true } }, socket: socketState('connected'), @@ -2784,6 +2790,9 @@ describe('Conversations — external-transfer disclosure surface (#4437 / S3)', }); }); + // 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'); });