{/* Plain-text fallback shown until Shiki finishes highlighting */}
{!loaded && (
{code}
diff --git a/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx b/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx
new file mode 100644
index 00000000..c2bfce00
--- /dev/null
+++ b/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx
@@ -0,0 +1,174 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+
+const { sendMock, openSettingsMock } = vi.hoisted(() => ({
+ sendMock: vi.fn(),
+ openSettingsMock: vi.fn(async () => ({ ok: true })),
+}))
+
+vi.mock('../../api/websocket', () => ({
+ wsManager: {
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ onMessage: vi.fn(() => () => {}),
+ clearHandlers: vi.fn(),
+ send: sendMock,
+ },
+}))
+
+vi.mock('../../api/sessions', () => ({
+ sessionsApi: {
+ getMessages: vi.fn(async () => ({ messages: [] })),
+ getSlashCommands: vi.fn(async () => ({ commands: [] })),
+ },
+}))
+
+vi.mock('../../stores/teamStore', () => ({
+ useTeamStore: {
+ getState: () => ({
+ getMemberBySessionId: vi.fn(() => null),
+ sendMessageToMember: vi.fn(async () => {}),
+ handleTeamCreated: vi.fn(),
+ handleTeamUpdate: vi.fn(),
+ handleTeamDeleted: vi.fn(),
+ }),
+ },
+}))
+
+vi.mock('../../stores/tabStore', () => ({
+ useTabStore: {
+ getState: () => ({
+ updateTabStatus: vi.fn(),
+ updateTabTitle: vi.fn(),
+ }),
+ },
+}))
+
+vi.mock('../../stores/sessionStore', () => ({
+ useSessionStore: {
+ getState: () => ({
+ updateSessionTitle: vi.fn(),
+ }),
+ },
+}))
+
+vi.mock('../../stores/cliTaskStore', () => ({
+ useCLITaskStore: {
+ getState: () => ({
+ fetchSessionTasks: vi.fn(),
+ tasks: [],
+ clearTasks: vi.fn(),
+ setTasksFromTodos: vi.fn(),
+ markCompletedAndDismissed: vi.fn(),
+ refreshTasks: vi.fn(),
+ }),
+ },
+}))
+
+vi.mock('../../api/computerUse', () => ({
+ computerUseApi: {
+ openSettings: openSettingsMock,
+ },
+}))
+
+import { useChatStore } from '../../stores/chatStore'
+import { ComputerUsePermissionModal } from './ComputerUsePermissionModal'
+
+describe('ComputerUsePermissionModal', () => {
+ beforeEach(() => {
+ sendMock.mockReset()
+ openSettingsMock.mockReset()
+ useChatStore.setState({ sessions: {} })
+ })
+
+ it('returns a full approval payload for resolved apps and requested flags', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Allow for session' }))
+
+ expect(sendMock).toHaveBeenCalledTimes(1)
+ expect(sendMock).toHaveBeenCalledWith('session-1', {
+ type: 'computer_use_permission_response',
+ requestId: 'cu-1',
+ response: {
+ granted: [
+ expect.objectContaining({
+ bundleId: 'com.apple.finder',
+ displayName: 'Finder',
+ tier: 'full',
+ }),
+ ],
+ denied: [
+ {
+ bundleId: 'Missing App',
+ reason: 'not_installed',
+ },
+ ],
+ flags: {
+ clipboardRead: true,
+ clipboardWrite: false,
+ systemKeyCombos: true,
+ },
+ userConsented: true,
+ },
+ })
+ })
+
+ it('opens System Settings from the macOS permission panel', async () => {
+ render(
+ ,
+ )
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Open Accessibility' }))
+ })
+
+ expect(openSettingsMock).toHaveBeenCalledWith('Privacy_Accessibility')
+ })
+})
diff --git a/desktop/src/components/chat/ComputerUsePermissionModal.tsx b/desktop/src/components/chat/ComputerUsePermissionModal.tsx
new file mode 100644
index 00000000..b6a9c9e9
--- /dev/null
+++ b/desktop/src/components/chat/ComputerUsePermissionModal.tsx
@@ -0,0 +1,311 @@
+import { useMemo, useState } from 'react'
+import { useTranslation } from '../../i18n'
+import { computerUseApi } from '../../api/computerUse'
+import { useChatStore } from '../../stores/chatStore'
+import type {
+ ComputerUsePermissionRequest,
+ ComputerUsePermissionResponse,
+} from '../../types/chat'
+import { Button } from '../shared/Button'
+import { Modal } from '../shared/Modal'
+
+type Props = {
+ sessionId: string
+ request: ComputerUsePermissionRequest | null
+}
+
+const DEFAULT_GRANT_FLAGS = {
+ clipboardRead: false,
+ clipboardWrite: false,
+ systemKeyCombos: false,
+} as const
+
+function denyAllResponse(): ComputerUsePermissionResponse {
+ return {
+ granted: [],
+ denied: [],
+ flags: { ...DEFAULT_GRANT_FLAGS },
+ userConsented: false,
+ }
+}
+
+function buildAllowResponse(
+ request: ComputerUsePermissionRequest,
+): ComputerUsePermissionResponse {
+ const now = Date.now()
+ const granted = request.apps.flatMap((app) => {
+ if (!app.resolved || app.alreadyGranted) return []
+ return [{
+ bundleId: app.resolved.bundleId,
+ displayName: app.resolved.displayName,
+ grantedAt: now,
+ tier: app.proposedTier,
+ }]
+ })
+
+ const denied = request.apps.flatMap((app) => {
+ if (app.resolved) return []
+ return [{
+ bundleId: app.requestedName,
+ reason: 'not_installed' as const,
+ }]
+ })
+
+ const flags = {
+ ...DEFAULT_GRANT_FLAGS,
+ ...Object.fromEntries(
+ Object.entries(request.requestedFlags).filter(([, value]) => value === true),
+ ),
+ }
+
+ return {
+ granted,
+ denied,
+ flags,
+ userConsented: true,
+ }
+}
+
+export function ComputerUsePermissionModal({ sessionId, request }: Props) {
+ const t = useTranslation()
+ const respondToComputerUsePermission = useChatStore(
+ (s) => s.respondToComputerUsePermission,
+ )
+ const [openingPane, setOpeningPane] = useState<
+ 'Privacy_Accessibility' | 'Privacy_ScreenCapture' | null
+ >(null)
+
+ const requestedFlags = useMemo(
+ () =>
+ request
+ ? Object.entries(request.requestedFlags)
+ .filter(([, enabled]) => enabled)
+ .map(([flag]) => flag)
+ : [],
+ [request],
+ )
+
+ if (!request) return null
+
+ const handleDeny = () => {
+ respondToComputerUsePermission(
+ sessionId,
+ request.requestId,
+ denyAllResponse(),
+ )
+ }
+
+ const handleAllow = () => {
+ respondToComputerUsePermission(
+ sessionId,
+ request.requestId,
+ buildAllowResponse(request),
+ )
+ }
+
+ const openSettings = async (
+ pane: 'Privacy_Accessibility' | 'Privacy_ScreenCapture',
+ ) => {
+ setOpeningPane(pane)
+ try {
+ await computerUseApi.openSettings(pane)
+ } finally {
+ setOpeningPane(null)
+ }
+ }
+
+ const tccState = request.tccState
+
+ return (
+
+ {t('computerUseApproval.deny')}
+
+ ) : (
+ <>
+
+
+ >
+ )
+ }
+ >
+ {tccState ? (
+
+
+ {t('computerUseApproval.tccHint')}
+
+
+
+
openSettings('Privacy_Accessibility')}
+ />
+ openSettings('Privacy_ScreenCapture')}
+ />
+
+
+
+ {t('computerUseApproval.tryAgainHint')}
+
+
+
+
+
+
+ ) : (
+
+ {request.reason ? (
+
+
+ {t('computerUseApproval.reason')}
+
+
+ {request.reason}
+
+
+ ) : null}
+
+
+ {request.apps.map((app) => {
+ const resolved = app.resolved
+ return (
+
+
+
+
+ {resolved?.displayName ?? app.requestedName}
+
+
+ {resolved?.bundleId ?? t('computerUseApproval.notInstalled')}
+
+
+
+ {app.proposedTier}
+
+
+
+ {!resolved ? (
+
+ {t('computerUseApproval.notInstalled')}
+
+ ) : null}
+
+ {app.alreadyGranted ? (
+
+ {t('computerUseApproval.alreadyGranted')}
+
+ ) : null}
+
+ {app.isSentinel ? (
+
+ {t('computerUseApproval.sensitiveApp')}
+
+ ) : null}
+
+ )
+ })}
+
+
+ {requestedFlags.length > 0 ? (
+
+
+ {t('computerUseApproval.alsoRequested')}
+
+
+ {requestedFlags.map((flag) => (
+
+ {flag}
+
+ ))}
+
+
+ ) : null}
+
+ {request.willHide && request.willHide.length > 0 ? (
+
+ {request.autoUnhideEnabled
+ ? t('computerUseApproval.hideWhileWorkingRestore', {
+ count: request.willHide.length,
+ })
+ : t('computerUseApproval.hideWhileWorking', {
+ count: request.willHide.length,
+ })}
+
+ ) : null}
+
+ )}
+
+ )
+}
+
+function PermissionRow({
+ label,
+ granted,
+ actionLabel,
+ actionLoading,
+ onAction,
+}: {
+ label: string
+ granted: boolean
+ actionLabel: string
+ actionLoading: boolean
+ onAction: () => void
+}) {
+ const t = useTranslation()
+
+ return (
+
+
+
+ {label}
+
+
+ {granted
+ ? t('computerUseApproval.granted')
+ : t('computerUseApproval.notGranted')}
+
+
+
+ {!granted ? (
+
+ ) : null}
+
+ )
+}
diff --git a/desktop/src/components/chat/DiffViewer.tsx b/desktop/src/components/chat/DiffViewer.tsx
index 5123d807..50e179e0 100644
--- a/desktop/src/components/chat/DiffViewer.tsx
+++ b/desktop/src/components/chat/DiffViewer.tsx
@@ -23,24 +23,24 @@ function inferLanguage(filePath: string): string {
/** Shared warm syntax theme — must stay in sync with CodeViewer */
const warmSyntaxTheme: PrismTheme = {
plain: {
- color: '#24201E',
+ color: 'var(--color-code-fg)',
backgroundColor: 'transparent',
},
styles: [
- { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: '#8C7E75', fontStyle: 'italic' as const } },
- { types: ['string', 'attr-value', 'template-string'], style: { color: '#437220' } },
- { types: ['keyword', 'selector', 'important', 'atrule'], style: { color: '#B8533B' } },
- { types: ['function'], style: { color: '#1D5A8C' } },
- { types: ['tag'], style: { color: '#B8533B' } },
- { types: ['number', 'boolean'], style: { color: '#1B7A6A' } },
- { types: ['operator'], style: { color: '#24201E' } },
- { types: ['punctuation'], style: { color: '#5C504A' } },
- { types: ['variable', 'parameter'], style: { color: '#24201E' } },
- { types: ['property', 'attr-name'], style: { color: '#7A3E20' } },
- { types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: '#7E5520' } },
- { types: ['regex'], style: { color: '#C15F3C' } },
- { types: ['inserted'], style: { color: '#1A7F37' } },
- { types: ['deleted'], style: { color: '#CF222E' } },
+ { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' as const } },
+ { types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
+ { types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
+ { types: ['function'], style: { color: 'var(--color-code-function)' } },
+ { types: ['tag'], style: { color: 'var(--color-code-keyword)' } },
+ { types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } },
+ { types: ['operator'], style: { color: 'var(--color-code-fg)' } },
+ { types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } },
+ { types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } },
+ { types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } },
+ { types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } },
+ { types: ['regex'], style: { color: 'var(--color-primary-container)' } },
+ { types: ['inserted'], style: { color: 'var(--color-code-inserted)' } },
+ { types: ['deleted'], style: { color: 'var(--color-code-deleted)' } },
],
}
@@ -65,30 +65,30 @@ function highlightSyntax(str: string, language: string) {
const diffStyles = {
variables: {
light: {
- diffViewerBackground: '#FDFCF9',
- diffViewerColor: '#3B3330',
- addedBackground: '#E8F5E2',
- addedColor: '#3B3330',
- removedBackground: '#FDECEA',
- removedColor: '#3B3330',
- wordAddedBackground: '#B8E4A8',
- wordRemovedBackground: '#F5B8B4',
- addedGutterBackground: '#D4EDCA',
- removedGutterBackground: '#F9D4D0',
- gutterBackground: '#F4F4F0',
- gutterBackgroundDark: '#EFEEEA',
- highlightBackground: '#FFF5D6',
- highlightGutterBackground: '#FFECB3',
- codeFoldGutterBackground: '#E4EDF6',
- codeFoldBackground: '#EDF4FB',
- emptyLineBackground: '#F4F4F0',
- gutterColor: '#87736D',
- addedGutterColor: '#1A7F37',
- removedGutterColor: '#CF222E',
- codeFoldContentColor: '#87736D',
- diffViewerTitleBackground: '#F4F4F0',
- diffViewerTitleColor: '#87736D',
- diffViewerTitleBorderColor: '#DAC1BA',
+ diffViewerBackground: 'var(--color-code-bg)',
+ diffViewerColor: 'var(--color-code-fg)',
+ addedBackground: 'var(--color-diff-added-bg)',
+ addedColor: 'var(--color-code-fg)',
+ removedBackground: 'var(--color-diff-removed-bg)',
+ removedColor: 'var(--color-code-fg)',
+ wordAddedBackground: 'var(--color-diff-added-word)',
+ wordRemovedBackground: 'var(--color-diff-removed-word)',
+ addedGutterBackground: 'var(--color-diff-added-gutter)',
+ removedGutterBackground: 'var(--color-diff-removed-gutter)',
+ gutterBackground: 'var(--color-surface-container-low)',
+ gutterBackgroundDark: 'var(--color-surface-container)',
+ highlightBackground: 'var(--color-diff-highlight-bg)',
+ highlightGutterBackground: 'var(--color-diff-highlight-gutter)',
+ codeFoldGutterBackground: 'var(--color-surface-container-high)',
+ codeFoldBackground: 'var(--color-surface-container-highest)',
+ emptyLineBackground: 'var(--color-surface-container-low)',
+ gutterColor: 'var(--color-text-tertiary)',
+ addedGutterColor: 'var(--color-diff-added-text)',
+ removedGutterColor: 'var(--color-diff-removed-text)',
+ codeFoldContentColor: 'var(--color-text-tertiary)',
+ diffViewerTitleBackground: 'var(--color-diff-title-bg)',
+ diffViewerTitleColor: 'var(--color-diff-title-color)',
+ diffViewerTitleBorderColor: 'var(--color-diff-title-border)',
},
},
diffContainer: {
@@ -128,8 +128,8 @@ export function DiffViewer({ filePath, oldString, newString }: Props) {
{filePath}