Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
66859e7
feat(privacy): consume external_transfer_pending socket event (#4437)
oxoxDev Jul 14, 2026
23dac53
feat(privacy): add privacy disclosure redux slice (#4437)
oxoxDev Jul 14, 2026
3a21aff
feat(privacy): wire external-transfer disclosures + mode hydration (#…
oxoxDev Jul 14, 2026
1a9b12a
feat(privacy): add persistent privacy status pill (#4437)
oxoxDev Jul 14, 2026
1fbea1e
feat(privacy): add in-chat external-transfer disclosure card (#4437)
oxoxDev Jul 14, 2026
8b87237
feat(privacy): add i18n copy for disclosure surface across all locale…
oxoxDev Jul 14, 2026
cc084e3
test(privacy): cover disclosure slice, socket handler, pill, card, la…
oxoxDev Jul 14, 2026
085723f
fix(privacy): drive status pill from live transfer state, not the dis…
oxoxDev Jul 14, 2026
2d446ab
fix(privacy): sync privacy-mode changes from settings into the store …
oxoxDev Jul 14, 2026
5f8312a
fix(privacy): add default fallback to privacyModeLabelKey (#4437)
oxoxDev Jul 14, 2026
d26d640
i18n(privacy): make disclosure copy state-parallel and agreement-safe…
oxoxDev Jul 14, 2026
5ea6a51
test(privacy): cover active-transfer state, settings sync, and label …
oxoxDev Jul 14, 2026
ae400ab
fix(privacy): keep pill separator grouped with chip to prevent wrap-s…
oxoxDev Jul 14, 2026
3e3b652
i18n(privacy): use idiomatic Hindi indexing term अनुक्रमित (#4437)
oxoxDev Jul 14, 2026
7fc5f8d
test(privacy): cover disclosure-surfacing branch in Conversations (#4…
oxoxDev Jul 14, 2026
e03aa17
Merge branch 'main' into pr/4849
M3gA-Mind Jul 14, 2026
b0cbf43
fix(privacy): expose dynamic privacy state in the status pill accessi…
M3gA-Mind Jul 14, 2026
5666512
Merge branch 'main' into pr/4849
M3gA-Mind Jul 14, 2026
4e7cf95
review: Arabic comma for kindSeparator + isolate disclosure fallback …
M3gA-Mind Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions app/src/components/PrivacyStatusIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import debug from 'debug';
import { useEffect } from 'react';

import { privacyModeLabelKey } from '../features/privacy/disclosureLabels';
import { useT } from '../lib/i18n/I18nContext';
import { useAppSelector } from '../store/hooks';

const pillLog = debug('privacy:pill');

interface PrivacyStatusIndicatorProps {
className?: string;
}

/**
* Persistent privacy-status pill (#4437 / S3). Mirrors {@link ConnectionIndicator}
* — an inline-flex chip with a coloured dot + tiny label. Shows the current
* Privacy Mode plus whether the *active task* is staying on-device or sending
* externally.
*
* The off-device sub-state is driven by `privacy.activeExternalByThread` — a
* flag set when an `external_transfer_pending` disclosure arrives and cleared
* on the turn boundary by ChatRuntimeProvider. It is deliberately NOT derived
* from the dismissible disclosure ledger: reading the ledger let a "Got it"
* dismissal flip the pill on-device mid-transfer, and let a stale historical
* entry pin it off-device during later local turns. "On-device" is therefore
* the ABSENCE of a live external transfer, never a positive "local" signal.
* Renders nothing (a self-nulling leading separator + chip) until the mode is
* hydrated so the pill is never misleading.
*/
const PrivacyStatusIndicator = ({ className = '' }: PrivacyStatusIndicatorProps) => {
const { t } = useT();
// Optional-chain the `privacy` slice — narrow test stores may omit it.
const privacyMode = useAppSelector(state => state.privacy?.privacyMode ?? null);
const selectedThreadId = useAppSelector(state => state.thread?.selectedThreadId ?? null);
const activeExternalByThread = useAppSelector(state => state.privacy?.activeExternalByThread);

// Local-only mode blocks external model calls (enforced core-side by S7), so
// the active task is always on-device there regardless of any stale flag.
const hasActiveExternalTransfer = selectedThreadId
? (activeExternalByThread?.[selectedThreadId] ?? false)
: false;
const localOnlyOverride = privacyMode === 'local_only';
const isExternal = !localOnlyOverride && hasActiveExternalTransfer;

// Diagnostics for the privacy-state flow (grep `privacy:pill`). Log only the
// derived booleans/status transitions — never provider payloads, disclosure
// contents, or user PII. Fires on transitions of the resolved state.
useEffect(() => {
pillLog(
'[privacy:pill] derive hydrated=%s mode=%s hasThread=%s localOnlyOverride=%s activeExternal=%s isExternal=%s',
String(privacyMode != null),
privacyMode ?? 'none',
String(selectedThreadId != null),
String(localOnlyOverride),
String(hasActiveExternalTransfer),
String(isExternal)
);
}, [privacyMode, selectedThreadId, localOnlyOverride, hasActiveExternalTransfer, isExternal]);

if (!privacyMode) return null;

const modeLabel = t(privacyModeLabelKey(privacyMode));
const stateLabel = isExternal ? t('privacy.status.external') : t('privacy.status.local');
const dotColor = isExternal ? 'bg-amber-500' : 'bg-sage-500';
const textColor = isExternal ? 'text-amber-500' : 'text-sage-500';

// The leading separator travels WITH the pill so the sidebar footer never
// renders a dangling `· ·` while the pill is un-hydrated (returns null). See
// AppSidebar — the version item owns the separator that follows the pill.
// Separator + chip are grouped in one inline-flex item so the footer's
// flex-wrap can never split the leading `·` onto its own line — they wrap
// together or not at all.
return (
<span className="inline-flex items-center gap-2">
<span aria-hidden="true" className="text-[10px] text-content-faint">
&middot;
</span>
<div
className={`inline-flex items-center gap-1.5 ${className}`}
role="status"
aria-label={`${t('privacy.status.ariaLabel')}: ${modeLabel} · ${stateLabel}`}
title={`${modeLabel} · ${stateLabel}`}>
<div className={`h-2 w-2 rounded-full ${dotColor} ${isExternal ? 'animate-pulse' : ''}`} />
<span className={`text-[10px] font-medium ${textColor}`}>
{modeLabel}
<span className="text-content-faint"> · </span>
{stateLabel}
</span>
</div>
</span>
);
};

export default PrivacyStatusIndicator;
126 changes: 126 additions & 0 deletions app/src/components/__tests__/PrivacyStatusIndicator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import type { PrivacyDisclosure } from '../../store/privacySlice';
import { renderWithProviders } from '../../test/test-utils';
import PrivacyStatusIndicator from '../PrivacyStatusIndicator';

function disclosure(over?: Partial<PrivacyDisclosure>): 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(<PrivacyStatusIndicator />, {
preloadedState: {
privacy: { privacyMode: null, disclosuresByThread: {}, activeExternalByThread: {} },
},
});
expect(container.firstChild).toBeNull();
});

it('shows the mode + on-device state when no external transfer is active', () => {
renderWithProviders(<PrivacyStatusIndicator />, {
preloadedState: {
privacy: { privacyMode: 'standard', disclosuresByThread: {}, activeExternalByThread: {} },
thread: { selectedThreadId: 'thread-1' },
},
});
const pill = screen.getByRole('status');
expect(pill).toHaveTextContent('Standard');
expect(pill).toHaveTextContent('On-device');
expect(pill).toHaveAttribute('title', 'Standard · On-device');
});

it('shows the off-device state when the active thread has a live external transfer', () => {
renderWithProviders(<PrivacyStatusIndicator />, {
preloadedState: {
privacy: {
privacyMode: 'standard',
disclosuresByThread: {},
activeExternalByThread: { 'thread-1': true },
},
thread: { selectedThreadId: 'thread-1' },
},
});
const pill = screen.getByRole('status');
expect(pill).toHaveTextContent('Off-device');
expect(pill).toHaveAttribute('title', 'Standard · Off-device');
});

it('always reads on-device in local-only mode, even with a live external flag', () => {
renderWithProviders(<PrivacyStatusIndicator />, {
preloadedState: {
privacy: {
privacyMode: 'local_only',
disclosuresByThread: {},
activeExternalByThread: { 'thread-1': true },
},
thread: { selectedThreadId: 'thread-1' },
},
});
const pill = screen.getByRole('status');
expect(pill).toHaveTextContent('Local-only');
expect(pill).toHaveTextContent('On-device');
});

it('ignores a live external transfer that belongs to a different thread', () => {
renderWithProviders(<PrivacyStatusIndicator />, {
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(<PrivacyStatusIndicator />, {
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(<PrivacyStatusIndicator />, {
preloadedState: {
privacy: {
privacyMode: 'standard',
disclosuresByThread: { 'thread-1': [disclosure()] },
activeExternalByThread: {},
},
thread: { selectedThreadId: 'thread-1' },
},
});
expect(screen.getByRole('status')).toHaveTextContent('On-device');
});
});
82 changes: 82 additions & 0 deletions app/src/components/chat/ExternalTransferDisclosureCard.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ 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 (
<div
role="status"
aria-label={t('privacy.disclosure.ariaLabel')}
className="rounded-xl border border-primary-200 bg-primary-50 p-3 text-sm shadow-sm dark:border-primary-800 dark:bg-primary-950">
<div className="flex items-start gap-2">
<span aria-hidden className="text-base leading-none text-primary-700 dark:text-primary-200">
🛜
</span>
<div className="min-w-0 flex-1">
<p className="font-semibold text-primary-900 dark:text-primary-100">
{t('privacy.disclosure.title')}
</p>
<p className="mt-1 break-words text-primary-800/90 dark:text-primary-200/90">{body}</p>

<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
variant="secondary"
size="sm"
data-analytics-id="privacy-disclosure-dismiss"
onClick={onDismiss}>
{t('privacy.disclosure.dismiss')}
</Button>
</div>
</div>
</div>
</div>
);
};

export default ExternalTransferDisclosureCard;
Original file line number Diff line number Diff line change
@@ -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>): 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(
<ExternalTransferDisclosureCard threadId="thread-1" disclosure={d} />,
{
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();
});
});
Loading
Loading