mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Merge remote-tracking branch 'origin/main' into fix/windows-computer-use-20260420
This commit is contained in:
commit
3dfcdbb405
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "claude-code-desktop",
|
||||
"private": true,
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "claude-code-desktop"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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()}`)
|
||||
},
|
||||
}
|
||||
|
||||
@ -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]">
|
||||
|
||||
@ -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}
|
||||
|
||||
174
desktop/src/components/chat/ComputerUsePermissionModal.test.tsx
Normal file
174
desktop/src/components/chat/ComputerUsePermissionModal.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
311
desktop/src/components/chat/ComputerUsePermissionModal.tsx
Normal file
311
desktop/src/components/chat/ComputerUsePermissionModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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} />
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '已回答',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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')
|
||||
{
|
||||
|
||||
@ -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)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -27,6 +27,7 @@ function createMemberSessionState() {
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
},
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
125
src/server/__tests__/skills.test.ts
Normal file
125
src/server/__tests__/skills.test.ts
Normal 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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
73
src/server/services/computerUseApprovalService.ts
Normal file
73
src/server/services/computerUseApprovalService.ts
Normal 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()
|
||||
@ -119,7 +119,7 @@ export class ConversationService {
|
||||
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.ts
|
||||
// chdir 后落到正确目录。
|
||||
//
|
||||
const childEnv = await this.buildChildEnv(workDir)
|
||||
const childEnv = await this.buildChildEnv(workDir, sdkUrl)
|
||||
|
||||
let proc: ReturnType<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.
|
||||
|
||||
@ -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
|
||||
// ============================================================================
|
||||
|
||||
@ -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' }>
|
||||
|
||||
@ -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
4
src/vendor/computer-use-mcp/toolCalls.ts
vendored
4
src/vendor/computer-use-mcp/toolCalls.ts
vendored
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user