Skip to content

Commit f255aa5

Browse files
committed
fix(desktop): keep the old notification rule with the flag off, and announce only the user's own chats
- Every existing notification is held back whenever the window is focused, as before. Only a background chat's completion, now marked as such, is shown when the focused window is somewhere else. With the flag off, a flag-off user watching the workflow panel is no longer told about the chat they are looking at. The settings label is back to its original wording. - Workspace chat events carry every member's chats. A completion is now announced only for a chat this user's own desktop was running, so a teammate's finished chat raises nothing. - The sidebar's activity reads get their own rate-limit bucket, so polling tabs can never spend the tokens a device needs to renew its leases. - An offline desktop's chat now says it is waiting for the device to come back, not that it is blocked. - The rule for which turns bind to a desktop is a named function, tested on its outcome.
1 parent 0f92eaa commit f255aa5

13 files changed

Lines changed: 109 additions & 35 deletions

File tree

‎apps/desktop/src/main/desktop-settings.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ describe('desktop notifications in the default "background only" mode', () => {
4949
title: 'Fix CI',
5050
body: 'Sim finished responding.',
5151
route: BACKGROUND_CHAT,
52+
background: true,
5253
})
5354

5455
expect(shown).toBe(true)
@@ -60,6 +61,7 @@ describe('desktop notifications in the default "background only" mode', () => {
6061
title: 'Fix CI',
6162
body: 'Sim finished responding.',
6263
route: BACKGROUND_CHAT,
64+
background: true,
6365
})
6466

6567
expect(shown).toBe(false)
@@ -74,4 +76,16 @@ describe('desktop notifications in the default "background only" mode', () => {
7476

7577
expect(shown).toBe(false)
7678
})
79+
80+
it('holds back an ordinary notification whenever the window is focused, wherever it is', () => {
81+
// The workflow panel's chat completes while the user watches it on the workflow page.
82+
const shown = service({ url: 'https://sim.ai/workspace/ws-1/w/wf-1', focused: true }).notify({
83+
title: 'Task complete',
84+
body: 'Sim finished responding.',
85+
route: BACKGROUND_CHAT,
86+
})
87+
88+
expect(shown).toBe(false)
89+
expect(Notification.instances).toHaveLength(0)
90+
})
7791
})

‎apps/desktop/src/main/desktop-settings.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,10 +218,12 @@ export function createDesktopSettingsService(
218218
return false
219219
}
220220
const window = deps.getMainWindow()
221+
// A focused window holds back every notification, as it always has. Only a background
222+
// chat's completion is shown anyway when the focused window is somewhere else.
221223
if (
222224
preferences.notificationsOnlyWhenUnfocused &&
223225
window?.isFocused() &&
224-
showsRoute(window, payload.route)
226+
(!payload.background || showsRoute(window, payload.route))
225227
) {
226228
return false
227229
}

