Merge remote-tracking branch 'origin/main' into fix/windows-computer-use-20260420

This commit is contained in:
Relakkes Yang 2026-04-20 15:29:53 +08:00
commit 3dfcdbb405
66 changed files with 2384 additions and 343 deletions

View File

@ -1,7 +1,7 @@
{
"name": "claude-code-desktop",
"private": true,
"version": "0.1.2",
"version": "0.1.3",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -1,6 +1,6 @@
[package]
name = "claude-code-desktop"
version = "0.1.2"
version = "0.1.3"
edition = "2021"
[lib]

View File

@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
"productName": "Claude Code Haha",
"version": "0.1.2",
"version": "0.1.3",
"identifier": "com.claude-code-haha.desktop",
"build": {
"frontendDist": "../dist",

View File

@ -1,7 +1,15 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { skillsApi } from '../api/skills'
vi.mock('../api/skills', () => ({
skillsApi: {
list: vi.fn(async () => ({ skills: [] })),
},
}))
// Import all pages
import { EmptySession } from '../pages/EmptySession'
import { ActiveSession } from '../pages/ActiveSession'
@ -13,6 +21,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'
/**
@ -20,6 +29,38 @@ import { useTabStore } from '../stores/tabStore'
* and contain key structural elements from the prototype.
*/
describe('Content-only pages render without errors', () => {
it('EmptySession slash picker includes dynamic skills before the first session starts', async () => {
vi.mocked(skillsApi.list).mockResolvedValueOnce({
skills: [
{
name: 'lark-mail',
description: 'Draft, send, and search emails',
source: 'user',
userInvocable: true,
contentLength: 120,
hasDirectory: true,
},
{
name: 'internal-only',
description: 'Should stay hidden',
source: 'user',
userInvocable: false,
contentLength: 60,
hasDirectory: true,
},
],
})
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '/', selectionStart: 1 },
})
expect(await screen.findByText('/lark-mail')).toBeInTheDocument()
expect(screen.queryByText('/internal-only')).not.toBeInTheDocument()
})
it('EmptySession renders mascot and composer', () => {
const { container } = render(<EmptySession />)
expect(container.querySelector('textarea')).toBeInTheDocument()
@ -49,6 +90,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: '',
@ -62,13 +104,72 @@ 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,
pendingComputerUsePermission: 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(<ActiveSession />)
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({
@ -83,6 +184,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: '',

View File

@ -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(<Settings />)
switchToSkillsTab()
expect(fetchSkills).toHaveBeenCalledWith('/workspace/project')
})
it('opens skill detail with metadata cards and parsed markdown body', () => {
useSkillStore.setState({
selectedSkill: {

View File

@ -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()}`)
},
}

View File

@ -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<Attachment[]>([])
@ -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,12 +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 (
<div className="bg-[#FAF9F5] px-4 py-4">
<div className="mx-auto max-w-[860px]">
<div className={isHeroComposer ? 'bg-[var(--color-surface)] px-8 pb-4' : 'bg-[var(--color-surface)] px-4 py-4'}>
<div className={isHeroComposer ? 'mx-auto flex w-full max-w-3xl flex-col gap-2' : 'mx-auto max-w-[860px]'}>
<div
className="relative rounded-xl border border-[#dac1ba]/15 bg-white p-4 transition-colors focus-within:border-[var(--color-border-focus)]"
style={{ boxShadow: '0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08)' }}
className={isHeroComposer
? 'glass-panel relative flex flex-col gap-3 rounded-xl p-4 transition-colors'
: 'glass-panel relative rounded-xl p-4 transition-colors'}
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
@ -437,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)]'
}`}
>
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">
/{command.name}
</span>
</div>
<span className="truncate text-right text-xs text-[var(--color-text-tertiary)]">
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">
/{command.name}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">
{command.description}
</span>
</button>
@ -466,32 +482,50 @@ export function ChatInput() {
)}
{attachments.length > 0 && (
<div className="px-3 pt-3">
isHeroComposer ? (
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
</div>
) : (
<div className="px-3 pt-3">
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
</div>
)
)}
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onPaste={handlePaste}
placeholder={
isWorkspaceMissing
? t('chat.placeholderMissing')
: isMemberSession
? t('teams.memberPlaceholder')
: t('chat.placeholder')
}
disabled={isWorkspaceMissing}
rows={1}
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"
/>
{isHeroComposer ? (
<div className="flex items-start gap-3">
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onPaste={handlePaste}
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
rows={2}
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)] disabled:opacity-50"
/>
</div>
) : (
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onPaste={handlePaste}
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
rows={1}
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"
/>
)}
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[#dac1ba]/10 px-3 py-3">
<div className={isHeroComposer
? 'flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3'
: 'absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] px-3 py-3'}>
<div className="flex items-center gap-2">
{!isMemberSession && (
<>
@ -499,29 +533,29 @@ export function ChatInput() {
<button
onClick={() => setPlusMenuOpen((value) => !value)}
aria-label="Open composer tools"
className="rounded-[var(--radius-md)] p-1.5 text-[var(--color-text-secondary)] transition-colors hover:bg-[#F4F4F0]"
className="rounded-[var(--radius-md)] p-1.5 text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[18px]">add</span>
</button>
{plusMenuOpen && (
<div className="absolute bottom-full left-0 z-50 mb-2 w-[240px] rounded-xl border border-[#dac1ba]/20 bg-white py-1 shadow-[0_18px_48px_rgba(27,28,26,0.12)]">
<div className="absolute bottom-full left-0 z-50 mb-2 w-[240px] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]">
<button
onClick={() => {
fileInputRef.current?.click()
setPlusMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.addFiles')}</span>
<span className="text-sm text-[var(--color-text-primary)]">{addFilesLabel}</span>
</button>
<button
onClick={insertSlashCommand}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="w-[24px] text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.slashCommands')}</span>
<span className="text-sm text-[var(--color-text-primary)]">{slashCommandsLabel}</span>
</button>
</div>
)}
@ -538,8 +572,10 @@ export function ChatInput() {
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
disabled={!isMemberSession && isActive ? false : !canSubmit}
title={!isMemberSession && isActive ? t('chat.stopTitle') : undefined}
className={`flex w-[112px] items-center justify-center gap-1 rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-all hover:opacity-90 disabled:opacity-30 ${
!isMemberSession && isActive ? 'bg-[var(--color-error)]' : 'bg-[var(--color-brand)]'
className={`flex w-[112px] items-center justify-center gap-1 rounded-lg px-3 py-1.5 text-xs font-semibold transition-all hover:brightness-105 disabled:opacity-30 ${
!isMemberSession && isActive
? 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
: 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)]'
}`}
>
<span className="material-symbols-outlined text-[14px]">

View File

@ -16,30 +16,30 @@ type Props = {
*/
const warmCodeTheme = {
name: 'warm-code',
type: 'light' as const,
fg: '#24201E',
type: 'dark' as const,
fg: 'var(--color-code-fg)',
bg: 'transparent',
tokenColors: [
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#5C6B7A', fontStyle: 'italic' } },
{ scope: ['string', 'string.quoted', 'string.template', 'string.other.link'], settings: { foreground: '#437220' } },
{ scope: ['string.regexp'], settings: { foreground: '#C15F3C' } },
{ scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: '#B8533B' } },
{ scope: ['keyword.operator'], settings: { foreground: '#B8533B' } },
{ scope: ['entity.name.function', 'support.function'], settings: { foreground: '#1D5A8C' } },
{ scope: ['entity.name.type', 'support.type', 'support.class', 'entity.name.class', 'entity.other.inherited-class'], settings: { foreground: '#7E5520' } },
{ scope: ['entity.name.type.parameter'], settings: { foreground: '#1B7A6A' } },
{ scope: ['variable', 'variable.other', 'variable.other.readwrite'], settings: { foreground: '#24201E' } },
{ scope: ['variable.parameter'], settings: { foreground: '#5C3D2E' } },
{ scope: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key'], settings: { foreground: '#7A3E20' } },
{ scope: ['variable.other.constant', 'variable.other.enummember'], settings: { foreground: '#7E5520' } },
{ scope: ['constant.numeric', 'constant.language'], settings: { foreground: '#1B7A6A' } },
{ scope: ['punctuation', 'meta.brace', 'meta.bracket'], settings: { foreground: '#5C504A' } },
{ scope: ['entity.name.tag', 'punctuation.definition.tag'], settings: { foreground: '#B8533B' } },
{ scope: ['entity.other.attribute-name'], settings: { foreground: '#7A3E20' } },
{ scope: ['meta.decorator', 'punctuation.decorator'], settings: { foreground: '#7E5520' } },
{ scope: ['markup.inserted', 'punctuation.definition.inserted'], settings: { foreground: '#1A7F37' } },
{ scope: ['markup.deleted', 'punctuation.definition.deleted'], settings: { foreground: '#CF222E' } },
{ scope: ['markup.heading', 'entity.name.section'], settings: { foreground: '#1D5A8C', fontStyle: 'bold' } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: 'var(--color-code-comment)', fontStyle: 'italic' } },
{ scope: ['string', 'string.quoted', 'string.template', 'string.other.link'], settings: { foreground: 'var(--color-code-string)' } },
{ scope: ['string.regexp'], settings: { foreground: 'var(--color-primary-container)' } },
{ scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: 'var(--color-code-keyword)' } },
{ scope: ['keyword.operator'], settings: { foreground: 'var(--color-code-keyword)' } },
{ scope: ['entity.name.function', 'support.function'], settings: { foreground: 'var(--color-code-function)' } },
{ scope: ['entity.name.type', 'support.type', 'support.class', 'entity.name.class', 'entity.other.inherited-class'], settings: { foreground: 'var(--color-code-type)' } },
{ scope: ['entity.name.type.parameter'], settings: { foreground: 'var(--color-code-number)' } },
{ scope: ['variable', 'variable.other', 'variable.other.readwrite'], settings: { foreground: 'var(--color-code-fg)' } },
{ scope: ['variable.parameter'], settings: { foreground: 'var(--color-code-parameter)' } },
{ scope: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key'], settings: { foreground: 'var(--color-code-property)' } },
{ scope: ['variable.other.constant', 'variable.other.enummember'], settings: { foreground: 'var(--color-code-type)' } },
{ scope: ['constant.numeric', 'constant.language'], settings: { foreground: 'var(--color-code-number)' } },
{ scope: ['punctuation', 'meta.brace', 'meta.bracket'], settings: { foreground: 'var(--color-code-punctuation)' } },
{ scope: ['entity.name.tag', 'punctuation.definition.tag'], settings: { foreground: 'var(--color-code-keyword)' } },
{ scope: ['entity.other.attribute-name'], settings: { foreground: 'var(--color-code-property)' } },
{ scope: ['meta.decorator', 'punctuation.decorator'], settings: { foreground: 'var(--color-code-type)' } },
{ scope: ['markup.inserted', 'punctuation.definition.inserted'], settings: { foreground: 'var(--color-code-inserted)' } },
{ scope: ['markup.deleted', 'punctuation.definition.deleted'], settings: { foreground: 'var(--color-code-deleted)' } },
{ scope: ['markup.heading', 'entity.name.section'], settings: { foreground: 'var(--color-code-function)', fontStyle: 'bold' } },
{ scope: ['markup.bold'], settings: { fontStyle: 'bold' } },
{ scope: ['markup.italic'], settings: { fontStyle: 'italic' } },
],
@ -74,7 +74,7 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
}, [code, language])
return (
<div ref={containerRef} className="code-viewer-area max-h-[420px] overflow-auto bg-[#FDFCF9]">
<div ref={containerRef} className="code-viewer-area max-h-[420px] overflow-auto bg-[var(--color-code-bg)]">
{/* Plain-text fallback shown until Shiki finishes highlighting */}
{!loaded && (
<pre
@ -86,7 +86,7 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
lineHeight: '1.45',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: '#24201E',
color: 'var(--color-code-fg)',
}}
>
{code}

View File

@ -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(
<ComputerUsePermissionModal
sessionId="session-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',
},
{
requestedName: 'Missing App',
isSentinel: false,
alreadyGranted: false,
proposedTier: 'full',
},
],
requestedFlags: {
clipboardRead: true,
systemKeyCombos: true,
},
screenshotFiltering: 'native',
willHide: [{ bundleId: 'com.apple.TextEdit', displayName: 'TextEdit' }],
autoUnhideEnabled: true,
}}
/>,
)
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(
<ComputerUsePermissionModal
sessionId="session-1"
request={{
requestId: 'cu-1',
reason: '',
apps: [],
requestedFlags: {},
screenshotFiltering: 'native',
tccState: {
accessibility: false,
screenRecording: true,
},
}}
/>,
)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Open Accessibility' }))
})
expect(openSettingsMock).toHaveBeenCalledWith('Privacy_Accessibility')
})
})

View File

@ -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 (
<Modal
open
onClose={handleDeny}
title={
tccState
? t('computerUseApproval.titleTcc')
: t('computerUseApproval.titleApps')
}
width={640}
footer={
tccState ? (
<Button variant="ghost" onClick={handleDeny}>
{t('computerUseApproval.deny')}
</Button>
) : (
<>
<Button variant="ghost" onClick={handleDeny}>
{t('computerUseApproval.deny')}
</Button>
<Button variant="primary" onClick={handleAllow}>
{t('computerUseApproval.allow')}
</Button>
</>
)
}
>
{tccState ? (
<div className="space-y-4">
<p className="text-sm text-[var(--color-text-secondary)]">
{t('computerUseApproval.tccHint')}
</p>
<div className="space-y-3">
<PermissionRow
label={t('computerUseApproval.accessibility')}
granted={tccState.accessibility}
actionLabel={t('computerUseApproval.openAccessibility')}
actionLoading={openingPane === 'Privacy_Accessibility'}
onAction={() => openSettings('Privacy_Accessibility')}
/>
<PermissionRow
label={t('computerUseApproval.screenRecording')}
granted={tccState.screenRecording}
actionLabel={t('computerUseApproval.openScreenRecording')}
actionLoading={openingPane === 'Privacy_ScreenCapture'}
onAction={() => openSettings('Privacy_ScreenCapture')}
/>
</div>
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3 text-xs text-[var(--color-text-tertiary)]">
{t('computerUseApproval.tryAgainHint')}
</div>
<div className="flex justify-end">
<Button variant="secondary" onClick={handleDeny}>
{t('computerUseApproval.tryAgain')}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
{request.reason ? (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-[var(--color-text-tertiary)]">
{t('computerUseApproval.reason')}
</div>
<div className="mt-1 text-sm text-[var(--color-text-primary)]">
{request.reason}
</div>
</div>
) : null}
<div className="space-y-2">
{request.apps.map((app) => {
const resolved = app.resolved
return (
<div
key={resolved?.bundleId ?? app.requestedName}
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--color-text-primary)]">
{resolved?.displayName ?? app.requestedName}
</div>
<div className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{resolved?.bundleId ?? t('computerUseApproval.notInstalled')}
</div>
</div>
<span className="rounded-full bg-[var(--color-surface-container)] px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-text-secondary)]">
{app.proposedTier}
</span>
</div>
{!resolved ? (
<p className="mt-2 text-xs text-[var(--color-error)]">
{t('computerUseApproval.notInstalled')}
</p>
) : null}
{app.alreadyGranted ? (
<p className="mt-2 text-xs text-[var(--color-success)]">
{t('computerUseApproval.alreadyGranted')}
</p>
) : null}
{app.isSentinel ? (
<p className="mt-2 text-xs text-[var(--color-warning)]">
{t('computerUseApproval.sensitiveApp')}
</p>
) : null}
</div>
)
})}
</div>
{requestedFlags.length > 0 ? (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-[var(--color-text-tertiary)]">
{t('computerUseApproval.alsoRequested')}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{requestedFlags.map((flag) => (
<span
key={flag}
className="rounded-full bg-[var(--color-surface-container)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]"
>
{flag}
</span>
))}
</div>
</div>
) : null}
{request.willHide && request.willHide.length > 0 ? (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3 text-sm text-[var(--color-text-secondary)]">
{request.autoUnhideEnabled
? t('computerUseApproval.hideWhileWorkingRestore', {
count: request.willHide.length,
})
: t('computerUseApproval.hideWhileWorking', {
count: request.willHide.length,
})}
</div>
) : null}
</div>
)}
</Modal>
)
}
function PermissionRow({
label,
granted,
actionLabel,
actionLoading,
onAction,
}: {
label: string
granted: boolean
actionLabel: string
actionLoading: boolean
onAction: () => void
}) {
const t = useTranslation()
return (
<div className="flex items-center justify-between gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
<div>
<div className="text-sm font-semibold text-[var(--color-text-primary)]">
{label}
</div>
<div className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{granted
? t('computerUseApproval.granted')
: t('computerUseApproval.notGranted')}
</div>
</div>
{!granted ? (
<Button
variant="secondary"
size="sm"
loading={actionLoading}
onClick={onAction}
>
{actionLabel}
</Button>
) : null}
</div>
)
}

View File

@ -23,24 +23,24 @@ function inferLanguage(filePath: string): string {
/** Shared warm syntax theme — must stay in sync with CodeViewer */
const warmSyntaxTheme: PrismTheme = {
plain: {
color: '#24201E',
color: 'var(--color-code-fg)',
backgroundColor: 'transparent',
},
styles: [
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: '#8C7E75', fontStyle: 'italic' as const } },
{ types: ['string', 'attr-value', 'template-string'], style: { color: '#437220' } },
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: '#B8533B' } },
{ types: ['function'], style: { color: '#1D5A8C' } },
{ types: ['tag'], style: { color: '#B8533B' } },
{ types: ['number', 'boolean'], style: { color: '#1B7A6A' } },
{ types: ['operator'], style: { color: '#24201E' } },
{ types: ['punctuation'], style: { color: '#5C504A' } },
{ types: ['variable', 'parameter'], style: { color: '#24201E' } },
{ types: ['property', 'attr-name'], style: { color: '#7A3E20' } },
{ types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: '#7E5520' } },
{ types: ['regex'], style: { color: '#C15F3C' } },
{ types: ['inserted'], style: { color: '#1A7F37' } },
{ types: ['deleted'], style: { color: '#CF222E' } },
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' as const } },
{ types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
{ types: ['function'], style: { color: 'var(--color-code-function)' } },
{ types: ['tag'], style: { color: 'var(--color-code-keyword)' } },
{ types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } },
{ types: ['operator'], style: { color: 'var(--color-code-fg)' } },
{ types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } },
{ types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } },
{ types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } },
{ types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } },
{ types: ['regex'], style: { color: 'var(--color-primary-container)' } },
{ types: ['inserted'], style: { color: 'var(--color-code-inserted)' } },
{ types: ['deleted'], style: { color: 'var(--color-code-deleted)' } },
],
}
@ -65,30 +65,30 @@ function highlightSyntax(str: string, language: string) {
const diffStyles = {
variables: {
light: {
diffViewerBackground: '#FDFCF9',
diffViewerColor: '#3B3330',
addedBackground: '#E8F5E2',
addedColor: '#3B3330',
removedBackground: '#FDECEA',
removedColor: '#3B3330',
wordAddedBackground: '#B8E4A8',
wordRemovedBackground: '#F5B8B4',
addedGutterBackground: '#D4EDCA',
removedGutterBackground: '#F9D4D0',
gutterBackground: '#F4F4F0',
gutterBackgroundDark: '#EFEEEA',
highlightBackground: '#FFF5D6',
highlightGutterBackground: '#FFECB3',
codeFoldGutterBackground: '#E4EDF6',
codeFoldBackground: '#EDF4FB',
emptyLineBackground: '#F4F4F0',
gutterColor: '#87736D',
addedGutterColor: '#1A7F37',
removedGutterColor: '#CF222E',
codeFoldContentColor: '#87736D',
diffViewerTitleBackground: '#F4F4F0',
diffViewerTitleColor: '#87736D',
diffViewerTitleBorderColor: '#DAC1BA',
diffViewerBackground: 'var(--color-code-bg)',
diffViewerColor: 'var(--color-code-fg)',
addedBackground: 'var(--color-diff-added-bg)',
addedColor: 'var(--color-code-fg)',
removedBackground: 'var(--color-diff-removed-bg)',
removedColor: 'var(--color-code-fg)',
wordAddedBackground: 'var(--color-diff-added-word)',
wordRemovedBackground: 'var(--color-diff-removed-word)',
addedGutterBackground: 'var(--color-diff-added-gutter)',
removedGutterBackground: 'var(--color-diff-removed-gutter)',
gutterBackground: 'var(--color-surface-container-low)',
gutterBackgroundDark: 'var(--color-surface-container)',
highlightBackground: 'var(--color-diff-highlight-bg)',
highlightGutterBackground: 'var(--color-diff-highlight-gutter)',
codeFoldGutterBackground: 'var(--color-surface-container-high)',
codeFoldBackground: 'var(--color-surface-container-highest)',
emptyLineBackground: 'var(--color-surface-container-low)',
gutterColor: 'var(--color-text-tertiary)',
addedGutterColor: 'var(--color-diff-added-text)',
removedGutterColor: 'var(--color-diff-removed-text)',
codeFoldContentColor: 'var(--color-text-tertiary)',
diffViewerTitleBackground: 'var(--color-diff-title-bg)',
diffViewerTitleColor: 'var(--color-diff-title-color)',
diffViewerTitleBorderColor: 'var(--color-diff-title-border)',
},
},
diffContainer: {
@ -128,8 +128,8 @@ export function DiffViewer({ filePath, oldString, newString }: Props) {
{filePath}
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] uppercase tracking-[0.14em]">
<span className="rounded-full bg-[#E8F5E2] px-2 py-0.5 text-[#1A7F37]">+{additions}</span>
<span className="rounded-full bg-[#FDECEA] px-2 py-0.5 text-[#CF222E]">-{deletions}</span>
<span className="rounded-full bg-[var(--color-diff-added-bg)] px-2 py-0.5 text-[var(--color-diff-added-text)]">+{additions}</span>
<span className="rounded-full bg-[var(--color-diff-removed-bg)] px-2 py-0.5 text-[var(--color-diff-removed-text)]">-{deletions}</span>
</div>
</div>
<CopyButton
@ -149,7 +149,7 @@ export function DiffViewer({ filePath, oldString, newString }: Props) {
renderContent={(str) => highlightSyntax(str, language)}
hideLineNumbers={false}
styles={diffStyles}
useDarkTheme={false}
useDarkTheme={document.documentElement.getAttribute('data-theme') === 'dark'}
/>
</div>
</div>

View File

@ -19,6 +19,7 @@ function makeSessionState(overrides: Partial<PerSessionState> = {}): PerSessionS
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',

View File

@ -227,10 +227,10 @@ export const MessageBlock = memo(function MessageBlock({
message.message.trim() !== '' &&
message.message !== displayMessage
return (
<div className="mb-3 px-4 py-2.5 rounded-lg bg-red-50 border border-red-200 text-sm text-[var(--color-error)]">
<div className="mb-3 px-4 py-2.5 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error-container)]/28 text-sm text-[var(--color-error)]">
<strong>Error:</strong> {displayMessage}
{showRawDetail && (
<div className="mt-1 whitespace-pre-wrap text-xs text-red-700/85">
<div className="mt-1 whitespace-pre-wrap text-xs text-[var(--color-on-error-container)]/85">
{message.message}
</div>
)}

View File

@ -18,17 +18,17 @@ type Props = {
* Uses Material Symbols Outlined names.
*/
const TOOL_META: Record<string, { icon: string; label: string; color: string }> = {
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 (
<div className="overflow-x-auto rounded-[var(--radius-md)] bg-[#1e1e1e] px-3 py-2.5">
<pre className="font-[var(--font-mono)] text-[11px] leading-[1.3] text-[#d4d4d4] whitespace-pre-wrap break-words">
<span className="text-[#28c840] select-none">$ </span>{obj.command}
<div className="overflow-x-auto rounded-[var(--radius-md)] bg-[var(--color-terminal-bg)] px-3 py-2.5">
<pre className="font-[var(--font-mono)] text-[11px] leading-[1.3] text-[var(--color-terminal-fg)] whitespace-pre-wrap break-words">
<span className="text-[var(--color-terminal-accent)] select-none">$ </span>{obj.command}
</pre>
</div>
)
@ -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 && (
<pre className="mt-2 max-h-[220px] overflow-y-auto overflow-x-auto rounded-[var(--radius-md)] bg-[#1e1e1e] px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.3] text-[#d4d4d4] whitespace-pre-wrap break-words">
<pre className="mt-2 max-h-[220px] overflow-y-auto overflow-x-auto rounded-[var(--radius-md)] bg-[var(--color-terminal-bg)] px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.3] text-[var(--color-terminal-fg)] whitespace-pre-wrap break-words">
{rawInput}
</pre>
)}

View File

@ -14,20 +14,20 @@ export function TerminalChrome({ title, children, className = '' }: Props) {
return (
<div className={`overflow-hidden rounded-2xl border border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-dim)] ${className}`}>
{/* Title bar with traffic lights */}
<div className="flex items-center gap-2 border-b border-[#1a1a1a] bg-[#2d2d2d] px-3 py-2">
<div className="flex items-center gap-2 border-b border-[var(--color-terminal-border)] bg-[var(--color-terminal-header)] px-3 py-2">
<div className="flex gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-[#ff5f57]" />
<div className="w-2.5 h-2.5 rounded-full bg-[#febc2e]" />
<div className="w-2.5 h-2.5 rounded-full bg-[#28c840]" />
<div className="w-2.5 h-2.5 rounded-full bg-[var(--color-terminal-danger)]" />
<div className="w-2.5 h-2.5 rounded-full bg-[var(--color-terminal-warning)]" />
<div className="w-2.5 h-2.5 rounded-full bg-[var(--color-terminal-accent)]" />
</div>
{title && (
<span className="ml-2 truncate font-[var(--font-mono)] text-[10px] text-[#999]">
<span className="ml-2 truncate font-[var(--font-mono)] text-[10px] text-[var(--color-terminal-muted)]">
{title}
</span>
)}
</div>
{/* Content */}
<div className="bg-[#1e1e1e] text-[#d4d4d4]">
<div className="bg-[var(--color-terminal-bg)] text-[var(--color-terminal-fg)]">
{children}
</div>
</div>

View File

@ -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 (
<div className={`overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)] ${
@ -76,7 +77,7 @@ export function ToolCallBlock({ toolName, input, result, compact = false }: Prop
{outputSummary}
</span>
)}
{result?.isError && (
{result?.isError && (
<span className="material-symbols-outlined shrink-0 text-[14px] text-[var(--color-error)]">error</span>
)}
{expandable && (
@ -115,8 +116,8 @@ function renderPreview(
if (toolName === 'Bash' && typeof obj.command === 'string') {
return (
<TerminalChrome title={typeof obj.description === 'string' ? obj.description : filePath}>
<div className="px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.3] text-[#d8d8d8]">
<span className="text-[#28c840]">$</span> {obj.command}
<div className="px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.3] text-[var(--color-terminal-fg)]">
<span className="text-[var(--color-terminal-accent)]">$</span> {obj.command}
</div>
</TerminalChrome>
)

View File

@ -51,7 +51,7 @@ export function ToolResultBlock({ content, isError, toolName, standalone = true
<span className={`px-2 py-0.5 rounded-full text-[9px] ${
isError
? 'bg-[var(--color-error)]/10'
: 'bg-[#D4EAB4] text-[#3B4C24]'
: 'bg-[var(--color-diff-added-bg)] text-[var(--color-diff-added-text)]'
}`}>
{isError ? t('tool.error') : t('tool.success')}
</span>

View File

@ -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(
<ToolCallBlock
toolName="mcp__computer-use__left_click"
input={{ coordinate: [120, 220] }}
result={{
content: '"Claude Code Haha" is not in the allowed applications and is currently in front. Take a new screenshot — it may have appeared since your last one.',
isError: true,
}}
/>,
)
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: '',

View File

@ -39,14 +39,18 @@ import { useUIStore } from '../../stores/uiStore'
describe('Sidebar', () => {
const connectToSession = vi.fn()
const disconnectSession = vi.fn()
const fetchSessions = vi.fn()
const createSession = vi.fn()
const deleteSession = vi.fn()
const addToast = vi.fn()
beforeEach(() => {
connectToSession.mockReset()
disconnectSession.mockReset()
fetchSessions.mockReset()
createSession.mockReset()
deleteSession.mockReset()
addToast.mockReset()
useTabStore.setState({ tabs: [], activeTabId: null })
@ -59,9 +63,11 @@ describe('Sidebar', () => {
availableProjects: [],
fetchSessions,
createSession,
deleteSession,
})
useChatStore.setState({
connectToSession,
disconnectSession,
} as Partial<ReturnType<typeof useChatStore.getState>>)
useUIStore.setState({
addToast,
@ -111,4 +117,42 @@ describe('Sidebar', () => {
expect(useTabStore.getState().tabs).toEqual([])
})
it('removes the matching tab when deleting a session from the sidebar', async () => {
deleteSession.mockResolvedValue(undefined)
useSessionStore.setState({
sessions: [
{
id: 'session-1',
title: 'Open Session',
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString(),
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
},
],
})
useTabStore.setState({
tabs: [{ sessionId: 'session-1', title: 'Open Session', type: 'session', status: 'idle' }],
activeTabId: 'session-1',
})
render(<Sidebar />)
fireEvent.contextMenu(screen.getByRole('button', { name: /Open Session/ }))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
})
await waitFor(() => {
expect(deleteSession).toHaveBeenCalledWith('session-1')
expect(disconnectSession).toHaveBeenCalledWith('session-1')
})
expect(useTabStore.getState().tabs).toEqual([])
expect(useTabStore.getState().activeTabId).toBeNull()
})
})

View File

@ -23,6 +23,8 @@ export function Sidebar() {
const renameSession = useSessionStore((s) => s.renameSession)
const addToast = useUIStore((s) => s.addToast)
const activeTabId = useTabStore((s) => s.activeTabId)
const closeTab = useTabStore((s) => s.closeTab)
const disconnectSession = useChatStore((s) => s.disconnectSession)
const [searchQuery, setSearchQuery] = useState('')
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
const [renamingId, setRenamingId] = useState<string | null>(null)
@ -64,7 +66,9 @@ export function Sidebar() {
const handleDelete = useCallback(async (id: string) => {
setContextMenu(null)
await deleteSession(id)
}, [deleteSession])
disconnectSession(id)
closeTab(id)
}, [closeTab, deleteSession, disconnectSession])
const handleStartRename = useCallback((id: string, currentTitle: string) => {
setContextMenu(null)
@ -113,8 +117,8 @@ export function Sidebar() {
<div className={`px-3 pb-1.5 flex items-center justify-between ${isTauri && !isWindows ? 'pt-[44px]' : 'pt-3'}`}>
<div className="flex items-center gap-2.5">
<img src="/app-icon.jpg" alt="" className="h-8 w-8 rounded-lg flex-shrink-0" />
<span className="text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: "'Manrope', sans-serif" }}>
Claude Code <span className="text-[#D97757]">Haha</span>
<span className="text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
Claude Code <span className="text-[var(--color-primary-container)]">Haha</span>
</span>
</div>
<a

View File

@ -77,7 +77,7 @@ export function WindowControls() {
<button
onClick={() => 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"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
<line x1="0" y1="0" x2="10" y2="10" />

View File

@ -78,4 +78,23 @@ describe('MarkdownRenderer', () => {
)
expect(screen.queryByTestId('mermaid-renderer')).not.toBeInTheDocument()
})
it('wraps markdown tables for horizontal overflow handling', () => {
const { container } = render(
<MarkdownRenderer
content={'| Name | Value |\n| --- | --- |\n| `index.html` | Ready |'}
/>,
)
expect(container.querySelector('.md-table-wrap')).toBeInTheDocument()
expect(screen.getByText('index.html')).toBeInTheDocument()
})
it('opens markdown links in a new tab safely', () => {
render(<MarkdownRenderer content={'[OpenAI](https://openai.com)'} />)
const link = screen.getByRole('link', { name: 'OpenAI' })
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'))
})
})

View File

@ -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 (
<div
className={proseClasses}
@ -184,7 +212,7 @@ export function MarkdownRenderer({ content, variant = 'default', className }: Pr
<div className={proseClasses} onClick={handleClick}>
{parts.map((part, i) =>
part.type === 'html' ? (
<div key={i} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(part.content, { ADD_TAGS: ['use'], ADD_ATTR: ['xlink:href'] }) }} />
<div key={i} dangerouslySetInnerHTML={{ __html: enhanceMarkdownHtml(part.content) }} />
) : shouldRenderAsMermaid(part.block) ? (
<MermaidRenderer key={part.block.id} code={part.block.code} />
) : (

View File

@ -47,7 +47,7 @@ export function ClaudeOfficialLogin() {
if (status === null) {
if (error) {
return (
<div className="text-xs text-[var(--color-error,#dc2626)]">
<div className="text-xs text-[var(--color-error)]">
{t('settings.claudeOfficialLogin.errorPrefix')}{error}
</div>
)
@ -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')}
</button>
{error && (
<div className="text-xs text-[var(--color-error,#dc2626)]">
<div className="text-xs text-[var(--color-error)]">
{t('settings.claudeOfficialLogin.errorPrefix')}{error}
</div>
)}

View File

@ -11,7 +11,7 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
const variantStyles: Record<ButtonVariant, string> = {
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:

View File

@ -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}

View File

@ -25,13 +25,13 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 transition-opacity duration-200"
className="absolute inset-0 bg-[var(--color-overlay-scrim)] transition-opacity duration-200"
onClick={onClose}
/>
{/* Modal content */}
<div
className="relative bg-[var(--color-surface)] rounded-[var(--radius-xl)] shadow-[var(--shadow-modal)] max-h-[85vh] flex flex-col"
className="glass-panel relative rounded-[var(--radius-xl)] max-h-[85vh] flex flex-col"
style={{ width, maxWidth: 'calc(100vw - 48px)' }}
role="dialog"
aria-modal="true"

View File

@ -10,18 +10,18 @@ export function ProjectContextChip({ workDir, repoName, branch }: Props) {
if (!label) return null
return (
<div className="inline-flex max-w-full items-center gap-2 rounded-full border border-[#dac1ba] px-4 py-2 text-sm text-[#87736D]">
<div className="inline-flex max-w-full items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-2 text-sm text-[var(--color-text-secondary)]">
{branch ? (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" className="shrink-0 text-[#6b5a54]">
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" className="shrink-0 text-[var(--color-text-secondary)]">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
) : (
<span className="material-symbols-outlined text-[18px] text-[#6b5a54]">folder</span>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">folder</span>
)}
<span className="truncate font-medium text-[#4f423d]">{label}</span>
<span className="truncate font-medium text-[var(--color-text-primary)]">{label}</span>
{branch ? (
<>
<span className="text-[#b49d95]">|</span>
<span className="text-[var(--color-text-tertiary)]">|</span>
<span className="truncate">{branch}</span>
</>
) : null}

View File

@ -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}

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo } from 'react'
import { useSkillStore } from '../../stores/skillStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTranslation } from '../../i18n'
import type { SkillMeta, SkillSource } from '../../types/skill'
@ -28,11 +29,15 @@ function estimateTokens(contentLength: number) {
export function SkillList() {
const { skills, isLoading, error, fetchSkills, fetchSkillDetail } =
useSkillStore()
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const t = useTranslation()
const activeSession = sessions.find((session) => session.id === activeSessionId)
const currentWorkDir = activeSession?.workDir || undefined
useEffect(() => {
fetchSkills()
}, [fetchSkills])
fetchSkills(currentWorkDir)
}, [fetchSkills, currentWorkDir])
const grouped = useMemo(() => {
const result: Partial<Record<SkillSource, SkillMeta[]>> = {}
@ -175,7 +180,7 @@ export function SkillList() {
key={`${skill.source}-${skill.name}`}
onClick={() =>
skill.hasDirectory &&
fetchSkillDetail(skill.source, skill.name)
fetchSkillDetail(skill.source, skill.name, currentWorkDir)
}
disabled={!skill.hasDirectory}
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:opacity-60 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-transparent"

View File

@ -165,7 +165,7 @@ export function TaskRow({ task, showLogs, onToggleLogs }: Props) {
{/* Delete */}
<button
onClick={() => setConfirmAction('delete')}
className={`${menuItem} text-[var(--color-error)] hover:bg-red-50`}
className={`${menuItem} text-[var(--color-error)] hover:bg-[var(--color-error-container)]/18`}
>
<span className="material-symbols-outlined text-[16px]">delete</span>
{t('common.delete')}
@ -239,8 +239,10 @@ function ConfirmPopover({ message, confirmLabel, onConfirm, onCancel, cancelLabe
</button>
<button
onClick={onConfirm}
className={`px-2.5 py-1 text-xs rounded-[var(--radius-sm)] text-white hover:opacity-90 transition-opacity ${
variant === 'error' ? 'bg-[var(--color-error)]' : 'bg-[var(--color-brand)]'
className={`px-2.5 py-1 text-xs rounded-[var(--radius-sm)] hover:opacity-90 transition-opacity ${
variant === 'error'
? 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
: 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)]'
}`}
>
{confirmLabel}

View File

@ -12,7 +12,7 @@ function RunOutput({ run }: { run: TaskRun }) {
// Show error prominently if present
if (run.error) {
return (
<div className="mt-2 p-2.5 rounded-[var(--radius-sm)] bg-red-50 border border-red-200 text-xs text-[var(--color-error)] whitespace-pre-wrap break-words max-h-40 overflow-y-auto">
<div className="mt-2 max-h-40 overflow-y-auto whitespace-pre-wrap break-words rounded-[var(--radius-sm)] border border-[var(--color-error)]/20 bg-[var(--color-error-container)]/28 p-2.5 text-xs text-[var(--color-error)]">
{run.error}
</div>
)

View File

@ -287,6 +287,10 @@ export const en = {
'settings.computerUse.flagSystemKeys': 'System Key Combos',
// Settings > General
'settings.general.appearanceTitle': 'Appearance',
'settings.general.appearanceDescription': 'Switch between the original light workspace and the new dark workspace.',
'settings.general.appearance.light': 'Light',
'settings.general.appearance.dark': 'Dark',
'settings.general.languageTitle': 'Language',
'settings.general.languageDescription': 'Choose the display language for the application.',
'settings.general.effortTitle': 'Effort Level',
@ -333,6 +337,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',

View File

@ -289,6 +289,10 @@ export const zh: Record<TranslationKey, string> = {
'settings.computerUse.flagSystemKeys': '系统快捷键',
// Settings > General
'settings.general.appearanceTitle': '配色主题',
'settings.general.appearanceDescription': '在亮色与暗色工作区之间切换,不影响原有亮色主题。',
'settings.general.appearance.light': '亮色',
'settings.general.appearance.dark': '暗色',
'settings.general.languageTitle': '语言',
'settings.general.languageDescription': '选择应用程序的显示语言。',
'settings.general.effortTitle': '推理强度',
@ -335,6 +339,28 @@ export const zh: Record<TranslationKey, string> = {
'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': '已回答',

View File

@ -2,6 +2,9 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './App'
import './theme/globals.css'
import { initializeTheme } from './stores/uiStore'
initializeTheme()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@ -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: '',

View File

@ -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'))
@ -139,11 +141,11 @@ export function ActiveSession() {
</>
) : (
<>
<img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[22px] shadow-[0_2px_12px_rgba(0,0,0,0.06)]" />
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: "'Manrope', sans-serif" }}>
<img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[22px]" style={{ boxShadow: 'var(--shadow-dropdown)' }} />
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
{t('empty.title')}
</h1>
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: "'Inter', sans-serif" }}>
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: 'var(--font-body)' }}>
{t('empty.subtitle')}
</p>
</>
@ -204,7 +206,14 @@ export function ActiveSession() {
<TeamStatusBar />
<ChatInput />
<ChatInput variant={isEmpty && !isMemberSession ? 'hero' : 'default'} />
{!isMemberSession && activeTabId ? (
<ComputerUsePermissionModal
sessionId={activeTabId}
request={pendingComputerUsePermission?.request ?? null}
/>
) : null}
</div>
)
}

View File

@ -19,7 +19,7 @@ export function AgentTeams() {
<>
<style>{pulseSubtleStyle}</style>
<div className="flex-1 flex flex-col relative overflow-hidden bg-[#FAF9F5] text-[#1B1C1A] font-['Inter'] selection:bg-[#ffdbd0]">
<div className="flex-1 flex flex-col relative overflow-hidden bg-[var(--color-surface)] text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-body)' }}>
{/* Code Content Area */}
<div className="flex-1 overflow-y-auto p-6 md:p-10 max-w-5xl mx-auto w-full">
<div className="space-y-8">
@ -27,14 +27,14 @@ export function AgentTeams() {
<div className="space-y-6">
{/* USER message */}
<div className="flex gap-4 group">
<div className="w-8 h-8 rounded-full bg-[#ffdbd0] flex-shrink-0 flex items-center justify-center text-[#390c00] font-bold text-xs">
<div className="w-8 h-8 rounded-full bg-[var(--color-primary-fixed)] flex-shrink-0 flex items-center justify-center text-[var(--color-on-primary)] font-bold text-xs">
U
</div>
<div className="space-y-2">
<p className="text-xs font-semibold text-[#87736D] uppercase tracking-widest">
<p className="text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-widest">
User
</p>
<p className="text-[#1B1C1A] leading-relaxed">
<p className="text-[var(--color-text-primary)] leading-relaxed">
{mockTeamMessages.userMessage}
</p>
</div>
@ -42,7 +42,7 @@ export function AgentTeams() {
{/* CLAUDE COMPANION response */}
<div className="flex gap-4 group">
<div className="w-8 h-8 rounded-full bg-[#677b4e] flex-shrink-0 flex items-center justify-center text-[#faffea]">
<div className="w-8 h-8 rounded-full bg-[var(--color-tertiary-container)] flex-shrink-0 flex items-center justify-center text-[var(--color-tertiary)]">
<span
className="material-symbols-outlined text-sm"
style={{ fontVariationSettings: "'FILL' 1" }}
@ -51,19 +51,19 @@ export function AgentTeams() {
</span>
</div>
<div className="space-y-4 flex-1">
<p className="text-xs font-semibold text-[#87736D] uppercase tracking-widest">
<p className="text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-widest">
Claude Companion
</p>
<div className="bg-[#f4f4f0] p-5 rounded-xl border border-[#dac1ba]/10 shadow-sm">
<p className="text-[#1B1C1A] mb-4">
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-5 shadow-[var(--shadow-dropdown)]">
<p className="mb-4 text-[var(--color-text-primary)]">
{mockTeamMessages.assistantMessage}
</p>
<div className="bg-[#dbdad6] p-4 rounded-lg font-['JetBrains_Mono'] text-[13px] text-[#54433e] overflow-x-auto">
<span className="text-[#ad5f45]">info:</span> spawning child_processes for parallel development
<div className="rounded-lg bg-[var(--color-surface-container-high)] p-4 font-[var(--font-mono)] text-[13px] text-[var(--color-text-secondary)] overflow-x-auto">
<span className="text-[var(--color-brand)]">info:</span> spawning child_processes for parallel development
<br />
<span className="text-[#2d628f]">active:</span> session-dev cluster initiated
<span className="text-[var(--color-secondary)]">active:</span> session-dev cluster initiated
<br />
<span className="text-[#4f6237]">ready:</span> 4 agents assigned
<span className="text-[var(--color-tertiary)]">ready:</span> 4 agents assigned
</div>
</div>
</div>
@ -72,21 +72,21 @@ export function AgentTeams() {
{/* ─── TEAM STRIP ─── */}
<div className="relative py-8">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-[#dac1ba]/20" />
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-[var(--color-border-separator)]" />
<div className="relative bg-[#FAF9F5] p-4 rounded-2xl flex flex-col md:flex-row md:items-center gap-4 border border-[#dac1ba]/15 shadow-sm overflow-hidden">
<div className="relative glass-panel p-4 rounded-2xl flex flex-col md:flex-row md:items-center gap-4 overflow-hidden">
{/* Team label */}
<div className="flex items-center gap-3 pr-4 md:border-r border-[#dac1ba]/30">
<div className="p-2 bg-[#ffb59d]/20 rounded-lg">
<span className="material-symbols-outlined text-[#8F482F] text-xl">
<div className="flex items-center gap-3 pr-4 md:border-r border-[var(--color-border-separator)]">
<div className="p-2 bg-[var(--color-primary-fixed)]/20 rounded-lg">
<span className="material-symbols-outlined text-[var(--color-brand)] text-xl">
groups
</span>
</div>
<div>
<h3 className="text-sm font-bold font-['Manrope'] text-[#1B1C1A]">
<h3 className="text-sm font-bold text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
Team: {mockTeam.name}
</h3>
<p className="text-[11px] font-medium text-[#87736D] uppercase tracking-tighter">
<p className="text-[11px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-tighter">
{mockTeam.memberCount} members
</p>
</div>
@ -99,14 +99,14 @@ export function AgentTeams() {
return (
<div
key={member.id}
className="flex items-center gap-2 px-3 py-1.5 bg-[#e3e2df] rounded-full border border-[#677b4e]/20 group hover:border-[#677b4e]/50 transition-all cursor-pointer"
className="flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-high)] rounded-full border border-[var(--color-success)]/20 group hover:border-[var(--color-success)]/50 transition-all cursor-pointer"
>
<div className="w-2 h-2 rounded-full bg-[#677b4e] shadow-[0_0_8px_rgba(103,123,78,0.4)]" />
<span className="text-xs font-semibold font-['Inter'] text-[#1B1C1A]">
<div className="w-2 h-2 rounded-full bg-[var(--color-success)] shadow-[0_0_8px_rgba(126,219,139,0.4)]" />
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
{member.role}
</span>
<span
className="material-symbols-outlined text-[14px] text-[#4f6237]"
className="material-symbols-outlined text-[14px] text-[var(--color-success)]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
check_circle
@ -119,14 +119,14 @@ export function AgentTeams() {
return (
<div
key={member.id}
className="flex items-center gap-2 px-3 py-1.5 bg-[#e3e2df] rounded-full border border-[#8F482F]/20 animate-pulse-subtle group hover:border-[#8F482F]/50 transition-all cursor-pointer"
className="flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-high)] rounded-full border border-[var(--color-brand)]/20 animate-pulse-subtle group hover:border-[var(--color-brand)]/50 transition-all cursor-pointer"
>
<div className="w-2 h-2 rounded-full bg-[#f59e0b] shadow-[0_0_8px_rgba(245,158,11,0.4)]" />
<span className="text-xs font-semibold font-['Inter'] text-[#1B1C1A]">
<div className="w-2 h-2 rounded-full bg-[var(--color-warning)] shadow-[0_0_8px_rgba(247,196,108,0.4)]" />
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
{member.role}
</span>
<span
className="material-symbols-outlined text-[14px] text-[#f59e0b]"
className="material-symbols-outlined text-[14px] text-[var(--color-warning)]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
sync
@ -138,13 +138,13 @@ export function AgentTeams() {
return (
<div
key={member.id}
className="flex items-center gap-2 px-3 py-1.5 bg-[#f4f4f0] rounded-full border border-[#dac1ba]/20 grayscale group hover:grayscale-0 hover:border-[#2d628f]/50 transition-all cursor-pointer"
className="flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-low)] rounded-full border border-[var(--color-border)] grayscale group hover:grayscale-0 hover:border-[var(--color-secondary)]/50 transition-all cursor-pointer"
>
<div className="w-2 h-2 rounded-full bg-[#87736D] shadow-[0_0_8px_rgba(135,115,109,0.2)]" />
<span className="text-xs font-semibold font-['Inter'] text-[#87736D] group-hover:text-[#1B1C1A]">
<div className="w-2 h-2 rounded-full bg-[var(--color-text-tertiary)] shadow-[0_0_8px_rgba(135,115,109,0.2)]" />
<span className="text-xs font-semibold text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-primary)]">
{member.role}
</span>
<span className="material-symbols-outlined text-[14px] text-[#87736D]">
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
{member.role === 'Tester' ? 'schedule' : 'pause_circle'}
</span>
</div>
@ -153,7 +153,7 @@ export function AgentTeams() {
</div>
{/* Expand button */}
<button className="ml-auto p-2 hover:bg-[#e9e8e4] rounded-full transition-colors text-[#87736D]">
<button className="ml-auto p-2 hover:bg-[var(--color-surface-hover)] rounded-full transition-colors text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-sm">expand_more</span>
</button>
</div>
@ -161,18 +161,18 @@ export function AgentTeams() {
{/* ─── Chat Composer ─── */}
<div className="max-w-3xl mx-auto w-full mt-auto">
<div className="relative bg-white rounded-xl border border-[#dac1ba]/15 p-1.5 shadow-md flex items-center gap-2 focus-within:border-[#dac1ba]/40 transition-all">
<div className="p-2 text-[#87736D]">
<div className="glass-panel relative rounded-xl p-1.5 flex items-center gap-2 transition-all">
<div className="p-2 text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined">attach_file</span>
</div>
<input
className="flex-1 bg-transparent border-none focus:ring-0 focus:outline-none text-sm font-['Inter'] text-[#1B1C1A] py-2"
className="flex-1 bg-transparent border-none focus:ring-0 focus:outline-none text-sm text-[var(--color-text-primary)] py-2"
placeholder="Type a command or ask Claude..."
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<button className="bg-[#8F482F] text-white w-9 h-9 rounded-lg flex items-center justify-center hover:bg-[#ad5f45] transition-all active:scale-95">
<button className="bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] w-9 h-9 rounded-lg flex items-center justify-center transition-all hover:brightness-105 active:scale-95">
<span
className="material-symbols-outlined text-lg"
style={{ fontVariationSettings: "'FILL' 1" }}
@ -182,12 +182,12 @@ export function AgentTeams() {
</button>
</div>
<div className="mt-3 flex justify-center gap-4">
<div className="flex items-center gap-1.5 text-[10px] text-[#87736D] font-semibold uppercase tracking-widest">
<span className="w-1.5 h-1.5 rounded-full bg-[#4f6237]" />
<div className="flex items-center gap-1.5 text-[10px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-widest">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)]" />
Auto-run enabled
</div>
<div className="flex items-center gap-1.5 text-[10px] text-[#87736D] font-semibold uppercase tracking-widest">
<span className="w-1.5 h-1.5 rounded-full bg-[#2d628f]" />
<div className="flex items-center gap-1.5 text-[10px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-widest">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-secondary)]" />
Local LLM
</div>
</div>

View File

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { skillsApi } from '../api/skills'
import { useTranslation } from '../i18n'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
@ -13,9 +14,11 @@ import {
FALLBACK_SLASH_COMMANDS,
findSlashToken,
insertSlashTrigger,
mergeSlashCommands,
replaceSlashCommand,
} from '../components/chat/composerUtils'
import type { AttachmentRef } from '../types/chat'
import type { SlashCommandOption } from '../components/chat/composerUtils'
type Attachment = {
id: string
@ -39,6 +42,7 @@ export function EmptySession() {
const [atCursorPos, setAtCursorPos] = useState(-1)
const [slashFilter, setSlashFilter] = useState('')
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
const [slashCommands, setSlashCommands] = useState<SlashCommandOption[]>([])
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const plusMenuRef = useRef<HTMLDivElement>(null)
@ -99,19 +103,50 @@ export function EmptySession() {
return () => document.removeEventListener('mousedown', handleClick)
}, [fileSearchOpen])
const filteredCommands = FALLBACK_SLASH_COMMANDS.filter((command) => {
if (!slashFilter) return true
useEffect(() => {
let cancelled = false
skillsApi.list(workDir || undefined)
.then(({ skills }) => {
if (cancelled) return
setSlashCommands(
skills
.filter((skill) => skill.userInvocable)
.map((skill) => ({
name: skill.name,
description: skill.description,
})),
)
})
.catch(() => {
if (!cancelled) {
setSlashCommands([])
}
})
return () => {
cancelled = true
}
}, [workDir])
const filteredCommands = useMemo(() => {
const source = mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS)
if (!slashFilter) return source
const lower = slashFilter.toLowerCase()
return command.name.toLowerCase().includes(lower) || command.description.toLowerCase().includes(lower)
})
return source.filter((command) => (
command.name.toLowerCase().includes(lower) ||
command.description.toLowerCase().includes(lower)
))
}, [slashCommands, slashFilter])
useEffect(() => {
setSlashSelectedIndex(0)
}, [slashFilter])
useEffect(() => {
if (slashMenuOpen && slashItemRefs.current[slashSelectedIndex]) {
slashItemRefs.current[slashSelectedIndex]?.scrollIntoView({ block: 'nearest' })
const activeItem = slashMenuOpen ? slashItemRefs.current[slashSelectedIndex] : null
if (typeof activeItem?.scrollIntoView === 'function') {
activeItem.scrollIntoView({ block: 'nearest' })
}
}, [slashMenuOpen, slashSelectedIndex])
@ -336,14 +371,14 @@ export function EmptySession() {
}
return (
<div className="relative flex flex-1 flex-col overflow-hidden bg-[#FAF9F5]">
<div className="relative flex flex-1 flex-col overflow-hidden bg-[var(--color-surface)]">
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
<div className="flex max-w-md flex-col items-center text-center">
<img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[22px] shadow-[0_2px_12px_rgba(0,0,0,0.06)]" />
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[#1B1C1A]" style={{ fontFamily: "'Manrope', sans-serif" }}>
<img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[22px]" style={{ boxShadow: 'var(--shadow-dropdown)' }} />
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
{t('empty.title')}
</h1>
<p className="mx-auto max-w-xs text-[#87736D]" style={{ fontFamily: "'Inter', sans-serif" }}>
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: 'var(--font-body)' }}>
{t('empty.subtitle')}
</p>
</div>
@ -352,8 +387,7 @@ export function EmptySession() {
<div className="absolute bottom-4 left-0 right-0 flex justify-center px-8">
<div className="flex w-full max-w-3xl flex-col gap-2">
<div
className="relative flex flex-col gap-3 rounded-xl border border-[#dac1ba]/15 bg-white p-4"
style={{ boxShadow: '0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08)' }}
className="glass-panel relative flex flex-col gap-3 rounded-xl p-4"
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
@ -382,7 +416,7 @@ export function EmptySession() {
{slashMenuOpen && filteredCommands.length > 0 && (
<div
ref={slashMenuRef}
className="absolute bottom-full left-0 right-0 z-50 mb-2 overflow-hidden rounded-xl border border-[#dac1ba]/20 bg-white shadow-[0_18px_48px_rgba(27,28,26,0.12)]"
className="absolute bottom-full left-0 right-0 z-50 mb-2 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
>
<div className="max-h-[260px] overflow-y-auto py-1">
{filteredCommands.map((command, index) => (
@ -391,12 +425,12 @@ export function EmptySession() {
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 ${
index === slashSelectedIndex ? 'bg-[#F4F4F0]' : 'hover:bg-[#F8F7F4]'
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)]'
}`}
>
<span className="text-sm font-semibold text-[#1B1C1A]">/{command.name}</span>
<span className="truncate text-xs text-[#87736D]">{command.description}</span>
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
</button>
))}
</div>
@ -414,41 +448,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}
/>
</div>
<div className="flex items-center justify-between border-t border-[#dac1ba]/10 pt-3">
<div className="flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3">
<div className="flex items-center gap-2">
<div ref={plusMenuRef} className="relative">
<button
onClick={() => setPlusMenuOpen((prev) => !prev)}
aria-label="Open composer tools"
className="rounded-lg p-1.5 text-[#87736D] transition-colors hover:bg-[#F4F4F0]"
className="rounded-lg p-1.5 text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[18px]">add</span>
</button>
{plusMenuOpen && (
<div className="absolute bottom-full left-0 mb-2 w-[240px] rounded-xl border border-[#dac1ba]/20 bg-white py-1 shadow-[0_18px_48px_rgba(27,28,26,0.12)]">
<div className="absolute bottom-full left-0 mb-2 w-[240px] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]">
<button
onClick={() => {
fileInputRef.current?.click()
setPlusMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[#1B1C1A] transition-colors hover:bg-[#F8F7F4]"
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[18px] text-[#87736D]">attach_file</span>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
{t('empty.addFiles')}
</button>
<button
onClick={insertSlashCommand}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[#1B1C1A] transition-colors hover:bg-[#F8F7F4]"
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="w-5 text-center text-[18px] font-bold text-[#87736D]">/</span>
<span className="w-5 text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
{t('empty.slashCommands')}
</button>
</div>
@ -463,7 +497,7 @@ export function EmptySession() {
<button
onClick={handleSubmit}
disabled={(!input.trim() && attachments.length === 0) || isSubmitting}
className="flex w-[112px] items-center justify-center gap-1 rounded-lg bg-[var(--color-brand)] px-3 py-1.5 text-xs font-semibold text-white transition-all hover:opacity-90 disabled:opacity-30"
className="flex w-[112px] items-center justify-center gap-1 rounded-lg bg-[image:var(--gradient-btn-primary)] px-3 py-1.5 text-xs font-semibold text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-all hover:brightness-105 disabled:opacity-30"
>
{t('common.run')}
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
@ -482,4 +516,3 @@ export function EmptySession() {
</div>
)
}

View File

@ -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() {
<div
className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
isOfficialActive
? '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)] cursor-pointer'
}`}
>
@ -155,7 +155,7 @@ function ProviderSettings() {
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.officialName')}</span>
{isOfficialActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">{t('common.active')}</span>
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('common.active')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.officialDesc')}</div>
@ -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() {
</span>
)}
{isActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">{t('common.active')}</span>
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('common.active')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
@ -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<EffortLevel, string> = {
@ -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 (
<div className="max-w-xl">
{/* Appearance selector */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.appearanceTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.appearanceDescription')}</p>
<div className="flex gap-2 mb-8">
{THEMES.map(({ value, label }) => (
<button
key={value}
onClick={() => void setTheme(value)}
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
theme === value
? 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{label}
</button>
))}
</div>
{/* Language selector */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.languageTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.languageDescription')}</p>

View File

@ -19,7 +19,7 @@ export function ToolInspection() {
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-[var(--color-primary-fixed)] text-[#75331C] text-[10px] font-bold rounded uppercase tracking-widest">
<span className="px-2 py-0.5 bg-[var(--color-primary-fixed)] text-[var(--color-on-primary)] text-[10px] font-bold rounded uppercase tracking-widest">
{toolType}
</span>
<h1 className="font-[var(--font-headline)] font-extrabold text-2xl text-[var(--color-on-surface)] tracking-tight">
@ -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() {
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="mt-1 w-6 h-6 rounded bg-[var(--color-primary-fixed)] flex items-center justify-center">
<span className="material-symbols-outlined text-[14px] text-[#75331C]">
<span className="material-symbols-outlined text-[14px] text-[var(--color-on-primary)]">
psychology
</span>
</div>
@ -179,8 +179,8 @@ export function ToolInspection() {
</div>
<div className="flex items-start gap-3">
<div className="mt-1 w-6 h-6 rounded bg-[#D4EAB4] flex items-center justify-center">
<span className="material-symbols-outlined text-[14px] text-[#3B4C24]">
<div className="mt-1 w-6 h-6 rounded bg-[var(--color-diff-added-bg)] flex items-center justify-center">
<span className="material-symbols-outlined text-[14px] text-[var(--color-diff-added-text)]">
science
</span>
</div>

View File

@ -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: '',

View File

@ -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<void>
@ -272,6 +284,20 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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')
{

View File

@ -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<void>
setEffort: (level: EffortLevel) => Promise<void>
setLocale: (locale: Locale) => void
setTheme: (theme: ThemeMode) => Promise<void>
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
@ -38,24 +41,29 @@ export const useSettingsStore = create<SettingsStore>((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<SettingsStore>((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)
}
},
}))

View File

@ -9,8 +9,8 @@ type SkillStore = {
isDetailLoading: boolean
error: string | null
fetchSkills: () => Promise<void>
fetchSkillDetail: (source: string, name: string) => Promise<void>
fetchSkills: (cwd?: string) => Promise<void>
fetchSkillDetail: (source: string, name: string, cwd?: string) => Promise<void>
clearSelection: () => void
}
@ -21,10 +21,10 @@ export const useSkillStore = create<SkillStore>((set) => ({
isDetailLoading: false,
error: null,
fetchSkills: async () => {
fetchSkills: async (cwd) => {
set({ isLoading: true, error: null })
try {
const { skills } = await skillsApi.list()
const { skills } = await skillsApi.list(cwd)
set({ skills, isLoading: false })
} catch (err) {
set({
@ -34,10 +34,10 @@ export const useSkillStore = create<SkillStore>((set) => ({
}
},
fetchSkillDetail: async (source, name) => {
fetchSkillDetail: async (source, name, cwd) => {
set({ isDetailLoading: true, error: null })
try {
const { detail } = await skillsApi.detail(source, name)
const { detail } = await skillsApi.detail(source, name, cwd)
set({ selectedSkill: detail, isDetailLoading: false })
} catch (err) {
set({

View File

@ -27,6 +27,7 @@ function createMemberSessionState() {
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',

View File

@ -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<UIStore>((set) => ({
theme: 'light',
theme: getStoredTheme(),
sidebarOpen: true,
activeView: 'code',
pendingSettingsTab: null,
@ -42,14 +63,16 @@ export const useUIStore = create<UIStore>((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 }
})
},

View File

@ -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 */

View File

@ -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<ComputerUseGrantFlags>
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

View File

@ -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
}

View File

@ -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:

View File

@ -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,

View File

@ -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:

View File

@ -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<string, string>
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'])

View File

@ -7,6 +7,7 @@ import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import { SessionService } from '../services/sessionService.js'
import { clearCommandsCache } from '../../commands.js'
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
// ============================================================================
@ -45,6 +46,20 @@ async function writeSessionFile(
return filePath
}
async function writeSkill(
rootDir: string,
skillName: string,
description: string,
): Promise<void> {
const skillDir = path.join(rootDir, skillName)
await fs.mkdir(skillDir, { recursive: true })
await fs.writeFile(
path.join(skillDir, 'SKILL.md'),
['---', `description: ${description}`, '---', '', `# ${skillName}`].join('\n'),
'utf-8',
)
}
// Sample entries matching real CLI format
function makeSnapshotEntry(): Record<string, unknown> {
return {
@ -122,6 +137,7 @@ describe('SessionService', () => {
})
afterEach(async () => {
clearCommandsCache()
await cleanupTmpDir()
})
@ -692,6 +708,37 @@ describe('Sessions API', () => {
expect(detail.title).toBe('New Custom Title')
})
it('GET /api/sessions/:id/slash-commands should include user and project skills before CLI init', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const workDir = path.join(tmpDir, 'workspace', 'app')
await fs.mkdir(path.join(workDir, '.claude', 'skills'), { recursive: true })
await fs.mkdir(path.join(tmpDir, 'skills'), { recursive: true })
await writeSkill(path.join(tmpDir, 'skills'), 'user-skill', 'User skill description')
await writeSkill(path.join(workDir, '.claude', 'skills'), 'project-skill', 'Project skill description')
await writeSessionFile('-tmp-api-test', sessionId, [
makeSnapshotEntry(),
makeSessionMetaEntry(workDir),
])
clearCommandsCache()
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
commands: Array<{ name: string; description: string }>
}
expect(body.commands).toContainEqual(
expect.objectContaining({ name: 'user-skill', description: 'User skill description' }),
)
expect(body.commands).toContainEqual(
expect.objectContaining({ name: 'project-skill', description: 'Project skill description' }),
)
})
// --------------------------------------------------------------------------
// Conversations API via /api/sessions/:id/chat
// --------------------------------------------------------------------------

View File

@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { getCwdState, setCwdState } from '../../bootstrap/state.js'
import { handleSkillsApi } from '../api/skills.js'
let tmpHome: string
let originalHome: string | undefined
let originalUserProfile: string | undefined
let originalClaudeConfigDir: string | undefined
let originalCwdState: string
function makeRequest(urlStr: string): { req: Request; url: URL; segments: string[] } {
const url = new URL(urlStr, 'http://localhost:3456')
const req = new Request(url.toString(), { method: 'GET' })
return {
req,
url,
segments: url.pathname.split('/').filter(Boolean),
}
}
async function writeSkill(root: string, skillName: string, content: string): Promise<void> {
const skillDir = path.join(root, skillName)
await fs.mkdir(skillDir, { recursive: true })
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8')
}
describe('Skills API', () => {
beforeEach(async () => {
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-skills-test-'))
originalHome = process.env.HOME
originalUserProfile = process.env.USERPROFILE
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
originalCwdState = getCwdState()
process.env.HOME = tmpHome
process.env.USERPROFILE = tmpHome
process.env.CLAUDE_CONFIG_DIR = path.join(tmpHome, '.claude')
setCwdState(tmpHome)
})
afterEach(async () => {
if (originalHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = originalHome
}
if (originalUserProfile === undefined) {
delete process.env.USERPROFILE
} else {
process.env.USERPROFILE = originalUserProfile
}
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
}
setCwdState(originalCwdState)
await fs.rm(tmpHome, { recursive: true, force: true })
})
it('lists user and project skills for the requested cwd', async () => {
const userSkillsRoot = path.join(tmpHome, '.claude', 'skills')
const projectRoot = path.join(tmpHome, 'workspace')
const cwd = path.join(projectRoot, 'packages', 'app')
await writeSkill(
userSkillsRoot,
'user-skill',
['---', 'description: User scope', '---', '', '# User skill'].join('\n'),
)
await writeSkill(
path.join(projectRoot, '.claude', 'skills'),
'project-skill',
['---', 'description: Project scope', '---', '', '# Project skill'].join('\n'),
)
const { req, url, segments } = makeRequest(`/api/skills?cwd=${encodeURIComponent(cwd)}`)
const res = await handleSkillsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json() as { skills: Array<{ name: string; source: string }> }
expect(body.skills).toContainEqual(expect.objectContaining({ name: 'user-skill', source: 'user' }))
expect(body.skills).toContainEqual(expect.objectContaining({ name: 'project-skill', source: 'project' }))
})
it('resolves project skill details from the nearest project skills directory', async () => {
const projectRoot = path.join(tmpHome, 'workspace')
const nestedRoot = path.join(projectRoot, 'packages', 'app')
const nestedSkillsRoot = path.join(nestedRoot, '.claude', 'skills')
const parentSkillsRoot = path.join(projectRoot, '.claude', 'skills')
await writeSkill(
parentSkillsRoot,
'shared-skill',
['---', 'description: Parent version', '---', '', 'parent body'].join('\n'),
)
await writeSkill(
nestedSkillsRoot,
'shared-skill',
['---', 'description: Child version', '---', '', 'child body'].join('\n'),
)
const { req, url, segments } = makeRequest(
`/api/skills/detail?source=project&name=shared-skill&cwd=${encodeURIComponent(nestedRoot)}`,
)
const res = await handleSkillsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json() as {
detail: { meta: { description: string }; skillRoot: string; files: Array<{ path: string; body?: string }> }
}
expect(body.detail.meta.description).toBe('Child version')
expect(body.detail.skillRoot).toBe(path.join(nestedSkillsRoot, 'shared-skill'))
expect(body.detail.files).toContainEqual(
expect.objectContaining({ path: 'SKILL.md', body: 'child body' }),
)
})
})

View File

@ -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 },

View File

@ -15,6 +15,8 @@
import { sessionService } from '../services/sessionService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { getSlashCommands } from '../ws/handler.js'
import { getCommandName } from '../../commands.js'
import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
export async function handleSessionsApi(
req: Request,
@ -78,7 +80,7 @@ export async function handleSessionsApi(
{ status: 405 }
)
}
return Response.json({ commands: getSlashCommands(sessionId) })
return await getSessionSlashCommands(sessionId)
}
// Route to conversations handler if sub-resource is 'chat'
@ -167,6 +169,28 @@ async function deleteSession(sessionId: string): Promise<Response> {
return Response.json({ ok: true })
}
async function getSessionSlashCommands(sessionId: string): Promise<Response> {
const cachedCommands = getSlashCommands(sessionId)
if (cachedCommands.length > 0) {
return Response.json({ commands: cachedCommands })
}
const workDir = await sessionService.getSessionWorkDir(sessionId)
if (!workDir) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const commands = await getSkillDirCommands(workDir)
const slashCommands = commands
.filter((command) => command.userInvocable !== false)
.map((command) => ({
name: getCommandName(command),
description: command.description || '',
}))
return Response.json({ commands: slashCommands })
}
async function getGitInfo(sessionId: string): Promise<Response> {
const workDir = await sessionService.getSessionWorkDir(sessionId)
if (!workDir) {

View File

@ -6,10 +6,12 @@
* ?source=user&name=xxx
*/
import * as os from 'os'
import * as path from 'path'
import * as fs from 'fs/promises'
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { getProjectDirsUpToHome } from '../../utils/markdownConfigLoader.js'
import { getCwd } from '../../utils/cwd.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
// ─── Types ───────────────────────────────────────────────────────────────────
@ -25,6 +27,8 @@ type SkillMeta = {
hasDirectory: boolean
}
type SkillSource = SkillMeta['source']
type FileTreeNode = {
name: string
path: string
@ -74,7 +78,15 @@ function normalizeFrontmatter(content: string, sourcePath?: string): {
}
function getUserSkillsDir(): string {
return path.join(os.homedir(), '.claude', 'skills')
return path.join(getClaudeConfigHomeDir(), 'skills')
}
function getRequestedCwd(url: URL): string {
return url.searchParams.get('cwd') || getCwd()
}
function getProjectSkillsDirs(cwd: string): string[] {
return getProjectDirsUpToHome('skills', cwd)
}
async function loadSkillMeta(
@ -191,6 +203,60 @@ async function buildFileTree(
return { tree, files }
}
async function collectSkillsFromRoots(
skillRoots: string[],
source: SkillSource,
): Promise<SkillMeta[]> {
const skills: SkillMeta[] = []
const seenNames = new Set<string>()
for (const root of skillRoots) {
let entries: import('fs').Dirent[]
try {
entries = await fs.readdir(root, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.') || seenNames.has(entry.name)) {
continue
}
const meta = await loadSkillMeta(path.join(root, entry.name), entry.name, source)
if (!meta) continue
seenNames.add(entry.name)
skills.push(meta)
}
}
return skills
}
async function resolveSkillDir(
source: SkillSource,
name: string,
cwd: string,
): Promise<string | null> {
const skillRoots =
source === 'user' ? [getUserSkillsDir()] : getProjectSkillsDirs(cwd)
for (const root of skillRoots) {
const skillDir = path.join(root, name)
try {
const stat = await fs.stat(skillDir)
if (stat.isDirectory()) {
return skillDir
}
} catch {
// Try the next candidate root.
}
}
return null
}
// ─── Router ──────────────────────────────────────────────────────────────────
export async function handleSkillsApi(
@ -207,7 +273,7 @@ export async function handleSkillsApi(
switch (sub) {
case undefined:
return await listSkills()
return await listSkills(url)
case 'detail':
return await getSkillDetail(url)
default:
@ -220,25 +286,14 @@ export async function handleSkillsApi(
// ─── Handlers ────────────────────────────────────────────────────────────────
async function listSkills(): Promise<Response> {
const skillsDir = getUserSkillsDir()
const skills: SkillMeta[] = []
try {
const entries = await fs.readdir(skillsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
const meta = await loadSkillMeta(
path.join(skillsDir, entry.name),
entry.name,
'user',
)
if (meta) skills.push(meta)
}
} catch {
// skills dir doesn't exist — return empty list
}
async function listSkills(url: URL): Promise<Response> {
const cwd = getRequestedCwd(url)
const [userSkills, projectSkills] = await Promise.all([
collectSkillsFromRoots([getUserSkillsDir()], 'user'),
collectSkillsFromRoots(getProjectSkillsDirs(cwd), 'project'),
])
const skills = [...userSkills, ...projectSkills]
skills.sort((a, b) => a.name.localeCompare(b.name))
return Response.json({ skills })
}
@ -256,21 +311,17 @@ async function getSkillDetail(url: URL): Promise<Response> {
throw ApiError.badRequest('Invalid skill name')
}
let skillDir: string
if (source === 'user') {
skillDir = path.join(getUserSkillsDir(), name)
} else {
if (source !== 'user' && source !== 'project') {
throw ApiError.badRequest(`Unsupported source: ${source}`)
}
try {
const stat = await fs.stat(skillDir)
if (!stat.isDirectory()) throw new Error()
} catch {
const cwd = getRequestedCwd(url)
const skillDir = await resolveSkillDir(source, name, cwd)
if (!skillDir) {
throw ApiError.notFound(`Skill not found: ${name}`)
}
const meta = await loadSkillMeta(skillDir, name, source as 'user')
const meta = await loadSkillMeta(skillDir, name, source)
if (!meta) {
throw ApiError.notFound(`Skill missing SKILL.md: ${name}`)
}

View File

@ -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<typeof setTimeout>
}
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000
class ComputerUseApprovalService {
private pending = new Map<string, PendingApproval>()
async requestApproval(
sessionId: string,
request: CuPermissionRequest,
): Promise<CuPermissionResponse> {
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<CuPermissionResponse>((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()

View File

@ -119,7 +119,7 @@ export class ConversationService {
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDirpreload.ts
// chdir 后落到正确目录。
//
const childEnv = await this.buildChildEnv(workDir)
const childEnv = await this.buildChildEnv(workDir, sdkUrl)
let proc: ReturnType<typeof Bun.spawn>
try {
@ -471,7 +471,10 @@ export class ConversationService {
return args
}
private async buildChildEnv(workDir: string): Promise<Record<string, string>> {
private async buildChildEnv(
workDir: string,
sdkUrl?: string,
): Promise<Record<string, string>> {
// 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.

View File

@ -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<ComputerUseGrantFlags>
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
// ============================================================================

View File

@ -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<WebSocketData>,
message: Extract<ClientMessage, { type: 'computer_use_permission_response' }>
) {
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<WebSocketData>,
message: Extract<ClientMessage, { type: 'set_permission_mode' }>

View File

@ -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<string> {
const { stdout, code } = await execFileNoThrow('pbpaste', [], { useCwd: false })
if (code !== 0) throw new Error(`pbpaste exited with code ${code}`)
return stdout
return callPythonHelper<string>('read_clipboard', {})
}
async function writeClipboardViaPbcopy(text: string): Promise<void> {
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<void> {
@ -66,8 +64,11 @@ async function typeViaClipboard(text: string): Promise<void> {
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<string[]> {

File diff suppressed because one or more lines are too long

View File

@ -2433,9 +2433,11 @@ async function handleType(
// IME/input-source state can corrupt keystroke-by-keystroke typing even for
// plain ASCII. The save/restore + read-back-verify lives in the EXECUTOR,
// not here. Here we just route.
const hasNonControlText = /[^\r\n\t]/u.test(text);
const viaClipboard =
(text.includes("\n") ||
(adapter.executor.capabilities.platform === "darwin" && text.length > 1)) &&
(adapter.executor.capabilities.platform === "darwin" &&
hasNonControlText)) &&
overrides.grantFlags.clipboardWrite &&
subGates.clipboardPasteMultiline;