Skip to content

Commit 2fb20a5

Browse files
committed
feat(desktop): show which chats a desktop runs in the background
Behind mothership-desktop-background-executor (resolved server-side and passed through the workspace feature flags): - GET /api/desktop/activity lists the caller's chats in a workspace whose turn runs on one of their desktops, as running, needs input (a call waits for approval) or blocked (that desktop is offline), with the device name. - The sidebar marks those chats with a status dot whose tooltip reads "Running on <device>", "Needs input" or "Blocked: <device> is offline"; unread replies keep the existing Ready dot. - A chat view showing a turn a desktop runs in the background only displays its desktop calls and never executes them.
1 parent 0a5f255 commit 2fb20a5

14 files changed

Lines changed: 492 additions & 2 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { listDesktopActivityContract } from '@/lib/api/contracts/desktop-executor'
2+
import { defineInternalJsonRoute, internalSessionAuth } from '@/lib/api/server/routes'
3+
import {
4+
desktopExecutorErrorPolicy,
5+
desktopExecutorRateLimit,
6+
} from '@/lib/api/server/routes/desktop-executor'
7+
import { listDesktopActivity } from '@/lib/desktop/application/executor'
8+
9+
export const dynamic = 'force-dynamic'
10+
11+
export const GET = defineInternalJsonRoute({
12+
contract: listDesktopActivityContract,
13+
auth: internalSessionAuth,
14+
operation: listDesktopActivity.operation,
15+
rateLimit: desktopExecutorRateLimit,
16+
errorPolicy: desktopExecutorErrorPolicy,
17+
mapInput: ({ query }) => ({ workspaceId: query.workspaceId }),
18+
useCase: listDesktopActivity,
19+
staticResponseHeaders: { 'Cache-Control': 'no-store' },
20+
})

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ async function render(
235235
'table-row-ttl': false,
236236
'mothership-model-selector': mocks.advanced,
237237
'mothership-plan-mode': mocks.plan,
238+
'mothership-desktop-background-executor': false,
238239
}}
239240
>
240241
<Harness />
@@ -313,6 +314,7 @@ it.each([
313314
'table-row-ttl': false,
314315
'mothership-model-selector': false,
315316
'mothership-plan-mode': planEnabled,
317+
'mothership-desktop-background-executor': false,
316318
}}
317319
>
318320
<Harness />
@@ -408,6 +410,7 @@ it('keeps restored queued skills scoped when replacing a draft', async () => {
408410
'table-row-ttl': false,
409411
'mothership-model-selector': mocks.advanced,
410412
'mothership-plan-mode': mocks.plan,
413+
'mothership-desktop-background-executor': false,
411414
}}
412415
>
413416
<Harness />

‎apps/sim/app/o/[organizationId]/layout.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export default async function OrganizationLayout({
7979
'table-row-ttl': tableRowTtlEnabled,
8080
'mothership-model-selector': modelSelectorEnabled,
8181
'mothership-plan-mode': planModeEnabled,
82+
'mothership-desktop-background-executor': false,
8283
}}
8384
>
8485
<OrganizationProvider context={context}>

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,60 @@ describe('tool events (dispatch → model + side effects)', () => {
108108
)
109109
})
110110