‎apps/desktop/src/main/ipc.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,11 @@ export function parseDesktopNotificationPayload(raw: unknown): DesktopNotificati
304304
if (typeof raw !== 'object' || raw === null) {
305305
return null
306306
}
307-
const { title, body, route } = raw as {
307+
const { title, body, route, background } = raw as {
308308
title?: unknown
309309
body?: unknown
310310
route?: unknown
311+
background?: unknown
311312
}
312313
if (
313314
typeof title !== 'string' ||
@@ -322,7 +323,12 @@ export function parseDesktopNotificationPayload(raw: unknown): DesktopNotificati
322323
if (route !== undefined && (typeof route !== 'string' || !isSafeInternalPath(route))) {
323324
return null
324325
}
325-
return { title, body, ...(route !== undefined ? { route } : {}) }
326+
return {
327+
title,
328+
body,
329+
...(route !== undefined ? { route } : {}),
330+
...(background === true ? { background: true } : {}),
331+
}
326332
}
327333

328334
export interface IpcDeps {

‎apps/sim/app/api/desktop/activity/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { listDesktopActivityContract } from '@/lib/api/contracts/desktop-executor'
22
import { defineInternalJsonRoute, internalSessionAuth } from '@/lib/api/server/routes'
33
import {
4+
desktopActivityRateLimit,
45
desktopExecutorErrorPolicy,
5-
desktopExecutorRateLimit,
66
} from '@/lib/api/server/routes/desktop-executor'
77
import { listDesktopActivity } from '@/lib/desktop/application/activity'
88

@@ -12,7 +12,7 @@ export const GET = defineInternalJsonRoute({
1212
contract: listDesktopActivityContract,
1313
auth: internalSessionAuth,
1414
operation: listDesktopActivity.operation,
15-
rateLimit: desktopExecutorRateLimit,
15+
rateLimit: desktopActivityRateLimit,
1616
errorPolicy: desktopExecutorErrorPolicy,
1717
mapInput: ({ query }) => ({ workspaceId: query.workspaceId }),
1818
useCase: listDesktopActivity,

‎apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export function Desktop() {
125125
/>
126126
<PreferenceRow
127127
id='desktop-notifications-unfocused'
128-
label='Notify only for chats in the background'
128+
label="Notify only when Sim isn't focused"
129129
checked={preferences.notificationsOnlyWhenUnfocused}
130130
disabled={notificationsDisabled || pendingPreference !== null}
131131
onCheckedChange={(checked) =>

‎apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ const DESKTOP_ACTIVITY_COLOR: Record<DesktopChatActivity['state'], string> = {
193193

194194
function desktopActivityLabel({ state, deviceName }: DesktopChatActivity): string {
195195
if (state === 'needs_input') return 'Needs input'
196-
if (state === 'blocked') return `Blocked: ${deviceName} is offline`
196+
if (state === 'blocked') return `Waiting for ${deviceName} to come back online`
197197
return `Running on ${deviceName}`
198198
}
199199

‎apps/sim/hooks/use-mothership-chat-events.test.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,9 @@ describe('reflectBackgroundChatStatus', () => {
302302
queryClient.setQueryData(mothershipChatKeys.ownerList('ws-1'), [
303303
{ id: 'chat-b', name: 'Fix CI' },
304304
])
305-
queryClient.setQueryData(desktopActivityKeys.list('ws-1'), [])
305+
queryClient.setQueryData(desktopActivityKeys.list('ws-1'), [
306+
{ chatId: 'chat-b', state: 'running', deviceName: 'Studio Mac' },
307+
])
306308
}
307309

308310
const activityStale = () =>
@@ -316,7 +318,12 @@ describe('reflectBackgroundChatStatus', () => {
316318
reflectBackgroundChatStatus(queryClient, 'ws-1', completed, true)
317319

318320
expect(shown).toEqual([
319-
{ title: 'Fix CI', body: 'Sim finished responding.', route: '/workspace/ws-1/chat/chat-b' },
321+
{
322+
title: 'Fix CI',
323+
body: 'Sim finished responding.',
324+
route: '/workspace/ws-1/chat/chat-b',
325+
background: true,
326+
},
320327
])
321328
})
322329

@@ -374,4 +381,17 @@ describe('reflectBackgroundChatStatus', () => {
374381

375382
expect(activityStale()).toBe(false)
376383
})
384+
385+
it("never announces a teammate's chat that finished in the same workspace", () => {
386+
showing('/workspace/ws-1/chat/chat-c')
387+
388+
reflectBackgroundChatStatus(
389+
queryClient,
390+
'ws-1',
391+
JSON.stringify({ chatId: 'teammate-chat', type: 'completed', streamId: 's-9' }),
392+
true
393+
)
394+
395+
expect(shown).toEqual([])
396+
})
377397
})

‎apps/sim/hooks/use-mothership-chat-events.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
33
import { getErrorMessage } from '@sim/utils/errors'
44
import type { QueryClient } from '@tanstack/react-query'
55
import { useQueryClient } from '@tanstack/react-query'
6+
import type { DesktopChatActivity } from '@/lib/api/contracts/desktop-executor'
67
import { getDesktopBridge } from '@/lib/desktop'
78
import { suspendDesktopChatScopes } from '@/lib/desktop/chat-scope'
89
import { createRotatingEventSource } from '@/lib/events/rotating-event-source'
@@ -189,8 +190,8 @@ function chatRoute(owner: MothershipChatOwner, chatId: string): string {
189190

190191
/**
191192
* Reflects a turn starting or ending in the chats that run in the background: the desktop
192-
* activity list changes and, with `announceCompletions`, a chat the user is not looking at that
193-
* finished its turn is announced. The desktop app decides whether to show that notification
193+
* activity list changes and, with `announceCompletions`, a chat of the user's own that their
194+
* desktop was running and that they are not looking at is announced when its turn finishes. The desktop app decides whether to show that notification
194195
* (notifications on, the chat not on screen in the focused window); the chat on screen announces
195196
* its own completion.
196197
*/
@@ -202,8 +203,18 @@ export function reflectBackgroundChatStatus(
202203
): void {
203204
const payload = parseChatStatusEventPayload(data)
204205
if (payload?.type !== 'started' && payload?.type !== 'completed') return
206+
// Read before the refresh below drops it. The events carry every member's chats in the
207+
// workspace; only one this user's own desktop was running is theirs to be told about.
208+
const ranOnDesktop =
209+
typeof owner === 'string' &&
210+
Boolean(
211+
queryClient
212+
.getQueryData<DesktopChatActivity[]>(desktopActivityKeys.list(owner))
213+
?.some((activity) => activity.chatId === payload.chatId)
214+
)
205215
queryClient.invalidateQueries({ queryKey: desktopActivityKeys.lists() })
206216
if (!announceCompletions || payload.type !== 'completed' || !payload.chatId) return
217+
if (!ranOnDesktop) return
207218
const settings = getDesktopBridge()?.settings
208219
if (!settings) return
209220
const route = chatRoute(owner, payload.chatId)
@@ -213,7 +224,12 @@ export function reflectBackgroundChatStatus(
213224
)
214225
const name = chats?.find((chat) => chat.id === payload.chatId)?.name
215226
void settings
216-
.notify({ title: name ?? 'Task complete', body: 'Sim finished responding.', route })
227+
.notify({
228+
title: name ?? 'Task complete',
229+
body: 'Sim finished responding.',
230+
route,
231+
background: true,
232+
})
217233
.catch((error) =>
218234
logger.warn('Could not show a chat completion notification', {
219235
error: getErrorMessage(error),

‎apps/sim/lib/api/server/routes/desktop-executor.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,12 @@ export const desktopExecutorRateLimit = internalRateLimits.user({
3434
bucketName: 'desktop-executor',
3535
config: { maxTokens: 600, refillRate: 10, refillIntervalMs: 1_000 },
3636
})
37+
38+
/**
39+
* The sidebar's desktop activity reads, in a bucket of their own: however many tabs poll it, they
40+
* can never spend the tokens a device needs to renew its leases.
41+
*/
42+
export const desktopActivityRateLimit = internalRateLimits.user({
43+
bucketName: 'desktop-activity',
44+
config: { maxTokens: 120, refillRate: 2, refillIntervalMs: 1_000 },
45+
})

‎apps/sim/lib/desktop/application/activity.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ const logger = createLogger('DesktopActivity')
1111
/**
1212
* - `running`: the desktop is working on the chat's turn.
1313
* - `needs_input`: the turn waits on the user: an approval, a terminal handoff, a browser takeover.
14-
* - `blocked`: the desktop the turn runs on is offline, so its desktop calls cannot start.
14+
* - `blocked`: the desktop the turn runs on is offline; its desktop calls fail as not run until it
15+
* returns, while the rest of the turn carries on.
1516
*/
1617
type DesktopChatActivityState = 'running' | 'needs_input' | 'blocked'
1718

@@ -35,7 +36,8 @@ async function readPresence(deviceId: string): Promise<boolean> {
3536
/**
3637
* Which of the caller's chats in a workspace are running on one of their desktops, and in what
3738
* state. Lists only the caller's own runs, never their content, so it needs no workspace role.
38-
* Runs already bound keep showing after the executor is turned off, until they end.
39+
* The endpoint is not flag-gated (runs already bound are listed until they end); the sidebar asks
40+
* only while the background executor is on for the user.
3941
*/
4042
export const listDesktopActivity = defineAuthorizedCredentialUserUseCase({
4143
// permission-group-exempt: reports only the caller's own runs, with no content.

0 commit comments

Comments
 (0)