From 433051f0bdb1115977b76cf29b12ba8f0c4c530c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 5 Oct 2026 17:16:59 -0700 Subject: [PATCH 1/7] 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 ", "Needs input" or "Blocked: 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. --- apps/sim/app/api/desktop/activity/route.ts | 20 ++ .../components/composer/composer.test.tsx | 3 + apps/sim/app/o/[organizationId]/layout.tsx | 1 + .../hooks/stream/handle-tool-event.test.ts | 54 +++++ .../home/hooks/stream/handle-tool-event.ts | 10 + .../home/hooks/stream/stream-test-helpers.ts | 1 + .../app/workspace/[workspaceId]/layout.tsx | 4 + .../providers/feature-flags-provider.tsx | 1 + .../w/components/sidebar/sidebar.tsx | 57 ++++- apps/sim/hooks/queries/desktop-activity.ts | 41 ++++ .../queries/utils/desktop-activity-keys.ts | 24 +++ .../sim/lib/api/contracts/desktop-executor.ts | 27 +++ .../application/activity.integration.ts | 200 ++++++++++++++++++ apps/sim/lib/desktop/executor/repository.ts | 51 +++++ 14 files changed, 492 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/api/desktop/activity/route.ts create mode 100644 apps/sim/hooks/queries/desktop-activity.ts create mode 100644 apps/sim/hooks/queries/utils/desktop-activity-keys.ts create mode 100644 apps/sim/lib/desktop/application/activity.integration.ts diff --git a/apps/sim/app/api/desktop/activity/route.ts b/apps/sim/app/api/desktop/activity/route.ts new file mode 100644 index 00000000000..176881de51e --- /dev/null +++ b/apps/sim/app/api/desktop/activity/route.ts @@ -0,0 +1,20 @@ +import { listDesktopActivityContract } from '@/lib/api/contracts/desktop-executor' +import { defineInternalJsonRoute, internalSessionAuth } from '@/lib/api/server/routes' +import { + desktopExecutorErrorPolicy, + desktopExecutorRateLimit, +} from '@/lib/api/server/routes/desktop-executor' +import { listDesktopActivity } from '@/lib/desktop/application/executor' + +export const dynamic = 'force-dynamic' + +export const GET = defineInternalJsonRoute({ + contract: listDesktopActivityContract, + auth: internalSessionAuth, + operation: listDesktopActivity.operation, + rateLimit: desktopExecutorRateLimit, + errorPolicy: desktopExecutorErrorPolicy, + mapInput: ({ query }) => ({ workspaceId: query.workspaceId }), + useCase: listDesktopActivity, + staticResponseHeaders: { 'Cache-Control': 'no-store' }, +}) diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx index aa1466b9499..e78a1c35fbe 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx @@ -235,6 +235,7 @@ async function render( 'table-row-ttl': false, 'mothership-model-selector': mocks.advanced, 'mothership-plan-mode': mocks.plan, + 'mothership-desktop-background-executor': false, }} > @@ -313,6 +314,7 @@ it.each([ 'table-row-ttl': false, 'mothership-model-selector': false, 'mothership-plan-mode': planEnabled, + 'mothership-desktop-background-executor': false, }} > @@ -408,6 +410,7 @@ it('keeps restored queued skills scoped when replacing a draft', async () => { 'table-row-ttl': false, 'mothership-model-selector': mocks.advanced, 'mothership-plan-mode': mocks.plan, + 'mothership-desktop-background-executor': false, }} > diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 3ad21c07b64..14ea8b747ca 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -79,6 +79,7 @@ export default async function OrganizationLayout({ 'table-row-ttl': tableRowTtlEnabled, 'mothership-model-selector': modelSelectorEnabled, 'mothership-plan-mode': planModeEnabled, + 'mothership-desktop-background-executor': false, }} > diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts index 2b744ca9404..e6cdb6c72b5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts @@ -108,6 +108,60 @@ describe('tool events (dispatch → model + side effects)', () => { ) }) + it("only shows a desktop call when the chat's turn runs on a desktop in the background", () => { + const startClientBrowserTool = vi.fn() + const deps = makeStreamLoopDeps({ startClientBrowserTool, chatIdRef: ref('chat-1') }) + vi.mocked(deps.queryClient.getQueryData).mockImplementation((key) => + JSON.stringify(key) === JSON.stringify(['desktop-activity', 'list', 'ws-1']) + ? [{ chatId: 'chat-1', state: 'running', deviceName: 'MacBook' }] + : undefined + ) + const ctx = createStreamLoopContext(deps) + + dispatchStreamEvent( + ctx, + toolEnv({ + phase: 'call', + executor: 'client', + mode: 'async', + toolCallId: 'click-1', + toolName: 'browser_click', + arguments: { ref: 'e1' }, + status: 'executing', + }) + ) + + expect(toolNode(ctx, 'click-1').status).toBe('running') + expect(startClientBrowserTool).not.toHaveBeenCalled() + }) + + it('runs a desktop call in the view when no desktop runs the chat in the background', () => { + const startClientBrowserTool = vi.fn() + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ startClientBrowserTool, chatIdRef: ref('chat-1') }) + ) + + dispatchStreamEvent( + ctx, + toolEnv({ + phase: 'call', + executor: 'client', + mode: 'async', + toolCallId: 'click-2', + toolName: 'browser_click', + arguments: { ref: 'e1' }, + status: 'executing', + }) + ) + + expect(startClientBrowserTool).toHaveBeenCalledWith( + 'click-2', + 'browser_click', + { ref: 'e1' }, + expect.anything() + ) + }) + it('never starts a skipped terminal command', () => { const startClientTerminalTool = vi.fn() const ctx = createStreamLoopContext(makeStreamLoopDeps({ startClientTerminalTool })) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts index 154aac0dce4..61604f4d18e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts @@ -37,6 +37,7 @@ import { resolveFileResourceSelectionId } from '@/app/workspace/[workspaceId]/ho import { deploymentKeys } from '@/hooks/queries/deployments' import { oauthCredentialKeys } from '@/hooks/queries/oauth/oauth-credentials' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { isChatRunOnDesktop } from '@/hooks/queries/utils/desktop-activity-keys' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys' @@ -195,9 +196,18 @@ export function handleToolEvent(ctx: StreamLoopContext, parsed: ToolEvent): void const args = payload.arguments as Record | undefined // Every client tailing the chat sees the call. A client without the desktop app leaves desktop // tools to it: its answer could only be an error, and that error would beat the real result. + // A turn running on a desktop's background executor belongs to that desktop: this view only shows it. + const runsInBackground = + isDesktopToolCall(name, args) && + isChatRunOnDesktop( + deps.queryClient, + deps.workspaceId, + deps.options.targetChatId ?? deps.chatIdRef.current + ) const shouldStartClientTool = isClientExecutedToolCall(name, args) && (isDesktopApp() || !isDesktopToolCall(name, args)) && + !runsInBackground && !isPartial && !deps.options.suppressedWorkflowToolStartIds?.has(rawId) && node?.kind === 'tool' && diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts index 1f406a501e9..34e8c2a26dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts @@ -25,6 +25,7 @@ export function makeStreamLoopDeps(overrides: Partial = {}): Str queryClient: { invalidateQueries: vi.fn(), cancelQueries: vi.fn(), + getQueryData: vi.fn(), setQueryData: vi.fn(), // double-cast-allowed: minimal QueryClient stub for stream-loop unit fixtures } as unknown as QueryClient, diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index d9fcd6f9a9e..006daa63cfd 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -5,6 +5,7 @@ import { SettingsNavigationProvider } from '@/components/settings/settings-navig import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag' +import { isDesktopBackgroundExecutorEnabled } from '@/lib/desktop/executor/flag' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry' import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability' @@ -67,6 +68,7 @@ export default async function WorkspaceLayout({ planModeEnabled, organizationHref, dashboardsEnabled, + desktopBackgroundExecutorEnabled, ] = await Promise.all([ cookies(), hostContext.hostOrganizationId @@ -84,6 +86,7 @@ export default async function WorkspaceLayout({ isPlanModeEnabled(), resolveOrganizationEntryPath(session), isDashboardsEnabled(hostContext.hostOrganizationId), + isDesktopBackgroundExecutorEnabled(session.user.id), prefetchWorkspaceAccess(queryClient, workspaceId, principal), prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext), ]) @@ -97,6 +100,7 @@ export default async function WorkspaceLayout({ 'table-row-ttl': tableRowTtlEnabled, 'mothership-model-selector': modelSelectorEnabled, 'mothership-plan-mode': planModeEnabled, + 'mothership-desktop-background-executor': desktopBackgroundExecutorEnabled, }} > diff --git a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx index d27ba042643..ac6eb96fcae 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx @@ -7,6 +7,7 @@ export interface WorkspaceFeatureFlags { 'table-row-ttl': boolean 'mothership-model-selector': boolean 'mothership-plan-mode': boolean + 'mothership-desktop-background-executor': boolean } const FeatureFlagsContext = createContext(null) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 683439f271b..f2b3e927899 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -41,6 +41,7 @@ import { import { createLogger } from '@sim/logger' import { useParams, usePathname, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import type { DesktopChatActivity } from '@/lib/api/contracts/desktop-executor' import { useSession } from '@/lib/auth/auth-client' import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions' import { focusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-shortcuts' @@ -123,6 +124,7 @@ import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' +import { useDesktopActivity } from '@/hooks/queries/desktop-activity' import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { type LogFilters, useLogsList } from '@/hooks/queries/logs' import type { MothershipChatMetadata } from '@/hooks/queries/mothership-chats' @@ -183,6 +185,42 @@ const SEARCH_MODAL_DATE_FORMAT = new Intl.DateTimeFormat(undefined, { minute: '2-digit', }) +const DESKTOP_ACTIVITY_COLOR: Record = { + running: '#EAB308', + needs_input: '#F97316', + blocked: 'var(--text-error)', +} + +function desktopActivityLabel({ state, deviceName }: DesktopChatActivity): string { + if (state === 'needs_input') return 'Needs input' + if (state === 'blocked') return `Blocked: ${deviceName} is offline` + return `Running on ${deviceName}` +} + +interface DesktopActivityDotProps { + activity: DesktopChatActivity +} + +/** The status of a chat one of the user's desktops is running in the background. */ +function DesktopActivityDot({ activity }: DesktopActivityDotProps) { + const label = desktopActivityLabel(activity) + return ( + + + + + +

{label}

+
+
+ ) +} + const SidebarChatItem = memo(function SidebarChatItem({ chat, isCurrentRoute, @@ -190,6 +228,7 @@ const SidebarChatItem = memo(function SidebarChatItem({ isActive, isUnread, isPinned, + desktopActivity, isMenuOpen, showCollapsedTooltips, onMultiSelectClick, @@ -203,6 +242,8 @@ const SidebarChatItem = memo(function SidebarChatItem({ isActive: boolean isUnread: boolean isPinned: boolean + /** Set while one of the user's desktops runs this chat's turn in the background. */ + desktopActivity?: DesktopChatActivity isMenuOpen: boolean showCollapsedTooltips: boolean onMultiSelectClick: (chatId: string, shiftKey: boolean) => void @@ -217,7 +258,7 @@ const SidebarChatItem = memo(function SidebarChatItem({ * transient state (a run in progress, or an unread reply elsewhere), while pinning * is persistent and already conveyed by the row sorting to the top of the list. */ - const showStatusDot = isActive || (!isCurrentRoute && isUnread) + const showStatusDot = Boolean(desktopActivity) || isActive || (!isCurrentRoute && isUnread) function handleDragStart(e: React.DragEvent) { e.dataTransfer.effectAllowed = 'copyMove' @@ -262,7 +303,9 @@ const SidebarChatItem = memo(function SidebarChatItem({ + ) : showStatusDot ? (