Skip to content

Commit ba88d8c

Browse files
oxoxDevclaude
andauthored
fix(webview/meet): gate orchestrator handoff on user opt-in (tinyhumansai#1299) (tinyhumansai#1310)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a08ec95 commit ba88d8c

25 files changed

Lines changed: 715 additions & 10 deletions

File tree

app/src/components/settings/panels/PrivacyPanel.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ const KIND_BADGE_CLASS: Record<PrivacyDataKind, string> = {
3535

3636
const PrivacyPanel = () => {
3737
const { navigateBack, breadcrumbs } = useSettingsNavigation();
38-
const { snapshot, setAnalyticsEnabled } = useCoreState();
38+
const { snapshot, setAnalyticsEnabled, setMeetAutoOrchestratorHandoff } = useCoreState();
3939
const analyticsEnabled = snapshot.analyticsEnabled;
40+
const meetAutoHandoff = snapshot.meetAutoOrchestratorHandoff;
4041

4142
const [capabilities, setCapabilities] = useState<AnnotatedCapability[]>([]);
4243
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading');
@@ -73,6 +74,15 @@ const PrivacyPanel = () => {
7374
}
7475
};
7576

77+
const handleToggleMeetAutoHandoff = async () => {
78+
const newValue = !meetAutoHandoff;
79+
try {
80+
await setMeetAutoOrchestratorHandoff(newValue);
81+
} catch (error) {
82+
console.warn('[privacy] failed to persist meet auto-handoff setting:', error);
83+
}
84+
};
85+
7686
return (
7787
<div data-testid="settings-privacy-panel">
7888
<SettingsHeader
@@ -168,6 +178,42 @@ const PrivacyPanel = () => {
168178
</div>
169179
</div>
170180

181+
{/* Meeting Follow-ups Section (#1299) */}
182+
<div>
183+
<h3 className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-3 px-1">
184+
Meeting follow-ups
185+
</h3>
186+
<div className="bg-white rounded-xl border border-stone-200 overflow-hidden">
187+
<div className="flex items-center justify-between p-4">
188+
<div className="flex-1 mr-4">
189+
<p className="text-sm font-medium text-stone-900">
190+
Auto-handoff Google Meet transcripts to the orchestrator
191+
</p>
192+
<p className="text-xs text-stone-500 mt-1 leading-relaxed">
193+
When a Google Meet call ends, OpenHuman&rsquo;s orchestrator can read the
194+
transcript and may take actions like drafting messages, scheduling follow-ups,
195+
or posting summaries to your connected Slack workspace. Off by default.
196+
</p>
197+
</div>
198+
<button
199+
data-testid="privacy-meet-handoff-toggle"
200+
onClick={handleToggleMeetAutoHandoff}
201+
aria-label="Auto-handoff Google Meet transcripts to the orchestrator"
202+
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
203+
meetAutoHandoff ? 'bg-primary-500' : 'bg-stone-600'
204+
}`}
205+
role="switch"
206+
aria-checked={meetAutoHandoff}>
207+
<span
208+
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
209+
meetAutoHandoff ? 'translate-x-5' : 'translate-x-0'
210+
}`}
211+
/>
212+
</button>
213+
</div>
214+
</div>
215+
</div>
216+
171217
{/* Info Box */}
172218
<div className="p-4 bg-stone-50 rounded-xl border border-stone-200">
173219
<div className="flex items-start space-x-3">

app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { screen, waitFor } from '@testing-library/react';
1+
import { fireEvent, screen, waitFor } from '@testing-library/react';
22
import { beforeEach, describe, expect, it, vi } from 'vitest';
33

44
import { renderWithProviders } from '../../../../test/test-utils';
@@ -7,8 +7,13 @@ import PrivacyPanel from '../PrivacyPanel';
77

88
vi.mock('../../../../utils/tauriCommands/aboutApp', () => ({ listCapabilities: vi.fn() }));
99

10+
const setMeetAutoOrchestratorHandoffMock = vi.fn();
1011
vi.mock('../../../../providers/CoreStateProvider', () => ({
11-
useCoreState: () => ({ snapshot: { analyticsEnabled: false }, setAnalyticsEnabled: vi.fn() }),
12+
useCoreState: () => ({
13+
snapshot: { analyticsEnabled: false, meetAutoOrchestratorHandoff: false },
14+
setAnalyticsEnabled: vi.fn(),
15+
setMeetAutoOrchestratorHandoff: (v: boolean) => setMeetAutoOrchestratorHandoffMock(v),
16+
}),
1217
}));
1318

1419
vi.mock('../../hooks/useSettingsNavigation', () => ({
@@ -88,7 +93,21 @@ describe('PrivacyPanel', () => {
8893
expect(screen.getByTestId('privacy-load-error')).toBeTruthy();
8994
});
9095
expect(screen.queryByTestId('privacy-capability-list')).toBeNull();
91-
// Analytics toggle still rendered
92-
expect(screen.getByRole('switch')).toBeTruthy();
96+
// Analytics + meet-handoff toggles still rendered
97+
expect(screen.getAllByRole('switch').length).toBeGreaterThanOrEqual(2);
98+
});
99+
100+
it('flips the meet auto-handoff toggle from OFF to ON when clicked (#1299)', async () => {
101+
vi.mocked(listCapabilities).mockResolvedValue([]);
102+
renderWithProviders(<PrivacyPanel />);
103+
104+
const toggle = await screen.findByTestId('privacy-meet-handoff-toggle');
105+
expect(toggle.getAttribute('aria-checked')).toBe('false');
106+
107+
fireEvent.click(toggle);
108+
109+
await waitFor(() => {
110+
expect(setMeetAutoOrchestratorHandoffMock).toHaveBeenCalledWith(true);
111+
});
93112
});
94113
});

app/src/lib/coreState/__tests__/store.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ function makeSnapshot(overrides: Partial<CoreAppSnapshot> = {}): CoreAppSnapshot
1010
onboardingCompleted: true,
1111
chatOnboardingCompleted: false,
1212
analyticsEnabled: false,
13+
meetAutoOrchestratorHandoff: false,
1314
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
1415
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
1516
...overrides,

app/src/lib/coreState/store.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ export interface CoreAppSnapshot {
4646
*/
4747
chatOnboardingCompleted: boolean;
4848
analyticsEnabled: boolean;
49+
/**
50+
* Whether ending a Google Meet call hands the transcript to the
51+
* orchestrator agent for proactive follow-up actions (drafting Slack
52+
* messages, scheduling, etc.). Mirrors
53+
* `Config::meet.auto_orchestrator_handoff` in the Rust core (see
54+
* `src/openhuman/config/schema/meet.rs`). Defaults to `false` —
55+
* privacy-conservative gate added in #1299. The webview meet flow
56+
* reads this before invoking `handoffToOrchestrator`.
57+
*/
58+
meetAutoOrchestratorHandoff: boolean;
4959
localState: CoreLocalState;
5060
runtime: CoreRuntimeSnapshot;
5161
}
@@ -66,6 +76,7 @@ const emptySnapshot: CoreAppSnapshot = {
6676
onboardingCompleted: false,
6777
chatOnboardingCompleted: false,
6878
analyticsEnabled: false,
79+
meetAutoOrchestratorHandoff: false,
6980
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
7081
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
7182
};

app/src/providers/CoreStateProvider.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { loadThreads, resetThreadCachesPreservingSelection } from '../store/thre
3232
import { getActiveUserId, setActiveUserId } from '../store/userScopedStorage';
3333
import {
3434
openhumanUpdateAnalyticsSettings,
35+
openhumanUpdateMeetSettings,
3536
restartApp,
3637
setOnboardingCompleted,
3738
storeSession,
@@ -66,6 +67,7 @@ interface CoreStateContextValue extends CoreState {
6667
refreshTeamMembers: (teamId: string) => Promise<void>;
6768
refreshTeamInvites: (teamId: string) => Promise<void>;
6869
setAnalyticsEnabled: (enabled: boolean) => Promise<void>;
70+
setMeetAutoOrchestratorHandoff: (enabled: boolean) => Promise<void>;
6971
setOnboardingCompletedFlag: (value: boolean) => Promise<void>;
7072
setEncryptionKey: (value: string | null) => Promise<void>;
7173
setPrimaryWalletAddress: (value: string | null) => Promise<void>;
@@ -142,6 +144,7 @@ function normalizeSnapshot(
142144
onboardingCompleted: result.onboardingCompleted,
143145
chatOnboardingCompleted: result.chatOnboardingCompleted,
144146
analyticsEnabled: result.analyticsEnabled,
147+
meetAutoOrchestratorHandoff: result.meetAutoOrchestratorHandoff ?? false,
145148
localState: {
146149
encryptionKey: result.localState.encryptionKey ?? null,
147150
primaryWalletAddress: result.localState.primaryWalletAddress ?? null,
@@ -479,6 +482,22 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
479482
[commitState, refresh]
480483
);
481484

485+
const setMeetAutoOrchestratorHandoff = useCallback(
486+
async (enabled: boolean) => {
487+
await openhumanUpdateMeetSettings({ auto_orchestrator_handoff: enabled });
488+
// Optimistic commit so the toggle flips instantly; full snapshot
489+
// refresh follows so the cached value matches what core just wrote.
490+
commitState(previous => ({
491+
...previous,
492+
snapshot: { ...previous.snapshot, meetAutoOrchestratorHandoff: enabled },
493+
}));
494+
await refresh().catch(err => {
495+
log('refresh failed after setMeetAutoOrchestratorHandoff: %O', sanitizeError(err));
496+
});
497+
},
498+
[commitState, refresh]
499+
);
500+
482501
const setOnboardingCompletedFlag = useCallback(
483502
async (value: boolean) => {
484503
await setOnboardingCompleted(value);
@@ -567,6 +586,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
567586
refreshTeamInvites,
568587
patchSnapshot,
569588
setAnalyticsEnabled,
589+
setMeetAutoOrchestratorHandoff,
570590
setOnboardingCompletedFlag,
571591
setEncryptionKey: value => updateLocalState({ encryptionKey: value }),
572592
setPrimaryWalletAddress: value => updateLocalState({ primaryWalletAddress: value }),
@@ -582,6 +602,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
582602
refreshTeams,
583603
patchSnapshot,
584604
setAnalyticsEnabled,
605+
setMeetAutoOrchestratorHandoff,
585606
setOnboardingCompletedFlag,
586607
state,
587608
storeSessionToken,

app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ function resetCoreStateStore() {
7474
onboardingCompleted: false,
7575
chatOnboardingCompleted: false,
7676
analyticsEnabled: false,
77+
meetAutoOrchestratorHandoff: false,
7778
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
7879
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
7980
},

app/src/providers/__tests__/CoreStateProvider.test.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useEffect } from 'react';
33
import { beforeEach, describe, expect, it, vi } from 'vitest';
44

55
import * as coreStateApi from '../../services/coreStateApi';
6+
import * as tauriCommands from '../../utils/tauriCommands';
67
import { setCoreStateSnapshot } from '../../lib/coreState/store';
78
import CoreStateProvider, { useCoreState } from '../CoreStateProvider';
89

@@ -78,6 +79,7 @@ function resetCoreStateStore() {
7879
onboardingCompleted: false,
7980
chatOnboardingCompleted: false,
8081
analyticsEnabled: false,
82+
meetAutoOrchestratorHandoff: false,
8183
localState: { encryptionKey: null, primaryWalletAddress: null, onboardingTasks: null },
8284
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
8385
},
@@ -241,4 +243,68 @@ describe('CoreStateProvider — identity-change cache clearing', () => {
241243
expect(ctx?.snapshot.currentUser).toEqual({ first_name: 'Ada', username: 'ada' })
242244
);
243245
});
246+
247+
it('setMeetAutoOrchestratorHandoff(true) calls update RPC + flips snapshot optimistically (#1299)', async () => {
248+
fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
249+
listTeams.mockResolvedValue([]);
250+
vi.mocked(tauriCommands.openhumanUpdateMeetSettings).mockReset();
251+
vi.mocked(tauriCommands.openhumanUpdateMeetSettings).mockResolvedValue({
252+
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
253+
logs: [],
254+
} as never);
255+
256+
let ctx: CoreStateContextValue | undefined;
257+
render(
258+
<CoreStateProvider>
259+
<Consumer
260+
captureCtx={next => {
261+
ctx = next;
262+
}}
263+
/>
264+
</CoreStateProvider>
265+
);
266+
267+
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
268+
expect(ctx?.snapshot.meetAutoOrchestratorHandoff).toBe(false);
269+
270+
await act(async () => {
271+
await ctx!.setMeetAutoOrchestratorHandoff(true);
272+
});
273+
274+
expect(vi.mocked(tauriCommands.openhumanUpdateMeetSettings)).toHaveBeenCalledWith({
275+
auto_orchestrator_handoff: true,
276+
});
277+
});
278+
279+
it('setMeetAutoOrchestratorHandoff swallows refresh errors after the RPC succeeds (#1299)', async () => {
280+
fetchSnapshot.mockResolvedValueOnce(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
281+
listTeams.mockResolvedValue([]);
282+
vi.mocked(tauriCommands.openhumanUpdateMeetSettings).mockReset();
283+
vi.mocked(tauriCommands.openhumanUpdateMeetSettings).mockResolvedValue({
284+
result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' },
285+
logs: [],
286+
} as never);
287+
288+
let ctx: CoreStateContextValue | undefined;
289+
render(
290+
<CoreStateProvider>
291+
<Consumer
292+
captureCtx={next => {
293+
ctx = next;
294+
}}
295+
/>
296+
</CoreStateProvider>
297+
);
298+
299+
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
300+
fetchSnapshot.mockRejectedValueOnce(new Error('refresh failed'));
301+
302+
await act(async () => {
303+
await expect(ctx!.setMeetAutoOrchestratorHandoff(false)).resolves.toBeUndefined();
304+
});
305+
306+
expect(vi.mocked(tauriCommands.openhumanUpdateMeetSettings)).toHaveBeenCalledWith({
307+
auto_orchestrator_handoff: false,
308+
});
309+
});
244310
});

0 commit comments

Comments
 (0)