diff --git a/apps/desktop/src/main/desktop-settings.test.ts b/apps/desktop/src/main/desktop-settings.test.ts new file mode 100644 index 00000000000..fdc2e135d8f --- /dev/null +++ b/apps/desktop/src/main/desktop-settings.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import type { BrowserWindow } from 'electron' +import { createDesktopSettingsService } from '@/main/desktop-settings' +import { Notification } from '@/test/electron-mock' + +function service(window: { url: string; focused: boolean } | null) { + const config = new Map() + return createDesktopSettingsService({ + config: { + get: (key: string) => config.get(key), + set: (key: string, value: unknown) => config.set(key, value), + flush: () => true, + } as never, + getMainWindow: () => + window + ? ({ + isFocused: () => window.focused, + webContents: { getURL: () => window.url }, + } as unknown as BrowserWindow) + : null, + openMainWindowAt: vi.fn(), + setAutoDownloadUpdates: vi.fn(), + setTrayEnabled: vi.fn(), + setBrowserEnabled: vi.fn(), + setTerminalEnabled: vi.fn(), + setBrowserTheme: vi.fn(), + setBrowserDefaultZoom: vi.fn(), + setTerminalDefaultZoom: vi.fn(), + getDefaultBrowserDownloadDirectory: () => '/tmp', + chooseBrowserDownloadDirectory: async () => null, + }) +} + +const BACKGROUND_CHAT = '/workspace/ws-1/chat/chat-b' + +describe('desktop notifications in the default "background only" mode', () => { + beforeEach(() => { + Notification.instances.length = 0 + }) + + it('announces a chat in the background while the user works in another one', () => { + const shown = service({ + url: 'https://sim.ai/workspace/ws-1/chat/chat-c', + focused: true, + }).notify({ + title: 'Fix CI', + body: 'Sim finished responding.', + route: BACKGROUND_CHAT, + background: true, + }) + + expect(shown).toBe(true) + expect(Notification.instances).toHaveLength(1) + }) + + it('stays quiet about the chat the focused window shows', () => { + const shown = service({ url: `https://sim.ai${BACKGROUND_CHAT}`, focused: true }).notify({ + title: 'Fix CI', + body: 'Sim finished responding.', + route: BACKGROUND_CHAT, + background: true, + }) + + expect(shown).toBe(false) + expect(Notification.instances).toHaveLength(0) + }) + + it('stays quiet about the app as a whole while it is focused', () => { + const shown = service({ url: 'https://sim.ai/workspace/ws-1/home', focused: true }).notify({ + title: 'Update ready', + body: 'Restart to update.', + }) + + expect(shown).toBe(false) + }) + + it('holds back an ordinary notification whenever the window is focused, wherever it is', () => { + // The workflow panel's chat completes while the user watches it on the workflow page. + const shown = service({ url: 'https://sim.ai/workspace/ws-1/w/wf-1', focused: true }).notify({ + title: 'Task complete', + body: 'Sim finished responding.', + route: BACKGROUND_CHAT, + }) + + expect(shown).toBe(false) + expect(Notification.instances).toHaveLength(0) + }) +}) diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts index 7f25296b99b..f14e0157fc9 100644 --- a/apps/desktop/src/main/desktop-settings.ts +++ b/apps/desktop/src/main/desktop-settings.ts @@ -105,6 +105,21 @@ function readPreferences( } } +/** + * Whether a focused window already shows what a notification is about. One without a route is + * about the app as a whole, which a focused window always shows; one about a chat is news only + * while that chat is in the background. + */ +function showsRoute(window: BrowserWindow, route: string | undefined): boolean { + if (!route) return true + try { + const shown = new URL(window.webContents.getURL()).pathname + return shown === new URL(route, 'https://sim.invalid').pathname + } catch { + return false + } +} + /** * Owns device preferences and their native side effects. Renderer code can * request a change, but only this main-process service touches login items, @@ -203,7 +218,13 @@ export function createDesktopSettingsService( return false } const window = deps.getMainWindow() - if (preferences.notificationsOnlyWhenUnfocused && window?.isFocused()) { + // A focused window holds back every notification, as it always has. Only a background + // chat's completion is shown anyway when the focused window is somewhere else. + if ( + preferences.notificationsOnlyWhenUnfocused && + window?.isFocused() && + (!payload.background || showsRoute(window, payload.route)) + ) { return false } diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 1ca3b569f59..cd5b81d8400 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -305,10 +305,11 @@ export function parseDesktopNotificationPayload(raw: unknown): DesktopNotificati if (typeof raw !== 'object' || raw === null) { return null } - const { title, body, route } = raw as { + const { title, body, route, background } = raw as { title?: unknown body?: unknown route?: unknown + background?: unknown } if ( typeof title !== 'string' || @@ -323,7 +324,12 @@ export function parseDesktopNotificationPayload(raw: unknown): DesktopNotificati if (route !== undefined && (typeof route !== 'string' || !isSafeInternalPath(route))) { return null } - return { title, body, ...(route !== undefined ? { route } : {}) } + return { + title, + body, + ...(route !== undefined ? { route } : {}), + ...(background === true ? { background: true } : {}), + } } export interface IpcDeps { diff --git a/apps/sim/app/api/copilot/chat/stream/desktop-executor.integration.ts b/apps/sim/app/api/copilot/chat/stream/desktop-executor.integration.ts new file mode 100644 index 00000000000..2a0bb1c84bd --- /dev/null +++ b/apps/sim/app/api/copilot/chat/stream/desktop-executor.integration.ts @@ -0,0 +1,134 @@ +/** + * A chat view learns from the stream it reads whether the desktop app's background executor runs + * the turn's desktop tools, so it only shows those calls. Exercised against real PostgreSQL and + * Redis through the production reconnect route, for the live tail and the replay batch a view + * attaches with after a reload. + */ +import { authMock, authMockFns } from '@sim/testing/mocks/auth.mock' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const { redisUrl } = await vi.hoisted(async () => { + const { readTestRedisUrl } = await import('@sim/db/testing/test-infrastructure') + const url = readTestRedisUrl() + if (url) process.env.REDIS_URL = url + return { redisUrl: url } +}) + +vi.mock('@/lib/auth', () => authMock) + +import { db } from '@sim/db' +import { + copilotChats, + copilotRuns, + desktopDevices, + permissions, + user, + workspace, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { NextRequest } from 'next/server' +import { closeRedisConnection } from '@/lib/core/config/redis' +import { MOTHERSHIP_DESKTOP_EXECUTOR_HEADER } from '@/lib/mothership/constants' +import { GET as streamGET } from '@/app/api/copilot/chat/stream/route' + +const userId = generateId() +const workspaceId = generateId() +const chatId = generateId() +const deviceId = generateId() + +async function finishedRun(desktopDeviceId: string | null): Promise { + const streamId = generateId() + await db.insert(copilotRuns).values({ + id: generateId(), + executionId: generateId(), + chatId, + userId, + workspaceId, + streamId, + status: 'complete', + desktopDeviceId, + }) + return streamId +} + +function reconnect(streamId: string, batch: boolean) { + return streamGET( + new NextRequest( + `http://localhost:3000/api/copilot/chat/stream?streamId=${streamId}&after=0${batch ? '&batch=true' : ''}` + ), + { params: Promise.resolve({}) } + ) +} + +describe.runIf(Boolean(redisUrl))('desktop executor binding on a chat stream', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Desktop stream fixture', + email: `${userId}@desktop-stream.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await db.insert(workspace).values({ + id: workspaceId, + name: 'Desktop stream fixture', + ownerId: userId, + billedAccountUserId: userId, + }) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + await db.insert(copilotChats).values({ id: chatId, userId, workspaceId, type: 'mothership' }) + await db.insert(desktopDevices).values({ + id: deviceId, + userId, + name: 'Studio Mac', + appVersion: '0.9.0', + platform: 'darwin-arm64', + capabilities: { executor: 1, browser: true, terminal: true, localFiles: true }, + }) + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: userId }, + session: { id: generateId() }, + }) + }) + + afterAll(async () => { + await db.delete(copilotRuns).where(eq(copilotRuns.chatId, chatId)) + await db.delete(copilotChats).where(eq(copilotChats.id, chatId)) + await db.delete(desktopDevices).where(eq(desktopDevices.id, deviceId)) + await db.delete(permissions).where(eq(permissions.userId, userId)) + await db.delete(workspace).where(eq(workspace.id, workspaceId)) + await db.delete(user).where(eq(user.id, userId)) + await closeRedisConnection() + }) + + it('marks the live tail and the replay batch of a turn a desktop runs', async () => { + const streamId = await finishedRun(deviceId) + + const tail = await reconnect(streamId, false) + await tail.text() + const batch = await (await reconnect(streamId, true)).json() + + expect(tail.headers.get(MOTHERSHIP_DESKTOP_EXECUTOR_HEADER)).toBe('device') + expect(batch.desktopToolsOnDevice).toBe(true) + }) + + it('leaves a turn the chat view runs unmarked', async () => { + const streamId = await finishedRun(null) + + const tail = await reconnect(streamId, false) + await tail.text() + const batch = await (await reconnect(streamId, true)).json() + + expect(tail.headers.get(MOTHERSHIP_DESKTOP_EXECUTOR_HEADER)).toBeNull() + expect(batch.desktopToolsOnDevice).toBeUndefined() + }) +}) diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index 3bdaeecf49d..b557b0a0145 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -13,7 +13,10 @@ import { } from '@/lib/api/server/routes' import { encodeSSEComment } from '@/lib/core/utils/sse' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { MOTHERSHIP_STREAM_REPLAY_HEADER } from '@/lib/mothership/constants' +import { + MOTHERSHIP_DESKTOP_EXECUTOR_HEADER, + MOTHERSHIP_STREAM_REPLAY_HEADER, +} from '@/lib/mothership/constants' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -285,6 +288,7 @@ async function handleResumeRequestBody({ previewSessions, status: run.status, ...(run.chatId ? { chatId: run.chatId } : {}), + ...(run.desktopDeviceId ? { desktopToolsOnDevice: true } : {}), }) } @@ -606,8 +610,10 @@ async function handleResumeRequestBody({ } return new Response(stream, { - headers: replayBody - ? { ...SSE_RESPONSE_HEADERS, [MOTHERSHIP_STREAM_REPLAY_HEADER]: 'log' } - : SSE_RESPONSE_HEADERS, + headers: { + ...SSE_RESPONSE_HEADERS, + ...(replayBody ? { [MOTHERSHIP_STREAM_REPLAY_HEADER]: 'log' } : {}), + ...(run.desktopDeviceId ? { [MOTHERSHIP_DESKTOP_EXECUTOR_HEADER]: 'device' } : {}), + }, }) } 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..3aa5c0b2162 --- /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 { + desktopActivityRateLimit, + desktopExecutorErrorPolicy, +} from '@/lib/api/server/routes/desktop-executor' +import { listDesktopActivity } from '@/lib/desktop/application/activity' + +export const dynamic = 'force-dynamic' + +export const GET = defineInternalJsonRoute({ + contract: listDesktopActivityContract, + auth: internalSessionAuth, + operation: listDesktopActivity.operation, + rateLimit: desktopActivityRateLimit, + 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-protocol.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream-protocol.ts index c27b6a28930..0b142054ff3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream-protocol.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream-protocol.ts @@ -25,6 +25,8 @@ export type StreamBatchResponse = { previewSessions?: FilePreviewSession[] status: string chatId?: string + /** The desktop app's background executor runs this turn's desktop tools. */ + desktopToolsOnDevice?: true } const STREAM_SCHEMA_ENFORCEMENT_PREFIX = 'Client stream schema enforcement failed.' @@ -108,6 +110,7 @@ export function parseStreamBatchResponse(value: unknown): StreamBatchResponse { ...(previewSessions ? { previewSessions } : {}), status: typeof value.status === 'string' ? value.status : 'unknown', ...(typeof value.chatId === 'string' && value.chatId ? { chatId: value.chatId } : {}), + ...(value.desktopToolsOnDevice === true ? { desktopToolsOnDevice: true as const } : {}), } } 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..9a4e60ee1cc 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,73 @@ describe('tool events (dispatch → model + side effects)', () => { ) }) + /** Starters that record which calls this view started, by tool call id. */ + function recordingStarters() { + const started: string[] = [] + const record = (toolCallId: string) => { + started.push(toolCallId) + } + return { + started, + starters: { + startClientBrowserTool: record, + startClientTerminalTool: record, + startClientLocalFilesystemTool: record, + startClientWorkflowTool: record, + }, + } + } + + function dispatchCall( + ctx: StreamLoopContext, + toolCallId: string, + toolName: string, + args: Record + ) { + dispatchStreamEvent( + ctx, + toolEnv({ + phase: 'call', + executor: 'client', + mode: 'async', + toolCallId, + toolName, + arguments: args, + status: 'executing', + }) + ) + } + + it("only shows desktop calls when the desktop's background executor runs the turn", () => { + const { started, starters } = recordingStarters() + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ + ...starters, + chatIdRef: ref('chat-1'), + options: { desktopToolsOnDevice: true }, + }) + ) + + dispatchCall(ctx, 'click-1', 'browser_click', { ref: 'e1' }) + dispatchCall(ctx, 'run-1', 'terminal', { operation: 'run', args: { command: 'ls' } }) + dispatchCall(ctx, 'read-1', 'read_local_file', { path: '~/notes.txt' }) + dispatchCall(ctx, 'workflow-1', 'run_workflow', { workflowId: 'wf-1' }) + + expect(toolNode(ctx, 'click-1').status).toBe('running') + expect(started).toEqual(['workflow-1']) + }) + + it('runs a desktop call in the view when no desktop runs the chat in the background', () => { + const { started, starters } = recordingStarters() + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ ...starters, chatIdRef: ref('chat-1') }) + ) + + dispatchCall(ctx, 'click-2', 'browser_click', { ref: 'e1' }) + + expect(started).toEqual(['click-2']) + }) + 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..9e8e50663b0 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 @@ -195,9 +195,12 @@ 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 a desktop's background executor runs belongs to that desktop: this view only shows it. + const isDesktopCall = isDesktopToolCall(name, args) const shouldStartClientTool = isClientExecutedToolCall(name, args) && - (isDesktopApp() || !isDesktopToolCall(name, args)) && + (isDesktopApp() || !isDesktopCall) && + !(isDesktopCall && deps.options.desktopToolsOnDevice) && !isPartial && !deps.options.suppressedWorkflowToolStartIds?.has(rawId) && node?.kind === 'tool' && diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts index 0ad8a63c1b2..de83519b5c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -56,6 +56,11 @@ export interface StreamLoopOptions { */ deferFlushes?: boolean suppressedWorkflowToolStartIds?: ReadonlySet + /** + * The desktop app's background executor runs this turn's desktop tools, as Sim says when it + * serves the stream. The view then only shows those calls: it never starts or reports them. + */ + desktopToolsOnDevice?: boolean targetChatId?: string shouldContinue?: () => boolean } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index ff5db89ef70..123cef77ad5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -56,6 +56,7 @@ import { import { MOTHERSHIP_CHAT_API_PATH, MOTHERSHIP_CHAT_ID_HEADER, + MOTHERSHIP_DESKTOP_EXECUTOR_HEADER, MOTHERSHIP_STREAM_REPLAY_HEADER, } from '@/lib/mothership/constants' import { sendMothershipMessage } from '@/lib/mothership/events' @@ -2683,6 +2684,7 @@ export function useChat( resumeCursor: latestCursor, deferFlushes: true, suppressedWorkflowToolStartIds: suppressedSeedWorkflowToolStartIds, + ...(opts.initialBatch?.desktopToolsOnDevice ? { desktopToolsOnDevice: true } : {}), ...(targetChatId ? { targetChatId } : {}), ...(shouldContinue ? { shouldContinue } : {}), } @@ -2753,6 +2755,9 @@ export function useChat( { preserveExistingState: preserveNextReplayState, resumeCursor: latestCursor, + ...(sseRes.headers.get(MOTHERSHIP_DESKTOP_EXECUTOR_HEADER) === 'device' + ? { desktopToolsOnDevice: true } + : {}), ...(targetChatId ? { targetChatId } : {}), ...(shouldContinue ? { shouldContinue } : {}), } @@ -2860,6 +2865,7 @@ export function useChat( resumeCursor: replaySelection.afterCursor, deferFlushes: true, suppressedWorkflowToolStartIds: getReplayCompletedWorkflowToolCallIds(batch.events), + ...(batch.desktopToolsOnDevice ? { desktopToolsOnDevice: true } : {}), ...(targetChatId ? { targetChatId } : {}), ...(shouldContinue ? { shouldContinue } : {}), } @@ -4092,6 +4098,9 @@ export function useChat( if (!response.body) throw new Error('No response body') const streamResult = await processSSEStream(response.body.getReader(), assistantId, gen, { + ...(response.headers.get(MOTHERSHIP_DESKTOP_EXECUTOR_HEADER) === 'device' + ? { desktopToolsOnDevice: true } + : {}), ...(streamTargetChatId ? { targetChatId: streamTargetChatId } : {}), }) if (streamGenRef.current === gen) { 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..2f8353dfb03 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,37 @@ const SEARCH_MODAL_DATE_FORMAT = new Intl.DateTimeFormat(undefined, { minute: '2-digit', }) +const DESKTOP_ACTIVITY_COLOR_CLASS: Record = { + running: 'bg-[#EAB308]', + needs_input: 'bg-[var(--warning)]', + blocked: 'bg-[var(--text-error)]', +} + +function desktopActivityLabel({ state, deviceName }: DesktopChatActivity): string { + if (state === 'needs_input') return 'Needs input' + if (state === 'blocked') return `Waiting for ${deviceName} to come back online` + return `Running on ${deviceName}` +} + +interface DesktopActivityDotProps { + activity: DesktopChatActivity +} + +/** + * The status of a chat one of the user's desktops is running in the background. The row's + * indicator slot is pointer-inert and gives way to the row's actions on hover, so the label is + * read out here and shown in the row's own tooltip. + */ +function DesktopActivityDot({ activity }: DesktopActivityDotProps) { + return ( + + ) +} + const SidebarChatItem = memo(function SidebarChatItem({ chat, isCurrentRoute, @@ -190,6 +223,7 @@ const SidebarChatItem = memo(function SidebarChatItem({ isActive, isUnread, isPinned, + desktopActivity, isMenuOpen, showCollapsedTooltips, onMultiSelectClick, @@ -203,6 +237,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 +253,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' @@ -239,7 +275,12 @@ const SidebarChatItem = memo(function SidebarChatItem({ } return ( - + + ) : showStatusDot ? (