mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
Co-authored-by: M3gA-Mind <elvin@tinyhumans.ai>
This commit is contained in:
@@ -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">
|
||||
·
|
||||
</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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { APP_VERSION } from '../../../utils/config';
|
||||
import ConnectionIndicator from '../../ConnectionIndicator';
|
||||
import PrivacyStatusIndicator from '../../PrivacyStatusIndicator';
|
||||
import { NavIcon } from './navIcons';
|
||||
import SidebarAppRail from './SidebarAppRail';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
@@ -84,9 +85,14 @@ export default function AppSidebar() {
|
||||
<span className="min-w-0 truncate">{t('nav.feedback')}</span>
|
||||
</button>
|
||||
{/* App-wide footer: connectivity status + build/version, pinned to the
|
||||
bottom of the sidebar. */}
|
||||
<div className="flex flex-shrink-0 items-center justify-center gap-2 border-t border-line px-2 py-0.5">
|
||||
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. */}
|
||||
<div className="flex flex-shrink-0 flex-wrap items-center justify-center gap-x-2 gap-y-0.5 border-t border-line px-2 py-0.5">
|
||||
<ConnectionIndicator />
|
||||
<PrivacyStatusIndicator />
|
||||
·
|
||||
<span className="text-[10px] text-content-faint">
|
||||
{t('settings.betaBuild').replace('{version}', APP_VERSION)}
|
||||
|
||||
@@ -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(<PrivacyModeSection />);
|
||||
renderWithProviders(<PrivacyModeSection />);
|
||||
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(<PrivacyModeSection />);
|
||||
renderWithProviders(<PrivacyModeSection />);
|
||||
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(<PrivacyModeSection />);
|
||||
renderWithProviders(<PrivacyModeSection />);
|
||||
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(<PrivacyModeSection />);
|
||||
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(<PrivacyModeSection />);
|
||||
renderWithProviders(<PrivacyModeSection />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('privacy-mode-option-standard')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
|
||||
@@ -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<PrivacyMode | null>(null);
|
||||
const [status, setStatus] = useState<Status>('loading');
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
|
||||
@@ -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<string, true> = {};
|
||||
|
||||
// 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<string, PrivacyDisclosure[]> = {};
|
||||
|
||||
// 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<string, QueuedFollowup[]> = {};
|
||||
@@ -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 (
|
||||
<div className="mb-2">
|
||||
<ExternalTransferDisclosureCard
|
||||
key={latest.id}
|
||||
threadId={disclosureThreadId}
|
||||
disclosure={latest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 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
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { EgressDataKind, EgressReason } from '../../../services/chatService';
|
||||
import type { PrivacyMode } from '../../../store/privacySlice';
|
||||
import { dataKindLabelKey, privacyModeLabelKey, reasonLabelKey } from '../disclosureLabels';
|
||||
|
||||
describe('disclosureLabels', () => {
|
||||
it('maps every data kind to a distinct friendly i18n key', () => {
|
||||
const kinds: EgressDataKind[] = [
|
||||
'prompt',
|
||||
'tool_arguments',
|
||||
'embedding_input',
|
||||
'file_content',
|
||||
'url',
|
||||
'metadata',
|
||||
];
|
||||
const keys = kinds.map(dataKindLabelKey);
|
||||
expect(keys).toEqual([
|
||||
'privacy.disclosure.kind.prompt',
|
||||
'privacy.disclosure.kind.toolArguments',
|
||||
'privacy.disclosure.kind.embeddingInput',
|
||||
'privacy.disclosure.kind.fileContent',
|
||||
'privacy.disclosure.kind.url',
|
||||
'privacy.disclosure.kind.metadata',
|
||||
]);
|
||||
// No collisions.
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it('falls back to the generic kind key for an unknown value', () => {
|
||||
expect(dataKindLabelKey('some_future_kind')).toBe('privacy.disclosure.kind.unknown');
|
||||
});
|
||||
|
||||
it('maps every reason to a distinct friendly i18n key', () => {
|
||||
const reasons: EgressReason[] = [
|
||||
'inference',
|
||||
'tool_call',
|
||||
'integration',
|
||||
'embedding',
|
||||
'network_fetch',
|
||||
];
|
||||
const keys = reasons.map(reasonLabelKey);
|
||||
expect(keys).toEqual([
|
||||
'privacy.disclosure.reason.inference',
|
||||
'privacy.disclosure.reason.toolCall',
|
||||
'privacy.disclosure.reason.integration',
|
||||
'privacy.disclosure.reason.embedding',
|
||||
'privacy.disclosure.reason.networkFetch',
|
||||
]);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it('falls back to the generic reason key for an unknown value', () => {
|
||||
expect(reasonLabelKey('some_future_reason')).toBe('privacy.disclosure.reason.unknown');
|
||||
});
|
||||
|
||||
it('maps every privacy mode to its label key', () => {
|
||||
const modes: PrivacyMode[] = ['local_only', 'standard', 'sensitive'];
|
||||
expect(modes.map(privacyModeLabelKey)).toEqual([
|
||||
'privacy.mode.localOnly',
|
||||
'privacy.mode.standard',
|
||||
'privacy.mode.sensitive',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the standard mode key for an unexpected mode value', () => {
|
||||
// Guards the always-visible pill against `t(undefined)` if the core ever
|
||||
// emits a mode string the client does not yet know (#4437 finding 5).
|
||||
expect(privacyModeLabelKey('some_future_mode')).toBe('privacy.mode.standard');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { EgressDataKind, EgressReason } from '../../services/chatService';
|
||||
import type { PrivacyMode } from '../../store/privacySlice';
|
||||
|
||||
/**
|
||||
* Map the snake_case egress enums to friendly, human-readable i18n keys so the
|
||||
* disclosure surface never renders raw wire strings (#4437 / S3). Each mapper
|
||||
* tolerates unknown/future values by falling back to a generic key.
|
||||
*/
|
||||
|
||||
export function dataKindLabelKey(kind: EgressDataKind | string): string {
|
||||
switch (kind) {
|
||||
case 'prompt':
|
||||
return 'privacy.disclosure.kind.prompt';
|
||||
case 'tool_arguments':
|
||||
return 'privacy.disclosure.kind.toolArguments';
|
||||
case 'embedding_input':
|
||||
return 'privacy.disclosure.kind.embeddingInput';
|
||||
case 'file_content':
|
||||
return 'privacy.disclosure.kind.fileContent';
|
||||
case 'url':
|
||||
return 'privacy.disclosure.kind.url';
|
||||
case 'metadata':
|
||||
return 'privacy.disclosure.kind.metadata';
|
||||
default:
|
||||
return 'privacy.disclosure.kind.unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export function reasonLabelKey(reason: EgressReason | string): string {
|
||||
switch (reason) {
|
||||
case 'inference':
|
||||
return 'privacy.disclosure.reason.inference';
|
||||
case 'tool_call':
|
||||
return 'privacy.disclosure.reason.toolCall';
|
||||
case 'integration':
|
||||
return 'privacy.disclosure.reason.integration';
|
||||
case 'embedding':
|
||||
return 'privacy.disclosure.reason.embedding';
|
||||
case 'network_fetch':
|
||||
return 'privacy.disclosure.reason.networkFetch';
|
||||
default:
|
||||
return 'privacy.disclosure.reason.unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export function privacyModeLabelKey(mode: PrivacyMode | string): string {
|
||||
switch (mode) {
|
||||
case 'local_only':
|
||||
return 'privacy.mode.localOnly';
|
||||
case 'standard':
|
||||
return 'privacy.mode.standard';
|
||||
case 'sensitive':
|
||||
return 'privacy.mode.sensitive';
|
||||
// The pill is always-visible, so an unexpected/future mode string must
|
||||
// still resolve to a real i18n key — never `undefined`, which `t()` would
|
||||
// choke on. Fall back to the neutral "Standard" label, matching the sibling
|
||||
// dataKind/reason mappers.
|
||||
default:
|
||||
return 'privacy.mode.standard';
|
||||
}
|
||||
}
|
||||
@@ -7070,6 +7070,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;
|
||||
|
||||
@@ -7234,6 +7234,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;
|
||||
|
||||
@@ -7449,6 +7449,29 @@ const messages: TranslationMap = {
|
||||
'flows.delete.confirm': 'Löschen',
|
||||
'flows.delete.deleting': 'Wird gelöscht…',
|
||||
'flows.canvas.renameLabel': 'Workflow umbenennen',
|
||||
|
||||
// Privacy status pill + per-action egress disclosure (#4437 / S3)
|
||||
'privacy.status.ariaLabel': 'Datenschutzstatus',
|
||||
'privacy.status.external': 'Außerhalb des Geräts',
|
||||
'privacy.status.local': 'Auf dem Gerät',
|
||||
'privacy.disclosure.title': 'Verlässt Ihr Gerät',
|
||||
'privacy.disclosure.body': 'Dadurch werden {kinds} an {destination} gesendet, weil {reason}.',
|
||||
'privacy.disclosure.dismiss': 'Verstanden',
|
||||
'privacy.disclosure.ariaLabel': 'Offenlegung externer Daten',
|
||||
'privacy.disclosure.kindSeparator': ', ',
|
||||
'privacy.disclosure.kind.prompt': 'Ihre Nachricht',
|
||||
'privacy.disclosure.kind.toolArguments': 'Werkzeug-Eingaben',
|
||||
'privacy.disclosure.kind.embeddingInput': 'zu indexierender Text',
|
||||
'privacy.disclosure.kind.fileContent': 'Dateiinhalte',
|
||||
'privacy.disclosure.kind.url': 'eine Webadresse',
|
||||
'privacy.disclosure.kind.metadata': 'Anfrage-Metadaten',
|
||||
'privacy.disclosure.kind.unknown': 'Daten',
|
||||
'privacy.disclosure.reason.inference': 'das KI-Modell es verarbeiten muss',
|
||||
'privacy.disclosure.reason.toolCall': 'ein Werkzeug es benötigt',
|
||||
'privacy.disclosure.reason.integration': 'eine verbundene Integration es benötigt',
|
||||
'privacy.disclosure.reason.embedding': 'es für die Suche indexiert werden muss',
|
||||
'privacy.disclosure.reason.networkFetch': 'eine Webanfrage es benötigt',
|
||||
'privacy.disclosure.reason.unknown': 'es für diese Aktion erforderlich ist',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1713,6 +1713,29 @@ const en: TranslationMap = {
|
||||
'privacy.mode.saved': 'Saved',
|
||||
'privacy.mode.saveError': 'Could not update privacy mode.',
|
||||
|
||||
// Privacy status pill + per-action egress disclosure (#4437 / S3)
|
||||
'privacy.status.ariaLabel': 'Privacy status',
|
||||
'privacy.status.external': 'Off-device',
|
||||
'privacy.status.local': 'On-device',
|
||||
'privacy.disclosure.title': 'Leaving your device',
|
||||
'privacy.disclosure.body': 'This will send {kinds} to {destination} because {reason}.',
|
||||
'privacy.disclosure.dismiss': 'Got it',
|
||||
'privacy.disclosure.ariaLabel': 'External data disclosure',
|
||||
'privacy.disclosure.kindSeparator': ', ',
|
||||
'privacy.disclosure.kind.prompt': 'your message',
|
||||
'privacy.disclosure.kind.toolArguments': 'tool inputs',
|
||||
'privacy.disclosure.kind.embeddingInput': 'text to index',
|
||||
'privacy.disclosure.kind.fileContent': 'file contents',
|
||||
'privacy.disclosure.kind.url': 'a web address',
|
||||
'privacy.disclosure.kind.metadata': 'request metadata',
|
||||
'privacy.disclosure.kind.unknown': 'data',
|
||||
'privacy.disclosure.reason.inference': 'the AI model needs to process it',
|
||||
'privacy.disclosure.reason.toolCall': 'a tool needs it',
|
||||
'privacy.disclosure.reason.integration': 'a connected integration needs it',
|
||||
'privacy.disclosure.reason.embedding': 'it needs to be indexed for search',
|
||||
'privacy.disclosure.reason.networkFetch': 'a web request needs it',
|
||||
'privacy.disclosure.reason.unknown': 'it is required for this action',
|
||||
|
||||
// Settings: About
|
||||
'settings.about.version': 'Version',
|
||||
'settings.about.updateAvailable': 'is available',
|
||||
|
||||
@@ -7383,6 +7383,29 @@ const messages: TranslationMap = {
|
||||
'flows.delete.confirm': 'Eliminar',
|
||||
'flows.delete.deleting': 'Eliminando…',
|
||||
'flows.canvas.renameLabel': 'Cambiar el nombre del flujo de trabajo',
|
||||
|
||||
// Privacy status pill + per-action egress disclosure (#4437 / S3)
|
||||
'privacy.status.ariaLabel': 'Estado de privacidad',
|
||||
'privacy.status.external': 'Fuera del dispositivo',
|
||||
'privacy.status.local': 'En el dispositivo',
|
||||
'privacy.disclosure.title': 'Sale de tu dispositivo',
|
||||
'privacy.disclosure.body': 'Esto enviará {kinds} a {destination} porque {reason}.',
|
||||
'privacy.disclosure.dismiss': 'Entendido',
|
||||
'privacy.disclosure.ariaLabel': 'Divulgación de datos externos',
|
||||
'privacy.disclosure.kindSeparator': ', ',
|
||||
'privacy.disclosure.kind.prompt': 'tu mensaje',
|
||||
'privacy.disclosure.kind.toolArguments': 'entradas de la herramienta',
|
||||
'privacy.disclosure.kind.embeddingInput': 'texto para indexar',
|
||||
'privacy.disclosure.kind.fileContent': 'contenido del archivo',
|
||||
'privacy.disclosure.kind.url': 'una dirección web',
|
||||
'privacy.disclosure.kind.metadata': 'metadatos de la solicitud',
|
||||
'privacy.disclosure.kind.unknown': 'datos',
|
||||
'privacy.disclosure.reason.inference': 'el modelo de IA necesita procesarlo',
|
||||
'privacy.disclosure.reason.toolCall': 'una herramienta lo necesita',
|
||||
'privacy.disclosure.reason.integration': 'una integración conectada lo necesita',
|
||||
'privacy.disclosure.reason.embedding': 'necesita indexarse para la búsqueda',
|
||||
'privacy.disclosure.reason.networkFetch': 'una solicitud web lo necesita',
|
||||
'privacy.disclosure.reason.unknown': 'es necesario para esta acción',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -7417,6 +7417,29 @@ const messages: TranslationMap = {
|
||||
'flows.delete.confirm': 'Supprimer',
|
||||
'flows.delete.deleting': 'Suppression…',
|
||||
'flows.canvas.renameLabel': 'Renommer le workflow',
|
||||
|
||||
// Privacy status pill + per-action egress disclosure (#4437 / S3)
|
||||
'privacy.status.ariaLabel': 'État de confidentialité',
|
||||
'privacy.status.external': 'Hors de l’appareil',
|
||||
'privacy.status.local': 'Sur l’appareil',
|
||||
'privacy.disclosure.title': 'Quitte votre appareil',
|
||||
'privacy.disclosure.body': 'Ceci enverra {kinds} vers {destination} car {reason}.',
|
||||
'privacy.disclosure.dismiss': 'Compris',
|
||||
'privacy.disclosure.ariaLabel': 'Divulgation de données externes',
|
||||
'privacy.disclosure.kindSeparator': ', ',
|
||||
'privacy.disclosure.kind.prompt': 'votre message',
|
||||
'privacy.disclosure.kind.toolArguments': 'les entrées de l’outil',
|
||||
'privacy.disclosure.kind.embeddingInput': 'le texte à indexer',
|
||||
'privacy.disclosure.kind.fileContent': 'le contenu du fichier',
|
||||
'privacy.disclosure.kind.url': 'une adresse web',
|
||||
'privacy.disclosure.kind.metadata': 'les métadonnées de la requête',
|
||||
'privacy.disclosure.kind.unknown': 'des données',
|
||||
'privacy.disclosure.reason.inference': 'le modèle d’IA doit le traiter',
|
||||
'privacy.disclosure.reason.toolCall': 'un outil en a besoin',
|
||||
'privacy.disclosure.reason.integration': 'une intégration connectée en a besoin',
|
||||
'privacy.disclosure.reason.embedding': 'il doit être indexé pour la recherche',
|
||||
'privacy.disclosure.reason.networkFetch': 'une requête web en a besoin',
|
||||
'privacy.disclosure.reason.unknown': 'il est requis pour cette action',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -7232,6 +7232,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;
|
||||
|
||||
@@ -7266,6 +7266,29 @@ const messages: TranslationMap = {
|
||||
'flows.delete.confirm': 'Hapus',
|
||||
'flows.delete.deleting': 'Menghapus…',
|
||||
'flows.canvas.renameLabel': 'Ganti nama alur kerja',
|
||||
|
||||
// Privacy status pill + per-action egress disclosure (#4437 / S3)
|
||||
'privacy.status.ariaLabel': 'Status privasi',
|
||||
'privacy.status.external': 'Di luar perangkat',
|
||||
'privacy.status.local': 'Di perangkat',
|
||||
'privacy.disclosure.title': 'Meninggalkan perangkat Anda',
|
||||
'privacy.disclosure.body': 'Ini akan mengirim {kinds} ke {destination} karena {reason}.',
|
||||
'privacy.disclosure.dismiss': 'Mengerti',
|
||||
'privacy.disclosure.ariaLabel': 'Pengungkapan data eksternal',
|
||||
'privacy.disclosure.kindSeparator': ', ',
|
||||
'privacy.disclosure.kind.prompt': 'pesan Anda',
|
||||
'privacy.disclosure.kind.toolArguments': 'masukan alat',
|
||||
'privacy.disclosure.kind.embeddingInput': 'teks untuk diindeks',
|
||||
'privacy.disclosure.kind.fileContent': 'isi berkas',
|
||||
'privacy.disclosure.kind.url': 'alamat web',
|
||||
'privacy.disclosure.kind.metadata': 'metadata permintaan',
|
||||
'privacy.disclosure.kind.unknown': 'data',
|
||||
'privacy.disclosure.reason.inference': 'model AI perlu memprosesnya',
|
||||
'privacy.disclosure.reason.toolCall': 'sebuah alat memerlukannya',
|
||||
'privacy.disclosure.reason.integration': 'integrasi yang terhubung memerlukannya',
|
||||
'privacy.disclosure.reason.embedding': 'perlu diindeks untuk pencarian',
|
||||
'privacy.disclosure.reason.networkFetch': 'permintaan web memerlukannya',
|
||||
'privacy.disclosure.reason.unknown': 'diperlukan untuk tindakan ini',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -7373,6 +7373,29 @@ const messages: TranslationMap = {
|
||||
'flows.delete.confirm': 'Elimina',
|
||||
'flows.delete.deleting': 'Eliminazione…',
|
||||
'flows.canvas.renameLabel': 'Rinomina flusso di lavoro',
|
||||
|
||||
// Privacy status pill + per-action egress disclosure (#4437 / S3)
|
||||
'privacy.status.ariaLabel': 'Stato privacy',
|
||||
'privacy.status.external': 'Fuori dal dispositivo',
|
||||
'privacy.status.local': 'Sul dispositivo',
|
||||
'privacy.disclosure.title': 'Sta lasciando il tuo dispositivo',
|
||||
'privacy.disclosure.body': 'Questo invierà {kinds} a {destination} perché {reason}.',
|
||||
'privacy.disclosure.dismiss': 'Ho capito',
|
||||
'privacy.disclosure.ariaLabel': 'Informativa sul trasferimento di dati esterni',
|
||||
'privacy.disclosure.kindSeparator': ', ',
|
||||
'privacy.disclosure.kind.prompt': 'il tuo messaggio',
|
||||
'privacy.disclosure.kind.toolArguments': 'input dello strumento',
|
||||
'privacy.disclosure.kind.embeddingInput': 'testo da indicizzare',
|
||||
'privacy.disclosure.kind.fileContent': 'contenuto del file',
|
||||
'privacy.disclosure.kind.url': 'un indirizzo web',
|
||||
'privacy.disclosure.kind.metadata': 'metadati della richiesta',
|
||||
'privacy.disclosure.kind.unknown': 'dati',
|
||||
'privacy.disclosure.reason.inference': 'il modello di IA deve elaborare i dati',
|
||||
'privacy.disclosure.reason.toolCall': 'uno strumento ne ha bisogno',
|
||||
'privacy.disclosure.reason.integration': 'un’integrazione collegata ne ha bisogno',
|
||||
'privacy.disclosure.reason.embedding': 'i dati devono essere indicizzati per la ricerca',
|
||||
'privacy.disclosure.reason.networkFetch': 'una richiesta web ne ha bisogno',
|
||||
'privacy.disclosure.reason.unknown': 'è necessario per questa azione',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -7152,6 +7152,30 @@ const messages: TranslationMap = {
|
||||
'flows.delete.confirm': '삭제',
|
||||
'flows.delete.deleting': '삭제 중…',
|
||||
'flows.canvas.renameLabel': '워크플로 이름 바꾸기',
|
||||
|
||||
// Privacy status pill + per-action egress disclosure (#4437 / S3)
|
||||
'privacy.status.ariaLabel': '개인정보 상태',
|
||||
'privacy.status.external': '기기 외',
|
||||
'privacy.status.local': '기기 내',
|
||||
'privacy.disclosure.title': '기기에서 나가는 중',
|
||||
'privacy.disclosure.body':
|
||||
'이 작업은 {destination}(으)로 다음 데이터를 보냅니다: {kinds}. ({reason})',
|
||||
'privacy.disclosure.dismiss': '확인',
|
||||
'privacy.disclosure.ariaLabel': '외부 데이터 공개',
|
||||
'privacy.disclosure.kindSeparator': ', ',
|
||||
'privacy.disclosure.kind.prompt': '메시지',
|
||||
'privacy.disclosure.kind.toolArguments': '도구 입력값',
|
||||
'privacy.disclosure.kind.embeddingInput': '색인할 텍스트',
|
||||
'privacy.disclosure.kind.fileContent': '파일 내용',
|
||||
'privacy.disclosure.kind.url': '웹 주소',
|
||||
'privacy.disclosure.kind.metadata': '요청 메타데이터',
|
||||
'privacy.disclosure.kind.unknown': '데이터',
|
||||
'privacy.disclosure.reason.inference': 'AI 모델이 이를 처리해야 하기 때문에',
|
||||
'privacy.disclosure.reason.toolCall': '도구가 이를 필요로 하기 때문에',
|
||||
'privacy.disclosure.reason.integration': '연결된 통합 기능이 이를 필요로 하기 때문에',
|
||||
'privacy.disclosure.reason.embedding': '검색을 위해 색인해야 하기 때문에',
|
||||
'privacy.disclosure.reason.networkFetch': '웹 요청이 이를 필요로 하기 때문에',
|
||||
'privacy.disclosure.reason.unknown': '이 작업에 필요하기 때문에',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -7342,6 +7342,30 @@ const messages: TranslationMap = {
|
||||
'flows.delete.confirm': 'Usuń',
|
||||
'flows.delete.deleting': 'Usuwanie…',
|
||||
'flows.canvas.renameLabel': 'Zmień nazwę przepływu pracy',
|
||||
|
||||
// Privacy status pill + per-action egress disclosure (#4437 / S3)
|
||||
'privacy.status.ariaLabel': 'Stan prywatności',
|
||||
'privacy.status.external': 'Poza urządzeniem',
|
||||
'privacy.status.local': 'Na urządzeniu',
|
||||
'privacy.disclosure.title': 'Opuszcza Twoje urządzenie',
|
||||
'privacy.disclosure.body':
|
||||
'Spowoduje to wysłanie następujących danych do {destination}: {kinds}. Powód: {reason}.',
|
||||
'privacy.disclosure.dismiss': 'Rozumiem',
|
||||
'privacy.disclosure.ariaLabel': 'Ujawnienie danych zewnętrznych',
|
||||
'privacy.disclosure.kindSeparator': ', ',
|
||||
'privacy.disclosure.kind.prompt': 'Twoja wiadomość',
|
||||
'privacy.disclosure.kind.toolArguments': 'dane wejściowe narzędzia',
|
||||
'privacy.disclosure.kind.embeddingInput': 'tekst do zindeksowania',
|
||||
'privacy.disclosure.kind.fileContent': 'zawartość pliku',
|
||||
'privacy.disclosure.kind.url': 'adres internetowy',
|
||||
'privacy.disclosure.kind.metadata': 'metadane żądania',
|
||||
'privacy.disclosure.kind.unknown': 'dane',
|
||||
'privacy.disclosure.reason.inference': 'model AI musi przetworzyć te dane',
|
||||
'privacy.disclosure.reason.toolCall': 'narzędzie potrzebuje tych danych',
|
||||
'privacy.disclosure.reason.integration': 'połączona integracja potrzebuje tych danych',
|
||||
'privacy.disclosure.reason.embedding': 'te dane muszą zostać zindeksowane do wyszukiwania',
|
||||
'privacy.disclosure.reason.networkFetch': 'żądanie sieciowe potrzebuje tych danych',
|
||||
'privacy.disclosure.reason.unknown': 'jest to wymagane do tej akcji',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -7356,6 +7356,29 @@ const messages: TranslationMap = {
|
||||
'flows.delete.confirm': 'Excluir',
|
||||
'flows.delete.deleting': 'Excluindo…',
|
||||
'flows.canvas.renameLabel': 'Renomear fluxo de trabalho',
|
||||
|
||||
// Privacy status pill + per-action egress disclosure (#4437 / S3)
|
||||
'privacy.status.ariaLabel': 'Estado de privacidade',
|
||||
'privacy.status.external': 'Fora do dispositivo',
|
||||
'privacy.status.local': 'No dispositivo',
|
||||
'privacy.disclosure.title': 'A sair do seu dispositivo',
|
||||
'privacy.disclosure.body': 'Isto irá enviar {kinds} para {destination} porque {reason}.',
|
||||
'privacy.disclosure.dismiss': 'Entendido',
|
||||
'privacy.disclosure.ariaLabel': 'Divulgação de dados externos',
|
||||
'privacy.disclosure.kindSeparator': ', ',
|
||||
'privacy.disclosure.kind.prompt': 'a sua mensagem',
|
||||
'privacy.disclosure.kind.toolArguments': 'entradas da ferramenta',
|
||||
'privacy.disclosure.kind.embeddingInput': 'texto para indexar',
|
||||
'privacy.disclosure.kind.fileContent': 'conteúdo do ficheiro',
|
||||
'privacy.disclosure.kind.url': 'um endereço web',
|
||||
'privacy.disclosure.kind.metadata': 'metadados do pedido',
|
||||
'privacy.disclosure.kind.unknown': 'dados',
|
||||
'privacy.disclosure.reason.inference': 'o modelo de IA precisa de processar estes dados',
|
||||
'privacy.disclosure.reason.toolCall': 'uma ferramenta precisa destes dados',
|
||||
'privacy.disclosure.reason.integration': 'uma integração ligada precisa destes dados',
|
||||
'privacy.disclosure.reason.embedding': 'estes dados precisam de ser indexados para pesquisa',
|
||||
'privacy.disclosure.reason.networkFetch': 'um pedido web precisa destes dados',
|
||||
'privacy.disclosure.reason.unknown': 'é necessário para esta ação',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -7312,6 +7312,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;
|
||||
|
||||
@@ -6843,6 +6843,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;
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
chatRuntime: chatRuntimeReducer,
|
||||
agentProfiles: agentProfileReducer,
|
||||
theme: themeReducer,
|
||||
privacy: privacyReducer,
|
||||
}),
|
||||
preloadedState: preload as never,
|
||||
});
|
||||
@@ -2712,3 +2714,105 @@ describe('Conversations — message list reserves room for the floating composer
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// External-transfer disclosure surface (#4437 / S3). The card's own unit test
|
||||
// covers the component in isolation; these drive the SELECTION path in
|
||||
// Conversations — latest-per-thread, `firstActiveThreadId` fallback, and
|
||||
// clear-after-dismissal — which the isolated card test cannot reach.
|
||||
describe('Conversations — external-transfer disclosure surface (#4437 / S3)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
|
||||
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
|
||||
});
|
||||
|
||||
function disclosure(over: Partial<PrivacyDisclosure> = {}): PrivacyDisclosure {
|
||||
return {
|
||||
id: 'd1',
|
||||
providerSlug: 'openai',
|
||||
service: 'OpenAI',
|
||||
isExternal: true,
|
||||
reason: 'inference',
|
||||
dataKinds: ['prompt'],
|
||||
riskLevel: 'unknown',
|
||||
riskCategories: [],
|
||||
receivedAt: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
it('surfaces the latest disclosure for the selected thread', async () => {
|
||||
const thread = makeThread({ id: 't-sel' });
|
||||
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
|
||||
await act(async () => {
|
||||
await renderConversations({
|
||||
thread: selectedThreadState(thread),
|
||||
socket: socketState('connected'),
|
||||
privacy: {
|
||||
privacyMode: 'standard',
|
||||
disclosuresByThread: {
|
||||
't-sel': [
|
||||
disclosure({ id: 'old', service: 'OldService' }),
|
||||
disclosure({ id: 'new', service: 'OpenAI' }),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const title = await screen.findByText('Leaving your device');
|
||||
const card = title.closest('[role="status"]');
|
||||
expect(card).not.toBeNull();
|
||||
// Latest wins — the newest disclosure's destination, not the older one.
|
||||
expect(card).toHaveTextContent('OpenAI');
|
||||
expect(card).not.toHaveTextContent('OldService');
|
||||
});
|
||||
|
||||
it('falls back to firstActiveThreadId when no thread is selected', async () => {
|
||||
const thread = makeThread({ id: 't-act' });
|
||||
// Keep thread loading pending so the mount flow's auto-select can't fire. With a
|
||||
// resolved load the sole thread `t-act` would be auto-selected, and the assertion
|
||||
// would then pass through the normal selected-thread path instead of the intended
|
||||
// `selectedThreadId ?? firstActiveThreadId` fallback (CR #4849). A pending load
|
||||
// leaves `selectedThreadId` null, so the fallback is the ONLY way the card shows.
|
||||
mockGetThreads.mockReturnValue(new Promise(() => {}));
|
||||
let store: Awaited<ReturnType<typeof renderConversations>>;
|
||||
await act(async () => {
|
||||
store = await renderConversations({
|
||||
// No selectedThreadId — only an active thread present.
|
||||
thread: { ...emptyThreadState, threads: [thread], activeThreadIds: { 't-act': true } },
|
||||
socket: socketState('connected'),
|
||||
privacy: {
|
||||
privacyMode: 'standard',
|
||||
disclosuresByThread: { 't-act': [disclosure({ service: 'Anthropic' })] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-selection must NOT have fired — the fallback branch is what surfaces the
|
||||
// card, not the normal selected-thread path.
|
||||
expect(store!.getState().thread.selectedThreadId).toBeNull();
|
||||
const title = await screen.findByText('Leaving your device');
|
||||
expect(title.closest('[role="status"]')).toHaveTextContent('Anthropic');
|
||||
});
|
||||
|
||||
it('clears the card after dismissal', async () => {
|
||||
const thread = makeThread({ id: 't-sel' });
|
||||
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
|
||||
await act(async () => {
|
||||
await renderConversations({
|
||||
thread: selectedThreadState(thread),
|
||||
socket: socketState('connected'),
|
||||
privacy: { privacyMode: 'standard', disclosuresByThread: { 't-sel': [disclosure()] } },
|
||||
});
|
||||
});
|
||||
|
||||
const title = await screen.findByText('Leaving your device');
|
||||
const card = title.closest('[role="status"]') as HTMLElement;
|
||||
await act(async () => {
|
||||
fireEvent.click(within(card).getByRole('button', { name: 'Got it' }));
|
||||
});
|
||||
expect(screen.queryByText('Leaving your device')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type ChatTaskBoardUpdatedEvent,
|
||||
type ChatToolCallEvent,
|
||||
type ChatToolResultEvent,
|
||||
type ExternalTransferPendingEvent,
|
||||
type ProactiveMessageEvent,
|
||||
segmentText,
|
||||
subscribeChatEvents,
|
||||
@@ -66,6 +67,12 @@ import {
|
||||
type WorkflowProposal,
|
||||
} from '../store/chatRuntimeSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import {
|
||||
clearActiveExternalForThread,
|
||||
disclosureFromEvent,
|
||||
hydratePrivacyMode,
|
||||
pushDisclosureForThread,
|
||||
} from '../store/privacySlice';
|
||||
import { selectSocketStatus } from '../store/socketSelectors';
|
||||
import {
|
||||
addInferenceResponse,
|
||||
@@ -356,6 +363,13 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
// only — it never gates or cancels a turn.
|
||||
const skillLatencyRef = useRef(createSkillToolChainLatencyTracker());
|
||||
|
||||
// Hydrate the current Privacy Mode once on mount so the persistent status
|
||||
// pill can show the posture immediately (#4437 / S3). Failures resolve to
|
||||
// null inside the thunk — the pill degrades gracefully.
|
||||
useEffect(() => {
|
||||
void dispatch(hydratePrivacyMode());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
toolTimelineRef.current = toolTimelineByThread;
|
||||
}, [toolTimelineByThread]);
|
||||
@@ -1057,6 +1071,25 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
})
|
||||
);
|
||||
},
|
||||
onExternalTransferPending: (event: ExternalTransferPendingEvent) => {
|
||||
// #4437 / S3 — DISCLOSURE ONLY. Project the egress descriptor onto the
|
||||
// thread's privacy ledger so the in-chat card + status pill can render
|
||||
// what/where/why. No approve/deny here (that's S4 #4438).
|
||||
rtLog('external_transfer_pending', {
|
||||
thread: event.thread_id,
|
||||
provider: event.provider_slug,
|
||||
service: event.service,
|
||||
reason: event.reason,
|
||||
kinds: event.data_kinds.length,
|
||||
external: String(event.is_external),
|
||||
});
|
||||
dispatch(
|
||||
pushDisclosureForThread({
|
||||
threadId: event.thread_id,
|
||||
disclosure: disclosureFromEvent(event),
|
||||
})
|
||||
);
|
||||
},
|
||||
onApprovalRequest: (event: ChatApprovalRequestEvent) => {
|
||||
rtLog('approval_request', {
|
||||
thread: event.thread_id,
|
||||
@@ -1188,6 +1221,10 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
dispatch(clearStreamingAssistantForThread({ threadId: event.thread_id }));
|
||||
dispatch(clearPendingApprovalForThread({ threadId: event.thread_id }));
|
||||
dispatch(clearPendingPlanReviewForThread({ threadId: event.thread_id }));
|
||||
// Turn boundary: this turn's external transfers are done, so return the
|
||||
// privacy pill to on-device. The (un-dismissed) disclosure ledger stays
|
||||
// for the in-chat card's history — only the live flag clears (#4437).
|
||||
dispatch(clearActiveExternalForThread({ threadId: event.thread_id }));
|
||||
|
||||
const existing = store.getState().chatRuntime.toolTimelineByThread[event.thread_id] ?? [];
|
||||
if (existing.length > 0) {
|
||||
@@ -1340,6 +1377,9 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
dispatch(clearStreamingAssistantForThread({ threadId: event.thread_id }));
|
||||
dispatch(clearPendingApprovalForThread({ threadId: event.thread_id }));
|
||||
dispatch(clearPendingPlanReviewForThread({ threadId: event.thread_id }));
|
||||
// Turn boundary (error path): clear the live external-transfer flag so
|
||||
// the privacy pill resets to on-device, mirroring the done path (#4437).
|
||||
dispatch(clearActiveExternalForThread({ threadId: event.thread_id }));
|
||||
|
||||
const existing = store.getState().chatRuntime.toolTimelineByThread[event.thread_id] ?? [];
|
||||
if (existing.length > 0) {
|
||||
@@ -1427,6 +1467,10 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
// can't complete.
|
||||
dispatch(clearPendingApprovalForThread({ threadId }));
|
||||
dispatch(clearPendingPlanReviewForThread({ threadId }));
|
||||
// A disconnect tears the turn down without a done/error event, so clear
|
||||
// the live external-transfer flag here too — otherwise the privacy pill
|
||||
// would stay off-device for a turn that can never complete (#4437).
|
||||
dispatch(clearActiveExternalForThread({ threadId }));
|
||||
dispatch(endInferenceTurn({ threadId }));
|
||||
}
|
||||
// A disconnect kills every in-flight turn on the dead session, so clear all
|
||||
|
||||
@@ -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<string, Handler[]>();
|
||||
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<typeof createMockSocket>) {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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<EgressRiskLevel> = 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;
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ExternalTransferPendingEvent } from '../../services/chatService';
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
import privacyReducer, {
|
||||
clearActiveExternalForThread,
|
||||
clearDisclosuresForThread,
|
||||
disclosureFromEvent,
|
||||
dismissDisclosureForThread,
|
||||
hydratePrivacyMode,
|
||||
type PrivacyDisclosure,
|
||||
pushDisclosureForThread,
|
||||
setPrivacyMode,
|
||||
} from '../privacySlice';
|
||||
import { resetUserScopedState } from '../resetActions';
|
||||
|
||||
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
const EVENT: ExternalTransferPendingEvent = {
|
||||
thread_id: 'thread-1',
|
||||
provider_slug: 'openai',
|
||||
service: 'OpenAI',
|
||||
is_external: true,
|
||||
reason: 'inference',
|
||||
data_kinds: ['prompt'],
|
||||
risk_level: 'unknown',
|
||||
risk_categories: [],
|
||||
};
|
||||
|
||||
function initial() {
|
||||
return privacyReducer(undefined, { type: '@@INIT' });
|
||||
}
|
||||
|
||||
describe('privacySlice — reducers', () => {
|
||||
it('has the expected initial state', () => {
|
||||
expect(initial()).toEqual({
|
||||
privacyMode: null,
|
||||
disclosuresByThread: {},
|
||||
activeExternalByThread: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('setPrivacyMode updates the mode', () => {
|
||||
const state = privacyReducer(initial(), setPrivacyMode('local_only'));
|
||||
expect(state.privacyMode).toBe('local_only');
|
||||
});
|
||||
|
||||
it('pushDisclosureForThread appends per thread', () => {
|
||||
const d = disclosureFromEvent(EVENT);
|
||||
const state = privacyReducer(
|
||||
initial(),
|
||||
pushDisclosureForThread({ threadId: 'thread-1', disclosure: d })
|
||||
);
|
||||
expect(state.disclosuresByThread['thread-1']).toHaveLength(1);
|
||||
expect(state.disclosuresByThread['thread-1'][0]).toMatchObject({
|
||||
providerSlug: 'openai',
|
||||
service: 'OpenAI',
|
||||
reason: 'inference',
|
||||
dataKinds: ['prompt'],
|
||||
isExternal: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('caps the per-thread ledger at 20 entries (drops oldest)', () => {
|
||||
let state = initial();
|
||||
let firstId = '';
|
||||
for (let i = 0; i < 25; i += 1) {
|
||||
const d = disclosureFromEvent(EVENT);
|
||||
if (i === 0) firstId = d.id;
|
||||
state = privacyReducer(state, pushDisclosureForThread({ threadId: 't', disclosure: d }));
|
||||
}
|
||||
const list = state.disclosuresByThread['t'];
|
||||
expect(list).toHaveLength(20);
|
||||
// The very first (oldest) disclosure was evicted.
|
||||
expect(list.some(entry => entry.id === firstId)).toBe(false);
|
||||
});
|
||||
|
||||
it('dismissDisclosureForThread removes one entry by id', () => {
|
||||
const a = disclosureFromEvent(EVENT);
|
||||
const b = disclosureFromEvent(EVENT);
|
||||
let state = privacyReducer(
|
||||
initial(),
|
||||
pushDisclosureForThread({ threadId: 'thread-1', disclosure: a })
|
||||
);
|
||||
state = privacyReducer(state, pushDisclosureForThread({ threadId: 'thread-1', disclosure: b }));
|
||||
state = privacyReducer(state, dismissDisclosureForThread({ threadId: 'thread-1', id: a.id }));
|
||||
expect(state.disclosuresByThread['thread-1']).toHaveLength(1);
|
||||
expect(state.disclosuresByThread['thread-1'][0].id).toBe(b.id);
|
||||
});
|
||||
|
||||
it('dismissing the last entry removes the thread key entirely', () => {
|
||||
const a = disclosureFromEvent(EVENT);
|
||||
let state = privacyReducer(
|
||||
initial(),
|
||||
pushDisclosureForThread({ threadId: 'thread-1', disclosure: a })
|
||||
);
|
||||
state = privacyReducer(state, dismissDisclosureForThread({ threadId: 'thread-1', id: a.id }));
|
||||
expect(state.disclosuresByThread['thread-1']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clearDisclosuresForThread drops all disclosures for a thread', () => {
|
||||
const a = disclosureFromEvent(EVENT);
|
||||
let state = privacyReducer(
|
||||
initial(),
|
||||
pushDisclosureForThread({ threadId: 'thread-1', disclosure: a })
|
||||
);
|
||||
state = privacyReducer(state, clearDisclosuresForThread({ threadId: 'thread-1' }));
|
||||
expect(state.disclosuresByThread['thread-1']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resetUserScopedState wipes disclosures, mode, and the active-external flags', () => {
|
||||
let state = privacyReducer(initial(), setPrivacyMode('standard'));
|
||||
state = privacyReducer(
|
||||
state,
|
||||
pushDisclosureForThread({ threadId: 't', disclosure: disclosureFromEvent(EVENT) })
|
||||
);
|
||||
expect(state.activeExternalByThread['t']).toBe(true);
|
||||
state = privacyReducer(state, resetUserScopedState());
|
||||
expect(state).toEqual({
|
||||
privacyMode: null,
|
||||
disclosuresByThread: {},
|
||||
activeExternalByThread: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('privacySlice — active external-transfer flag (#4437 finding 1)', () => {
|
||||
it('pushing an external disclosure marks the thread active-external', () => {
|
||||
const state = privacyReducer(
|
||||
initial(),
|
||||
pushDisclosureForThread({ threadId: 'thread-1', disclosure: disclosureFromEvent(EVENT) })
|
||||
);
|
||||
expect(state.activeExternalByThread['thread-1']).toBe(true);
|
||||
});
|
||||
|
||||
it('a non-external disclosure does NOT mark the thread active-external', () => {
|
||||
const localEvent: ExternalTransferPendingEvent = { ...EVENT, is_external: false };
|
||||
const state = privacyReducer(
|
||||
initial(),
|
||||
pushDisclosureForThread({ threadId: 'thread-1', disclosure: disclosureFromEvent(localEvent) })
|
||||
);
|
||||
expect(state.activeExternalByThread['thread-1']).toBeUndefined();
|
||||
});
|
||||
|
||||
// Finding 1a: dismissing the card must NOT flip the pill off while the
|
||||
// transfer is still active — the active flag survives dismissal.
|
||||
it('dismissing the disclosure card leaves the active-external flag set', () => {
|
||||
const d = disclosureFromEvent(EVENT);
|
||||
let state = privacyReducer(
|
||||
initial(),
|
||||
pushDisclosureForThread({ threadId: 'thread-1', disclosure: d })
|
||||
);
|
||||
state = privacyReducer(state, dismissDisclosureForThread({ threadId: 'thread-1', id: d.id }));
|
||||
// Ledger entry gone…
|
||||
expect(state.disclosuresByThread['thread-1']).toBeUndefined();
|
||||
// …but the live transfer flag remains until the turn boundary clears it.
|
||||
expect(state.activeExternalByThread['thread-1']).toBe(true);
|
||||
});
|
||||
|
||||
// Finding 1b: the turn boundary clears the active flag even though the
|
||||
// (un-dismissed) ledger entry is still there for the card's history.
|
||||
it('clearActiveExternalForThread clears the flag but keeps the ledger', () => {
|
||||
const d = disclosureFromEvent(EVENT);
|
||||
let state = privacyReducer(
|
||||
initial(),
|
||||
pushDisclosureForThread({ threadId: 'thread-1', disclosure: d })
|
||||
);
|
||||
state = privacyReducer(state, clearActiveExternalForThread({ threadId: 'thread-1' }));
|
||||
expect(state.activeExternalByThread['thread-1']).toBeUndefined();
|
||||
// The disclosure history is untouched — only the live flag was cleared.
|
||||
expect(state.disclosuresByThread['thread-1']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('clearActiveExternalForThread is a no-op for an unknown thread', () => {
|
||||
const state = privacyReducer(initial(), clearActiveExternalForThread({ threadId: 'nope' }));
|
||||
expect(state.activeExternalByThread).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('privacySlice — disclosureFromEvent', () => {
|
||||
it('maps wire snake_case fields onto the disclosure and assigns unique ids', () => {
|
||||
const a = disclosureFromEvent(EVENT);
|
||||
const b = disclosureFromEvent(EVENT);
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(a).toMatchObject({
|
||||
providerSlug: 'openai',
|
||||
service: 'OpenAI',
|
||||
isExternal: true,
|
||||
reason: 'inference',
|
||||
dataKinds: ['prompt'],
|
||||
riskLevel: 'unknown',
|
||||
riskCategories: [],
|
||||
});
|
||||
expect(typeof a.receivedAt).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('privacySlice — hydratePrivacyMode thunk', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sets the mode from the double-wrapped RPC result on success', async () => {
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { mode: 'sensitive' } } as never);
|
||||
const action = await hydratePrivacyMode()(vi.fn(), vi.fn(), undefined);
|
||||
expect(action.payload).toBe('sensitive');
|
||||
|
||||
const state = privacyReducer(initial(), action as never);
|
||||
expect(state.privacyMode).toBe('sensitive');
|
||||
});
|
||||
|
||||
it('resolves to null (and leaves mode untouched) on RPC failure', async () => {
|
||||
vi.mocked(callCoreRpc).mockRejectedValueOnce(new Error('core down'));
|
||||
const action = await hydratePrivacyMode()(vi.fn(), vi.fn(), undefined);
|
||||
expect(action.payload).toBeNull();
|
||||
|
||||
const seeded = privacyReducer(initial(), setPrivacyMode('standard'));
|
||||
const state = privacyReducer(seeded, action as never);
|
||||
// Null payload must not clobber an already-known mode.
|
||||
expect(state.privacyMode).toBe('standard');
|
||||
});
|
||||
});
|
||||
|
||||
// Type guard: exported PrivacyDisclosure stays structurally stable.
|
||||
const _typecheck: PrivacyDisclosure = disclosureFromEvent(EVENT);
|
||||
void _typecheck;
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import debug from 'debug';
|
||||
|
||||
import type {
|
||||
EgressDataKind,
|
||||
EgressReason,
|
||||
EgressRiskLevel,
|
||||
ExternalTransferPendingEvent,
|
||||
} from '../services/chatService';
|
||||
import { callCoreRpc } from '../services/coreRpcClient';
|
||||
import { CORE_RPC_METHODS } from '../services/rpcMethods';
|
||||
import { resetUserScopedState } from './resetActions';
|
||||
|
||||
const privacyLog = debug('privacy:slice');
|
||||
|
||||
/**
|
||||
* Privacy Mode values as serialized by the Rust core (snake_case). Mirrors the
|
||||
* `PrivacyMode` type in {@link ../components/settings/panels/PrivacyModeSection}.
|
||||
* The disclosure surface reads this to show the *current posture* alongside the
|
||||
* per-action egress state — it does NOT own the setting (that stays in
|
||||
* PrivacyModeSection); the slice is hydrated on boot and kept loosely in sync.
|
||||
*/
|
||||
export type PrivacyMode = 'local_only' | 'standard' | 'sensitive';
|
||||
|
||||
/**
|
||||
* One external-transfer disclosure projected onto a thread (#4437 / S3). Built
|
||||
* from an `external_transfer_pending` socket event. DISCLOSURE ONLY — there is
|
||||
* no approve/deny decision here (that is S4 #4438); the only user action is
|
||||
* dismissal.
|
||||
*/
|
||||
export interface PrivacyDisclosure {
|
||||
/** Client-generated id — the dismissal handle and React key. */
|
||||
id: string;
|
||||
/** Provider identifier (e.g. `openai`). Public, not PII. */
|
||||
providerSlug: string;
|
||||
/** Human-facing destination service (e.g. `OpenAI`, `Gmail`). */
|
||||
service: string;
|
||||
/** Whether the transfer leaves the device (always true in practice). */
|
||||
isExternal: boolean;
|
||||
/** Why the transfer is happening. */
|
||||
reason: EgressReason;
|
||||
/** Categories of user data being sent. */
|
||||
dataKinds: EgressDataKind[];
|
||||
/** Core-assigned risk grade. `"unknown"` today. */
|
||||
riskLevel: EgressRiskLevel;
|
||||
/** Named risk categories. Empty today. */
|
||||
riskCategories: string[];
|
||||
/** When the disclosure was received, milliseconds since epoch. */
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
interface PrivacyState {
|
||||
/**
|
||||
* Current data-egress posture. `null` until hydrated from the core (or if the
|
||||
* RPC fails). Kept for the persistent status pill; not authoritative — the
|
||||
* setting lives in the core and is edited via PrivacyModeSection.
|
||||
*/
|
||||
privacyMode: PrivacyMode | null;
|
||||
/**
|
||||
* Per-thread disclosure ledger, newest last. The disclosure card renders the
|
||||
* most recent entry for the active thread; dismissal removes one entry by id.
|
||||
*
|
||||
* IMPORTANT: this ledger is user-DISMISSIBLE history for the in-chat card
|
||||
* only. It MUST NOT drive the status pill's on/off-device sub-state — a
|
||||
* dismissal removing the last entry would otherwise flip the pill to
|
||||
* "on-device" while the transfer is still in flight, and an un-dismissed
|
||||
* historical entry would keep it "off-device" during later purely-local
|
||||
* turns. The pill reads {@link activeExternalByThread} instead.
|
||||
*/
|
||||
disclosuresByThread: Record<string, PrivacyDisclosure[]>;
|
||||
/**
|
||||
* 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<string, boolean>;
|
||||
}
|
||||
|
||||
const initialState: PrivacyState = {
|
||||
privacyMode: null,
|
||||
disclosuresByThread: {},
|
||||
activeExternalByThread: {},
|
||||
};
|
||||
|
||||
/** Cap the per-thread ledger so a chatty turn can't grow it unbounded. */
|
||||
const MAX_DISCLOSURES_PER_THREAD = 20;
|
||||
|
||||
let disclosureSeq = 0;
|
||||
|
||||
/**
|
||||
* Build a {@link PrivacyDisclosure} from a socket event. Exported so the store
|
||||
* wiring in {@link ../providers/ChatRuntimeProvider} and unit tests share one
|
||||
* mapping. `id` is a monotonically-increasing client id (stable within a
|
||||
* session, never sent anywhere).
|
||||
*/
|
||||
export function disclosureFromEvent(event: ExternalTransferPendingEvent): PrivacyDisclosure {
|
||||
disclosureSeq += 1;
|
||||
return {
|
||||
id: `disclosure-${disclosureSeq}`,
|
||||
providerSlug: event.provider_slug,
|
||||
service: event.service,
|
||||
isExternal: event.is_external,
|
||||
reason: event.reason,
|
||||
dataKinds: event.data_kinds,
|
||||
riskLevel: event.risk_level,
|
||||
riskCategories: event.risk_categories,
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the current Privacy Mode from the core on boot. Mirrors the RPC
|
||||
* PrivacyModeSection uses (`config_get_privacy_mode`) whose result is the
|
||||
* double-wrapped `{ result: { mode } }` shape. Failures resolve to `null` so
|
||||
* the pill degrades gracefully rather than throwing.
|
||||
*/
|
||||
export const hydratePrivacyMode = createAsyncThunk<PrivacyMode | null>(
|
||||
'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<PrivacyMode>) => {
|
||||
privacyLog('[privacy] setPrivacyMode %s', action.payload);
|
||||
state.privacyMode = action.payload;
|
||||
},
|
||||
/** Append a disclosure for a thread, capping the ledger length. */
|
||||
pushDisclosureForThread: (
|
||||
state,
|
||||
action: PayloadAction<{ threadId: string; disclosure: PrivacyDisclosure }>
|
||||
) => {
|
||||
const { threadId, disclosure } = action.payload;
|
||||
const list = (state.disclosuresByThread[threadId] ??= []);
|
||||
list.push(disclosure);
|
||||
if (list.length > MAX_DISCLOSURES_PER_THREAD) {
|
||||
list.splice(0, list.length - MAX_DISCLOSURES_PER_THREAD);
|
||||
}
|
||||
// Mark the thread as having a live external transfer so the status pill
|
||||
// flips off-device. This is independent of the dismissible ledger above:
|
||||
// dismissing the card does NOT clear it (the transfer is still active) —
|
||||
// only the turn-boundary `clearActiveExternalForThread` does.
|
||||
if (disclosure.isExternal) {
|
||||
state.activeExternalByThread[threadId] = true;
|
||||
}
|
||||
privacyLog(
|
||||
'[privacy] pushDisclosureForThread thread=%s service=%s external=%s depth=%d',
|
||||
threadId,
|
||||
disclosure.service,
|
||||
String(disclosure.isExternal),
|
||||
list.length
|
||||
);
|
||||
},
|
||||
/** Dismiss a single disclosure by id (the only user action in S3). */
|
||||
dismissDisclosureForThread: (
|
||||
state,
|
||||
action: PayloadAction<{ threadId: string; id: string }>
|
||||
) => {
|
||||
const { threadId, id } = action.payload;
|
||||
const list = state.disclosuresByThread[threadId];
|
||||
if (!list) return;
|
||||
const next = list.filter(d => d.id !== id);
|
||||
if (next.length === 0) {
|
||||
delete state.disclosuresByThread[threadId];
|
||||
} else {
|
||||
state.disclosuresByThread[threadId] = next;
|
||||
}
|
||||
privacyLog('[privacy] dismissDisclosureForThread thread=%s id=%s', threadId, id);
|
||||
},
|
||||
/** Clear all disclosures for a thread (e.g. on turn start / thread reset). */
|
||||
clearDisclosuresForThread: (state, action: PayloadAction<{ threadId: string }>) => {
|
||||
delete state.disclosuresByThread[action.payload.threadId];
|
||||
privacyLog('[privacy] clearDisclosuresForThread thread=%s', action.payload.threadId);
|
||||
},
|
||||
/**
|
||||
* Clear the live external-transfer flag for a thread. Dispatched by
|
||||
* ChatRuntimeProvider on the turn boundary (chat_done / chat_error /
|
||||
* disconnect reconcile) so the pill returns to on-device once the turn's
|
||||
* external activity is over — even though the (un-dismissed) ledger entries
|
||||
* remain for the in-chat card's history.
|
||||
*/
|
||||
clearActiveExternalForThread: (state, action: PayloadAction<{ threadId: string }>) => {
|
||||
if (state.activeExternalByThread[action.payload.threadId]) {
|
||||
delete state.activeExternalByThread[action.payload.threadId];
|
||||
privacyLog('[privacy] clearActiveExternalForThread thread=%s', action.payload.threadId);
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
// On identity flip / sign-out, drop per-user disclosure history. The
|
||||
// privacy mode is a core-side setting re-hydrated on the next boot, so it
|
||||
// is safe to reset here too.
|
||||
builder.addCase(resetUserScopedState, () => initialState);
|
||||
builder.addCase(hydratePrivacyMode.fulfilled, (state, action) => {
|
||||
if (action.payload) state.privacyMode = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setPrivacyMode,
|
||||
pushDisclosureForThread,
|
||||
dismissDisclosureForThread,
|
||||
clearDisclosuresForThread,
|
||||
clearActiveExternalForThread,
|
||||
} = privacySlice.actions;
|
||||
|
||||
export default privacySlice.reducer;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user