Skip to content

Commit ea220b3

Browse files
committed
fix(composer): react to desktop preference updates
1 parent 29cff7b commit ea220b3

4 files changed

Lines changed: 114 additions & 10 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
44
import {
55
Button,
66
cn,
@@ -22,6 +22,7 @@ import {
2222
BROWSER_SESSION_RESOURCE_ID,
2323
TERMINAL_SESSION_RESOURCE_ID,
2424
} from '@/lib/copilot/resources/types'
25+
import { subscribeDesktopPreferences } from '@/lib/desktop'
2526
import { isTerminalAvailable } from '@/lib/terminal/transport'
2627
import {
2728
type AvailableItem,
@@ -145,6 +146,16 @@ export function useAvailableResources(
145146
): AvailableResources {
146147
const enabled = options?.enabled ?? true
147148
const excludeTypes = options?.excludeTypes
149+
const browserAvailable = useSyncExternalStore(
150+
subscribeDesktopPreferences,
151+
isBrowserAgentAvailable,
152+
() => false
153+
)
154+
const terminalAvailable = useSyncExternalStore(
155+
subscribeDesktopPreferences,
156+
isTerminalAvailable,
157+
() => false
158+
)
148159
// Destructured without `= []` defaults on purpose: a literal default allocates a
149160
// fresh array every render while `data` is undefined (exactly the disabled state),
150161
// which would bust the group memo below on every render. Undefined is stable.
@@ -292,7 +303,7 @@ export function useAvailableResources(
292303
]
293304
// The live browser panel — desktop app only (needs the agent-browser
294305
// bridge). There is one top-level panel; repeated launches open inner tabs.
295-
if (isBrowserAgentAvailable()) {
306+
if (browserAvailable) {
296307
groups.push({
297308
type: 'browser' as const,
298309
items: [
@@ -305,7 +316,7 @@ export function useAvailableResources(
305316
}
306317
// The live terminal — desktop app only (needs the PTY bridge), and a
307318
// single top-level panel like the browser.
308-
if (isTerminalAvailable()) {
319+
if (terminalAvailable) {
309320
groups.push({
310321
type: 'terminal' as const,
311322
items: [
@@ -319,6 +330,8 @@ export function useAvailableResources(
319330
return groups.filter((g) => !excluded.has(g.type)).sort(byResourceMenuOrder)
320331
}, [
321332
enabled,
333+
browserAvailable,
334+
terminalAvailable,
322335
workflows,
323336
folders,
324337
fileFolders,

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @vitest-environment jsdom
33
*/
44
import { act, createRef } from 'react'
5+
import type { DesktopPreferences } from '@sim/desktop-bridge'
56
import { createRoot, type Root } from 'react-dom/client'
67
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
78

@@ -51,6 +52,7 @@ import {
5152
BROWSER_SESSION_RESOURCE_ID,
5253
TERMINAL_SESSION_RESOURCE_ID,
5354
} from '@/lib/copilot/resources/types'
55+
import { setDesktopPreferencesSnapshot } from '@/lib/desktop'
5456
import {
5557
mapResourceToContext,
5658
type PlusMenuHandle,
@@ -60,6 +62,16 @@ import { PlusMenuDropdown } from '@/app/workspace/[workspaceId]/home/components/
6062
let root: Root
6163
let container: HTMLDivElement
6264

65+
const PREFERENCES: DesktopPreferences = {
66+
notificationsEnabled: true,
67+
notificationSounds: true,
68+
notificationsOnlyWhenUnfocused: true,
69+
launchAtLogin: false,
70+
autoDownloadUpdates: true,
71+
browserEnabled: true,
72+
terminalEnabled: true,
73+
}
74+
6375
function openMenu(mention = false) {
6476
const ref = createRef<PlusMenuHandle>()
6577
const onResourceSelect = vi.fn()
@@ -110,6 +122,7 @@ describe('PlusMenuDropdown desktop resources', () => {
110122
vi.clearAllMocks()
111123
fixtures.browserAvailable.mockReturnValue(true)
112124
fixtures.terminalAvailable.mockReturnValue(true)
125+
setDesktopPreferencesSnapshot(PREFERENCES)
113126
Object.defineProperty(Element.prototype, 'scrollIntoView', {
114127
configurable: true,
115128
value: vi.fn(),
@@ -165,6 +178,19 @@ describe('PlusMenuDropdown desktop resources', () => {
165178
})
166179
})
167180

181+
it.each([false, true])(
182+
'updates mounted desktop rows when preferences change in mention=%s mode',
183+
(mention) => {
184+
openMenu(mention)
185+
expect(menuItems().map((item) => item.textContent)).toContain('Browser')
186+
187+
fixtures.browserAvailable.mockReturnValue(false)
188+
act(() => setDesktopPreferencesSnapshot({ ...PREFERENCES, browserEnabled: false }))
189+
expect(menuItems().map((item) => item.textContent)).not.toContain('Browser')
190+
expect(menuItems().map((item) => item.textContent)).toContain('Terminal')
191+
}
192+
)
193+
168194
it('finds Browser through plus-menu search and selects it with Enter', () => {
169195
const { onResourceSelect } = openMenu()
170196
const search = document.querySelector<HTMLInputElement>(

apps/sim/lib/desktop/index.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,25 +75,32 @@ export function prefersInPlaceNavigation(): boolean {
7575
* reads availability synchronously while the shell only answers over async
7676
* IPC. An unread value uses the shell default (enabled).
7777
*
78-
* The cache is authoritative at call time, which is what tool execution and
79-
* capability reporting need. React trees that read it in a memo settle on the
80-
* next mount — flipping a switch happens on a settings route, so the chat view
81-
* has unmounted by then anyway.
78+
* Synchronous callers read the cache at call time. React consumers subscribe
79+
* to the same snapshot so asynchronous initialization and settings changes
80+
* update mounted UI without keeping a second copy of the preferences.
8281
*/
8382
let devicePreferences: DesktopPreferences | null = null
8483
let devicePreferencesLoad: Promise<void> | null = null
84+
const devicePreferencesListeners = new Set<() => void>()
8585

8686
function loadDevicePreferences(): Promise<void> {
8787
devicePreferencesLoad ??=
8888
getDesktopBridge()
8989
?.settings.getPreferences()
90-
.then((preferences) => {
91-
devicePreferences = preferences
92-
})
90+
.then(setDesktopPreferencesSnapshot)
9391
.catch(() => {}) ?? Promise.resolve()
9492
return devicePreferencesLoad
9593
}
9694

95+
/** Subscribes React consumers to the shared desktop-preference snapshot. */
96+
export function subscribeDesktopPreferences(listener: () => void): () => void {
97+
devicePreferencesListeners.add(listener)
98+
void loadDevicePreferences()
99+
return () => {
100+
devicePreferencesListeners.delete(listener)
101+
}
102+
}
103+
97104
function isSurfaceSwitchedOn(key: 'browserEnabled' | 'terminalEnabled'): boolean {
98105
void loadDevicePreferences()
99106
return devicePreferences?.[key] !== false
@@ -106,6 +113,7 @@ function isSurfaceSwitchedOn(key: 'browserEnabled' | 'terminalEnabled'): boolean
106113
export function setDesktopPreferencesSnapshot(preferences: DesktopPreferences): void {
107114
devicePreferences = preferences
108115
devicePreferencesLoad = Promise.resolve()
116+
for (const listener of devicePreferencesListeners) listener()
109117
}
110118

111119
/** True when the agent browser is installed and switched on for this device. */
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type { DesktopPreferences } from '@sim/desktop-bridge'
5+
import { afterEach, expect, it, vi } from 'vitest'
6+
import {
7+
isBrowserAgentEnabled,
8+
isTerminalEnabled,
9+
setDesktopPreferencesSnapshot,
10+
subscribeDesktopPreferences,
11+
} from '@/lib/desktop'
12+
13+
afterEach(() => vi.unstubAllGlobals())
14+
15+
it('publishes asynchronous startup preferences and later settings changes to subscribers', async () => {
16+
const preferences: DesktopPreferences = {
17+
notificationsEnabled: true,
18+
notificationSounds: true,
19+
notificationsOnlyWhenUnfocused: true,
20+
launchAtLogin: false,
21+
autoDownloadUpdates: true,
22+
browserEnabled: false,
23+
terminalEnabled: true,
24+
}
25+
const pending = Promise.withResolvers<DesktopPreferences>()
26+
const getPreferences = vi.fn(() => pending.promise)
27+
vi.stubGlobal('window', { simDesktop: { settings: { getPreferences } } })
28+
const listener = vi.fn()
29+
const unsubscribe = subscribeDesktopPreferences(listener)
30+
const otherListener = vi.fn()
31+
const unsubscribeOther = subscribeDesktopPreferences(otherListener)
32+
33+
try {
34+
expect(getPreferences).toHaveBeenCalledOnce()
35+
expect(isBrowserAgentEnabled()).toBe(true)
36+
expect(isTerminalEnabled()).toBe(true)
37+
expect(listener).not.toHaveBeenCalled()
38+
39+
pending.resolve(preferences)
40+
await pending.promise
41+
expect(listener).toHaveBeenCalledOnce()
42+
expect(otherListener).toHaveBeenCalledOnce()
43+
expect(isBrowserAgentEnabled()).toBe(false)
44+
expect(isTerminalEnabled()).toBe(true)
45+
46+
unsubscribeOther()
47+
const next = { ...preferences, browserEnabled: true }
48+
setDesktopPreferencesSnapshot(next)
49+
expect(listener).toHaveBeenCalledTimes(2)
50+
expect(otherListener).toHaveBeenCalledOnce()
51+
expect(isBrowserAgentEnabled()).toBe(true)
52+
expect(getPreferences).toHaveBeenCalledOnce()
53+
} finally {
54+
unsubscribe()
55+
unsubscribeOther()
56+
}
57+
})

0 commit comments

Comments
 (0)