From 76823047f468184cad4247ce90e9197ff089891b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 20 Apr 2026 10:23:19 +0800 Subject: [PATCH 1/4] Add a switchable dark theme for the desktop workspace This introduces a persisted light/dark appearance setting, maps the desktop shell onto semantic theme tokens, and reworks the highest-traffic chat/settings surfaces so the new dark mode is usable without regressing the original light theme. The same pass tightens markdown rendering for chat replies by improving inline code, table overflow handling, and safe external-link behavior so dark-mode content stays legible in real conversations. Constraint: Preserve the existing light theme while adding a user-selectable dark theme in Settings > General Constraint: Avoid introducing new dependencies for styling or markdown handling Rejected: Replacing the light palette with a single dual-purpose palette | would risk broad visual regressions across the existing desktop UI Rejected: Implementing dark mode only for shell chrome | leaves chat markdown, diffs, and permission flows visually broken Confidence: high Scope-risk: moderate Reversibility: clean Directive: New desktop UI should use semantic theme variables instead of hard-coded color literals so both themes stay aligned Tested: bun run lint; bun run test; bun run build; browser review of theme switching, provider/permission states, and chat surfaces Not-tested: Prototype-style pages with remaining hard-coded colors (scheduled/session control mock surfaces) were not fully normalized in this change --- desktop/src/components/chat/ChatInput.tsx | 21 +- desktop/src/components/chat/CodeViewer.tsx | 48 ++-- desktop/src/components/chat/DiffViewer.tsx | 84 +++---- desktop/src/components/chat/MessageList.tsx | 4 +- .../src/components/chat/PermissionDialog.tsx | 32 +-- .../src/components/chat/TerminalChrome.tsx | 12 +- desktop/src/components/chat/ToolCallBlock.tsx | 4 +- .../src/components/chat/ToolResultBlock.tsx | 2 +- desktop/src/components/layout/Sidebar.tsx | 4 +- .../src/components/layout/WindowControls.tsx | 2 +- .../markdown/MarkdownRenderer.test.tsx | 19 ++ .../components/markdown/MarkdownRenderer.tsx | 48 +++- .../settings/ClaudeOfficialLogin.tsx | 6 +- desktop/src/components/shared/Button.tsx | 2 +- desktop/src/components/shared/Input.tsx | 4 +- desktop/src/components/shared/Modal.tsx | 4 +- .../components/shared/ProjectContextChip.tsx | 10 +- desktop/src/components/shared/Textarea.tsx | 2 +- desktop/src/components/tasks/TaskRow.tsx | 8 +- .../src/components/tasks/TaskRunsPanel.tsx | 2 +- desktop/src/i18n/locales/en.ts | 4 + desktop/src/i18n/locales/zh.ts | 4 + desktop/src/main.tsx | 3 + desktop/src/pages/ActiveSession.tsx | 6 +- desktop/src/pages/AgentTeams.tsx | 80 +++---- desktop/src/pages/EmptySession.tsx | 40 ++-- desktop/src/pages/Settings.tsx | 42 +++- desktop/src/pages/ToolInspection.tsx | 12 +- desktop/src/stores/settingsStore.ts | 24 +- desktop/src/stores/uiStore.ts | 33 ++- desktop/src/theme/globals.css | 209 +++++++++++++++++- desktop/src/types/settings.ts | 2 + 32 files changed, 552 insertions(+), 225 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 4dcfe1cb..6f4cb8e1 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -394,11 +394,10 @@ export function ChatInput() { } return ( -
+
event.preventDefault()} onDrop={handleDrop} > @@ -491,7 +490,7 @@ export function ChatInput() { className="w-full resize-none bg-transparent py-2 pb-12 text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50" /> -
+
{!isMemberSession && ( <> @@ -499,26 +498,26 @@ export function ChatInput() { {plusMenuOpen && ( -
+
diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index a50286ea..d98468e4 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -227,10 +227,10 @@ export const MessageBlock = memo(function MessageBlock({ message.message.trim() !== '' && message.message !== displayMessage return ( -
+
Error: {displayMessage} {showRawDetail && ( -
+
{message.message}
)} diff --git a/desktop/src/components/chat/PermissionDialog.tsx b/desktop/src/components/chat/PermissionDialog.tsx index dce69385..671fc7db 100644 --- a/desktop/src/components/chat/PermissionDialog.tsx +++ b/desktop/src/components/chat/PermissionDialog.tsx @@ -18,17 +18,17 @@ type Props = { * Uses Material Symbols Outlined names. */ const TOOL_META: Record = { - Bash: { icon: 'terminal', label: 'Bash', color: '#CA8A04' }, - Edit: { icon: 'edit_note', label: 'Edit File', color: '#8F482F' }, - Write: { icon: 'edit_document', label: 'Write File', color: '#16A34A' }, - Read: { icon: 'description', label: 'Read File', color: '#2D628F' }, - Glob: { icon: 'search', label: 'Glob Search', color: '#2D628F' }, - Grep: { icon: 'find_in_page', label: 'Grep Search', color: '#2D628F' }, - Agent: { icon: 'smart_toy', label: 'Agent', color: '#7C3AED' }, - WebSearch: { icon: 'travel_explore', label: 'Web Search', color: '#2D628F' }, - WebFetch: { icon: 'cloud_download', label: 'Web Fetch', color: '#2D628F' }, - NotebookEdit: { icon: 'note', label: 'Notebook Edit', color: '#8F482F' }, - Skill: { icon: 'auto_awesome', label: 'Skill', color: '#7C3AED' }, + Bash: { icon: 'terminal', label: 'Bash', color: 'var(--color-warning)' }, + Edit: { icon: 'edit_note', label: 'Edit File', color: 'var(--color-brand)' }, + Write: { icon: 'edit_document', label: 'Write File', color: 'var(--color-success)' }, + Read: { icon: 'description', label: 'Read File', color: 'var(--color-secondary)' }, + Glob: { icon: 'search', label: 'Glob Search', color: 'var(--color-secondary)' }, + Grep: { icon: 'find_in_page', label: 'Grep Search', color: 'var(--color-secondary)' }, + Agent: { icon: 'smart_toy', label: 'Agent', color: 'var(--color-tertiary)' }, + WebSearch: { icon: 'travel_explore', label: 'Web Search', color: 'var(--color-secondary)' }, + WebFetch: { icon: 'cloud_download', label: 'Web Fetch', color: 'var(--color-secondary)' }, + NotebookEdit: { icon: 'note', label: 'Notebook Edit', color: 'var(--color-brand)' }, + Skill: { icon: 'auto_awesome', label: 'Skill', color: 'var(--color-tertiary)' }, } /** @@ -100,9 +100,9 @@ function renderPermissionPreview(toolName: string, input: unknown) { if (toolName === 'Bash' && typeof obj.command === 'string') { return ( -
-
-          $ {obj.command}
+      
+
+          $ {obj.command}
         
) @@ -119,7 +119,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr const isPending = pendingPermission?.requestId === requestId const [showRaw, setShowRaw] = useState(false) - const meta = TOOL_META[toolName] || { icon: 'shield', label: toolName, color: '#87736D' } + const meta = TOOL_META[toolName] || { icon: 'shield', label: toolName, color: 'var(--color-text-tertiary)' } const details = extractToolDetails(toolName, input, t) const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2) const preview = renderPermissionPreview(toolName, input) @@ -215,7 +215,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr )} {allowRawToggle && showRaw && ( -
+          
             {rawInput}
           
)} diff --git a/desktop/src/components/chat/TerminalChrome.tsx b/desktop/src/components/chat/TerminalChrome.tsx index 26d94636..63f934b1 100644 --- a/desktop/src/components/chat/TerminalChrome.tsx +++ b/desktop/src/components/chat/TerminalChrome.tsx @@ -14,20 +14,20 @@ export function TerminalChrome({ title, children, className = '' }: Props) { return (
{/* Title bar with traffic lights */} -
+
-
-
-
+
+
+
{title && ( - + {title} )}
{/* Content */} -
+
{children}
diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx index f8e53ee2..13aa2b09 100644 --- a/desktop/src/components/chat/ToolCallBlock.tsx +++ b/desktop/src/components/chat/ToolCallBlock.tsx @@ -115,8 +115,8 @@ function renderPreview( if (toolName === 'Bash' && typeof obj.command === 'string') { return ( -
- $ {obj.command} +
+ $ {obj.command}
) diff --git a/desktop/src/components/chat/ToolResultBlock.tsx b/desktop/src/components/chat/ToolResultBlock.tsx index 0d3818be..16ef5be6 100644 --- a/desktop/src/components/chat/ToolResultBlock.tsx +++ b/desktop/src/components/chat/ToolResultBlock.tsx @@ -51,7 +51,7 @@ export function ToolResultBlock({ content, isError, toolName, standalone = true {isError ? t('tool.error') : t('tool.success')} diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index dc5b3bce..af13259d 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -113,8 +113,8 @@ export function Sidebar() {
- - Claude Code Haha + + Claude Code Haha
runWindowAction(() => win.close())} aria-label="Close window" - className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[#e81123] hover:text-white transition-colors" + className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-window-close-hover)] hover:text-white transition-colors" > diff --git a/desktop/src/components/markdown/MarkdownRenderer.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.test.tsx index 3f005df8..2bf5db60 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.test.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.test.tsx @@ -78,4 +78,23 @@ describe('MarkdownRenderer', () => { ) expect(screen.queryByTestId('mermaid-renderer')).not.toBeInTheDocument() }) + + it('wraps markdown tables for horizontal overflow handling', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.md-table-wrap')).toBeInTheDocument() + expect(screen.getByText('index.html')).toBeInTheDocument() + }) + + it('opens markdown links in a new tab safely', () => { + render() + + const link = screen.getByRole('link', { name: 'OpenAI' }) + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')) + }) }) diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx index 6bb60abd..78a004d2 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.tsx @@ -68,6 +68,35 @@ marked.setOptions({ }) marked.use({ renderer }) +function enhanceMarkdownHtml(html: string): string { + const cleanHtml = DOMPurify.sanitize(html, { + ADD_TAGS: ['use'], + ADD_ATTR: ['xlink:href'], + }) + + if (typeof document === 'undefined') { + return cleanHtml + } + + const container = document.createElement('div') + container.innerHTML = cleanHtml + + container.querySelectorAll('table').forEach((table) => { + if (table.parentElement?.classList.contains('md-table-wrap')) return + const wrapper = document.createElement('div') + wrapper.className = 'md-table-wrap' + table.parentNode?.insertBefore(wrapper, table) + wrapper.appendChild(table) + }) + + container.querySelectorAll('a[href]').forEach((link) => { + link.setAttribute('target', '_blank') + link.setAttribute('rel', 'noreferrer noopener') + }) + + return container.innerHTML +} + function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[] } { pendingCodeBlocks = [] const html = marked.parse(content) as string @@ -76,19 +105,20 @@ function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[] return { html, codeBlocks } } -const BASE_PROSE_CLASSES = `prose prose-sm max-w-none text-[var(--color-text-primary)] +const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm max-w-none text-[var(--color-text-primary)] prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold prose-p:my-2 prose-p:leading-relaxed prose-p:break-words - prose-code:text-[13px] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-surface-info)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded + prose-code:text-[13px] prose-code:text-[var(--color-primary-fixed)] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-surface-container-high)] prose-code:border prose-code:border-[var(--color-border)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:before:hidden prose-code:after:hidden prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none prose-a:text-[var(--color-text-accent)] prose-a:no-underline hover:prose-a:underline prose-strong:text-[var(--color-text-primary)] prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 - prose-table:w-full prose-table:table-auto prose-table:text-sm - prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 prose-th:whitespace-normal prose-th:break-words prose-th:align-top - prose-td:px-3 prose-td:py-2 prose-td:border-[var(--color-border)] prose-td:whitespace-normal prose-td:break-words prose-td:align-top` + prose-table:my-0 prose-table:w-full prose-table:table-auto prose-table:text-sm + prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 prose-th:text-left prose-th:whitespace-normal prose-th:break-words prose-th:align-top prose-th:border-b prose-th:border-[var(--color-border)] + prose-td:px-3 prose-td:py-2 prose-td:border-b prose-td:border-[var(--color-border)] prose-td:whitespace-normal prose-td:break-words prose-td:align-top prose-td:bg-[var(--color-surface)] + [&_.md-table-wrap]:my-5 [&_.md-table-wrap]:overflow-x-auto [&_.md-table-wrap]:rounded-xl [&_.md-table-wrap]:border [&_.md-table-wrap]:border-[var(--color-border)] [&_.md-table-wrap]:bg-[var(--color-surface-container-lowest)]` const DOCUMENT_PROSE_CLASSES = ` prose-p:text-[15px] prose-p:leading-7 @@ -104,9 +134,7 @@ const DOCUMENT_PROSE_CLASSES = ` prose-ul:pl-5 prose-ul:[&>li]:marker:text-[var(--color-text-tertiary)] prose-ol:pl-5 prose-ol:[&>li]:marker:text-[var(--color-text-tertiary)] prose-li:my-1.5 - prose-table:my-5 - prose-th:text-left - prose-td:bg-[var(--color-surface)]` + prose-table:my-0` function getProseClasses(variant: 'default' | 'document', className?: string) { return [BASE_PROSE_CLASSES, variant === 'document' ? DOCUMENT_PROSE_CLASSES : '', className ?? ''] @@ -170,7 +198,7 @@ export function MarkdownRenderer({ content, variant = 'default', className }: Pr }, []) if (codeBlocks.length === 0) { - const cleanHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['use'], ADD_ATTR: ['xlink:href'] }) + const cleanHtml = enhanceMarkdownHtml(html) return (
{parts.map((part, i) => part.type === 'html' ? ( -
+
) : shouldRenderAsMermaid(part.block) ? ( ) : ( diff --git a/desktop/src/components/settings/ClaudeOfficialLogin.tsx b/desktop/src/components/settings/ClaudeOfficialLogin.tsx index 9d5bfb63..91a5be3b 100644 --- a/desktop/src/components/settings/ClaudeOfficialLogin.tsx +++ b/desktop/src/components/settings/ClaudeOfficialLogin.tsx @@ -47,7 +47,7 @@ export function ClaudeOfficialLogin() { if (status === null) { if (error) { return ( -
+
{t('settings.claudeOfficialLogin.errorPrefix')}{error}
) @@ -91,14 +91,14 @@ export function ClaudeOfficialLogin() { type="button" onClick={handleLogin} disabled={isLoading} - className="self-start px-4 py-2 text-sm rounded-md bg-[var(--color-accent,#c96342)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity" + className="self-start rounded-md bg-[image:var(--gradient-btn-primary)] px-4 py-2 text-sm text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] hover:brightness-105 disabled:opacity-50 transition-opacity" > {isLoading ? t('settings.claudeOfficialLogin.loginStarting') : t('settings.claudeOfficialLogin.loginButton')} {error && ( -
+
{t('settings.claudeOfficialLogin.errorPrefix')}{error}
)} diff --git a/desktop/src/components/shared/Button.tsx b/desktop/src/components/shared/Button.tsx index f4eccad2..2e5fb00e 100644 --- a/desktop/src/components/shared/Button.tsx +++ b/desktop/src/components/shared/Button.tsx @@ -11,7 +11,7 @@ type ButtonProps = ButtonHTMLAttributes & { const variantStyles: Record = { primary: - 'bg-[var(--color-btn-primary-bg)] text-[var(--color-btn-primary-fg)] hover:bg-[var(--color-btn-primary-bg-hover)] active:bg-[var(--color-btn-primary-bg-active)]', + 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] hover:bg-[image:var(--gradient-btn-primary-hover)] hover:brightness-105 active:translate-y-[1px]', secondary: 'bg-[var(--color-surface)] text-[var(--color-text-primary)] border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)]', danger: diff --git a/desktop/src/components/shared/Input.tsx b/desktop/src/components/shared/Input.tsx index cae1f1b5..75ea7cc8 100644 --- a/desktop/src/components/shared/Input.tsx +++ b/desktop/src/components/shared/Input.tsx @@ -24,8 +24,8 @@ export function Input({ label, error, required, className = '', id, ...props }: placeholder:text-[var(--color-text-tertiary)] transition-colors duration-150 ${error - ? 'border-[var(--color-error)] focus:shadow-[0_0_0_3px_rgba(171,43,63,0.1)]' - : 'border-[var(--color-border)] focus:border-[var(--color-border-focus)] focus:shadow-[0_0_0_3px_rgba(153,153,153,0.1)]' + ? 'border-[var(--color-error)] focus:shadow-[var(--shadow-error-ring)]' + : 'border-[var(--color-border)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]' } outline-none ${className} diff --git a/desktop/src/components/shared/Modal.tsx b/desktop/src/components/shared/Modal.tsx index a74a79a6..c1f5b278 100644 --- a/desktop/src/components/shared/Modal.tsx +++ b/desktop/src/components/shared/Modal.tsx @@ -25,13 +25,13 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
{/* Backdrop */}
{/* Modal content */}
+
{branch ? ( - + ) : ( - folder + folder )} - {label} + {label} {branch ? ( <> - | + | {branch} ) : null} diff --git a/desktop/src/components/shared/Textarea.tsx b/desktop/src/components/shared/Textarea.tsx index 96515cc6..04fa209c 100644 --- a/desktop/src/components/shared/Textarea.tsx +++ b/desktop/src/components/shared/Textarea.tsx @@ -25,7 +25,7 @@ export function Textarea({ label, error, required, className = '', id, ...props transition-colors duration-150 ${error ? 'border-[var(--color-error)]' - : 'border-[var(--color-border)] focus:border-[var(--color-border-focus)] focus:shadow-[0_0_0_3px_rgba(153,153,153,0.1)]' + : 'border-[var(--color-border)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]' } outline-none ${className} diff --git a/desktop/src/components/tasks/TaskRow.tsx b/desktop/src/components/tasks/TaskRow.tsx index 06fef9d6..8a1f538b 100644 --- a/desktop/src/components/tasks/TaskRow.tsx +++ b/desktop/src/components/tasks/TaskRow.tsx @@ -165,7 +165,7 @@ export function TaskRow({ task, showLogs, onToggleLogs }: Props) { {/* Delete */}
@@ -161,18 +161,18 @@ export function AgentTeams() { {/* ─── Chat Composer ─── */}
-
-
+
+
attach_file
setInputValue(e.target.value)} /> -
-
- +
+ Auto-run enabled
-
- +
+ Local LLM
diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 8e1df3e1..653dbf2d 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -336,14 +336,14 @@ export function EmptySession() { } return ( -
+
- Claude Code Haha -

+ Claude Code Haha +

{t('empty.title')}

-

+

{t('empty.subtitle')}

@@ -352,8 +352,7 @@ export function EmptySession() {
event.preventDefault()} onDrop={handleDrop} > @@ -382,7 +381,7 @@ export function EmptySession() { {slashMenuOpen && filteredCommands.length > 0 && (
{filteredCommands.map((command, index) => ( @@ -392,11 +391,11 @@ export function EmptySession() { onClick={() => selectSlashCommand(command.name)} onMouseEnter={() => setSlashSelectedIndex(index)} className={`flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left transition-colors ${ - index === slashSelectedIndex ? 'bg-[#F4F4F0]' : 'hover:bg-[#F8F7F4]' + index === slashSelectedIndex ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]' }`} > - /{command.name} - {command.description} + /{command.name} + {command.description} ))}
@@ -414,41 +413,41 @@ export function EmptySession() { onChange={(event) => handleInputChange(event.target.value, event.target.selectionStart ?? event.target.value.length)} onKeyDown={handleKeyDown} onPaste={handlePaste} - className="flex-1 resize-none border-none bg-transparent py-2 leading-relaxed text-[#1B1C1A] outline-none placeholder:text-[#87736D]/50" - style={{ fontFamily: "'Inter', sans-serif" }} + className="flex-1 resize-none border-none bg-transparent py-2 leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" + style={{ fontFamily: 'var(--font-body)' }} placeholder={t('empty.placeholder')} rows={2} />
-
+
{plusMenuOpen && ( -
+
@@ -463,7 +462,7 @@ export function EmptySession() {
) } - diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index a2eb55ff..ede5cd5b 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -5,7 +5,7 @@ import { useTranslation } from '../i18n' import { Modal } from '../components/shared/Modal' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' -import type { PermissionMode, EffortLevel } from '../types/settings' +import type { PermissionMode, EffortLevel, ThemeMode } from '../types/settings' import type { Locale } from '../i18n' import { PROVIDER_PRESETS } from '../config/providerPresets' import type { ProviderPreset } from '../config/providerPresets' @@ -142,7 +142,7 @@ function ProviderSettings() {
@@ -155,7 +155,7 @@ function ProviderSettings() {
{t('settings.providers.officialName')} {isOfficialActive && ( - {t('common.active')} + {t('common.active')} )}
{t('settings.providers.officialDesc')}
@@ -185,7 +185,7 @@ function ProviderSettings() { key={provider.id} className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all group ${ isActive - ? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)]' + ? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]' : 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]' }`} > @@ -202,7 +202,7 @@ function ProviderSettings() { )} {isActive && ( - {t('common.active')} + {t('common.active')} )}
@@ -437,8 +437,8 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) onClick={() => handlePresetChange(preset)} className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-all ${ selectedPreset.id === preset.id - ? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]' - : 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)]' + ? 'border-[var(--color-brand)] bg-[var(--color-surface-container-high)] text-[var(--color-brand)] shadow-[var(--shadow-focus-ring)]' + : 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]' }`} > {preset.name} @@ -621,7 +621,7 @@ function PermissionSettings() { onClick={() => setPermissionMode(mode)} className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-all text-left ${ isSelected - ? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)]' + ? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]' : 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]' }`} > @@ -646,7 +646,7 @@ function PermissionSettings() { // ─── General Settings ────────────────────────────────────── function GeneralSettings() { - const { effortLevel, setEffort, locale, setLocale } = useSettingsStore() + const { effortLevel, setEffort, locale, setLocale, theme, setTheme } = useSettingsStore() const t = useTranslation() const EFFORT_LABELS: Record = { @@ -661,8 +661,32 @@ function GeneralSettings() { { value: 'zh', label: '中文' }, ] + const THEMES: Array<{ value: ThemeMode; label: string }> = [ + { value: 'light', label: t('settings.general.appearance.light') }, + { value: 'dark', label: t('settings.general.appearance.dark') }, + ] + return (
+ {/* Appearance selector */} +

{t('settings.general.appearanceTitle')}

+

{t('settings.general.appearanceDescription')}

+
+ {THEMES.map(({ value, label }) => ( + + ))} +
+ {/* Language selector */}

{t('settings.general.languageTitle')}

{t('settings.general.languageDescription')}

diff --git a/desktop/src/pages/ToolInspection.tsx b/desktop/src/pages/ToolInspection.tsx index 59965d71..c37ebdbe 100644 --- a/desktop/src/pages/ToolInspection.tsx +++ b/desktop/src/pages/ToolInspection.tsx @@ -19,7 +19,7 @@ export function ToolInspection() {
- + {toolType}

@@ -126,8 +126,8 @@ export function ToolInspection() { const isRemoved = line.type === 'removed' let rowBg = '' - if (isAdded) rowBg = 'bg-[rgba(103,123,78,0.15)]' - else if (isRemoved) rowBg = 'bg-[rgba(143,72,47,0.15)]' + if (isAdded) rowBg = 'bg-[var(--color-diff-added-bg)]' + else if (isRemoved) rowBg = 'bg-[var(--color-diff-removed-bg)]' let lineNoColor = 'text-[var(--color-outline)] opacity-40' if (isAdded) lineNoColor = 'text-[var(--color-tertiary)] opacity-40' @@ -161,7 +161,7 @@ export function ToolInspection() {
- + psychology
@@ -179,8 +179,8 @@ export function ToolInspection() {
-
- +
+ science
diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index b8831a96..7d1f4419 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -1,8 +1,9 @@ import { create } from 'zustand' import { settingsApi } from '../api/settings' import { modelsApi } from '../api/models' -import type { PermissionMode, EffortLevel, ModelInfo } from '../types/settings' +import type { PermissionMode, EffortLevel, ModelInfo, ThemeMode } from '../types/settings' import type { Locale } from '../i18n' +import { useUIStore } from './uiStore' const LOCALE_STORAGE_KEY = 'cc-haha-locale' @@ -21,6 +22,7 @@ type SettingsStore = { availableModels: ModelInfo[] activeProviderName: string | null locale: Locale + theme: ThemeMode isLoading: boolean error: string | null @@ -29,6 +31,7 @@ type SettingsStore = { setModel: (modelId: string) => Promise setEffort: (level: EffortLevel) => Promise setLocale: (locale: Locale) => void + setTheme: (theme: ThemeMode) => Promise } export const useSettingsStore = create((set, get) => ({ @@ -38,24 +41,29 @@ export const useSettingsStore = create((set, get) => ({ availableModels: [], activeProviderName: null, locale: getStoredLocale(), + theme: useUIStore.getState().theme, isLoading: false, error: null, fetchAll: async () => { set({ isLoading: true, error: null }) try { - const [{ mode }, modelsRes, { model }, { level }] = await Promise.all([ + const [{ mode }, modelsRes, { model }, { level }, userSettings] = await Promise.all([ settingsApi.getPermissionMode(), modelsApi.list(), modelsApi.getCurrent(), modelsApi.getEffort(), + settingsApi.getUser(), ]) + const theme = userSettings.theme === 'dark' ? 'dark' : 'light' + useUIStore.getState().setTheme(theme) set({ permissionMode: mode, availableModels: modelsRes.models, activeProviderName: modelsRes.provider?.name ?? null, currentModel: model, effortLevel: level, + theme, isLoading: false, error: null, }) @@ -97,4 +105,16 @@ export const useSettingsStore = create((set, get) => ({ set({ locale }) try { localStorage.setItem(LOCALE_STORAGE_KEY, locale) } catch { /* noop */ } }, + + setTheme: async (theme) => { + const prev = get().theme + set({ theme }) + useUIStore.getState().setTheme(theme) + try { + await settingsApi.updateUser({ theme }) + } catch { + set({ theme: prev }) + useUIStore.getState().setTheme(prev) + } + }, })) diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts index 57904645..27903371 100644 --- a/desktop/src/stores/uiStore.ts +++ b/desktop/src/stores/uiStore.ts @@ -1,4 +1,25 @@ import { create } from 'zustand' +import type { ThemeMode } from '../types/settings' + +const THEME_STORAGE_KEY = 'cc-haha-theme' + +function getStoredTheme(): ThemeMode { + try { + const stored = localStorage.getItem(THEME_STORAGE_KEY) + if (stored === 'light' || stored === 'dark') return stored + } catch { /* localStorage unavailable */ } + return 'light' +} + +export function applyTheme(theme: ThemeMode) { + if (typeof document === 'undefined') return + document.documentElement.setAttribute('data-theme', theme) + document.documentElement.style.colorScheme = theme +} + +export function initializeTheme() { + applyTheme(getStoredTheme()) +} export type Toast = { id: string @@ -12,14 +33,14 @@ export type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings' type UIStore = { - theme: 'light' | 'dark' + theme: ThemeMode sidebarOpen: boolean activeView: ActiveView pendingSettingsTab: SettingsTab | null activeModal: string | null toasts: Toast[] - setTheme: (theme: 'light' | 'dark') => void + setTheme: (theme: ThemeMode) => void toggleTheme: () => void toggleSidebar: () => void setSidebarOpen: (open: boolean) => void @@ -34,7 +55,7 @@ type UIStore = { let toastCounter = 0 export const useUIStore = create((set) => ({ - theme: 'light', + theme: getStoredTheme(), sidebarOpen: true, activeView: 'code', pendingSettingsTab: null, @@ -42,14 +63,16 @@ export const useUIStore = create((set) => ({ toasts: [], setTheme: (theme) => { - document.documentElement.setAttribute('data-theme', theme) + applyTheme(theme) + try { localStorage.setItem(THEME_STORAGE_KEY, theme) } catch { /* noop */ } set({ theme }) }, toggleTheme: () => { set((state) => { const next = state.theme === 'light' ? 'dark' : 'light' - document.documentElement.setAttribute('data-theme', next) + applyTheme(next) + try { localStorage.setItem(THEME_STORAGE_KEY, next) } catch { /* noop */ } return { theme: next } }) }, diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index 62e2ff66..d1340de7 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -124,6 +124,7 @@ --color-error: #BA1A1A; --color-error-container: #FFDAD6; + --color-on-error-container: #410002; --color-inverse-surface: #2F312E; --color-inverse-on-surface: #F2F1ED; @@ -139,7 +140,10 @@ } /* ─── Semantic Aliases (used by layout & infrastructure components) ── */ -:root { +:root, +[data-theme="light"] { + color-scheme: light; + /* Layout dimensions */ --sidebar-width: 280px; --titlebar-height: 40px; @@ -169,16 +173,67 @@ /* Shadows */ --shadow-dropdown: 0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08); + --shadow-focus-ring: 0 0 0 1px rgba(143, 72, 47, 0.16); + --shadow-error-ring: 0 0 0 1px rgba(186, 26, 26, 0.18); + --shadow-button-primary: 0 8px 24px rgba(143, 72, 47, 0.18); /* Button colors */ - --color-btn-primary-bg: var(--color-primary); --color-btn-primary-fg: var(--color-on-primary); - --color-btn-primary-bg-hover: var(--color-primary-container); - --color-btn-primary-bg-active: var(--color-primary); + --gradient-btn-primary: linear-gradient(135deg, var(--color-primary), var(--color-primary-container)); + --gradient-btn-primary-hover: linear-gradient(135deg, var(--color-primary-container), var(--color-primary)); /* User message bubble */ --color-surface-user-msg: var(--color-surface-container); + /* Glass / overlays */ + --color-overlay-scrim: rgba(27, 28, 26, 0.48); + --color-surface-glass: rgba(255, 255, 255, 0.84); + --color-surface-glass-border: rgba(218, 193, 186, 0.22); + + /* Code + diff */ + --color-code-bg: #FDFCF9; + --color-code-fg: #24201E; + --color-code-comment: #5C6B7A; + --color-code-string: #437220; + --color-code-keyword: #B8533B; + --color-code-function: #1D5A8C; + --color-code-number: #1B7A6A; + --color-code-property: #7A3E20; + --color-code-type: #7E5520; + --color-code-parameter: #5C3D2E; + --color-code-punctuation: #5C504A; + --color-code-inserted: #1A7F37; + --color-code-deleted: #CF222E; + + --color-diff-added-bg: #E8F5E2; + --color-diff-added-word: #B8E4A8; + --color-diff-added-gutter: #D4EDCA; + --color-diff-added-text: #1A7F37; + --color-diff-removed-bg: #FDECEA; + --color-diff-removed-word: #F5B8B4; + --color-diff-removed-gutter: #F9D4D0; + --color-diff-removed-text: #CF222E; + --color-diff-highlight-bg: #FFF5D6; + --color-diff-highlight-gutter: #FFECB3; + --color-diff-title-bg: #F4F4F0; + --color-diff-title-color: #87736D; + --color-diff-title-border: #DAC1BA; + + /* Terminal */ + --color-terminal-header: #2D2D2D; + --color-terminal-bg: #1E1E1E; + --color-terminal-border: #1A1A1A; + --color-terminal-fg: #D4D4D4; + --color-terminal-muted: #999999; + --color-terminal-accent: #28C840; + --color-terminal-danger: #FF5F57; + --color-terminal-warning: #FEBC2E; + + /* Misc */ + --color-window-close-hover: #E81123; + --color-selection-bg: rgba(255, 219, 208, 0.9); + --color-selection-fg: #390C00; + /* Radii */ --radius-sm: 4px; --radius-md: 8px; @@ -187,6 +242,120 @@ --radius-full: 9999px; } +[data-theme="dark"] { + color-scheme: dark; + + --color-primary: #FFB59F; + --color-primary-container: #FF6E40; + --color-primary-fixed: #FFD9CF; + --color-primary-fixed-dim: #FFB59F; + + --color-on-primary: #2C120B; + --color-on-surface: #E5E2E1; + --color-on-surface-variant: #B7AAA5; + + --color-surface: #131313; + --color-surface-bright: #201F1F; + --color-surface-dim: #0E0E0E; + --color-surface-container: #201F1F; + --color-surface-container-low: #1C1B1B; + --color-surface-container-high: #2A2929; + --color-surface-container-highest: #353534; + --color-surface-container-lowest: #0E0E0E; + --color-surface-variant: #252120; + --color-background: #0E0E0E; + + --color-outline: #8D7F7A; + --color-outline-variant: #5A4138; + + --color-secondary: #CDBDFF; + --color-secondary-container: #3A3050; + --color-tertiary: #00DAF3; + --color-tertiary-container: #003D44; + + --color-error: #FFB4AB; + --color-error-container: #93000A; + --color-on-error-container: #FFDAD6; + + --color-inverse-surface: #E5E2E1; + --color-inverse-on-surface: #1B1C1A; + --color-inverse-primary: #8F482F; + + --color-success: #7EDB8B; + --color-warning: #F7C46C; + + --color-surface-sidebar: var(--color-surface-container-low); + --color-surface-hover: var(--color-surface-container-highest); + --color-surface-selected: var(--color-surface-container); + + --color-border: rgba(90, 65, 56, 0.18); + --color-border-focus: rgba(255, 181, 159, 0.55); + --color-border-separator: rgba(90, 65, 56, 0.12); + + --color-text-primary: var(--color-on-surface); + --color-text-secondary: var(--color-on-surface-variant); + --color-text-tertiary: #8F827D; + + --color-surface-info: rgba(32, 31, 31, 0.88); + --color-brand: var(--color-primary); + --color-text-accent: var(--color-tertiary); + + --shadow-dropdown: 0 12px 32px rgba(0, 0, 0, 0.4); + --shadow-focus-ring: 0 0 0 1px rgba(255, 181, 159, 0.32); + --shadow-error-ring: 0 0 0 1px rgba(255, 180, 171, 0.26); + --shadow-button-primary: 0 12px 24px rgba(255, 110, 64, 0.18); + + --gradient-btn-primary: linear-gradient(135deg, var(--color-primary), var(--color-primary-container)); + --gradient-btn-primary-hover: linear-gradient(135deg, var(--color-primary-container), var(--color-primary)); + + --color-surface-user-msg: rgba(32, 31, 31, 0.92); + + --color-overlay-scrim: rgba(6, 6, 6, 0.56); + --color-surface-glass: rgba(32, 31, 31, 0.8); + --color-surface-glass-border: rgba(90, 65, 56, 0.18); + + --color-code-bg: #0E0E0E; + --color-code-fg: #E5E2E1; + --color-code-comment: #8F8683; + --color-code-string: #88C988; + --color-code-keyword: #FF8F70; + --color-code-function: #00DAF3; + --color-code-number: #74E0F7; + --color-code-property: #FFC9B8; + --color-code-type: #CDBDFF; + --color-code-parameter: #E5E2E1; + --color-code-punctuation: #9B908C; + --color-code-inserted: #8EEA9A; + --color-code-deleted: #FFB59F; + + --color-diff-added-bg: rgba(126, 219, 139, 0.12); + --color-diff-added-word: rgba(126, 219, 139, 0.22); + --color-diff-added-gutter: rgba(126, 219, 139, 0.18); + --color-diff-added-text: #8EEA9A; + --color-diff-removed-bg: rgba(255, 110, 64, 0.12); + --color-diff-removed-word: rgba(255, 110, 64, 0.24); + --color-diff-removed-gutter: rgba(255, 110, 64, 0.18); + --color-diff-removed-text: #FFB59F; + --color-diff-highlight-bg: rgba(205, 189, 255, 0.12); + --color-diff-highlight-gutter: rgba(205, 189, 255, 0.16); + --color-diff-title-bg: var(--color-surface-container-low); + --color-diff-title-color: var(--color-text-tertiary); + --color-diff-title-border: rgba(90, 65, 56, 0.15); + + --color-terminal-header: #242323; + --color-terminal-bg: #121212; + --color-terminal-border: #1A1919; + --color-terminal-fg: #D7D2D0; + --color-terminal-muted: #8F8683; + --color-terminal-accent: #7EF18A; + --color-terminal-danger: #FF6D67; + --color-terminal-warning: #F8C55F; + + --color-window-close-hover: #C63B44; + --color-selection-bg: rgba(255, 181, 159, 0.32); + --color-selection-fg: #FBE7E1; +} + /* ─── Material Symbols ─────────────────────────────────────────── */ .material-symbols-outlined { font-family: 'Material Symbols Outlined'; @@ -222,6 +391,11 @@ html, body, #root { -moz-osx-font-smoothing: grayscale; } +::selection { + background: var(--color-selection-bg); + color: var(--color-selection-fg); +} + /* Tauri drag region */ [data-tauri-drag-region] { -webkit-app-region: drag; @@ -235,7 +409,32 @@ button, input, textarea, select, a, [role="button"] { /* Custom shadow from prototype */ .custom-shadow { - box-shadow: 0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08); + box-shadow: var(--shadow-dropdown); +} + +.glass-panel { + background: var(--color-surface-glass); + border: 1px solid var(--color-surface-glass-border); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: var(--shadow-dropdown); +} + +.glass-panel:focus-within { + box-shadow: var(--shadow-focus-ring), var(--shadow-dropdown); +} + +.markdown-prose .md-table-wrap table { + border-collapse: separate; + border-spacing: 0; +} + +.markdown-prose .md-table-wrap tbody tr:last-child td { + border-bottom: 0; +} + +.markdown-prose a code { + color: inherit; } /* Scrollbar */ diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 24f741fc..68918d97 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -3,6 +3,7 @@ export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions' | 'dontAsk' export type EffortLevel = 'low' | 'medium' | 'high' | 'max' +export type ThemeMode = 'light' | 'dark' export type ModelInfo = { id: string @@ -16,5 +17,6 @@ export type UserSettings = { modelContext?: string effort?: EffortLevel permissionMode?: PermissionMode + theme?: ThemeMode [key: string]: unknown } From 5cd6b5d07b5865e35893f33aa9535c827504f466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 20 Apr 2026 12:12:02 +0800 Subject: [PATCH 2/4] Prevent desktop Computer Use from stalling on missing approvals and unstable text input Desktop sessions were missing a visible request_access approval path and could mis-detect their own app window as an unapproved frontmost target, which caused Computer Use clicks to fail even after opening the intended app. On macOS, text entry was also split across inconsistent clipboard and keystroke paths, making Electron inputs unreliable for Chinese and short strings. This change adds a desktop approval bridge over the existing session websocket, renders a dedicated desktop approval modal, threads the real desktop bundle id into the Computer Use executor, and switches macOS clipboard typing onto the native pasteboard plus system paste shortcut path. It also makes tool error results expandable in the desktop chat UI so frontmost-gate failures are fully visible during debugging. Constraint: Desktop sessions run the CLI over the SDK websocket path, so Ink tool JSX dialogs are not visible there Constraint: macOS IME and Electron text inputs are unreliable with pyautogui.write and generic hotkey synthesis Rejected: Reuse CLI setToolJSX dialogs in desktop mode | no transport for mid-call Ink UI over the SDK bridge Rejected: Keep shell pbcopy/pbpaste for clipboard typing | inconsistent with NSPasteboard path and less reliable for Chinese text Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep desktop Computer Use approvals and macOS text-entry behavior on a single bridge/path; avoid reintroducing separate CLI-only and desktop-only codepaths for the same action Tested: python3 -m unittest runtime/test_helpers.py Tested: bun test src/utils/computerUse/permissions.test.ts src/server/__tests__/conversation-service.test.ts Tested: cd desktop && bun run test ComputerUsePermissionModal chatStore Tested: cd desktop && bun run test chatBlocks Tested: cd desktop && bun run lint Not-tested: End-to-end manual Computer Use interaction against a live Electron target app on macOS --- desktop/src/__tests__/pages.test.tsx | 2 + .../chat/ComputerUsePermissionModal.test.tsx | 174 ++++++++++ .../chat/ComputerUsePermissionModal.tsx | 311 ++++++++++++++++++ .../src/components/chat/MessageList.test.tsx | 1 + desktop/src/components/chat/ToolCallBlock.tsx | 5 +- .../src/components/chat/chatBlocks.test.tsx | 24 +- desktop/src/i18n/locales/en.ts | 22 ++ desktop/src/i18n/locales/zh.ts | 22 ++ desktop/src/pages/ActiveSession.test.tsx | 2 + desktop/src/pages/ActiveSession.tsx | 9 + desktop/src/stores/chatStore.test.ts | 131 ++++++++ desktop/src/stores/chatStore.ts | 70 +++- desktop/src/stores/teamStore.ts | 1 + desktop/src/types/chat.ts | 60 ++++ runtime/mac_helper.py | 71 +++- runtime/test_helpers.py | 40 ++- runtime/win_helper.py | 8 + .../__tests__/conversation-service.test.ts | 13 + src/server/api/computer-use.ts | 30 ++ .../services/computerUseApprovalService.ts | 73 ++++ src/server/services/conversationService.ts | 23 +- src/server/ws/events.ts | 60 ++++ src/server/ws/handler.ts | 22 ++ src/utils/computerUse/executor.ts | 19 +- src/utils/computerUse/wrapper.tsx | 46 ++- src/vendor/computer-use-mcp/toolCalls.ts | 4 +- 26 files changed, 1217 insertions(+), 26 deletions(-) create mode 100644 desktop/src/components/chat/ComputerUsePermissionModal.test.tsx create mode 100644 desktop/src/components/chat/ComputerUsePermissionModal.tsx create mode 100644 src/server/services/computerUseApprovalService.ts diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index b38dd120..3f93f3b5 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -49,6 +49,7 @@ describe('Content-only pages render without errors', () => { activeToolName: null, activeThinkingId: null, pendingPermission: null, + pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', @@ -83,6 +84,7 @@ describe('Content-only pages render without errors', () => { activeToolName: null, activeThinkingId: null, pendingPermission: null, + pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', 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/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index ca1e83d2..9ad7523c 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -19,6 +19,7 @@ function makeSessionState(overrides: Partial = {}): PerSessionS activeToolName: null, activeThinkingId: null, pendingPermission: null, + pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx index f8e53ee2..fb07abea 100644 --- a/desktop/src/components/chat/ToolCallBlock.tsx +++ b/desktop/src/components/chat/ToolCallBlock.tsx @@ -41,7 +41,8 @@ export function ToolCallBlock({ toolName, input, result, compact = false }: Prop const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t]) const details = useMemo(() => renderDetails(toolName, obj, t), [obj, toolName, t]) - const expandable = toolName === 'Edit' || toolName === 'Write' + const hasResultDetails = Boolean(result && extractTextContent(result.content)) + const expandable = toolName === 'Edit' || toolName === 'Write' || hasResultDetails return (
)} - {result?.isError && ( + {result?.isError && ( error )} {expandable && ( diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index 2e999e26..45f00d68 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -40,7 +40,7 @@ describe('chat blocks', () => { fireEvent.click(screen.getByRole('button')) - expect(container.textContent).not.toContain('Tool Input') + expect(container.textContent).toContain('Tool Input') expect(container.textContent).not.toContain('const answer = 42') }) @@ -62,6 +62,27 @@ describe('chat blocks', () => { expect(container.textContent).not.toContain('file-a') }) + it('expands tool errors so full Computer Use gate messages are readable', () => { + const { container } = render( + , + ) + + expect(container.textContent).toContain('mcp__computer-use__left_click') + expect(container.textContent).not.toContain('Take a new screenshot') + + fireEvent.click(screen.getByRole('button')) + + expect(container.textContent).toContain('Take a new screenshot') + expect(container.textContent).toContain('allowed applications') + }) + it('shows a diff preview for edit permission requests', () => { useChatStore.setState({ sessions: { @@ -83,6 +104,7 @@ describe('chat blocks', () => { new_string: 'const count = 2', }, }, + pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 316432b6..f7e5b778 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -331,6 +331,28 @@ export const en = { 'permission.showFullInput': 'Show full input', 'permission.replacingContent': 'Replacing content in file', + // ─── Computer Use Approval ────────────────────────────────────── + 'computerUseApproval.titleApps': 'Computer Use wants to control these apps', + 'computerUseApproval.titleTcc': 'Computer Use needs macOS permissions', + 'computerUseApproval.reason': 'Why Claude is asking', + 'computerUseApproval.allow': 'Allow for session', + 'computerUseApproval.deny': 'Deny', + 'computerUseApproval.alreadyGranted': 'Already granted for this session', + 'computerUseApproval.notInstalled': 'App not installed', + 'computerUseApproval.sensitiveApp': 'This app is treated as sensitive and deserves extra review.', + 'computerUseApproval.alsoRequested': 'Also requested', + 'computerUseApproval.hideWhileWorking': '{count} other apps will be hidden while Claude works.', + 'computerUseApproval.hideWhileWorkingRestore': '{count} other apps will be hidden while Claude works, then restored when Claude is done.', + 'computerUseApproval.accessibility': 'Accessibility', + 'computerUseApproval.screenRecording': 'Screen Recording', + 'computerUseApproval.granted': 'Granted', + 'computerUseApproval.notGranted': 'Not granted', + 'computerUseApproval.openAccessibility': 'Open Accessibility', + 'computerUseApproval.openScreenRecording': 'Open Screen Recording', + 'computerUseApproval.tryAgain': 'Try again', + 'computerUseApproval.tccHint': 'Grant the missing permissions in System Settings, then come back and choose "Try again".', + 'computerUseApproval.tryAgainHint': 'Try again returns control to Claude so it can call request_access once more after macOS permission changes take effect.', + // ─── Ask User Question ────────────────────────────────────── 'question.needsInput': 'Claude needs your input', 'question.answered': 'Answered', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 56401ef8..455e915e 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -333,6 +333,28 @@ export const zh: Record = { 'permission.showFullInput': '显示完整输入', 'permission.replacingContent': '替换文件内容', + // ─── Computer Use Approval ────────────────────────────────────── + 'computerUseApproval.titleApps': 'Computer Use 想控制这些应用', + 'computerUseApproval.titleTcc': 'Computer Use 需要 macOS 权限', + 'computerUseApproval.reason': '请求原因', + 'computerUseApproval.allow': '本次会话允许', + 'computerUseApproval.deny': '拒绝', + 'computerUseApproval.alreadyGranted': '本次会话已授权', + 'computerUseApproval.notInstalled': '应用未安装', + 'computerUseApproval.sensitiveApp': '该应用属于高敏感类别,请额外确认后再授权。', + 'computerUseApproval.alsoRequested': '同时请求了', + 'computerUseApproval.hideWhileWorking': 'Claude 工作时会隐藏另外 {count} 个应用。', + 'computerUseApproval.hideWhileWorkingRestore': 'Claude 工作时会隐藏另外 {count} 个应用,结束后会自动恢复。', + 'computerUseApproval.accessibility': '辅助功能', + 'computerUseApproval.screenRecording': '屏幕录制', + 'computerUseApproval.granted': '已授权', + 'computerUseApproval.notGranted': '未授权', + 'computerUseApproval.openAccessibility': '打开辅助功能设置', + 'computerUseApproval.openScreenRecording': '打开屏幕录制设置', + 'computerUseApproval.tryAgain': '稍后重试', + 'computerUseApproval.tccHint': '先在系统设置里授予缺失权限,返回后再点“稍后重试”。', + 'computerUseApproval.tryAgainHint': '“稍后重试”会把控制权交还给 Claude,让它在 macOS 权限生效后重新调用 request_access。', + // ─── Ask User Question ────────────────────────────────────── 'question.needsInput': 'Claude 需要你的输入', 'question.answered': '已回答', diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index a4100d13..52f17ff0 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -79,6 +79,7 @@ describe('ActiveSession task polling', () => { activeToolName: null, activeThinkingId: null, pendingPermission: null, + pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', @@ -157,6 +158,7 @@ describe('ActiveSession task polling', () => { activeToolName: null, activeThinkingId: null, pendingPermission: null, + pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 7e8515f4..6919b998 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -7,6 +7,7 @@ import { useTeamStore } from '../stores/teamStore' import { useTranslation } from '../i18n' import { MessageList } from '../components/chat/MessageList' import { ChatInput } from '../components/chat/ChatInput' +import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal' import { TeamStatusBar } from '../components/teams/TeamStatusBar' import { SessionTaskBar } from '../components/chat/SessionTaskBar' @@ -17,6 +18,7 @@ export function ActiveSession() { const sessions = useSessionStore((s) => s.sessions) const connectToSession = useChatStore((s) => s.connectToSession) const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) + const pendingComputerUsePermission = sessionState?.pendingComputerUsePermission ?? null const fetchSessionTasks = useCLITaskStore((s) => s.fetchSessionTasks) const trackedTaskSessionId = useCLITaskStore((s) => s.sessionId) const hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed')) @@ -205,6 +207,13 @@ export function ActiveSession() { + + {!isMemberSession && activeTabId ? ( + + ) : null}
) } diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 811e3d09..9ac30215 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -166,6 +166,7 @@ describe('chatStore history mapping', () => { activeToolName: null, activeThinkingId: null, pendingPermission: null, + pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', @@ -222,6 +223,7 @@ describe('chatStore history mapping', () => { activeToolName: null, activeThinkingId: null, pendingPermission: null, + pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', @@ -252,6 +254,7 @@ describe('chatStore history mapping', () => { activeToolName: null, activeThinkingId: null, pendingPermission: null, + pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', @@ -300,6 +303,7 @@ describe('chatStore history mapping', () => { activeToolName: null, activeThinkingId: null, pendingPermission: null, + pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', @@ -325,6 +329,132 @@ describe('chatStore history mapping', () => { expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.streamingText).toBe('') }) + it('tracks Computer Use approval requests separately from generic tool permissions', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'computer_use_permission_request', + requestId: 'cu-1', + request: { + requestId: 'cu-1', + reason: 'Open Finder and inspect a file', + apps: [ + { + requestedName: 'Finder', + resolved: { + bundleId: 'com.apple.finder', + displayName: 'Finder', + }, + isSentinel: false, + alreadyGranted: false, + proposedTier: 'full', + }, + ], + requestedFlags: { clipboardRead: true }, + screenshotFiltering: 'native', + }, + }) + + expect( + useChatStore.getState().sessions[TEST_SESSION_ID]?.pendingComputerUsePermission, + ).toMatchObject({ + requestId: 'cu-1', + request: { + reason: 'Open Finder and inspect a file', + }, + }) + expect( + useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState, + ).toBe('permission_pending') + }) + + it('sends Computer Use approval payloads back over websocket', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: { + messages: [], + chatState: 'permission_pending', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: { + requestId: 'cu-1', + request: { + requestId: 'cu-1', + reason: 'Open Finder', + apps: [], + requestedFlags: {}, + screenshotFiltering: 'native', + }, + }, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + useChatStore.getState().respondToComputerUsePermission(TEST_SESSION_ID, 'cu-1', { + granted: [], + denied: [], + flags: { + clipboardRead: true, + clipboardWrite: false, + systemKeyCombos: false, + }, + userConsented: true, + }) + + expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { + type: 'computer_use_permission_response', + requestId: 'cu-1', + response: { + granted: [], + denied: [], + flags: { + clipboardRead: true, + clipboardWrite: false, + systemKeyCombos: false, + }, + userConsented: true, + }, + }) + expect( + useChatStore.getState().sessions[TEST_SESSION_ID]?.pendingComputerUsePermission, + ).toBeNull() + expect( + useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState, + ).toBe('tool_executing') + }) + it('routes member-session messages through team mailbox delivery instead of websocket', async () => { const memberSessionId = 'team-member:security-reviewer@test-team' getMemberBySessionIdMock.mockReturnValue({ @@ -345,6 +475,7 @@ describe('chatStore history mapping', () => { activeToolName: null, activeThinkingId: null, pendingPermission: null, + pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index c64f7f1a..e8420753 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -13,6 +13,8 @@ import type { AgentTaskNotification, AttachmentRef, ChatState, + ComputerUsePermissionRequest, + ComputerUsePermissionResponse, UIAttachment, UIMessage, ServerMessage, @@ -36,6 +38,10 @@ export type PerSessionState = { input: unknown description?: string } | null + pendingComputerUsePermission: { + requestId: string + request: ComputerUsePermissionRequest + } | null tokenUsage: TokenUsage elapsedSeconds: number statusVerb: string @@ -54,6 +60,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = { activeToolName: null, activeThinkingId: null, pendingPermission: null, + pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', @@ -74,6 +81,11 @@ type ChatStore = { disconnectSession: (sessionId: string) => void sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void respondToPermission: (sessionId: string, requestId: string, allowed: boolean, rule?: string) => void + respondToComputerUsePermission: ( + sessionId: string, + requestId: string, + response: ComputerUsePermissionResponse, + ) => void setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void stopGeneration: (sessionId: string) => void loadHistory: (sessionId: string) => Promise @@ -272,6 +284,20 @@ export const useChatStore = create((set, get) => ({ set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) })) }, + respondToComputerUsePermission: (sessionId, requestId, response) => { + wsManager.send(sessionId, { + type: 'computer_use_permission_response', + requestId, + response, + }) + set((s) => ({ + sessions: updateSessionIn(s.sessions, sessionId, () => ({ + pendingComputerUsePermission: null, + chatState: response.userConsented === false ? 'idle' : 'tool_executing', + })), + })) + }, + setSessionPermissionMode: (sessionId, mode) => { if (!get().sessions[sessionId]) return wsManager.send(sessionId, { type: 'set_permission_mode', mode }) @@ -289,7 +315,18 @@ export const useChatStore = create((set, get) => ({ const session = s.sessions[sessionId] if (!session) return s if (session.elapsedTimer) clearInterval(session.elapsedTimer) - return { sessions: { ...s.sessions, [sessionId]: { ...session, chatState: 'idle', elapsedTimer: null } } } + return { + sessions: { + ...s.sessions, + [sessionId]: { + ...session, + chatState: 'idle', + pendingPermission: null, + pendingComputerUsePermission: null, + elapsedTimer: null, + }, + }, + } }) }, @@ -456,6 +493,7 @@ export const useChatStore = create((set, get) => ({ case 'permission_request': update((s) => ({ pendingPermission: { requestId: msg.requestId, toolName: msg.toolName, input: msg.input, description: msg.description }, + pendingComputerUsePermission: null, chatState: 'permission_pending', activeThinkingId: null, messages: [...s.messages, { @@ -465,6 +503,18 @@ export const useChatStore = create((set, get) => ({ })) break + case 'computer_use_permission_request': + update(() => ({ + pendingComputerUsePermission: { + requestId: msg.requestId, + request: msg.request, + }, + pendingPermission: null, + chatState: 'permission_pending', + activeThinkingId: null, + })) + break + case 'message_complete': { const session = get().sessions[sessionId] if (!session) break @@ -476,7 +526,14 @@ export const useChatStore = create((set, get) => ({ })) } if (session.elapsedTimer) clearInterval(session.elapsedTimer) - update(() => ({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, elapsedTimer: null })) + update(() => ({ + tokenUsage: msg.usage, + chatState: 'idle', + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + elapsedTimer: null, + })) break } @@ -488,7 +545,14 @@ export const useChatStore = create((set, get) => ({ newMessages.push({ id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() }) } newMessages.push({ id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }) - return { messages: newMessages, chatState: 'idle', activeThinkingId: null, streamingText: '' } + return { + messages: newMessages, + chatState: 'idle', + activeThinkingId: null, + streamingText: '', + pendingPermission: null, + pendingComputerUsePermission: null, + } }) useTabStore.getState().updateTabStatus(sessionId, 'error') { diff --git a/desktop/src/stores/teamStore.ts b/desktop/src/stores/teamStore.ts index 93e0b574..ce6d7e71 100644 --- a/desktop/src/stores/teamStore.ts +++ b/desktop/src/stores/teamStore.ts @@ -27,6 +27,7 @@ function createMemberSessionState() { activeToolName: null, activeThinkingId: null, pendingPermission: null, + pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index dddebead..d9666321 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -7,6 +7,11 @@ import type { PermissionMode } from './settings' export type ClientMessage = | { type: 'user_message'; content: string; attachments?: AttachmentRef[] } | { type: 'permission_response'; requestId: string; allowed: boolean; rule?: string } + | { + type: 'computer_use_permission_response' + requestId: string + response: ComputerUsePermissionResponse + } | { type: 'set_permission_mode'; mode: PermissionMode } | { type: 'stop_generation' } | { type: 'ping' } @@ -35,6 +40,11 @@ export type ServerMessage = | { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown; parentToolUseId?: string } | { type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; parentToolUseId?: string } | { type: 'permission_request'; requestId: string; toolName: string; input: unknown; description?: string } + | { + type: 'computer_use_permission_request' + requestId: string + request: ComputerUsePermissionRequest + } | { type: 'message_complete'; usage: TokenUsage } | { type: 'thinking'; text: string } | { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number } @@ -63,6 +73,56 @@ export type TeamMemberStatus = { currentTask?: string } +export type ComputerUseGrantFlags = { + clipboardRead: boolean + clipboardWrite: boolean + systemKeyCombos: boolean +} + +export type ComputerUseResolvedApp = { + bundleId: string + displayName: string + path?: string + iconDataUrl?: string +} + +export type ComputerUseResolvedAppRequest = { + requestedName: string + resolved?: ComputerUseResolvedApp + isSentinel: boolean + alreadyGranted: boolean + proposedTier: 'read' | 'click' | 'full' +} + +export type ComputerUsePermissionRequest = { + requestId: string + reason: string + apps: ComputerUseResolvedAppRequest[] + requestedFlags: Partial + screenshotFiltering: 'native' | 'none' + tccState?: { + accessibility: boolean + screenRecording: boolean + } + willHide?: Array<{ bundleId: string; displayName: string }> + autoUnhideEnabled?: boolean +} + +export type ComputerUsePermissionResponse = { + granted: Array<{ + bundleId: string + displayName: string + grantedAt: number + tier?: 'read' | 'click' | 'full' + }> + denied: Array<{ + bundleId: string + reason: 'user_denied' | 'not_installed' + }> + flags: ComputerUseGrantFlags + userConsented?: boolean +} + export type AgentTaskNotification = { taskId: string toolUseId: string diff --git a/runtime/mac_helper.py b/runtime/mac_helper.py index ceb4fd27..1bfae38e 100755 --- a/runtime/mac_helper.py +++ b/runtime/mac_helper.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse import base64 +import ctypes import json import os import subprocess @@ -175,6 +176,33 @@ def run_osascript(script: str) -> str: return result.stdout.strip() +def applescript_modifier(name: str) -> str: + if name == "command": + return "command down" + if name == "option": + return "option down" + if name == "shift": + return "shift down" + if name == "ctrl": + return "control down" + if name == "fn": + return "fn down" + raise ValueError(f"Unsupported AppleScript modifier: {name}") + + +def send_keystroke_via_osascript(character: str, modifiers: list[str] | None = None) -> None: + escaped = character.replace("\\", "\\\\").replace('"', '\\"') + if modifiers: + modifier_expr = ", ".join(applescript_modifier(m) for m in modifiers) + script = ( + 'tell application "System Events" to keystroke ' + f'"{escaped}" using {{{modifier_expr}}}' + ) + else: + script = f'tell application "System Events" to keystroke "{escaped}"' + run_osascript(script) + + def get_displays() -> list[dict[str, Any]]: max_displays = 32 err, active, count = CGGetActiveDisplayList(max_displays, None, None) @@ -472,6 +500,10 @@ def write_clipboard(text: str) -> None: pb.setString_forType_(text, NSPasteboardTypeString) +def paste_clipboard() -> None: + send_keystroke_via_osascript("v", ["command"]) + + def detect_screen_recording_permission() -> bool | None: """Best-effort passive screen-recording probe with no system prompt. @@ -520,12 +552,29 @@ def detect_screen_recording_permission() -> bool | None: return None -def check_permissions() -> dict[str, bool | None]: - accessibility = True +def detect_accessibility_permission() -> bool: + """ + Use the official macOS Accessibility trust API. + + The previous System Events / AppleScript probe was too weak: it could + succeed even when the current helper process was not actually trusted for + input control, which led the desktop UI to report Accessibility as granted + while mouse/keyboard control still failed at runtime. + """ + framework_path = "/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices" try: - run_osascript('tell application "System Events" to get name of first process') + application_services = ctypes.CDLL(framework_path) + application_services.AXIsProcessTrusted.restype = ctypes.c_bool + application_services.AXIsProcessTrusted.argtypes = [] + return bool(application_services.AXIsProcessTrusted()) except Exception: - accessibility = False + # Fail closed: if the trust API can't be queried, treat accessibility + # as unavailable instead of reporting a misleading success state. + return False + + +def check_permissions() -> dict[str, bool | None]: + accessibility = detect_accessibility_permission() screen_recording = detect_screen_recording_permission() return { "accessibility": accessibility, @@ -559,7 +608,15 @@ def scroll(x: int, y: int, delta_x: int, delta_y: int) -> None: def key_action(sequence: str, repeat: int = 1) -> None: parts = [normalize_key(part) for part in sequence.split("+") if part.strip()] for _ in range(max(1, repeat)): - if len(parts) == 1: + if parts == ["command", "v"]: + paste_clipboard() + elif parts == ["command", "a"]: + send_keystroke_via_osascript("a", ["command"]) + elif parts == ["command", "c"]: + send_keystroke_via_osascript("c", ["command"]) + elif parts == ["command", "x"]: + send_keystroke_via_osascript("x", ["command"]) + elif len(parts) == 1: pyautogui.press(parts[0]) else: pyautogui.hotkey(*parts, interval=0.02) @@ -703,6 +760,10 @@ def main() -> int: write_clipboard(str(payload.get("text") or "")) json_output({"ok": True, "result": True}) return 0 + if command == "paste_clipboard": + paste_clipboard() + json_output({"ok": True, "result": True}) + return 0 error_output(f"Unknown command: {command}", code="bad_command") return 2 except Exception as exc: diff --git a/runtime/test_helpers.py b/runtime/test_helpers.py index 4c5e8db1..540d754c 100644 --- a/runtime/test_helpers.py +++ b/runtime/test_helpers.py @@ -154,7 +154,7 @@ class TestJSONProtocol(unittest.TestCase): "move_mouse", "scroll", "mouse_down", "mouse_up", "cursor_position", "frontmost_app", "app_under_point", "list_installed_apps", "list_running_apps", "open_app", - "read_clipboard", "write_clipboard", + "read_clipboard", "write_clipboard", "paste_clipboard", } for helper in [MAC_HELPER, WIN_HELPER]: if not helper.exists(): @@ -247,6 +247,42 @@ class TestWinHelperPermissions(unittest.TestCase): self.assertIn('"screenRecording": True', func_source) +class TestMacHelperPermissions(unittest.TestCase): + """macOS helper permission detection should use the official trust API.""" + + def test_check_permissions_uses_ax_api_instead_of_system_events(self): + if not MAC_HELPER.exists(): + self.skipTest("mac_helper.py not found") + + source = MAC_HELPER.read_text() + + self.assertIn("def detect_accessibility_permission()", source) + self.assertIn("AXIsProcessTrusted", source) + + start = source.index("def check_permissions()") + rest = source[start:] + lines = rest.split("\n") + func_lines = [lines[0]] + for line in lines[1:]: + if line and not line[0].isspace() and not line.startswith("#"): + break + func_lines.append(line) + func_source = "\n".join(func_lines) + + self.assertIn("detect_accessibility_permission()", func_source) + self.assertNotIn('tell application "System Events"', func_source) + + def test_clipboard_shortcuts_use_osascript_path(self): + if not MAC_HELPER.exists(): + self.skipTest("mac_helper.py not found") + + source = MAC_HELPER.read_text() + self.assertIn("def paste_clipboard()", source) + self.assertIn('send_keystroke_via_osascript("v", ["command"])', source) + self.assertIn('if parts == ["command", "v"]:', source) + self.assertIn('elif parts == ["command", "a"]:', source) + + class TestCrossPlatformFunctions(unittest.TestCase): """Test functions that are identical between both helpers.""" @@ -275,7 +311,7 @@ class TestCrossPlatformFunctions(unittest.TestCase): """Input action functions (click, scroll, etc.) should be identical.""" if not MAC_HELPER.exists() or not WIN_HELPER.exists(): self.skipTest("Both helpers required") - for func in ["click", "scroll", "key_action", "hold_keys", "type_text"]: + for func in ["click", "scroll", "hold_keys", "type_text"]: mac_src = self._get_function_body(MAC_HELPER, func) win_src = self._get_function_body(WIN_HELPER, func) self.assertEqual(mac_src, win_src, diff --git a/runtime/win_helper.py b/runtime/win_helper.py index f6ff848e..cc190ee2 100644 --- a/runtime/win_helper.py +++ b/runtime/win_helper.py @@ -513,6 +513,10 @@ def write_clipboard(text: str) -> None: pyperclip.copy(text) +def paste_clipboard() -> None: + pyautogui.hotkey("ctrl", "v", interval=0.02) + + # --------------------------------------------------------------------------- # Permissions — Windows doesn't have macOS-style TCC # --------------------------------------------------------------------------- @@ -704,6 +708,10 @@ def main() -> int: write_clipboard(str(payload.get("text") or "")) json_output({"ok": True, "result": True}) return 0 + if command == "paste_clipboard": + paste_clipboard() + json_output({"ok": True, "result": True}) + return 0 error_output(f"Unknown command: {command}", code="bad_command") return 2 except Exception as exc: diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 210846e3..249a3439 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -146,6 +146,19 @@ describe('ConversationService', () => { expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined() }) + test('buildChildEnv injects desktop Computer Use host bundle id for sdk sessions', async () => { + const service = new ConversationService() as any + const env = (await service.buildChildEnv( + '/tmp', + 'ws://127.0.0.1:3456/sdk/test-session?token=test-token', + )) as Record + + expect(env.CC_HAHA_COMPUTER_USE_HOST_BUNDLE_ID).toBe( + 'com.claude-code-haha.desktop', + ) + expect(env.CC_HAHA_DESKTOP_SERVER_URL).toBe('http://127.0.0.1:3456') + }) + test('uses bun entrypoint fallback on Windows dev mode', () => { const service = new ConversationService() as any const args = service.resolveCliArgs(['--print']) diff --git a/src/server/api/computer-use.ts b/src/server/api/computer-use.ts index fed13ea0..1650dd10 100644 --- a/src/server/api/computer-use.ts +++ b/src/server/api/computer-use.ts @@ -12,6 +12,8 @@ import { access, readFile, mkdir, writeFile } from 'fs/promises' import { createHash } from 'crypto' import path from 'path' import { fileURLToPath } from 'url' +import type { CuPermissionRequest } from '../../vendor/computer-use-mcp/types.js' +import { computerUseApprovalService } from '../services/computerUseApprovalService.js' // Embed helper scripts at compile time so they're available in bundled mode // @ts-ignore — Bun text import import MAC_HELPER_CONTENT from '../../../runtime/mac_helper.py' with { type: 'text' } @@ -345,6 +347,11 @@ type ComputerUseConfig = { } } +type RequestAccessBody = { + sessionId?: string + request?: CuPermissionRequest +} + const DEFAULT_CONFIG: ComputerUseConfig = { authorizedApps: [], grantFlags: { clipboardRead: true, clipboardWrite: true, systemKeyCombos: true }, @@ -453,6 +460,29 @@ export async function handleComputerUseApi( return Response.json({ ok: true }) } + if (action === 'request-access' && req.method === 'POST') { + try { + const body = (await req.json()) as RequestAccessBody + if (!body.sessionId || !body.request?.requestId) { + return Response.json( + { error: 'BAD_REQUEST', message: 'sessionId and request are required' }, + { status: 400 }, + ) + } + + const response = await computerUseApprovalService.requestApproval( + body.sessionId, + body.request, + ) + return Response.json(response) + } catch (error) { + const message = + error instanceof Error ? error.message : 'Computer Use approval failed' + const status = message.includes('not connected') ? 409 : 500 + return Response.json({ error: 'COMPUTER_USE_APPROVAL_FAILED', message }, { status }) + } + } + return Response.json( { error: 'NOT_FOUND', message: `Unknown computer-use action: ${action}` }, { status: 404 }, diff --git a/src/server/services/computerUseApprovalService.ts b/src/server/services/computerUseApprovalService.ts new file mode 100644 index 00000000..055c4efb --- /dev/null +++ b/src/server/services/computerUseApprovalService.ts @@ -0,0 +1,73 @@ +import type { CuPermissionRequest, CuPermissionResponse } from '../../vendor/computer-use-mcp/types.js' +import { sendToSession } from '../ws/handler.js' + +type PendingApproval = { + sessionId: string + resolve: (response: CuPermissionResponse) => void + reject: (error: Error) => void + timeout: ReturnType +} + +const REQUEST_TIMEOUT_MS = 5 * 60 * 1000 + +class ComputerUseApprovalService { + private pending = new Map() + + async requestApproval( + sessionId: string, + request: CuPermissionRequest, + ): Promise { + const existing = this.pending.get(request.requestId) + if (existing) { + clearTimeout(existing.timeout) + existing.reject(new Error('Computer Use approval request superseded')) + this.pending.delete(request.requestId) + } + + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(request.requestId) + reject(new Error('Computer Use approval timed out')) + }, REQUEST_TIMEOUT_MS) + + this.pending.set(request.requestId, { + sessionId, + resolve, + reject, + timeout, + }) + + const sent = sendToSession(sessionId, { + type: 'computer_use_permission_request', + requestId: request.requestId, + request, + }) + + if (!sent) { + clearTimeout(timeout) + this.pending.delete(request.requestId) + reject(new Error('Desktop session is not connected')) + } + }) + } + + resolveApproval(requestId: string, response: CuPermissionResponse): boolean { + const pending = this.pending.get(requestId) + if (!pending) return false + clearTimeout(pending.timeout) + this.pending.delete(requestId) + pending.resolve(response) + return true + } + + cancelSession(sessionId: string): void { + for (const [requestId, pending] of this.pending.entries()) { + if (pending.sessionId !== sessionId) continue + clearTimeout(pending.timeout) + this.pending.delete(requestId) + pending.reject(new Error('Desktop session disconnected during Computer Use approval')) + } + } +} + +export const computerUseApprovalService = new ComputerUseApprovalService() diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 2ae4dd62..f76a16e8 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -119,7 +119,7 @@ export class ConversationService { // 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.ts // chdir 后落到正确目录。 // - const childEnv = await this.buildChildEnv(workDir) + const childEnv = await this.buildChildEnv(workDir, sdkUrl) let proc: ReturnType try { @@ -471,7 +471,10 @@ export class ConversationService { return args } - private async buildChildEnv(workDir: string): Promise> { + private async buildChildEnv( + workDir: string, + sdkUrl?: string, + ): Promise> { // Provider isolation: when Desktop has its own provider config/index, // strip inherited provider env vars so the child CLI reads fresh values // from ~/.claude/cc-haha/settings.json instead of stale process.env. @@ -497,11 +500,27 @@ export class ConversationService { } } + let desktopServerUrl: string | undefined + if (sdkUrl) { + try { + const parsed = new URL(sdkUrl) + desktopServerUrl = `http://${parsed.host}` + } catch { + desktopServerUrl = undefined + } + } + return { ...cleanEnv, CLAUDE_CODE_ENABLE_TASKS: '1', CALLER_DIR: workDir, PWD: workDir, + ...(sdkUrl + ? { CC_HAHA_COMPUTER_USE_HOST_BUNDLE_ID: 'com.claude-code-haha.desktop' } + : {}), + ...(desktopServerUrl + ? { CC_HAHA_DESKTOP_SERVER_URL: desktopServerUrl } + : {}), // Tell the CLI entrypoint to skip project .env loading. Provider env // should come from Desktop-managed config or inherited launch env, not // be reintroduced from the repo's .env file. diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index 508740ec..56533cdc 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -11,6 +11,11 @@ export type ClientMessage = | { type: 'user_message'; content: string; attachments?: AttachmentRef[] } | { type: 'permission_response'; requestId: string; allowed: boolean; rule?: string } + | { + type: 'computer_use_permission_response' + requestId: string + response: ComputerUsePermissionResponse + } | { type: 'set_permission_mode'; mode: string } | { type: 'stop_generation' } | { type: 'ping' } @@ -34,6 +39,11 @@ export type ServerMessage = | { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown; parentToolUseId?: string } | { type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; parentToolUseId?: string } | { type: 'permission_request'; requestId: string; toolName: string; input: unknown; description?: string } + | { + type: 'computer_use_permission_request' + requestId: string + request: ComputerUsePermissionRequest + } | { type: 'message_complete'; usage: TokenUsage } | { type: 'thinking'; text: string } | { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number } @@ -62,6 +72,56 @@ export type TeamMemberStatus = { currentTask?: string } +export type ComputerUseGrantFlags = { + clipboardRead: boolean + clipboardWrite: boolean + systemKeyCombos: boolean +} + +export type ComputerUseResolvedApp = { + bundleId: string + displayName: string + path?: string + iconDataUrl?: string +} + +export type ComputerUseResolvedAppRequest = { + requestedName: string + resolved?: ComputerUseResolvedApp + isSentinel: boolean + alreadyGranted: boolean + proposedTier: 'read' | 'click' | 'full' +} + +export type ComputerUsePermissionRequest = { + requestId: string + reason: string + apps: ComputerUseResolvedAppRequest[] + requestedFlags: Partial + screenshotFiltering: 'native' | 'none' + tccState?: { + accessibility: boolean + screenRecording: boolean + } + willHide?: Array<{ bundleId: string; displayName: string }> + autoUnhideEnabled?: boolean +} + +export type ComputerUsePermissionResponse = { + granted: Array<{ + bundleId: string + displayName: string + grantedAt: number + tier?: 'read' | 'click' | 'full' + }> + denied: Array<{ + bundleId: string + reason: 'user_denied' | 'not_installed' + }> + flags: ComputerUseGrantFlags + userConsented?: boolean +} + // ============================================================================ // Internal types // ============================================================================ diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 20986103..19fa3dcf 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -13,6 +13,7 @@ import { ConversationStartupError, conversationService, } from '../services/conversationService.js' +import { computerUseApprovalService } from '../services/computerUseApprovalService.js' import { sessionService } from '../services/sessionService.js' import { SettingsService } from '../services/settingsService.js' import { ProviderService } from '../services/providerService.js' @@ -118,6 +119,10 @@ export const handleWebSocket = { handlePermissionResponse(ws, message) break + case 'computer_use_permission_response': + handleComputerUsePermissionResponse(ws, message) + break + case 'set_permission_mode': handleSetPermissionMode(ws, message) break @@ -148,6 +153,7 @@ export const handleWebSocket = { } console.log(`[WS] Client disconnected from session: ${sessionId} (${code}: ${reason})`) + computerUseApprovalService.cancelSession(sessionId) activeSessions.delete(sessionId) cleanupStreamState(sessionId) sessionSlashCommands.delete(sessionId) @@ -300,6 +306,22 @@ function handlePermissionResponse( console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`) } +function handleComputerUsePermissionResponse( + ws: ServerWebSocket, + message: Extract +) { + const { sessionId } = ws.data + const ok = computerUseApprovalService.resolveApproval( + message.requestId, + message.response, + ) + if (!ok) { + console.warn( + `[WS] Ignored Computer Use permission response for unknown request ${message.requestId} from ${sessionId}` + ) + } +} + function handleSetPermissionMode( ws: ServerWebSocket, message: Extract diff --git a/src/utils/computerUse/executor.ts b/src/utils/computerUse/executor.ts index 7f6b4f59..2fedee66 100644 --- a/src/utils/computerUse/executor.ts +++ b/src/utils/computerUse/executor.ts @@ -15,13 +15,14 @@ import type { ScreenshotResult, } from '../../vendor/computer-use-mcp/index.js' import { API_RESIZE_PARAMS, targetImageSize } from '../../vendor/computer-use-mcp/index.js' -import { execFileNoThrow } from '../execFileNoThrow.js' import { sleep } from '../sleep.js' import { CLI_CU_CAPABILITIES, CLI_HOST_BUNDLE_ID } from './common.js' import { callPythonHelper } from './pythonBridge.js' const SCREENSHOT_JPEG_QUALITY = 0.75 const MOVE_SETTLE_MS = 50 +const hostBundleId = + process.env.CC_HAHA_COMPUTER_USE_HOST_BUNDLE_ID || CLI_HOST_BUNDLE_ID type PythonDisplayGeometry = DisplayGeometry @@ -48,14 +49,11 @@ function normalizeDisplayGeometry(display: PythonDisplayGeometry): DisplayGeomet } async function readClipboardViaPbpaste(): Promise { - const { stdout, code } = await execFileNoThrow('pbpaste', [], { useCwd: false }) - if (code !== 0) throw new Error(`pbpaste exited with code ${code}`) - return stdout + return callPythonHelper('read_clipboard', {}) } async function writeClipboardViaPbcopy(text: string): Promise { - const { code } = await execFileNoThrow('pbcopy', [], { input: text, useCwd: false }) - if (code !== 0) throw new Error(`pbcopy exited with code ${code}`) + await callPythonHelper('write_clipboard', { text }) } async function typeViaClipboard(text: string): Promise { @@ -66,8 +64,11 @@ async function typeViaClipboard(text: string): Promise { try { await writeClipboardViaPbcopy(text) - await callPythonHelper('key', { keySequence: 'command+v', repeat: 1 }) - await sleep(100) + // Give NSPasteboard a beat before paste, then keep the new contents + // resident long enough for Electron/WebView fields to consume them. + await sleep(40) + await callPythonHelper('paste_clipboard', {}) + await sleep(180) } finally { if (typeof saved === 'string') { try { @@ -88,7 +89,7 @@ export function createCliExecutor(_opts: { return { capabilities: { ...CLI_CU_CAPABILITIES, - hostBundleId: CLI_HOST_BUNDLE_ID, + hostBundleId, }, async prepareForAction(): Promise { diff --git a/src/utils/computerUse/wrapper.tsx b/src/utils/computerUse/wrapper.tsx index 9b336cfb..783d90b7 100644 --- a/src/utils/computerUse/wrapper.tsx +++ b/src/utils/computerUse/wrapper.tsx @@ -51,6 +51,7 @@ type Binding = { */ let binding: Binding | undefined; let currentToolUseContext: ToolUseContext | undefined; +const desktopServerUrl = process.env.CC_HAHA_DESKTOP_SERVER_URL; function tuc(): ToolUseContext { // Safe: `binding` is only populated when `currentToolUseContext` is set. // Called only from within `ctx` callbacks, which only fire during dispatch. @@ -230,6 +231,37 @@ export function buildSessionContext(): ComputerUseSessionContext { formatLockHeldMessage: formatLockHeld }; } + +async function runDesktopPermissionDialog( + req: CuPermissionRequest, + signal: AbortSignal, +): Promise { + if (!desktopServerUrl) { + throw new Error('Desktop server URL is not configured') + } + + const response = await fetch(`${desktopServerUrl}/api/computer-use/request-access`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + sessionId: getSessionId(), + request: req + }), + signal + }) + + if (!response.ok) { + const message = await response.text().catch(() => '') + throw new Error( + `Desktop Computer Use approval failed (${response.status}): ${message || response.statusText}`, + ) + } + + return await response.json() as CuPermissionResponse +} + /** * Load pre-authorized apps from ~/.claude/cc-haha/computer-use-config.json. * Called once when the binding is first created. Pre-authorized apps @@ -362,8 +394,20 @@ export function getComputerUseMCPToolOverrides(toolName: string): ComputerUseMCP */ async function runPermissionDialog(req: CuPermissionRequest): Promise { const context = tuc(); + let desktopBridgeError: Error | undefined; + if (desktopServerUrl) { + try { + return await runDesktopPermissionDialog(req, context.abortController.signal); + } catch (error) { + desktopBridgeError = error instanceof Error ? error : new Error(String(error)); + logForDebugging(`[Computer Use] Desktop approval bridge failed: ${desktopBridgeError.message}`); + } + } const setToolJSX = context.setToolJSX; if (!setToolJSX) { + if (desktopBridgeError) { + throw desktopBridgeError; + } // Shouldn't happen — main.tsx gate excludes non-interactive. Fail safe. return { granted: [], @@ -400,4 +444,4 @@ async function runPermissionDialog(req: CuPermissionRequest): Promise 1)) && + (adapter.executor.capabilities.platform === "darwin" && + hasNonControlText)) && overrides.grantFlags.clipboardWrite && subGates.clipboardPasteMultiline; From 261a85ab76c7e62c8e30a00a2ee9e02cb7b3c5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 20 Apr 2026 13:36:19 +0800 Subject: [PATCH 3/4] Restore desktop project-skill discovery and align empty-session composer The desktop app previously lost project-level skills in two places: the settings browser only queried user skills, and the slash-command picker for a fresh session depended on CLI init state that does not exist before the first turn. This change makes the skills APIs cwd-aware, falls back to project skill loading before CLI init, syncs sidebar session deletion with open tabs, and aligns the empty-session composer styling so the pre-message session UI stays consistent. Constraint: Fresh desktop sessions need slash-command discovery before the CLI websocket emits system/init Constraint: Only repository code should be committed; build artifacts, installs, and /tmp smoke fixtures stay out of git Rejected: Rebuild slash-command listings from full getCommands() in the sessions API | introduced auth-gated command dependencies unrelated to local skill discovery Rejected: Unify EmptySession and ChatInput into one full component now | higher regression risk for normal chat interactions than a visual-only hero variant Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep pre-message skill discovery keyed to session workDir so project-level .claude/skills remain visible before the first turn Tested: bun test src/server/__tests__/sessions.test.ts; cd desktop && bun run test -- --run src/__tests__/skillsSettings.test.tsx src/components/layout/Sidebar.test.tsx src/__tests__/pages.test.tsx src/pages/ActiveSession.test.tsx; cd desktop && bun run lint Not-tested: Manual desktop smoke test against the rebuilt .app bundle in the local GUI --- desktop/src/__tests__/pages.test.tsx | 61 ++++++++- desktop/src/__tests__/skillsSettings.test.tsx | 45 +++++++ desktop/src/api/skills.ts | 18 ++- desktop/src/components/chat/ChatInput.tsx | 105 ++++++++++----- .../src/components/layout/Sidebar.test.tsx | 44 ++++++ desktop/src/components/layout/Sidebar.tsx | 6 +- desktop/src/components/skills/SkillList.tsx | 11 +- desktop/src/pages/ActiveSession.tsx | 2 +- desktop/src/pages/EmptySession.tsx | 6 +- desktop/src/stores/skillStore.ts | 12 +- src/server/__tests__/sessions.test.ts | 47 +++++++ src/server/__tests__/skills.test.ts | 125 ++++++++++++++++++ src/server/api/sessions.ts | 26 +++- src/server/api/skills.ts | 111 +++++++++++----- 14 files changed, 533 insertions(+), 86 deletions(-) create mode 100644 src/server/__tests__/skills.test.ts diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index b38dd120..831be9f7 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -13,6 +13,7 @@ import { ToolInspection } from '../pages/ToolInspection' import { Sidebar } from '../components/layout/Sidebar' import { UserMessage } from '../components/chat/UserMessage' import { useChatStore } from '../stores/chatStore' +import { useSessionStore } from '../stores/sessionStore' import { useTabStore } from '../stores/tabStore' /** @@ -62,13 +63,71 @@ describe('Content-only pages render without errors', () => { // With empty messages, the hero is shown expect(container.innerHTML).toContain('New session') // ChatInput has a textarea - expect(container.querySelector('textarea')).toBeInTheDocument() + const textarea = container.querySelector('textarea') + expect(textarea).toBeInTheDocument() + expect(textarea).toHaveAttribute('placeholder', 'Ask anything...') + expect(textarea).toHaveAttribute('rows', '2') expect(container.innerHTML).not.toContain('Preview') // Cleanup useTabStore.setState({ tabs: [], activeTabId: null }) useChatStore.setState({ sessions: {} }) }) + it('ActiveSession keeps the compact composer once messages exist', () => { + const SESSION_ID = 'test-active-session-with-messages' + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [{ + id: 'msg-1', + type: 'user_text', + content: 'hello', + timestamp: Date.now(), + }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 1, + projectPath: '', + workDir: null, + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + + render() + + const textarea = screen.getByPlaceholderText('Ask Claude to edit, debug or explain...') + expect(textarea).toHaveAttribute('rows', '1') + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + it('ActiveSession shows a single primary action button while a turn is active', () => { useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] }) useChatStore.setState({ diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx index 1c522862..19e46203 100644 --- a/desktop/src/__tests__/skillsSettings.test.tsx +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -5,6 +5,8 @@ import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' import { useSkillStore } from '../stores/skillStore' import { useSettingsStore } from '../stores/settingsStore' +import { useSessionStore } from '../stores/sessionStore' +import { useTabStore, SETTINGS_TAB_ID } from '../stores/tabStore' vi.mock('../api/agents', () => ({ agentsApi: { @@ -60,6 +62,26 @@ describe('Settings > Skills tab', () => { beforeEach(() => { vi.clearAllMocks() useSettingsStore.setState({ locale: 'en' }) + useSessionStore.setState({ + sessions: [ + { + id: 'session-1', + title: 'Active session', + createdAt: '2026-04-20T00:00:00.000Z', + modifiedAt: '2026-04-20T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }, + ], + activeSessionId: 'session-1', + isLoading: false, + error: null, + selectedProjects: [], + availableProjects: ['/workspace/project'], + }) + useTabStore.setState({ tabs: [], activeTabId: null }) useSkillStore.setState({ skills: [], selectedSkill: null, @@ -106,6 +128,29 @@ describe('Settings > Skills tab', () => { expect(screen.getByText('Second skill description')).toBeInTheDocument() }) + it('uses the active session workDir even when settings tab is focused', () => { + const fetchSkills = vi.fn() + useSkillStore.setState({ + skills: [], + selectedSkill: null, + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills, + fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL, + clearSelection: MOCK_CLEAR_SELECTION, + }) + useTabStore.setState({ + activeTabId: SETTINGS_TAB_ID, + tabs: [{ sessionId: SETTINGS_TAB_ID, title: 'Settings', type: 'settings', status: 'idle' }], + }) + + render() + switchToSkillsTab() + + expect(fetchSkills).toHaveBeenCalledWith('/workspace/project') + }) + it('opens skill detail with metadata cards and parsed markdown body', () => { useSkillStore.setState({ selectedSkill: { diff --git a/desktop/src/api/skills.ts b/desktop/src/api/skills.ts index 3ad50b04..18b0c878 100644 --- a/desktop/src/api/skills.ts +++ b/desktop/src/api/skills.ts @@ -2,10 +2,18 @@ import { api } from './client' import type { SkillMeta, SkillDetail } from '../types/skill' export const skillsApi = { - list: () => api.get<{ skills: SkillMeta[] }>('/api/skills'), + list: (cwd?: string) => { + const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' + return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`) + }, - detail: (source: string, name: string) => - api.get<{ detail: SkillDetail }>( - `/api/skills/detail?source=${encodeURIComponent(source)}&name=${encodeURIComponent(name)}`, - ), + detail: (source: string, name: string, cwd?: string) => { + const query = new URLSearchParams({ + source, + name, + }) + if (cwd) query.set('cwd', cwd) + + return api.get<{ detail: SkillDetail }>(`/api/skills/detail?${query.toString()}`) + }, } diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 6f4cb8e1..b9cff77a 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -30,7 +30,11 @@ type Attachment = { data?: string } -export function ChatInput() { +type ChatInputProps = { + variant?: 'default' | 'hero' +} + +export function ChatInput({ variant = 'default' }: ChatInputProps) { const t = useTranslation() const [input, setInput] = useState('') const [attachments, setAttachments] = useState([]) @@ -62,6 +66,7 @@ export function ChatInput() { const isActive = chatState !== 'idle' const isWorkspaceMissing = activeSession?.workDirExists === false const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0)) + const isHeroComposer = variant === 'hero' && !isMemberSession useEffect(() => { textareaRef.current?.focus() @@ -393,11 +398,25 @@ export function ChatInput() { }) } + const composerPlaceholder = + isHeroComposer + ? t('empty.placeholder') + : isWorkspaceMissing + ? t('chat.placeholderMissing') + : isMemberSession + ? t('teams.memberPlaceholder') + : t('chat.placeholder') + + const addFilesLabel = isHeroComposer ? t('empty.addFiles') : t('chat.addFiles') + const slashCommandsLabel = isHeroComposer ? t('empty.slashCommands') : t('chat.slashCommands') + return ( -
-
+
+
event.preventDefault()} onDrop={handleDrop} > @@ -436,18 +455,16 @@ export function ChatInput() { ref={(el) => { slashItemRefs.current[index] = el }} onClick={() => selectSlashCommand(command.name)} onMouseEnter={() => setSlashSelectedIndex(index)} - className={`flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left transition-colors ${ + className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${ index === slashSelectedIndex ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]' }`} > -
- - /{command.name} - -
- + + /{command.name} + + {command.description} @@ -465,32 +482,50 @@ export function ChatInput() { )} {attachments.length > 0 && ( -
+ isHeroComposer ? ( -
+ ) : ( +
+ +
+ ) )} -