111+
it("only shows a desktop call when the chat's turn runs on a desktop in the background", () => {
112+
const startClientBrowserTool = vi.fn()
113+
const deps = makeStreamLoopDeps({ startClientBrowserTool, chatIdRef: ref('chat-1') })
114+
vi.mocked(deps.queryClient.getQueryData).mockImplementation((key) =>
115+
JSON.stringify(key) === JSON.stringify(['desktop-activity', 'list', 'ws-1'])
116+
? [{ chatId: 'chat-1', state: 'running', deviceName: 'MacBook' }]
117+
: undefined
118+
)
119+
const ctx = createStreamLoopContext(deps)
120+
121+
dispatchStreamEvent(
122+
ctx,
123+
toolEnv({
124+
phase: 'call',
125+
executor: 'client',
126+
mode: 'async',
127+
toolCallId: 'click-1',
128+
toolName: 'browser_click',
129+
arguments: { ref: 'e1' },
130+
status: 'executing',
131+
})
132+
)
133+
134+
expect(toolNode(ctx, 'click-1').status).toBe('running')
135+
expect(startClientBrowserTool).not.toHaveBeenCalled()
136+
})
137+
138+
it('runs a desktop call in the view when no desktop runs the chat in the background', () => {
139+
const startClientBrowserTool = vi.fn()
140+
const ctx = createStreamLoopContext(
141+
makeStreamLoopDeps({ startClientBrowserTool, chatIdRef: ref('chat-1') })
142+
)
143+
144+
dispatchStreamEvent(
145+
ctx,
146+
toolEnv({
147+
phase: 'call',
148+
executor: 'client',
149+
mode: 'async',
150+
toolCallId: 'click-2',
151+
toolName: 'browser_click',
152+
arguments: { ref: 'e1' },
153+
status: 'executing',
154+
})
155+
)
156+
157+
expect(startClientBrowserTool).toHaveBeenCalledWith(
158+
'click-2',
159+
'browser_click',
160+
{ ref: 'e1' },
161+
expect.anything()
162+
)
163+
})
164+
111165
it('never starts a skipped terminal command', () => {
112166
const startClientTerminalTool = vi.fn()
113167
const ctx = createStreamLoopContext(makeStreamLoopDeps({ startClientTerminalTool }))

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { resolveFileResourceSelectionId } from '@/app/workspace/[workspaceId]/ho
3737
import { deploymentKeys } from '@/hooks/queries/deployments'
3838
import { oauthCredentialKeys } from '@/hooks/queries/oauth/oauth-credentials'
3939
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
40+
import { isChatRunOnDesktop } from '@/hooks/queries/utils/desktop-activity-keys'
4041
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
4142
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
4243
import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys'
@@ -195,9 +196,18 @@ export function handleToolEvent(ctx: StreamLoopContext, parsed: ToolEvent): void
195196
const args = payload.arguments as Record<string, unknown> | undefined
196197
// Every client tailing the chat sees the call. A client without the desktop app leaves desktop
197198
// tools to it: its answer could only be an error, and that error would beat the real result.
199+
// A turn running on a desktop's background executor belongs to that desktop: this view only shows it.
200+
const runsInBackground =
201+
isDesktopToolCall(name, args) &&
202+
isChatRunOnDesktop(
203+
deps.queryClient,
204+
deps.workspaceId,
205+
deps.options.targetChatId ?? deps.chatIdRef.current
206+
)
198207
const shouldStartClientTool =
199208
isClientExecutedToolCall(name, args) &&
200209
(isDesktopApp() || !isDesktopToolCall(name, args)) &&
210+
!runsInBackground &&
201211
!isPartial &&
202212
!deps.options.suppressedWorkflowToolStartIds?.has(rawId) &&
203213
node?.kind === 'tool' &&

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export function makeStreamLoopDeps(overrides: Partial<StreamLoopDeps> = {}): Str
2525
queryClient: {
2626
invalidateQueries: vi.fn(),
2727
cancelQueries: vi.fn(),
28+
getQueryData: vi.fn(),
2829
setQueryData: vi.fn(),
2930
// double-cast-allowed: minimal QueryClient stub for stream-loop unit fixtures
3031
} as unknown as QueryClient,

‎apps/sim/app/workspace/[workspaceId]/layout.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SettingsNavigationProvider } from '@/components/settings/settings-navig
55
import { getSession } from '@/lib/auth'
66
import { getActiveOrganizationId } from '@/lib/auth/session-response'
77
import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
8+
import { isDesktopBackgroundExecutorEnabled } from '@/lib/desktop/executor/flag'
89
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
910
import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry'
1011
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
@@ -67,6 +68,7 @@ export default async function WorkspaceLayout({
6768
planModeEnabled,
6869
organizationHref,
6970
dashboardsEnabled,
71+
desktopBackgroundExecutorEnabled,
7072
] = await Promise.all([
7173
cookies(),
7274
hostContext.hostOrganizationId
@@ -84,6 +86,7 @@ export default async function WorkspaceLayout({
8486
isPlanModeEnabled(),
8587
resolveOrganizationEntryPath(session),
8688
isDashboardsEnabled(hostContext.hostOrganizationId),
89+
isDesktopBackgroundExecutorEnabled(session.user.id),
8790
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
8891
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),
8992
])
@@ -97,6 +100,7 @@ export default async function WorkspaceLayout({
97100
'table-row-ttl': tableRowTtlEnabled,
98101
'mothership-model-selector': modelSelectorEnabled,
99102
'mothership-plan-mode': planModeEnabled,
103+
'mothership-desktop-background-executor': desktopBackgroundExecutorEnabled,
100104
}}
101105
>
102106
<WorkspaceHostProvider workspaceId={workspaceId} initialContext={hostContext}>

‎apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface WorkspaceFeatureFlags {
77
'table-row-ttl': boolean
88
'mothership-model-selector': boolean
99
'mothership-plan-mode': boolean
10+
'mothership-desktop-background-executor': boolean
1011
}
1112

1213
const FeatureFlagsContext = createContext<WorkspaceFeatureFlags | null>(null)

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

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
import { createLogger } from '@sim/logger'
4242
import { useParams, usePathname, useRouter } from 'next/navigation'
4343
import { usePostHog } from 'posthog-js/react'
44+
import type { DesktopChatActivity } from '@/lib/api/contracts/desktop-executor'
4445
import { useSession } from '@/lib/auth/auth-client'
4546
import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions'
4647
import { focusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-shortcuts'
@@ -123,6 +124,7 @@ import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
123124
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
124125
import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary'
125126
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
127+
import { useDesktopActivity } from '@/hooks/queries/desktop-activity'
126128
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
127129
import { type LogFilters, useLogsList } from '@/hooks/queries/logs'
128130
import type { MothershipChatMetadata } from '@/hooks/queries/mothership-chats'
@@ -183,13 +185,50 @@ const SEARCH_MODAL_DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
183185
minute: '2-digit',
184186
})
185187

188+
const DESKTOP_ACTIVITY_COLOR: Record<DesktopChatActivity['state'], string> = {
189+
running: '#EAB308',
190+
needs_input: '#F97316',
191+
blocked: 'var(--text-error)',
192+
}
193+
194+
function desktopActivityLabel({ state, deviceName }: DesktopChatActivity): string {
195+
if (state === 'needs_input') return 'Needs input'
196+
if (state === 'blocked') return `Blocked: ${deviceName} is offline`
197+
return `Running on ${deviceName}`
198+
}
199+
200+
interface DesktopActivityDotProps {
201+
activity: DesktopChatActivity
202+
}
203+
204+
/** The status of a chat one of the user's desktops is running in the background. */
205+
function DesktopActivityDot({ activity }: DesktopActivityDotProps) {
206+
const label = desktopActivityLabel(activity)
207+
return (
208+
<Tooltip.Root>
209+
<Tooltip.Trigger asChild>
210+
<span
211+
role='img'
212+
aria-label={label}
213+
className='size-[6px] rounded-full'
214+
style={{ backgroundColor: DESKTOP_ACTIVITY_COLOR[activity.state] }}
215+
/>
216+
</Tooltip.Trigger>
217+
<Tooltip.Content>
218+
<p>{label}</p>
219+
</Tooltip.Content>
220+
</Tooltip.Root>
221+
)
222+
}
223+
186224
const SidebarChatItem = memo(function SidebarChatItem({
187225
chat,
188226
isCurrentRoute,
189227
isSelected,
190228
isActive,
191229
isUnread,
192230
isPinned,
231+
desktopActivity,
193232
isMenuOpen,
194233
showCollapsedTooltips,
195234
onMultiSelectClick,
@@ -203,6 +242,8 @@ const SidebarChatItem = memo(function SidebarChatItem({
203242
isActive: boolean
204243
isUnread: boolean
205244
isPinned: boolean
245+
/** Set while one of the user's desktops runs this chat's turn in the background. */
246+
desktopActivity?: DesktopChatActivity
206247
isMenuOpen: boolean
207248
showCollapsedTooltips: boolean
208249
onMultiSelectClick: (chatId: string, shiftKey: boolean) => void
@@ -217,7 +258,7 @@ const SidebarChatItem = memo(function SidebarChatItem({
217258
* transient state (a run in progress, or an unread reply elsewhere), while pinning
218259
* is persistent and already conveyed by the row sorting to the top of the list.
219260
*/
220-
const showStatusDot = isActive || (!isCurrentRoute && isUnread)
261+
const showStatusDot = Boolean(desktopActivity) || isActive || (!isCurrentRoute && isUnread)
221262

222263
function handleDragStart(e: React.DragEvent) {
223264
e.dataTransfer.effectAllowed = 'copyMove'
@@ -262,7 +303,9 @@ const SidebarChatItem = memo(function SidebarChatItem({
262303
<RowActions
263304
open={isMenuOpen}
264305
indicator={
265-
showStatusDot ? (
306+
desktopActivity ? (
307+
<DesktopActivityDot activity={desktopActivity} />
308+
) : showStatusDot ? (
266309
<span
267310
aria-hidden='true'
268311
className='size-[6px] rounded-full'
@@ -843,6 +886,15 @@ export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps)
843886
)
844887

845888
useMothershipChatEvents(workspaceId, chatEnabled && !permissionConfig.hideCopilot)
889+
const desktopExecutorEnabled = useFeatureFlag('mothership-desktop-background-executor')
890+
const { data: desktopActivity } = useDesktopActivity(
891+
workspaceId,
892+
desktopExecutorEnabled && chatEnabled && !permissionConfig.hideCopilot
893+
)
894+
const desktopActivityByChat = useMemo(
895+
() => new Map((desktopActivity ?? []).map((activity) => [activity.chatId, activity])),
896+
[desktopActivity]
897+
)
846898

847899
/**
848900
* Stays empty when Chat is disabled, which also drops the command palette's
@@ -1537,6 +1589,7 @@ export const Sidebar = memo(function Sidebar({ organizationHref }: SidebarProps)
15371589
isActive={!!chat.isActive}
15381590
isUnread={!!chat.isUnread}
15391591
isPinned={!!chat.isPinned}
1592+
desktopActivity={desktopActivityByChat.get(chat.id)}
15401593
isMenuOpen={menuOpenChatId === chat.id}
15411594
showCollapsedTooltips={showCollapsedTooltips}
15421595
onMultiSelectClick={handleChatClick}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
'use client'
2+
3+
import { keepPreviousData, useQuery } from '@tanstack/react-query'
4+
import { requestJson } from '@/lib/api/client/request'
5+
import {
6+
type DesktopChatActivity,
7+
listDesktopActivityContract,
8+
} from '@/lib/api/contracts/desktop-executor'
9+
import { desktopActivityKeys } from '@/hooks/queries/utils/desktop-activity-keys'
10+
11+
const DESKTOP_ACTIVITY_STALE_TIME = 10 * 1000
12+
/**
13+
* Presence, approvals and new background turns change without a chat event this query hears, so
14+
* it is re-read on a timer: often while a desktop runs a chat, rarely otherwise. Only users in the
15+
* executor's rollout ever run it.
16+
*/
17+
const DESKTOP_ACTIVITY_ACTIVE_REFETCH_MS = 15 * 1000
18+
const DESKTOP_ACTIVITY_IDLE_REFETCH_MS = 30 * 1000
19+
20+
async function fetchDesktopActivity(
21+
workspaceId: string,
22+
signal?: AbortSignal
23+
): Promise<DesktopChatActivity[]> {
24+
const data = await requestJson(listDesktopActivityContract, { query: { workspaceId }, signal })
25+
return data.chats
26+
}
27+
28+
/** The user's chats in this workspace whose turn runs on one of their desktops. */
29+
export function useDesktopActivity(workspaceId: string | undefined, enabled: boolean) {
30+
return useQuery({
31+
queryKey: desktopActivityKeys.list(workspaceId),
32+
queryFn: ({ signal }) => fetchDesktopActivity(workspaceId as string, signal),
33+
enabled: Boolean(workspaceId) && enabled,
34+
staleTime: DESKTOP_ACTIVITY_STALE_TIME,
35+
refetchInterval: (query) =>
36+
query.state.data?.length
37+
? DESKTOP_ACTIVITY_ACTIVE_REFETCH_MS
38+
: DESKTOP_ACTIVITY_IDLE_REFETCH_MS,
39+
placeholderData: keepPreviousData,
40+
})
41+
}

0 commit comments

Comments
 (0)