mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33: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",
|
"name": "claude-code-desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "claude-code-desktop"
|
name = "claude-code-desktop"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
|
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
|
||||||
"productName": "Claude Code Haha",
|
"productName": "Claude Code Haha",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"identifier": "com.claude-code-haha.desktop",
|
"identifier": "com.claude-code-haha.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"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 { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
|
|
||||||
|
import { skillsApi } from '../api/skills'
|
||||||
|
|
||||||
|
vi.mock('../api/skills', () => ({
|
||||||
|
skillsApi: {
|
||||||
|
list: vi.fn(async () => ({ skills: [] })),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
// Import all pages
|
// Import all pages
|
||||||
import { EmptySession } from '../pages/EmptySession'
|
import { EmptySession } from '../pages/EmptySession'
|
||||||
import { ActiveSession } from '../pages/ActiveSession'
|
import { ActiveSession } from '../pages/ActiveSession'
|
||||||
@ -13,6 +21,7 @@ import { ToolInspection } from '../pages/ToolInspection'
|
|||||||
import { Sidebar } from '../components/layout/Sidebar'
|
import { Sidebar } from '../components/layout/Sidebar'
|
||||||
import { UserMessage } from '../components/chat/UserMessage'
|
import { UserMessage } from '../components/chat/UserMessage'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
import { useTabStore } from '../stores/tabStore'
|
import { useTabStore } from '../stores/tabStore'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -20,6 +29,38 @@ import { useTabStore } from '../stores/tabStore'
|
|||||||
* and contain key structural elements from the prototype.
|
* and contain key structural elements from the prototype.
|
||||||
*/
|
*/
|
||||||
describe('Content-only pages render without errors', () => {
|
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', () => {
|
it('EmptySession renders mascot and composer', () => {
|
||||||
const { container } = render(<EmptySession />)
|
const { container } = render(<EmptySession />)
|
||||||
expect(container.querySelector('textarea')).toBeInTheDocument()
|
expect(container.querySelector('textarea')).toBeInTheDocument()
|
||||||
@ -49,6 +90,7 @@ describe('Content-only pages render without errors', () => {
|
|||||||
activeToolName: null,
|
activeToolName: null,
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
@ -62,13 +104,72 @@ describe('Content-only pages render without errors', () => {
|
|||||||
// With empty messages, the hero is shown
|
// With empty messages, the hero is shown
|
||||||
expect(container.innerHTML).toContain('New session')
|
expect(container.innerHTML).toContain('New session')
|
||||||
// ChatInput has a textarea
|
// 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')
|
expect(container.innerHTML).not.toContain('Preview')
|
||||||
// Cleanup
|
// Cleanup
|
||||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||||
useChatStore.setState({ sessions: {} })
|
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', () => {
|
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' }] })
|
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] })
|
||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
@ -83,6 +184,7 @@ describe('Content-only pages render without errors', () => {
|
|||||||
activeToolName: null,
|
activeToolName: null,
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import '@testing-library/jest-dom'
|
|||||||
import { Settings } from '../pages/Settings'
|
import { Settings } from '../pages/Settings'
|
||||||
import { useSkillStore } from '../stores/skillStore'
|
import { useSkillStore } from '../stores/skillStore'
|
||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
|
import { useTabStore, SETTINGS_TAB_ID } from '../stores/tabStore'
|
||||||
|
|
||||||
vi.mock('../api/agents', () => ({
|
vi.mock('../api/agents', () => ({
|
||||||
agentsApi: {
|
agentsApi: {
|
||||||
@ -60,6 +62,26 @@ describe('Settings > Skills tab', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
useSettingsStore.setState({ locale: 'en' })
|
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({
|
useSkillStore.setState({
|
||||||
skills: [],
|
skills: [],
|
||||||
selectedSkill: null,
|
selectedSkill: null,
|
||||||
@ -106,6 +128,29 @@ describe('Settings > Skills tab', () => {
|
|||||||
expect(screen.getByText('Second skill description')).toBeInTheDocument()
|
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', () => {
|
it('opens skill detail with metadata cards and parsed markdown body', () => {
|
||||||
useSkillStore.setState({
|
useSkillStore.setState({
|
||||||
selectedSkill: {
|
selectedSkill: {
|
||||||
|
|||||||
@ -2,10 +2,18 @@ import { api } from './client'
|
|||||||
import type { SkillMeta, SkillDetail } from '../types/skill'
|
import type { SkillMeta, SkillDetail } from '../types/skill'
|
||||||
|
|
||||||
export const skillsApi = {
|
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) =>
|
detail: (source: string, name: string, cwd?: string) => {
|
||||||
api.get<{ detail: SkillDetail }>(
|
const query = new URLSearchParams({
|
||||||
`/api/skills/detail?source=${encodeURIComponent(source)}&name=${encodeURIComponent(name)}`,
|
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
|
data?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput() {
|
type ChatInputProps = {
|
||||||
|
variant?: 'default' | 'hero'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
@ -62,6 +66,7 @@ export function ChatInput() {
|
|||||||
const isActive = chatState !== 'idle'
|
const isActive = chatState !== 'idle'
|
||||||
const isWorkspaceMissing = activeSession?.workDirExists === false
|
const isWorkspaceMissing = activeSession?.workDirExists === false
|
||||||
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0))
|
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0))
|
||||||
|
const isHeroComposer = variant === 'hero' && !isMemberSession
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
textareaRef.current?.focus()
|
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 (
|
return (
|
||||||
<div className="bg-[#FAF9F5] px-4 py-4">
|
<div className={isHeroComposer ? 'bg-[var(--color-surface)] px-8 pb-4' : 'bg-[var(--color-surface)] px-4 py-4'}>
|
||||||
<div className="mx-auto max-w-[860px]">
|
<div className={isHeroComposer ? 'mx-auto flex w-full max-w-3xl flex-col gap-2' : 'mx-auto max-w-[860px]'}>
|
||||||
<div
|
<div
|
||||||
className="relative rounded-xl border border-[#dac1ba]/15 bg-white p-4 transition-colors focus-within:border-[var(--color-border-focus)]"
|
className={isHeroComposer
|
||||||
style={{ boxShadow: '0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08)' }}
|
? '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()}
|
onDragOver={(event) => event.preventDefault()}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
@ -437,18 +455,16 @@ export function ChatInput() {
|
|||||||
ref={(el) => { slashItemRefs.current[index] = el }}
|
ref={(el) => { slashItemRefs.current[index] = el }}
|
||||||
onClick={() => selectSlashCommand(command.name)}
|
onClick={() => selectSlashCommand(command.name)}
|
||||||
onMouseEnter={() => setSlashSelectedIndex(index)}
|
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
|
index === slashSelectedIndex
|
||||||
? 'bg-[var(--color-surface-hover)]'
|
? 'bg-[var(--color-surface-hover)]'
|
||||||
: 'hover: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)]">
|
||||||
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">
|
/{command.name}
|
||||||
/{command.name}
|
</span>
|
||||||
</span>
|
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">
|
||||||
</div>
|
|
||||||
<span className="truncate text-right text-xs text-[var(--color-text-tertiary)]">
|
|
||||||
{command.description}
|
{command.description}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -466,32 +482,50 @@ export function ChatInput() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="px-3 pt-3">
|
isHeroComposer ? (
|
||||||
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
|
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
|
||||||
</div>
|
) : (
|
||||||
|
<div className="px-3 pt-3">
|
||||||
|
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<textarea
|
{isHeroComposer ? (
|
||||||
ref={textareaRef}
|
<div className="flex items-start gap-3">
|
||||||
value={input}
|
<textarea
|
||||||
onChange={handleInputChange}
|
ref={textareaRef}
|
||||||
onKeyDown={handleKeyDown}
|
value={input}
|
||||||
onCompositionStart={() => { composingRef.current = true }}
|
onChange={handleInputChange}
|
||||||
onCompositionEnd={() => { composingRef.current = false }}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onCompositionStart={() => { composingRef.current = true }}
|
||||||
placeholder={
|
onCompositionEnd={() => { composingRef.current = false }}
|
||||||
isWorkspaceMissing
|
onPaste={handlePaste}
|
||||||
? t('chat.placeholderMissing')
|
placeholder={composerPlaceholder}
|
||||||
: isMemberSession
|
disabled={isWorkspaceMissing}
|
||||||
? t('teams.memberPlaceholder')
|
rows={2}
|
||||||
: t('chat.placeholder')
|
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"
|
||||||
}
|
/>
|
||||||
disabled={isWorkspaceMissing}
|
</div>
|
||||||
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"
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
{!isMemberSession && (
|
{!isMemberSession && (
|
||||||
<>
|
<>
|
||||||
@ -499,29 +533,29 @@ export function ChatInput() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPlusMenuOpen((value) => !value)}
|
onClick={() => setPlusMenuOpen((value) => !value)}
|
||||||
aria-label="Open composer tools"
|
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>
|
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{plusMenuOpen && (
|
{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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
fileInputRef.current?.click()
|
fileInputRef.current?.click()
|
||||||
setPlusMenuOpen(false)
|
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="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>
|
||||||
<button
|
<button
|
||||||
onClick={insertSlashCommand}
|
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="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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -538,8 +572,10 @@ export function ChatInput() {
|
|||||||
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
|
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
|
||||||
disabled={!isMemberSession && isActive ? false : !canSubmit}
|
disabled={!isMemberSession && isActive ? false : !canSubmit}
|
||||||
title={!isMemberSession && isActive ? t('chat.stopTitle') : undefined}
|
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 ${
|
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)]' : 'bg-[var(--color-brand)]'
|
!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]">
|
<span className="material-symbols-outlined text-[14px]">
|
||||||
|
|||||||
@ -16,30 +16,30 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
const warmCodeTheme = {
|
const warmCodeTheme = {
|
||||||
name: 'warm-code',
|
name: 'warm-code',
|
||||||
type: 'light' as const,
|
type: 'dark' as const,
|
||||||
fg: '#24201E',
|
fg: 'var(--color-code-fg)',
|
||||||
bg: 'transparent',
|
bg: 'transparent',
|
||||||
tokenColors: [
|
tokenColors: [
|
||||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#5C6B7A', fontStyle: 'italic' } },
|
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: 'var(--color-code-comment)', fontStyle: 'italic' } },
|
||||||
{ scope: ['string', 'string.quoted', 'string.template', 'string.other.link'], settings: { foreground: '#437220' } },
|
{ scope: ['string', 'string.quoted', 'string.template', 'string.other.link'], settings: { foreground: 'var(--color-code-string)' } },
|
||||||
{ scope: ['string.regexp'], settings: { foreground: '#C15F3C' } },
|
{ scope: ['string.regexp'], settings: { foreground: 'var(--color-primary-container)' } },
|
||||||
{ scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: '#B8533B' } },
|
{ scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: 'var(--color-code-keyword)' } },
|
||||||
{ scope: ['keyword.operator'], settings: { foreground: '#B8533B' } },
|
{ scope: ['keyword.operator'], settings: { foreground: 'var(--color-code-keyword)' } },
|
||||||
{ scope: ['entity.name.function', 'support.function'], settings: { foreground: '#1D5A8C' } },
|
{ 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: '#7E5520' } },
|
{ 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: '#1B7A6A' } },
|
{ scope: ['entity.name.type.parameter'], settings: { foreground: 'var(--color-code-number)' } },
|
||||||
{ scope: ['variable', 'variable.other', 'variable.other.readwrite'], settings: { foreground: '#24201E' } },
|
{ scope: ['variable', 'variable.other', 'variable.other.readwrite'], settings: { foreground: 'var(--color-code-fg)' } },
|
||||||
{ scope: ['variable.parameter'], settings: { foreground: '#5C3D2E' } },
|
{ scope: ['variable.parameter'], settings: { foreground: 'var(--color-code-parameter)' } },
|
||||||
{ scope: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key'], settings: { foreground: '#7A3E20' } },
|
{ 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: '#7E5520' } },
|
{ scope: ['variable.other.constant', 'variable.other.enummember'], settings: { foreground: 'var(--color-code-type)' } },
|
||||||
{ scope: ['constant.numeric', 'constant.language'], settings: { foreground: '#1B7A6A' } },
|
{ scope: ['constant.numeric', 'constant.language'], settings: { foreground: 'var(--color-code-number)' } },
|
||||||
{ scope: ['punctuation', 'meta.brace', 'meta.bracket'], settings: { foreground: '#5C504A' } },
|
{ scope: ['punctuation', 'meta.brace', 'meta.bracket'], settings: { foreground: 'var(--color-code-punctuation)' } },
|
||||||
{ scope: ['entity.name.tag', 'punctuation.definition.tag'], settings: { foreground: '#B8533B' } },
|
{ scope: ['entity.name.tag', 'punctuation.definition.tag'], settings: { foreground: 'var(--color-code-keyword)' } },
|
||||||
{ scope: ['entity.other.attribute-name'], settings: { foreground: '#7A3E20' } },
|
{ scope: ['entity.other.attribute-name'], settings: { foreground: 'var(--color-code-property)' } },
|
||||||
{ scope: ['meta.decorator', 'punctuation.decorator'], settings: { foreground: '#7E5520' } },
|
{ scope: ['meta.decorator', 'punctuation.decorator'], settings: { foreground: 'var(--color-code-type)' } },
|
||||||
{ scope: ['markup.inserted', 'punctuation.definition.inserted'], settings: { foreground: '#1A7F37' } },
|
{ scope: ['markup.inserted', 'punctuation.definition.inserted'], settings: { foreground: 'var(--color-code-inserted)' } },
|
||||||
{ scope: ['markup.deleted', 'punctuation.definition.deleted'], settings: { foreground: '#CF222E' } },
|
{ scope: ['markup.deleted', 'punctuation.definition.deleted'], settings: { foreground: 'var(--color-code-deleted)' } },
|
||||||
{ scope: ['markup.heading', 'entity.name.section'], settings: { foreground: '#1D5A8C', fontStyle: 'bold' } },
|
{ scope: ['markup.heading', 'entity.name.section'], settings: { foreground: 'var(--color-code-function)', fontStyle: 'bold' } },
|
||||||
{ scope: ['markup.bold'], settings: { fontStyle: 'bold' } },
|
{ scope: ['markup.bold'], settings: { fontStyle: 'bold' } },
|
||||||
{ scope: ['markup.italic'], settings: { fontStyle: 'italic' } },
|
{ scope: ['markup.italic'], settings: { fontStyle: 'italic' } },
|
||||||
],
|
],
|
||||||
@ -74,7 +74,7 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
|
|||||||
}, [code, language])
|
}, [code, language])
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Plain-text fallback shown until Shiki finishes highlighting */}
|
||||||
{!loaded && (
|
{!loaded && (
|
||||||
<pre
|
<pre
|
||||||
@ -86,7 +86,7 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
|
|||||||
lineHeight: '1.45',
|
lineHeight: '1.45',
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
color: '#24201E',
|
color: 'var(--color-code-fg)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{code}
|
{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 */
|
/** Shared warm syntax theme — must stay in sync with CodeViewer */
|
||||||
const warmSyntaxTheme: PrismTheme = {
|
const warmSyntaxTheme: PrismTheme = {
|
||||||
plain: {
|
plain: {
|
||||||
color: '#24201E',
|
color: 'var(--color-code-fg)',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
styles: [
|
styles: [
|
||||||
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: '#8C7E75', fontStyle: 'italic' as const } },
|
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' as const } },
|
||||||
{ types: ['string', 'attr-value', 'template-string'], style: { color: '#437220' } },
|
{ types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
|
||||||
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: '#B8533B' } },
|
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
|
||||||
{ types: ['function'], style: { color: '#1D5A8C' } },
|
{ types: ['function'], style: { color: 'var(--color-code-function)' } },
|
||||||
{ types: ['tag'], style: { color: '#B8533B' } },
|
{ types: ['tag'], style: { color: 'var(--color-code-keyword)' } },
|
||||||
{ types: ['number', 'boolean'], style: { color: '#1B7A6A' } },
|
{ types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } },
|
||||||
{ types: ['operator'], style: { color: '#24201E' } },
|
{ types: ['operator'], style: { color: 'var(--color-code-fg)' } },
|
||||||
{ types: ['punctuation'], style: { color: '#5C504A' } },
|
{ types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } },
|
||||||
{ types: ['variable', 'parameter'], style: { color: '#24201E' } },
|
{ types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } },
|
||||||
{ types: ['property', 'attr-name'], style: { color: '#7A3E20' } },
|
{ types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } },
|
||||||
{ types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: '#7E5520' } },
|
{ types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } },
|
||||||
{ types: ['regex'], style: { color: '#C15F3C' } },
|
{ types: ['regex'], style: { color: 'var(--color-primary-container)' } },
|
||||||
{ types: ['inserted'], style: { color: '#1A7F37' } },
|
{ types: ['inserted'], style: { color: 'var(--color-code-inserted)' } },
|
||||||
{ types: ['deleted'], style: { color: '#CF222E' } },
|
{ types: ['deleted'], style: { color: 'var(--color-code-deleted)' } },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,30 +65,30 @@ function highlightSyntax(str: string, language: string) {
|
|||||||
const diffStyles = {
|
const diffStyles = {
|
||||||
variables: {
|
variables: {
|
||||||
light: {
|
light: {
|
||||||
diffViewerBackground: '#FDFCF9',
|
diffViewerBackground: 'var(--color-code-bg)',
|
||||||
diffViewerColor: '#3B3330',
|
diffViewerColor: 'var(--color-code-fg)',
|
||||||
addedBackground: '#E8F5E2',
|
addedBackground: 'var(--color-diff-added-bg)',
|
||||||
addedColor: '#3B3330',
|
addedColor: 'var(--color-code-fg)',
|
||||||
removedBackground: '#FDECEA',
|
removedBackground: 'var(--color-diff-removed-bg)',
|
||||||
removedColor: '#3B3330',
|
removedColor: 'var(--color-code-fg)',
|
||||||
wordAddedBackground: '#B8E4A8',
|
wordAddedBackground: 'var(--color-diff-added-word)',
|
||||||
wordRemovedBackground: '#F5B8B4',
|
wordRemovedBackground: 'var(--color-diff-removed-word)',
|
||||||
addedGutterBackground: '#D4EDCA',
|
addedGutterBackground: 'var(--color-diff-added-gutter)',
|
||||||
removedGutterBackground: '#F9D4D0',
|
removedGutterBackground: 'var(--color-diff-removed-gutter)',
|
||||||
gutterBackground: '#F4F4F0',
|
gutterBackground: 'var(--color-surface-container-low)',
|
||||||
gutterBackgroundDark: '#EFEEEA',
|
gutterBackgroundDark: 'var(--color-surface-container)',
|
||||||
highlightBackground: '#FFF5D6',
|
highlightBackground: 'var(--color-diff-highlight-bg)',
|
||||||
highlightGutterBackground: '#FFECB3',
|
highlightGutterBackground: 'var(--color-diff-highlight-gutter)',
|
||||||
codeFoldGutterBackground: '#E4EDF6',
|
codeFoldGutterBackground: 'var(--color-surface-container-high)',
|
||||||
codeFoldBackground: '#EDF4FB',
|
codeFoldBackground: 'var(--color-surface-container-highest)',
|
||||||
emptyLineBackground: '#F4F4F0',
|
emptyLineBackground: 'var(--color-surface-container-low)',
|
||||||
gutterColor: '#87736D',
|
gutterColor: 'var(--color-text-tertiary)',
|
||||||
addedGutterColor: '#1A7F37',
|
addedGutterColor: 'var(--color-diff-added-text)',
|
||||||
removedGutterColor: '#CF222E',
|
removedGutterColor: 'var(--color-diff-removed-text)',
|
||||||
codeFoldContentColor: '#87736D',
|
codeFoldContentColor: 'var(--color-text-tertiary)',
|
||||||
diffViewerTitleBackground: '#F4F4F0',
|
diffViewerTitleBackground: 'var(--color-diff-title-bg)',
|
||||||
diffViewerTitleColor: '#87736D',
|
diffViewerTitleColor: 'var(--color-diff-title-color)',
|
||||||
diffViewerTitleBorderColor: '#DAC1BA',
|
diffViewerTitleBorderColor: 'var(--color-diff-title-border)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
diffContainer: {
|
diffContainer: {
|
||||||
@ -128,8 +128,8 @@ export function DiffViewer({ filePath, oldString, newString }: Props) {
|
|||||||
{filePath}
|
{filePath}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-2 text-[10px] uppercase tracking-[0.14em]">
|
<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-[var(--color-diff-added-bg)] px-2 py-0.5 text-[var(--color-diff-added-text)]">+{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-removed-bg)] px-2 py-0.5 text-[var(--color-diff-removed-text)]">-{deletions}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CopyButton
|
<CopyButton
|
||||||
@ -149,7 +149,7 @@ export function DiffViewer({ filePath, oldString, newString }: Props) {
|
|||||||
renderContent={(str) => highlightSyntax(str, language)}
|
renderContent={(str) => highlightSyntax(str, language)}
|
||||||
hideLineNumbers={false}
|
hideLineNumbers={false}
|
||||||
styles={diffStyles}
|
styles={diffStyles}
|
||||||
useDarkTheme={false}
|
useDarkTheme={document.documentElement.getAttribute('data-theme') === 'dark'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ function makeSessionState(overrides: Partial<PerSessionState> = {}): PerSessionS
|
|||||||
activeToolName: null,
|
activeToolName: null,
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
|
|||||||
@ -227,10 +227,10 @@ export const MessageBlock = memo(function MessageBlock({
|
|||||||
message.message.trim() !== '' &&
|
message.message.trim() !== '' &&
|
||||||
message.message !== displayMessage
|
message.message !== displayMessage
|
||||||
return (
|
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}
|
<strong>Error:</strong> {displayMessage}
|
||||||
{showRawDetail && (
|
{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}
|
{message.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -18,17 +18,17 @@ type Props = {
|
|||||||
* Uses Material Symbols Outlined names.
|
* Uses Material Symbols Outlined names.
|
||||||
*/
|
*/
|
||||||
const TOOL_META: Record<string, { icon: string; label: string; color: string }> = {
|
const TOOL_META: Record<string, { icon: string; label: string; color: string }> = {
|
||||||
Bash: { icon: 'terminal', label: 'Bash', color: '#CA8A04' },
|
Bash: { icon: 'terminal', label: 'Bash', color: 'var(--color-warning)' },
|
||||||
Edit: { icon: 'edit_note', label: 'Edit File', color: '#8F482F' },
|
Edit: { icon: 'edit_note', label: 'Edit File', color: 'var(--color-brand)' },
|
||||||
Write: { icon: 'edit_document', label: 'Write File', color: '#16A34A' },
|
Write: { icon: 'edit_document', label: 'Write File', color: 'var(--color-success)' },
|
||||||
Read: { icon: 'description', label: 'Read File', color: '#2D628F' },
|
Read: { icon: 'description', label: 'Read File', color: 'var(--color-secondary)' },
|
||||||
Glob: { icon: 'search', label: 'Glob Search', color: '#2D628F' },
|
Glob: { icon: 'search', label: 'Glob Search', color: 'var(--color-secondary)' },
|
||||||
Grep: { icon: 'find_in_page', label: 'Grep Search', color: '#2D628F' },
|
Grep: { icon: 'find_in_page', label: 'Grep Search', color: 'var(--color-secondary)' },
|
||||||
Agent: { icon: 'smart_toy', label: 'Agent', color: '#7C3AED' },
|
Agent: { icon: 'smart_toy', label: 'Agent', color: 'var(--color-tertiary)' },
|
||||||
WebSearch: { icon: 'travel_explore', label: 'Web Search', color: '#2D628F' },
|
WebSearch: { icon: 'travel_explore', label: 'Web Search', color: 'var(--color-secondary)' },
|
||||||
WebFetch: { icon: 'cloud_download', label: 'Web Fetch', color: '#2D628F' },
|
WebFetch: { icon: 'cloud_download', label: 'Web Fetch', color: 'var(--color-secondary)' },
|
||||||
NotebookEdit: { icon: 'note', label: 'Notebook Edit', color: '#8F482F' },
|
NotebookEdit: { icon: 'note', label: 'Notebook Edit', color: 'var(--color-brand)' },
|
||||||
Skill: { icon: 'auto_awesome', label: 'Skill', color: '#7C3AED' },
|
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') {
|
if (toolName === 'Bash' && typeof obj.command === 'string') {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto rounded-[var(--radius-md)] bg-[#1e1e1e] px-3 py-2.5">
|
<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-[#d4d4d4] whitespace-pre-wrap break-words">
|
<pre className="font-[var(--font-mono)] text-[11px] leading-[1.3] text-[var(--color-terminal-fg)] whitespace-pre-wrap break-words">
|
||||||
<span className="text-[#28c840] select-none">$ </span>{obj.command}
|
<span className="text-[var(--color-terminal-accent)] select-none">$ </span>{obj.command}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -119,7 +119,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
|
|||||||
const isPending = pendingPermission?.requestId === requestId
|
const isPending = pendingPermission?.requestId === requestId
|
||||||
const [showRaw, setShowRaw] = useState(false)
|
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 details = extractToolDetails(toolName, input, t)
|
||||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
|
const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
|
||||||
const preview = renderPermissionPreview(toolName, input)
|
const preview = renderPermissionPreview(toolName, input)
|
||||||
@ -215,7 +215,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{allowRawToggle && showRaw && (
|
{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}
|
{rawInput}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -14,20 +14,20 @@ export function TerminalChrome({ title, children, className = '' }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className={`overflow-hidden rounded-2xl border border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-dim)] ${className}`}>
|
<div className={`overflow-hidden rounded-2xl border border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-dim)] ${className}`}>
|
||||||
{/* Title bar with traffic lights */}
|
{/* 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="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-[var(--color-terminal-danger)]" />
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-[#febc2e]" />
|
<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-[#28c840]" />
|
<div className="w-2.5 h-2.5 rounded-full bg-[var(--color-terminal-accent)]" />
|
||||||
</div>
|
</div>
|
||||||
{title && (
|
{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}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="bg-[#1e1e1e] text-[#d4d4d4]">
|
<div className="bg-[var(--color-terminal-bg)] text-[var(--color-terminal-fg)]">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</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 preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t])
|
||||||
const details = useMemo(() => renderDetails(toolName, obj, t), [obj, 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 (
|
return (
|
||||||
<div className={`overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)] ${
|
<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}
|
{outputSummary}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{result?.isError && (
|
{result?.isError && (
|
||||||
<span className="material-symbols-outlined shrink-0 text-[14px] text-[var(--color-error)]">error</span>
|
<span className="material-symbols-outlined shrink-0 text-[14px] text-[var(--color-error)]">error</span>
|
||||||
)}
|
)}
|
||||||
{expandable && (
|
{expandable && (
|
||||||
@ -115,8 +116,8 @@ function renderPreview(
|
|||||||
if (toolName === 'Bash' && typeof obj.command === 'string') {
|
if (toolName === 'Bash' && typeof obj.command === 'string') {
|
||||||
return (
|
return (
|
||||||
<TerminalChrome title={typeof obj.description === 'string' ? obj.description : filePath}>
|
<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]">
|
<div className="px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.3] text-[var(--color-terminal-fg)]">
|
||||||
<span className="text-[#28c840]">$</span> {obj.command}
|
<span className="text-[var(--color-terminal-accent)]">$</span> {obj.command}
|
||||||
</div>
|
</div>
|
||||||
</TerminalChrome>
|
</TerminalChrome>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -51,7 +51,7 @@ export function ToolResultBlock({ content, isError, toolName, standalone = true
|
|||||||
<span className={`px-2 py-0.5 rounded-full text-[9px] ${
|
<span className={`px-2 py-0.5 rounded-full text-[9px] ${
|
||||||
isError
|
isError
|
||||||
? 'bg-[var(--color-error)]/10'
|
? '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')}
|
{isError ? t('tool.error') : t('tool.success')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -40,7 +40,7 @@ describe('chat blocks', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole('button'))
|
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')
|
expect(container.textContent).not.toContain('const answer = 42')
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -62,6 +62,27 @@ describe('chat blocks', () => {
|
|||||||
expect(container.textContent).not.toContain('file-a')
|
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', () => {
|
it('shows a diff preview for edit permission requests', () => {
|
||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
sessions: {
|
sessions: {
|
||||||
@ -83,6 +104,7 @@ describe('chat blocks', () => {
|
|||||||
new_string: 'const count = 2',
|
new_string: 'const count = 2',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
|
|||||||
@ -39,14 +39,18 @@ import { useUIStore } from '../../stores/uiStore'
|
|||||||
|
|
||||||
describe('Sidebar', () => {
|
describe('Sidebar', () => {
|
||||||
const connectToSession = vi.fn()
|
const connectToSession = vi.fn()
|
||||||
|
const disconnectSession = vi.fn()
|
||||||
const fetchSessions = vi.fn()
|
const fetchSessions = vi.fn()
|
||||||
const createSession = vi.fn()
|
const createSession = vi.fn()
|
||||||
|
const deleteSession = vi.fn()
|
||||||
const addToast = vi.fn()
|
const addToast = vi.fn()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
connectToSession.mockReset()
|
connectToSession.mockReset()
|
||||||
|
disconnectSession.mockReset()
|
||||||
fetchSessions.mockReset()
|
fetchSessions.mockReset()
|
||||||
createSession.mockReset()
|
createSession.mockReset()
|
||||||
|
deleteSession.mockReset()
|
||||||
addToast.mockReset()
|
addToast.mockReset()
|
||||||
|
|
||||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||||
@ -59,9 +63,11 @@ describe('Sidebar', () => {
|
|||||||
availableProjects: [],
|
availableProjects: [],
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
createSession,
|
createSession,
|
||||||
|
deleteSession,
|
||||||
})
|
})
|
||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
connectToSession,
|
connectToSession,
|
||||||
|
disconnectSession,
|
||||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||||
useUIStore.setState({
|
useUIStore.setState({
|
||||||
addToast,
|
addToast,
|
||||||
@ -111,4 +117,42 @@ describe('Sidebar', () => {
|
|||||||
|
|
||||||
expect(useTabStore.getState().tabs).toEqual([])
|
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 renameSession = useSessionStore((s) => s.renameSession)
|
||||||
const addToast = useUIStore((s) => s.addToast)
|
const addToast = useUIStore((s) => s.addToast)
|
||||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||||
|
const closeTab = useTabStore((s) => s.closeTab)
|
||||||
|
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
|
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||||
@ -64,7 +66,9 @@ export function Sidebar() {
|
|||||||
const handleDelete = useCallback(async (id: string) => {
|
const handleDelete = useCallback(async (id: string) => {
|
||||||
setContextMenu(null)
|
setContextMenu(null)
|
||||||
await deleteSession(id)
|
await deleteSession(id)
|
||||||
}, [deleteSession])
|
disconnectSession(id)
|
||||||
|
closeTab(id)
|
||||||
|
}, [closeTab, deleteSession, disconnectSession])
|
||||||
|
|
||||||
const handleStartRename = useCallback((id: string, currentTitle: string) => {
|
const handleStartRename = useCallback((id: string, currentTitle: string) => {
|
||||||
setContextMenu(null)
|
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={`px-3 pb-1.5 flex items-center justify-between ${isTauri && !isWindows ? 'pt-[44px]' : 'pt-3'}`}>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<img src="/app-icon.jpg" alt="" className="h-8 w-8 rounded-lg flex-shrink-0" />
|
<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" }}>
|
<span className="text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
|
||||||
Claude Code <span className="text-[#D97757]">Haha</span>
|
Claude Code <span className="text-[var(--color-primary-container)]">Haha</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@ -77,7 +77,7 @@ export function WindowControls() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => runWindowAction(() => win.close())}
|
onClick={() => runWindowAction(() => win.close())}
|
||||||
aria-label="Close window"
|
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">
|
<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" />
|
<line x1="0" y1="0" x2="10" y2="10" />
|
||||||
|
|||||||
@ -78,4 +78,23 @@ describe('MarkdownRenderer', () => {
|
|||||||
)
|
)
|
||||||
expect(screen.queryByTestId('mermaid-renderer')).not.toBeInTheDocument()
|
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 })
|
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[] } {
|
function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[] } {
|
||||||
pendingCodeBlocks = []
|
pendingCodeBlocks = []
|
||||||
const html = marked.parse(content) as string
|
const html = marked.parse(content) as string
|
||||||
@ -76,19 +105,20 @@ function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[]
|
|||||||
return { html, codeBlocks }
|
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-headings:text-[var(--color-text-primary)] prose-headings:font-semibold
|
||||||
prose-p:my-2 prose-p:leading-relaxed
|
prose-p:my-2 prose-p:leading-relaxed
|
||||||
prose-p:break-words
|
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-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-a:text-[var(--color-text-accent)] prose-a:no-underline hover:prose-a:underline
|
||||||
prose-strong:text-[var(--color-text-primary)]
|
prose-strong:text-[var(--color-text-primary)]
|
||||||
prose-ul:my-2 prose-ol:my-2
|
prose-ul:my-2 prose-ol:my-2
|
||||||
prose-li:my-0.5
|
prose-li:my-0.5
|
||||||
prose-table:w-full prose-table:table-auto prose-table:text-sm
|
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:whitespace-normal prose-th:break-words prose-th:align-top
|
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-[var(--color-border)] prose-td:whitespace-normal prose-td:break-words prose-td:align-top`
|
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 = `
|
const DOCUMENT_PROSE_CLASSES = `
|
||||||
prose-p:text-[15px] prose-p:leading-7
|
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-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-ol:pl-5 prose-ol:[&>li]:marker:text-[var(--color-text-tertiary)]
|
||||||
prose-li:my-1.5
|
prose-li:my-1.5
|
||||||
prose-table:my-5
|
prose-table:my-0`
|
||||||
prose-th:text-left
|
|
||||||
prose-td:bg-[var(--color-surface)]`
|
|
||||||
|
|
||||||
function getProseClasses(variant: 'default' | 'document', className?: string) {
|
function getProseClasses(variant: 'default' | 'document', className?: string) {
|
||||||
return [BASE_PROSE_CLASSES, variant === 'document' ? DOCUMENT_PROSE_CLASSES : '', className ?? '']
|
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) {
|
if (codeBlocks.length === 0) {
|
||||||
const cleanHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['use'], ADD_ATTR: ['xlink:href'] })
|
const cleanHtml = enhanceMarkdownHtml(html)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={proseClasses}
|
className={proseClasses}
|
||||||
@ -184,7 +212,7 @@ export function MarkdownRenderer({ content, variant = 'default', className }: Pr
|
|||||||
<div className={proseClasses} onClick={handleClick}>
|
<div className={proseClasses} onClick={handleClick}>
|
||||||
{parts.map((part, i) =>
|
{parts.map((part, i) =>
|
||||||
part.type === 'html' ? (
|
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) ? (
|
) : shouldRenderAsMermaid(part.block) ? (
|
||||||
<MermaidRenderer key={part.block.id} code={part.block.code} />
|
<MermaidRenderer key={part.block.id} code={part.block.code} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export function ClaudeOfficialLogin() {
|
|||||||
if (status === null) {
|
if (status === null) {
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="text-xs text-[var(--color-error,#dc2626)]">
|
<div className="text-xs text-[var(--color-error)]">
|
||||||
{t('settings.claudeOfficialLogin.errorPrefix')}{error}
|
{t('settings.claudeOfficialLogin.errorPrefix')}{error}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -91,14 +91,14 @@ export function ClaudeOfficialLogin() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogin}
|
onClick={handleLogin}
|
||||||
disabled={isLoading}
|
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
|
{isLoading
|
||||||
? t('settings.claudeOfficialLogin.loginStarting')
|
? t('settings.claudeOfficialLogin.loginStarting')
|
||||||
: t('settings.claudeOfficialLogin.loginButton')}
|
: t('settings.claudeOfficialLogin.loginButton')}
|
||||||
</button>
|
</button>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-[var(--color-error,#dc2626)]">
|
<div className="text-xs text-[var(--color-error)]">
|
||||||
{t('settings.claudeOfficialLogin.errorPrefix')}{error}
|
{t('settings.claudeOfficialLogin.errorPrefix')}{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|||||||
|
|
||||||
const variantStyles: Record<ButtonVariant, string> = {
|
const variantStyles: Record<ButtonVariant, string> = {
|
||||||
primary:
|
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:
|
secondary:
|
||||||
'bg-[var(--color-surface)] text-[var(--color-text-primary)] border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)]',
|
'bg-[var(--color-surface)] text-[var(--color-text-primary)] border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)]',
|
||||||
danger:
|
danger:
|
||||||
|
|||||||
@ -24,8 +24,8 @@ export function Input({ label, error, required, className = '', id, ...props }:
|
|||||||
placeholder:text-[var(--color-text-tertiary)]
|
placeholder:text-[var(--color-text-tertiary)]
|
||||||
transition-colors duration-150
|
transition-colors duration-150
|
||||||
${error
|
${error
|
||||||
? 'border-[var(--color-error)] focus:shadow-[0_0_0_3px_rgba(171,43,63,0.1)]'
|
? 'border-[var(--color-error)] focus:shadow-[var(--shadow-error-ring)]'
|
||||||
: '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
|
outline-none
|
||||||
${className}
|
${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">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<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}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal content */}
|
{/* Modal content */}
|
||||||
<div
|
<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)' }}
|
style={{ width, maxWidth: 'calc(100vw - 48px)' }}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|||||||
@ -10,18 +10,18 @@ export function ProjectContextChip({ workDir, repoName, branch }: Props) {
|
|||||||
if (!label) return null
|
if (!label) return null
|
||||||
|
|
||||||
return (
|
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 ? (
|
{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" />
|
<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>
|
</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 ? (
|
{branch ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-[#b49d95]">|</span>
|
<span className="text-[var(--color-text-tertiary)]">|</span>
|
||||||
<span className="truncate">{branch}</span>
|
<span className="truncate">{branch}</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export function Textarea({ label, error, required, className = '', id, ...props
|
|||||||
transition-colors duration-150
|
transition-colors duration-150
|
||||||
${error
|
${error
|
||||||
? 'border-[var(--color-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
|
outline-none
|
||||||
${className}
|
${className}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { useSkillStore } from '../../stores/skillStore'
|
import { useSkillStore } from '../../stores/skillStore'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { SkillMeta, SkillSource } from '../../types/skill'
|
import type { SkillMeta, SkillSource } from '../../types/skill'
|
||||||
|
|
||||||
@ -28,11 +29,15 @@ function estimateTokens(contentLength: number) {
|
|||||||
export function SkillList() {
|
export function SkillList() {
|
||||||
const { skills, isLoading, error, fetchSkills, fetchSkillDetail } =
|
const { skills, isLoading, error, fetchSkills, fetchSkillDetail } =
|
||||||
useSkillStore()
|
useSkillStore()
|
||||||
|
const sessions = useSessionStore((s) => s.sessions)
|
||||||
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
|
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
||||||
|
const currentWorkDir = activeSession?.workDir || undefined
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSkills()
|
fetchSkills(currentWorkDir)
|
||||||
}, [fetchSkills])
|
}, [fetchSkills, currentWorkDir])
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const result: Partial<Record<SkillSource, SkillMeta[]>> = {}
|
const result: Partial<Record<SkillSource, SkillMeta[]>> = {}
|
||||||
@ -175,7 +180,7 @@ export function SkillList() {
|
|||||||
key={`${skill.source}-${skill.name}`}
|
key={`${skill.source}-${skill.name}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
skill.hasDirectory &&
|
skill.hasDirectory &&
|
||||||
fetchSkillDetail(skill.source, skill.name)
|
fetchSkillDetail(skill.source, skill.name, currentWorkDir)
|
||||||
}
|
}
|
||||||
disabled={!skill.hasDirectory}
|
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"
|
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 */}
|
{/* Delete */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmAction('delete')}
|
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>
|
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
@ -239,8 +239,10 @@ function ConfirmPopover({ message, confirmLabel, onConfirm, onCancel, cancelLabe
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className={`px-2.5 py-1 text-xs rounded-[var(--radius-sm)] text-white hover:opacity-90 transition-opacity ${
|
className={`px-2.5 py-1 text-xs rounded-[var(--radius-sm)] hover:opacity-90 transition-opacity ${
|
||||||
variant === 'error' ? 'bg-[var(--color-error)]' : 'bg-[var(--color-brand)]'
|
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}
|
{confirmLabel}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ function RunOutput({ run }: { run: TaskRun }) {
|
|||||||
// Show error prominently if present
|
// Show error prominently if present
|
||||||
if (run.error) {
|
if (run.error) {
|
||||||
return (
|
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}
|
{run.error}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -287,6 +287,10 @@ export const en = {
|
|||||||
'settings.computerUse.flagSystemKeys': 'System Key Combos',
|
'settings.computerUse.flagSystemKeys': 'System Key Combos',
|
||||||
|
|
||||||
// Settings > General
|
// 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.languageTitle': 'Language',
|
||||||
'settings.general.languageDescription': 'Choose the display language for the application.',
|
'settings.general.languageDescription': 'Choose the display language for the application.',
|
||||||
'settings.general.effortTitle': 'Effort Level',
|
'settings.general.effortTitle': 'Effort Level',
|
||||||
@ -333,6 +337,28 @@ export const en = {
|
|||||||
'permission.showFullInput': 'Show full input',
|
'permission.showFullInput': 'Show full input',
|
||||||
'permission.replacingContent': 'Replacing content in file',
|
'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 ──────────────────────────────────────
|
// ─── Ask User Question ──────────────────────────────────────
|
||||||
'question.needsInput': 'Claude needs your input',
|
'question.needsInput': 'Claude needs your input',
|
||||||
'question.answered': 'Answered',
|
'question.answered': 'Answered',
|
||||||
|
|||||||
@ -289,6 +289,10 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.computerUse.flagSystemKeys': '系统快捷键',
|
'settings.computerUse.flagSystemKeys': '系统快捷键',
|
||||||
|
|
||||||
// Settings > General
|
// Settings > General
|
||||||
|
'settings.general.appearanceTitle': '配色主题',
|
||||||
|
'settings.general.appearanceDescription': '在亮色与暗色工作区之间切换,不影响原有亮色主题。',
|
||||||
|
'settings.general.appearance.light': '亮色',
|
||||||
|
'settings.general.appearance.dark': '暗色',
|
||||||
'settings.general.languageTitle': '语言',
|
'settings.general.languageTitle': '语言',
|
||||||
'settings.general.languageDescription': '选择应用程序的显示语言。',
|
'settings.general.languageDescription': '选择应用程序的显示语言。',
|
||||||
'settings.general.effortTitle': '推理强度',
|
'settings.general.effortTitle': '推理强度',
|
||||||
@ -335,6 +339,28 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'permission.showFullInput': '显示完整输入',
|
'permission.showFullInput': '显示完整输入',
|
||||||
'permission.replacingContent': '替换文件内容',
|
'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 ──────────────────────────────────────
|
// ─── Ask User Question ──────────────────────────────────────
|
||||||
'question.needsInput': 'Claude 需要你的输入',
|
'question.needsInput': 'Claude 需要你的输入',
|
||||||
'question.answered': '已回答',
|
'question.answered': '已回答',
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { App } from './App'
|
import { App } from './App'
|
||||||
import './theme/globals.css'
|
import './theme/globals.css'
|
||||||
|
import { initializeTheme } from './stores/uiStore'
|
||||||
|
|
||||||
|
initializeTheme()
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@ -79,6 +79,7 @@ describe('ActiveSession task polling', () => {
|
|||||||
activeToolName: null,
|
activeToolName: null,
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
@ -157,6 +158,7 @@ describe('ActiveSession task polling', () => {
|
|||||||
activeToolName: null,
|
activeToolName: null,
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { useTeamStore } from '../stores/teamStore'
|
|||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import { MessageList } from '../components/chat/MessageList'
|
import { MessageList } from '../components/chat/MessageList'
|
||||||
import { ChatInput } from '../components/chat/ChatInput'
|
import { ChatInput } from '../components/chat/ChatInput'
|
||||||
|
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
|
||||||
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
|
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
|
||||||
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
|
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ export function ActiveSession() {
|
|||||||
const sessions = useSessionStore((s) => s.sessions)
|
const sessions = useSessionStore((s) => s.sessions)
|
||||||
const connectToSession = useChatStore((s) => s.connectToSession)
|
const connectToSession = useChatStore((s) => s.connectToSession)
|
||||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
||||||
|
const pendingComputerUsePermission = sessionState?.pendingComputerUsePermission ?? null
|
||||||
const fetchSessionTasks = useCLITaskStore((s) => s.fetchSessionTasks)
|
const fetchSessionTasks = useCLITaskStore((s) => s.fetchSessionTasks)
|
||||||
const trackedTaskSessionId = useCLITaskStore((s) => s.sessionId)
|
const trackedTaskSessionId = useCLITaskStore((s) => s.sessionId)
|
||||||
const hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed'))
|
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)]" />
|
<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: "'Manrope', sans-serif" }}>
|
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
|
||||||
{t('empty.title')}
|
{t('empty.title')}
|
||||||
</h1>
|
</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')}
|
{t('empty.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
@ -204,7 +206,14 @@ export function ActiveSession() {
|
|||||||
|
|
||||||
<TeamStatusBar />
|
<TeamStatusBar />
|
||||||
|
|
||||||
<ChatInput />
|
<ChatInput variant={isEmpty && !isMemberSession ? 'hero' : 'default'} />
|
||||||
|
|
||||||
|
{!isMemberSession && activeTabId ? (
|
||||||
|
<ComputerUsePermissionModal
|
||||||
|
sessionId={activeTabId}
|
||||||
|
request={pendingComputerUsePermission?.request ?? null}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export function AgentTeams() {
|
|||||||
<>
|
<>
|
||||||
<style>{pulseSubtleStyle}</style>
|
<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 */}
|
{/* Code Content Area */}
|
||||||
<div className="flex-1 overflow-y-auto p-6 md:p-10 max-w-5xl mx-auto w-full">
|
<div className="flex-1 overflow-y-auto p-6 md:p-10 max-w-5xl mx-auto w-full">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@ -27,14 +27,14 @@ export function AgentTeams() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* USER message */}
|
{/* USER message */}
|
||||||
<div className="flex gap-4 group">
|
<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
|
U
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
User
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#1B1C1A] leading-relaxed">
|
<p className="text-[var(--color-text-primary)] leading-relaxed">
|
||||||
{mockTeamMessages.userMessage}
|
{mockTeamMessages.userMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -42,7 +42,7 @@ export function AgentTeams() {
|
|||||||
|
|
||||||
{/* CLAUDE COMPANION response */}
|
{/* CLAUDE COMPANION response */}
|
||||||
<div className="flex gap-4 group">
|
<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
|
<span
|
||||||
className="material-symbols-outlined text-sm"
|
className="material-symbols-outlined text-sm"
|
||||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||||
@ -51,19 +51,19 @@ export function AgentTeams() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 flex-1">
|
<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
|
Claude Companion
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-[#f4f4f0] p-5 rounded-xl border border-[#dac1ba]/10 shadow-sm">
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-5 shadow-[var(--shadow-dropdown)]">
|
||||||
<p className="text-[#1B1C1A] mb-4">
|
<p className="mb-4 text-[var(--color-text-primary)]">
|
||||||
{mockTeamMessages.assistantMessage}
|
{mockTeamMessages.assistantMessage}
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-[#dbdad6] p-4 rounded-lg font-['JetBrains_Mono'] text-[13px] text-[#54433e] overflow-x-auto">
|
<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-[#ad5f45]">info:</span> spawning child_processes for parallel development
|
<span className="text-[var(--color-brand)]">info:</span> spawning child_processes for parallel development
|
||||||
<br />
|
<br />
|
||||||
<span className="text-[#2d628f]">active:</span> session-dev cluster initiated
|
<span className="text-[var(--color-secondary)]">active:</span> session-dev cluster initiated
|
||||||
<br />
|
<br />
|
||||||
<span className="text-[#4f6237]">ready:</span> 4 agents assigned
|
<span className="text-[var(--color-tertiary)]">ready:</span> 4 agents assigned
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -72,21 +72,21 @@ export function AgentTeams() {
|
|||||||
|
|
||||||
{/* ─── TEAM STRIP ─── */}
|
{/* ─── TEAM STRIP ─── */}
|
||||||
<div className="relative py-8">
|
<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 */}
|
{/* Team label */}
|
||||||
<div className="flex items-center gap-3 pr-4 md:border-r border-[#dac1ba]/30">
|
<div className="flex items-center gap-3 pr-4 md:border-r border-[var(--color-border-separator)]">
|
||||||
<div className="p-2 bg-[#ffb59d]/20 rounded-lg">
|
<div className="p-2 bg-[var(--color-primary-fixed)]/20 rounded-lg">
|
||||||
<span className="material-symbols-outlined text-[#8F482F] text-xl">
|
<span className="material-symbols-outlined text-[var(--color-brand)] text-xl">
|
||||||
groups
|
groups
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<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}
|
Team: {mockTeam.name}
|
||||||
</h3>
|
</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
|
{mockTeam.memberCount} members
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -99,14 +99,14 @@ export function AgentTeams() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={member.id}
|
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)]" />
|
<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 font-['Inter'] text-[#1B1C1A]">
|
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
|
||||||
{member.role}
|
{member.role}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="material-symbols-outlined text-[14px] text-[#4f6237]"
|
className="material-symbols-outlined text-[14px] text-[var(--color-success)]"
|
||||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||||
>
|
>
|
||||||
check_circle
|
check_circle
|
||||||
@ -119,14 +119,14 @@ export function AgentTeams() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={member.id}
|
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)]" />
|
<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 font-['Inter'] text-[#1B1C1A]">
|
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
|
||||||
{member.role}
|
{member.role}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="material-symbols-outlined text-[14px] text-[#f59e0b]"
|
className="material-symbols-outlined text-[14px] text-[var(--color-warning)]"
|
||||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||||
>
|
>
|
||||||
sync
|
sync
|
||||||
@ -138,13 +138,13 @@ export function AgentTeams() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={member.id}
|
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)]" />
|
<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 font-['Inter'] text-[#87736D] group-hover:text-[#1B1C1A]">
|
<span className="text-xs font-semibold text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-primary)]">
|
||||||
{member.role}
|
{member.role}
|
||||||
</span>
|
</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'}
|
{member.role === 'Tester' ? 'schedule' : 'pause_circle'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -153,7 +153,7 @@ export function AgentTeams() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expand button */}
|
{/* 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>
|
<span className="material-symbols-outlined text-sm">expand_more</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -161,18 +161,18 @@ export function AgentTeams() {
|
|||||||
|
|
||||||
{/* ─── Chat Composer ─── */}
|
{/* ─── Chat Composer ─── */}
|
||||||
<div className="max-w-3xl mx-auto w-full mt-auto">
|
<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="glass-panel relative rounded-xl p-1.5 flex items-center gap-2 transition-all">
|
||||||
<div className="p-2 text-[#87736D]">
|
<div className="p-2 text-[var(--color-text-secondary)]">
|
||||||
<span className="material-symbols-outlined">attach_file</span>
|
<span className="material-symbols-outlined">attach_file</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<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..."
|
placeholder="Type a command or ask Claude..."
|
||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
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
|
<span
|
||||||
className="material-symbols-outlined text-lg"
|
className="material-symbols-outlined text-lg"
|
||||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||||
@ -182,12 +182,12 @@ export function AgentTeams() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex justify-center gap-4">
|
<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">
|
<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-[#4f6237]" />
|
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)]" />
|
||||||
Auto-run enabled
|
Auto-run enabled
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-[#87736D] font-semibold uppercase tracking-widest">
|
<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-[#2d628f]" />
|
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-secondary)]" />
|
||||||
Local LLM
|
Local LLM
|
||||||
</div>
|
</div>
|
||||||
</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 { useTranslation } from '../i18n'
|
||||||
import { useSessionStore } from '../stores/sessionStore'
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
@ -13,9 +14,11 @@ import {
|
|||||||
FALLBACK_SLASH_COMMANDS,
|
FALLBACK_SLASH_COMMANDS,
|
||||||
findSlashToken,
|
findSlashToken,
|
||||||
insertSlashTrigger,
|
insertSlashTrigger,
|
||||||
|
mergeSlashCommands,
|
||||||
replaceSlashCommand,
|
replaceSlashCommand,
|
||||||
} from '../components/chat/composerUtils'
|
} from '../components/chat/composerUtils'
|
||||||
import type { AttachmentRef } from '../types/chat'
|
import type { AttachmentRef } from '../types/chat'
|
||||||
|
import type { SlashCommandOption } from '../components/chat/composerUtils'
|
||||||
|
|
||||||
type Attachment = {
|
type Attachment = {
|
||||||
id: string
|
id: string
|
||||||
@ -39,6 +42,7 @@ export function EmptySession() {
|
|||||||
const [atCursorPos, setAtCursorPos] = useState(-1)
|
const [atCursorPos, setAtCursorPos] = useState(-1)
|
||||||
const [slashFilter, setSlashFilter] = useState('')
|
const [slashFilter, setSlashFilter] = useState('')
|
||||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
||||||
|
const [slashCommands, setSlashCommands] = useState<SlashCommandOption[]>([])
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const plusMenuRef = useRef<HTMLDivElement>(null)
|
const plusMenuRef = useRef<HTMLDivElement>(null)
|
||||||
@ -99,19 +103,50 @@ export function EmptySession() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClick)
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
}, [fileSearchOpen])
|
}, [fileSearchOpen])
|
||||||
|
|
||||||
const filteredCommands = FALLBACK_SLASH_COMMANDS.filter((command) => {
|
useEffect(() => {
|
||||||
if (!slashFilter) return true
|
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()
|
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(() => {
|
useEffect(() => {
|
||||||
setSlashSelectedIndex(0)
|
setSlashSelectedIndex(0)
|
||||||
}, [slashFilter])
|
}, [slashFilter])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (slashMenuOpen && slashItemRefs.current[slashSelectedIndex]) {
|
const activeItem = slashMenuOpen ? slashItemRefs.current[slashSelectedIndex] : null
|
||||||
slashItemRefs.current[slashSelectedIndex]?.scrollIntoView({ block: 'nearest' })
|
if (typeof activeItem?.scrollIntoView === 'function') {
|
||||||
|
activeItem.scrollIntoView({ block: 'nearest' })
|
||||||
}
|
}
|
||||||
}, [slashMenuOpen, slashSelectedIndex])
|
}, [slashMenuOpen, slashSelectedIndex])
|
||||||
|
|
||||||
@ -336,14 +371,14 @@ export function EmptySession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 flex-1 flex-col items-center justify-center p-8 pb-32">
|
||||||
<div className="flex max-w-md flex-col items-center text-center">
|
<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)]" />
|
<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-[#1B1C1A]" style={{ fontFamily: "'Manrope', sans-serif" }}>
|
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
|
||||||
{t('empty.title')}
|
{t('empty.title')}
|
||||||
</h1>
|
</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')}
|
{t('empty.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -352,8 +387,7 @@ export function EmptySession() {
|
|||||||
<div className="absolute bottom-4 left-0 right-0 flex justify-center px-8">
|
<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="flex w-full max-w-3xl flex-col gap-2">
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-col gap-3 rounded-xl border border-[#dac1ba]/15 bg-white p-4"
|
className="glass-panel relative flex flex-col gap-3 rounded-xl p-4"
|
||||||
style={{ boxShadow: '0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08)' }}
|
|
||||||
onDragOver={(event) => event.preventDefault()}
|
onDragOver={(event) => event.preventDefault()}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
@ -382,7 +416,7 @@ export function EmptySession() {
|
|||||||
{slashMenuOpen && filteredCommands.length > 0 && (
|
{slashMenuOpen && filteredCommands.length > 0 && (
|
||||||
<div
|
<div
|
||||||
ref={slashMenuRef}
|
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">
|
<div className="max-h-[260px] overflow-y-auto py-1">
|
||||||
{filteredCommands.map((command, index) => (
|
{filteredCommands.map((command, index) => (
|
||||||
@ -391,12 +425,12 @@ export function EmptySession() {
|
|||||||
ref={(el) => { slashItemRefs.current[index] = el }}
|
ref={(el) => { slashItemRefs.current[index] = el }}
|
||||||
onClick={() => selectSlashCommand(command.name)}
|
onClick={() => selectSlashCommand(command.name)}
|
||||||
onMouseEnter={() => setSlashSelectedIndex(index)}
|
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-[#F4F4F0]' : 'hover:bg-[#F8F7F4]'
|
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="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span>
|
||||||
<span className="truncate text-xs text-[#87736D]">{command.description}</span>
|
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -414,41 +448,41 @@ export function EmptySession() {
|
|||||||
onChange={(event) => handleInputChange(event.target.value, event.target.selectionStart ?? event.target.value.length)}
|
onChange={(event) => handleInputChange(event.target.value, event.target.selectionStart ?? event.target.value.length)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
className="flex-1 resize-none border-none bg-transparent py-2 leading-relaxed text-[#1B1C1A] outline-none placeholder:text-[#87736D]/50"
|
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: "'Inter', sans-serif" }}
|
style={{ fontFamily: 'var(--font-body)' }}
|
||||||
placeholder={t('empty.placeholder')}
|
placeholder={t('empty.placeholder')}
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 className="flex items-center gap-2">
|
||||||
<div ref={plusMenuRef} className="relative">
|
<div ref={plusMenuRef} className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPlusMenuOpen((prev) => !prev)}
|
onClick={() => setPlusMenuOpen((prev) => !prev)}
|
||||||
aria-label="Open composer tools"
|
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>
|
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{plusMenuOpen && (
|
{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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
fileInputRef.current?.click()
|
fileInputRef.current?.click()
|
||||||
setPlusMenuOpen(false)
|
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')}
|
{t('empty.addFiles')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={insertSlashCommand}
|
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')}
|
{t('empty.slashCommands')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -463,7 +497,7 @@ export function EmptySession() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={(!input.trim() && attachments.length === 0) || isSubmitting}
|
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')}
|
{t('common.run')}
|
||||||
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||||
@ -482,4 +516,3 @@ export function EmptySession() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { useTranslation } from '../i18n'
|
|||||||
import { Modal } from '../components/shared/Modal'
|
import { Modal } from '../components/shared/Modal'
|
||||||
import { Input } from '../components/shared/Input'
|
import { Input } from '../components/shared/Input'
|
||||||
import { Button } from '../components/shared/Button'
|
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 type { Locale } from '../i18n'
|
||||||
import { PROVIDER_PRESETS } from '../config/providerPresets'
|
import { PROVIDER_PRESETS } from '../config/providerPresets'
|
||||||
import type { ProviderPreset } from '../config/providerPresets'
|
import type { ProviderPreset } from '../config/providerPresets'
|
||||||
@ -142,7 +142,7 @@ function ProviderSettings() {
|
|||||||
<div
|
<div
|
||||||
className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
|
className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
|
||||||
isOfficialActive
|
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'
|
: '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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.officialName')}</span>
|
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.officialName')}</span>
|
||||||
{isOfficialActive && (
|
{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>
|
||||||
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.officialDesc')}</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}
|
key={provider.id}
|
||||||
className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all group ${
|
className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all group ${
|
||||||
isActive
|
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)]'
|
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -202,7 +202,7 @@ function ProviderSettings() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isActive && (
|
{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>
|
||||||
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
|
<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)}
|
onClick={() => handlePresetChange(preset)}
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-all ${
|
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-all ${
|
||||||
selectedPreset.id === preset.id
|
selectedPreset.id === preset.id
|
||||||
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
|
? '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)]'
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{preset.name}
|
{preset.name}
|
||||||
@ -621,7 +621,7 @@ function PermissionSettings() {
|
|||||||
onClick={() => setPermissionMode(mode)}
|
onClick={() => setPermissionMode(mode)}
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-all text-left ${
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-all text-left ${
|
||||||
isSelected
|
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)]'
|
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -646,7 +646,7 @@ function PermissionSettings() {
|
|||||||
// ─── General Settings ──────────────────────────────────────
|
// ─── General Settings ──────────────────────────────────────
|
||||||
|
|
||||||
function GeneralSettings() {
|
function GeneralSettings() {
|
||||||
const { effortLevel, setEffort, locale, setLocale } = useSettingsStore()
|
const { effortLevel, setEffort, locale, setLocale, theme, setTheme } = useSettingsStore()
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
|
|
||||||
const EFFORT_LABELS: Record<EffortLevel, string> = {
|
const EFFORT_LABELS: Record<EffortLevel, string> = {
|
||||||
@ -661,8 +661,32 @@ function GeneralSettings() {
|
|||||||
{ value: 'zh', label: '中文' },
|
{ 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 (
|
return (
|
||||||
<div className="max-w-xl">
|
<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 */}
|
{/* Language selector */}
|
||||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.languageTitle')}</h2>
|
<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>
|
<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="flex items-start justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<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}
|
{toolType}
|
||||||
</span>
|
</span>
|
||||||
<h1 className="font-[var(--font-headline)] font-extrabold text-2xl text-[var(--color-on-surface)] tracking-tight">
|
<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'
|
const isRemoved = line.type === 'removed'
|
||||||
|
|
||||||
let rowBg = ''
|
let rowBg = ''
|
||||||
if (isAdded) rowBg = 'bg-[rgba(103,123,78,0.15)]'
|
if (isAdded) rowBg = 'bg-[var(--color-diff-added-bg)]'
|
||||||
else if (isRemoved) rowBg = 'bg-[rgba(143,72,47,0.15)]'
|
else if (isRemoved) rowBg = 'bg-[var(--color-diff-removed-bg)]'
|
||||||
|
|
||||||
let lineNoColor = 'text-[var(--color-outline)] opacity-40'
|
let lineNoColor = 'text-[var(--color-outline)] opacity-40'
|
||||||
if (isAdded) lineNoColor = 'text-[var(--color-tertiary)] 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="space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
<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
|
psychology
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -179,8 +179,8 @@ export function ToolInspection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-1 w-6 h-6 rounded bg-[#D4EAB4] flex items-center justify-center">
|
<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-[#3B4C24]">
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-diff-added-text)]">
|
||||||
science
|
science
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -166,6 +166,7 @@ describe('chatStore history mapping', () => {
|
|||||||
activeToolName: null,
|
activeToolName: null,
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
@ -222,6 +223,7 @@ describe('chatStore history mapping', () => {
|
|||||||
activeToolName: null,
|
activeToolName: null,
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
@ -252,6 +254,7 @@ describe('chatStore history mapping', () => {
|
|||||||
activeToolName: null,
|
activeToolName: null,
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
@ -300,6 +303,7 @@ describe('chatStore history mapping', () => {
|
|||||||
activeToolName: null,
|
activeToolName: null,
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
@ -325,6 +329,132 @@ describe('chatStore history mapping', () => {
|
|||||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.streamingText).toBe('')
|
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 () => {
|
it('routes member-session messages through team mailbox delivery instead of websocket', async () => {
|
||||||
const memberSessionId = 'team-member:security-reviewer@test-team'
|
const memberSessionId = 'team-member:security-reviewer@test-team'
|
||||||
getMemberBySessionIdMock.mockReturnValue({
|
getMemberBySessionIdMock.mockReturnValue({
|
||||||
@ -345,6 +475,7 @@ describe('chatStore history mapping', () => {
|
|||||||
activeToolName: null,
|
activeToolName: null,
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import type {
|
|||||||
AgentTaskNotification,
|
AgentTaskNotification,
|
||||||
AttachmentRef,
|
AttachmentRef,
|
||||||
ChatState,
|
ChatState,
|
||||||
|
ComputerUsePermissionRequest,
|
||||||
|
ComputerUsePermissionResponse,
|
||||||
UIAttachment,
|
UIAttachment,
|
||||||
UIMessage,
|
UIMessage,
|
||||||
ServerMessage,
|
ServerMessage,
|
||||||
@ -36,6 +38,10 @@ export type PerSessionState = {
|
|||||||
input: unknown
|
input: unknown
|
||||||
description?: string
|
description?: string
|
||||||
} | null
|
} | null
|
||||||
|
pendingComputerUsePermission: {
|
||||||
|
requestId: string
|
||||||
|
request: ComputerUsePermissionRequest
|
||||||
|
} | null
|
||||||
tokenUsage: TokenUsage
|
tokenUsage: TokenUsage
|
||||||
elapsedSeconds: number
|
elapsedSeconds: number
|
||||||
statusVerb: string
|
statusVerb: string
|
||||||
@ -54,6 +60,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
|
|||||||
activeToolName: null,
|
activeToolName: null,
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
@ -74,6 +81,11 @@ type ChatStore = {
|
|||||||
disconnectSession: (sessionId: string) => void
|
disconnectSession: (sessionId: string) => void
|
||||||
sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void
|
sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void
|
||||||
respondToPermission: (sessionId: string, requestId: string, allowed: boolean, rule?: string) => 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
|
setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void
|
||||||
stopGeneration: (sessionId: string) => void
|
stopGeneration: (sessionId: string) => void
|
||||||
loadHistory: (sessionId: string) => Promise<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' })) }))
|
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) => {
|
setSessionPermissionMode: (sessionId, mode) => {
|
||||||
if (!get().sessions[sessionId]) return
|
if (!get().sessions[sessionId]) return
|
||||||
wsManager.send(sessionId, { type: 'set_permission_mode', mode })
|
wsManager.send(sessionId, { type: 'set_permission_mode', mode })
|
||||||
@ -289,7 +315,18 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
const session = s.sessions[sessionId]
|
const session = s.sessions[sessionId]
|
||||||
if (!session) return s
|
if (!session) return s
|
||||||
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
|
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':
|
case 'permission_request':
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
pendingPermission: { requestId: msg.requestId, toolName: msg.toolName, input: msg.input, description: msg.description },
|
pendingPermission: { requestId: msg.requestId, toolName: msg.toolName, input: msg.input, description: msg.description },
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
chatState: 'permission_pending',
|
chatState: 'permission_pending',
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
messages: [...s.messages, {
|
messages: [...s.messages, {
|
||||||
@ -465,6 +503,18 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
}))
|
}))
|
||||||
break
|
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': {
|
case 'message_complete': {
|
||||||
const session = get().sessions[sessionId]
|
const session = get().sessions[sessionId]
|
||||||
if (!session) break
|
if (!session) break
|
||||||
@ -476,7 +526,14 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
|
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
|
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: 'assistant_text' as const, content: pendingText, timestamp: Date.now() })
|
||||||
}
|
}
|
||||||
newMessages.push({ id: nextId(), type: 'error', message: msg.message, code: msg.code, 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')
|
useTabStore.getState().updateTabStatus(sessionId, 'error')
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { settingsApi } from '../api/settings'
|
import { settingsApi } from '../api/settings'
|
||||||
import { modelsApi } from '../api/models'
|
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 type { Locale } from '../i18n'
|
||||||
|
import { useUIStore } from './uiStore'
|
||||||
|
|
||||||
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
|
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
|
||||||
|
|
||||||
@ -21,6 +22,7 @@ type SettingsStore = {
|
|||||||
availableModels: ModelInfo[]
|
availableModels: ModelInfo[]
|
||||||
activeProviderName: string | null
|
activeProviderName: string | null
|
||||||
locale: Locale
|
locale: Locale
|
||||||
|
theme: ThemeMode
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
|
||||||
@ -29,6 +31,7 @@ type SettingsStore = {
|
|||||||
setModel: (modelId: string) => Promise<void>
|
setModel: (modelId: string) => Promise<void>
|
||||||
setEffort: (level: EffortLevel) => Promise<void>
|
setEffort: (level: EffortLevel) => Promise<void>
|
||||||
setLocale: (locale: Locale) => void
|
setLocale: (locale: Locale) => void
|
||||||
|
setTheme: (theme: ThemeMode) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||||
@ -38,24 +41,29 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|||||||
availableModels: [],
|
availableModels: [],
|
||||||
activeProviderName: null,
|
activeProviderName: null,
|
||||||
locale: getStoredLocale(),
|
locale: getStoredLocale(),
|
||||||
|
theme: useUIStore.getState().theme,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchAll: async () => {
|
fetchAll: async () => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const [{ mode }, modelsRes, { model }, { level }] = await Promise.all([
|
const [{ mode }, modelsRes, { model }, { level }, userSettings] = await Promise.all([
|
||||||
settingsApi.getPermissionMode(),
|
settingsApi.getPermissionMode(),
|
||||||
modelsApi.list(),
|
modelsApi.list(),
|
||||||
modelsApi.getCurrent(),
|
modelsApi.getCurrent(),
|
||||||
modelsApi.getEffort(),
|
modelsApi.getEffort(),
|
||||||
|
settingsApi.getUser(),
|
||||||
])
|
])
|
||||||
|
const theme = userSettings.theme === 'dark' ? 'dark' : 'light'
|
||||||
|
useUIStore.getState().setTheme(theme)
|
||||||
set({
|
set({
|
||||||
permissionMode: mode,
|
permissionMode: mode,
|
||||||
availableModels: modelsRes.models,
|
availableModels: modelsRes.models,
|
||||||
activeProviderName: modelsRes.provider?.name ?? null,
|
activeProviderName: modelsRes.provider?.name ?? null,
|
||||||
currentModel: model,
|
currentModel: model,
|
||||||
effortLevel: level,
|
effortLevel: level,
|
||||||
|
theme,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
@ -97,4 +105,16 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|||||||
set({ locale })
|
set({ locale })
|
||||||
try { localStorage.setItem(LOCALE_STORAGE_KEY, locale) } catch { /* noop */ }
|
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
|
isDetailLoading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
|
||||||
fetchSkills: () => Promise<void>
|
fetchSkills: (cwd?: string) => Promise<void>
|
||||||
fetchSkillDetail: (source: string, name: string) => Promise<void>
|
fetchSkillDetail: (source: string, name: string, cwd?: string) => Promise<void>
|
||||||
clearSelection: () => void
|
clearSelection: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,10 +21,10 @@ export const useSkillStore = create<SkillStore>((set) => ({
|
|||||||
isDetailLoading: false,
|
isDetailLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchSkills: async () => {
|
fetchSkills: async (cwd) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const { skills } = await skillsApi.list()
|
const { skills } = await skillsApi.list(cwd)
|
||||||
set({ skills, isLoading: false })
|
set({ skills, isLoading: false })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
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 })
|
set({ isDetailLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const { detail } = await skillsApi.detail(source, name)
|
const { detail } = await skillsApi.detail(source, name, cwd)
|
||||||
set({ selectedSkill: detail, isDetailLoading: false })
|
set({ selectedSkill: detail, isDetailLoading: false })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
|
|||||||
@ -27,6 +27,7 @@ function createMemberSessionState() {
|
|||||||
activeToolName: null,
|
activeToolName: null,
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
|
pendingComputerUsePermission: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
|
|||||||
@ -1,4 +1,25 @@
|
|||||||
import { create } from 'zustand'
|
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 = {
|
export type Toast = {
|
||||||
id: string
|
id: string
|
||||||
@ -12,14 +33,14 @@ export type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' |
|
|||||||
type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings'
|
type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings'
|
||||||
|
|
||||||
type UIStore = {
|
type UIStore = {
|
||||||
theme: 'light' | 'dark'
|
theme: ThemeMode
|
||||||
sidebarOpen: boolean
|
sidebarOpen: boolean
|
||||||
activeView: ActiveView
|
activeView: ActiveView
|
||||||
pendingSettingsTab: SettingsTab | null
|
pendingSettingsTab: SettingsTab | null
|
||||||
activeModal: string | null
|
activeModal: string | null
|
||||||
toasts: Toast[]
|
toasts: Toast[]
|
||||||
|
|
||||||
setTheme: (theme: 'light' | 'dark') => void
|
setTheme: (theme: ThemeMode) => void
|
||||||
toggleTheme: () => void
|
toggleTheme: () => void
|
||||||
toggleSidebar: () => void
|
toggleSidebar: () => void
|
||||||
setSidebarOpen: (open: boolean) => void
|
setSidebarOpen: (open: boolean) => void
|
||||||
@ -34,7 +55,7 @@ type UIStore = {
|
|||||||
let toastCounter = 0
|
let toastCounter = 0
|
||||||
|
|
||||||
export const useUIStore = create<UIStore>((set) => ({
|
export const useUIStore = create<UIStore>((set) => ({
|
||||||
theme: 'light',
|
theme: getStoredTheme(),
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
activeView: 'code',
|
activeView: 'code',
|
||||||
pendingSettingsTab: null,
|
pendingSettingsTab: null,
|
||||||
@ -42,14 +63,16 @@ export const useUIStore = create<UIStore>((set) => ({
|
|||||||
toasts: [],
|
toasts: [],
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
document.documentElement.setAttribute('data-theme', theme)
|
applyTheme(theme)
|
||||||
|
try { localStorage.setItem(THEME_STORAGE_KEY, theme) } catch { /* noop */ }
|
||||||
set({ theme })
|
set({ theme })
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleTheme: () => {
|
toggleTheme: () => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const next = state.theme === 'light' ? 'dark' : 'light'
|
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 }
|
return { theme: next }
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@ -124,6 +124,7 @@
|
|||||||
|
|
||||||
--color-error: #BA1A1A;
|
--color-error: #BA1A1A;
|
||||||
--color-error-container: #FFDAD6;
|
--color-error-container: #FFDAD6;
|
||||||
|
--color-on-error-container: #410002;
|
||||||
|
|
||||||
--color-inverse-surface: #2F312E;
|
--color-inverse-surface: #2F312E;
|
||||||
--color-inverse-on-surface: #F2F1ED;
|
--color-inverse-on-surface: #F2F1ED;
|
||||||
@ -139,7 +140,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Semantic Aliases (used by layout & infrastructure components) ── */
|
/* ─── Semantic Aliases (used by layout & infrastructure components) ── */
|
||||||
:root {
|
:root,
|
||||||
|
[data-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
/* Layout dimensions */
|
/* Layout dimensions */
|
||||||
--sidebar-width: 280px;
|
--sidebar-width: 280px;
|
||||||
--titlebar-height: 40px;
|
--titlebar-height: 40px;
|
||||||
@ -169,16 +173,67 @@
|
|||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
--shadow-dropdown: 0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08);
|
--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 */
|
/* Button colors */
|
||||||
--color-btn-primary-bg: var(--color-primary);
|
|
||||||
--color-btn-primary-fg: var(--color-on-primary);
|
--color-btn-primary-fg: var(--color-on-primary);
|
||||||
--color-btn-primary-bg-hover: var(--color-primary-container);
|
--gradient-btn-primary: linear-gradient(135deg, var(--color-primary), var(--color-primary-container));
|
||||||
--color-btn-primary-bg-active: var(--color-primary);
|
--gradient-btn-primary-hover: linear-gradient(135deg, var(--color-primary-container), var(--color-primary));
|
||||||
|
|
||||||
/* User message bubble */
|
/* User message bubble */
|
||||||
--color-surface-user-msg: var(--color-surface-container);
|
--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 */
|
/* Radii */
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
--radius-md: 8px;
|
--radius-md: 8px;
|
||||||
@ -187,6 +242,120 @@
|
|||||||
--radius-full: 9999px;
|
--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 ─────────────────────────────────────────── */
|
||||||
.material-symbols-outlined {
|
.material-symbols-outlined {
|
||||||
font-family: 'Material Symbols Outlined';
|
font-family: 'Material Symbols Outlined';
|
||||||
@ -222,6 +391,11 @@ html, body, #root {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--color-selection-bg);
|
||||||
|
color: var(--color-selection-fg);
|
||||||
|
}
|
||||||
|
|
||||||
/* Tauri drag region */
|
/* Tauri drag region */
|
||||||
[data-tauri-drag-region] {
|
[data-tauri-drag-region] {
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
@ -235,7 +409,32 @@ button, input, textarea, select, a, [role="button"] {
|
|||||||
|
|
||||||
/* Custom shadow from prototype */
|
/* Custom shadow from prototype */
|
||||||
.custom-shadow {
|
.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 */
|
/* Scrollbar */
|
||||||
|
|||||||
@ -7,6 +7,11 @@ import type { PermissionMode } from './settings'
|
|||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: 'user_message'; content: string; attachments?: AttachmentRef[] }
|
| { type: 'user_message'; content: string; attachments?: AttachmentRef[] }
|
||||||
| { type: 'permission_response'; requestId: string; allowed: boolean; rule?: string }
|
| { 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: 'set_permission_mode'; mode: PermissionMode }
|
||||||
| { type: 'stop_generation' }
|
| { type: 'stop_generation' }
|
||||||
| { type: 'ping' }
|
| { type: 'ping' }
|
||||||
@ -35,6 +40,11 @@ export type ServerMessage =
|
|||||||
| { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown; parentToolUseId?: string }
|
| { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown; parentToolUseId?: string }
|
||||||
| { type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; 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: '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: 'message_complete'; usage: TokenUsage }
|
||||||
| { type: 'thinking'; text: string }
|
| { type: 'thinking'; text: string }
|
||||||
| { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number }
|
| { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number }
|
||||||
@ -63,6 +73,56 @@ export type TeamMemberStatus = {
|
|||||||
currentTask?: string
|
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 = {
|
export type AgentTaskNotification = {
|
||||||
taskId: string
|
taskId: string
|
||||||
toolUseId: string
|
toolUseId: string
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions' | 'dontAsk'
|
export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions' | 'dontAsk'
|
||||||
|
|
||||||
export type EffortLevel = 'low' | 'medium' | 'high' | 'max'
|
export type EffortLevel = 'low' | 'medium' | 'high' | 'max'
|
||||||
|
export type ThemeMode = 'light' | 'dark'
|
||||||
|
|
||||||
export type ModelInfo = {
|
export type ModelInfo = {
|
||||||
id: string
|
id: string
|
||||||
@ -16,5 +17,6 @@ export type UserSettings = {
|
|||||||
modelContext?: string
|
modelContext?: string
|
||||||
effort?: EffortLevel
|
effort?: EffortLevel
|
||||||
permissionMode?: PermissionMode
|
permissionMode?: PermissionMode
|
||||||
|
theme?: ThemeMode
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
|
import ctypes
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -175,6 +176,33 @@ def run_osascript(script: str) -> str:
|
|||||||
return result.stdout.strip()
|
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]]:
|
def get_displays() -> list[dict[str, Any]]:
|
||||||
max_displays = 32
|
max_displays = 32
|
||||||
err, active, count = CGGetActiveDisplayList(max_displays, None, None)
|
err, active, count = CGGetActiveDisplayList(max_displays, None, None)
|
||||||
@ -472,6 +500,10 @@ def write_clipboard(text: str) -> None:
|
|||||||
pb.setString_forType_(text, NSPasteboardTypeString)
|
pb.setString_forType_(text, NSPasteboardTypeString)
|
||||||
|
|
||||||
|
|
||||||
|
def paste_clipboard() -> None:
|
||||||
|
send_keystroke_via_osascript("v", ["command"])
|
||||||
|
|
||||||
|
|
||||||
def detect_screen_recording_permission() -> bool | None:
|
def detect_screen_recording_permission() -> bool | None:
|
||||||
"""Best-effort passive screen-recording probe with no system prompt.
|
"""Best-effort passive screen-recording probe with no system prompt.
|
||||||
|
|
||||||
@ -520,12 +552,29 @@ def detect_screen_recording_permission() -> bool | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def check_permissions() -> dict[str, bool | None]:
|
def detect_accessibility_permission() -> bool:
|
||||||
accessibility = True
|
"""
|
||||||
|
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:
|
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:
|
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()
|
screen_recording = detect_screen_recording_permission()
|
||||||
return {
|
return {
|
||||||
"accessibility": accessibility,
|
"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:
|
def key_action(sequence: str, repeat: int = 1) -> None:
|
||||||
parts = [normalize_key(part) for part in sequence.split("+") if part.strip()]
|
parts = [normalize_key(part) for part in sequence.split("+") if part.strip()]
|
||||||
for _ in range(max(1, repeat)):
|
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])
|
pyautogui.press(parts[0])
|
||||||
else:
|
else:
|
||||||
pyautogui.hotkey(*parts, interval=0.02)
|
pyautogui.hotkey(*parts, interval=0.02)
|
||||||
@ -703,6 +760,10 @@ def main() -> int:
|
|||||||
write_clipboard(str(payload.get("text") or ""))
|
write_clipboard(str(payload.get("text") or ""))
|
||||||
json_output({"ok": True, "result": True})
|
json_output({"ok": True, "result": True})
|
||||||
return 0
|
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")
|
error_output(f"Unknown command: {command}", code="bad_command")
|
||||||
return 2
|
return 2
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@ -154,7 +154,7 @@ class TestJSONProtocol(unittest.TestCase):
|
|||||||
"move_mouse", "scroll", "mouse_down", "mouse_up",
|
"move_mouse", "scroll", "mouse_down", "mouse_up",
|
||||||
"cursor_position", "frontmost_app", "app_under_point",
|
"cursor_position", "frontmost_app", "app_under_point",
|
||||||
"list_installed_apps", "list_running_apps", "open_app",
|
"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]:
|
for helper in [MAC_HELPER, WIN_HELPER]:
|
||||||
if not helper.exists():
|
if not helper.exists():
|
||||||
@ -247,6 +247,42 @@ class TestWinHelperPermissions(unittest.TestCase):
|
|||||||
self.assertIn('"screenRecording": True', func_source)
|
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):
|
class TestCrossPlatformFunctions(unittest.TestCase):
|
||||||
"""Test functions that are identical between both helpers."""
|
"""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."""
|
"""Input action functions (click, scroll, etc.) should be identical."""
|
||||||
if not MAC_HELPER.exists() or not WIN_HELPER.exists():
|
if not MAC_HELPER.exists() or not WIN_HELPER.exists():
|
||||||
self.skipTest("Both helpers required")
|
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)
|
mac_src = self._get_function_body(MAC_HELPER, func)
|
||||||
win_src = self._get_function_body(WIN_HELPER, func)
|
win_src = self._get_function_body(WIN_HELPER, func)
|
||||||
self.assertEqual(mac_src, win_src,
|
self.assertEqual(mac_src, win_src,
|
||||||
|
|||||||
@ -513,6 +513,10 @@ def write_clipboard(text: str) -> None:
|
|||||||
pyperclip.copy(text)
|
pyperclip.copy(text)
|
||||||
|
|
||||||
|
|
||||||
|
def paste_clipboard() -> None:
|
||||||
|
pyautogui.hotkey("ctrl", "v", interval=0.02)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Permissions — Windows doesn't have macOS-style TCC
|
# Permissions — Windows doesn't have macOS-style TCC
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -704,6 +708,10 @@ def main() -> int:
|
|||||||
write_clipboard(str(payload.get("text") or ""))
|
write_clipboard(str(payload.get("text") or ""))
|
||||||
json_output({"ok": True, "result": True})
|
json_output({"ok": True, "result": True})
|
||||||
return 0
|
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")
|
error_output(f"Unknown command: {command}", code="bad_command")
|
||||||
return 2
|
return 2
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@ -146,6 +146,19 @@ describe('ConversationService', () => {
|
|||||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
|
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', () => {
|
test('uses bun entrypoint fallback on Windows dev mode', () => {
|
||||||
const service = new ConversationService() as any
|
const service = new ConversationService() as any
|
||||||
const args = service.resolveCliArgs(['--print'])
|
const args = service.resolveCliArgs(['--print'])
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import * as fs from 'node:fs/promises'
|
|||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import { SessionService } from '../services/sessionService.js'
|
import { SessionService } from '../services/sessionService.js'
|
||||||
|
import { clearCommandsCache } from '../../commands.js'
|
||||||
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
|
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -45,6 +46,20 @@ async function writeSessionFile(
|
|||||||
return filePath
|
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
|
// Sample entries matching real CLI format
|
||||||
function makeSnapshotEntry(): Record<string, unknown> {
|
function makeSnapshotEntry(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
@ -122,6 +137,7 @@ describe('SessionService', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
clearCommandsCache()
|
||||||
await cleanupTmpDir()
|
await cleanupTmpDir()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -692,6 +708,37 @@ describe('Sessions API', () => {
|
|||||||
expect(detail.title).toBe('New Custom Title')
|
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
|
// 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 { createHash } from 'crypto'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
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
|
// Embed helper scripts at compile time so they're available in bundled mode
|
||||||
// @ts-ignore — Bun text import
|
// @ts-ignore — Bun text import
|
||||||
import MAC_HELPER_CONTENT from '../../../runtime/mac_helper.py' with { type: 'text' }
|
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 = {
|
const DEFAULT_CONFIG: ComputerUseConfig = {
|
||||||
authorizedApps: [],
|
authorizedApps: [],
|
||||||
grantFlags: { clipboardRead: true, clipboardWrite: true, systemKeyCombos: true },
|
grantFlags: { clipboardRead: true, clipboardWrite: true, systemKeyCombos: true },
|
||||||
@ -453,6 +460,29 @@ export async function handleComputerUseApi(
|
|||||||
return Response.json({ ok: true })
|
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(
|
return Response.json(
|
||||||
{ error: 'NOT_FOUND', message: `Unknown computer-use action: ${action}` },
|
{ error: 'NOT_FOUND', message: `Unknown computer-use action: ${action}` },
|
||||||
{ status: 404 },
|
{ status: 404 },
|
||||||
|
|||||||
@ -15,6 +15,8 @@
|
|||||||
import { sessionService } from '../services/sessionService.js'
|
import { sessionService } from '../services/sessionService.js'
|
||||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||||
import { getSlashCommands } from '../ws/handler.js'
|
import { getSlashCommands } from '../ws/handler.js'
|
||||||
|
import { getCommandName } from '../../commands.js'
|
||||||
|
import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
|
||||||
|
|
||||||
export async function handleSessionsApi(
|
export async function handleSessionsApi(
|
||||||
req: Request,
|
req: Request,
|
||||||
@ -78,7 +80,7 @@ export async function handleSessionsApi(
|
|||||||
{ status: 405 }
|
{ status: 405 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return Response.json({ commands: getSlashCommands(sessionId) })
|
return await getSessionSlashCommands(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to conversations handler if sub-resource is 'chat'
|
// 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 })
|
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> {
|
async function getGitInfo(sessionId: string): Promise<Response> {
|
||||||
const workDir = await sessionService.getSessionWorkDir(sessionId)
|
const workDir = await sessionService.getSessionWorkDir(sessionId)
|
||||||
if (!workDir) {
|
if (!workDir) {
|
||||||
|
|||||||
@ -6,10 +6,12 @@
|
|||||||
* ?source=user&name=xxx
|
* ?source=user&name=xxx
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as os from 'os'
|
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import * as fs from 'fs/promises'
|
import * as fs from 'fs/promises'
|
||||||
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
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'
|
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
@ -25,6 +27,8 @@ type SkillMeta = {
|
|||||||
hasDirectory: boolean
|
hasDirectory: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SkillSource = SkillMeta['source']
|
||||||
|
|
||||||
type FileTreeNode = {
|
type FileTreeNode = {
|
||||||
name: string
|
name: string
|
||||||
path: string
|
path: string
|
||||||
@ -74,7 +78,15 @@ function normalizeFrontmatter(content: string, sourcePath?: string): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getUserSkillsDir(): 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(
|
async function loadSkillMeta(
|
||||||
@ -191,6 +203,60 @@ async function buildFileTree(
|
|||||||
return { tree, files }
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Router ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function handleSkillsApi(
|
export async function handleSkillsApi(
|
||||||
@ -207,7 +273,7 @@ export async function handleSkillsApi(
|
|||||||
|
|
||||||
switch (sub) {
|
switch (sub) {
|
||||||
case undefined:
|
case undefined:
|
||||||
return await listSkills()
|
return await listSkills(url)
|
||||||
case 'detail':
|
case 'detail':
|
||||||
return await getSkillDetail(url)
|
return await getSkillDetail(url)
|
||||||
default:
|
default:
|
||||||
@ -220,25 +286,14 @@ export async function handleSkillsApi(
|
|||||||
|
|
||||||
// ─── Handlers ────────────────────────────────────────────────────────────────
|
// ─── Handlers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function listSkills(): Promise<Response> {
|
async function listSkills(url: URL): Promise<Response> {
|
||||||
const skillsDir = getUserSkillsDir()
|
const cwd = getRequestedCwd(url)
|
||||||
const skills: SkillMeta[] = []
|
const [userSkills, projectSkills] = await Promise.all([
|
||||||
|
collectSkillsFromRoots([getUserSkillsDir()], 'user'),
|
||||||
try {
|
collectSkillsFromRoots(getProjectSkillsDirs(cwd), 'project'),
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const skills = [...userSkills, ...projectSkills]
|
||||||
skills.sort((a, b) => a.name.localeCompare(b.name))
|
skills.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
return Response.json({ skills })
|
return Response.json({ skills })
|
||||||
}
|
}
|
||||||
@ -256,21 +311,17 @@ async function getSkillDetail(url: URL): Promise<Response> {
|
|||||||
throw ApiError.badRequest('Invalid skill name')
|
throw ApiError.badRequest('Invalid skill name')
|
||||||
}
|
}
|
||||||
|
|
||||||
let skillDir: string
|
if (source !== 'user' && source !== 'project') {
|
||||||
if (source === 'user') {
|
|
||||||
skillDir = path.join(getUserSkillsDir(), name)
|
|
||||||
} else {
|
|
||||||
throw ApiError.badRequest(`Unsupported source: ${source}`)
|
throw ApiError.badRequest(`Unsupported source: ${source}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const cwd = getRequestedCwd(url)
|
||||||
const stat = await fs.stat(skillDir)
|
const skillDir = await resolveSkillDir(source, name, cwd)
|
||||||
if (!stat.isDirectory()) throw new Error()
|
if (!skillDir) {
|
||||||
} catch {
|
|
||||||
throw ApiError.notFound(`Skill not found: ${name}`)
|
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) {
|
if (!meta) {
|
||||||
throw ApiError.notFound(`Skill missing SKILL.md: ${name}`)
|
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
|
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.ts
|
||||||
// chdir 后落到正确目录。
|
// chdir 后落到正确目录。
|
||||||
//
|
//
|
||||||
const childEnv = await this.buildChildEnv(workDir)
|
const childEnv = await this.buildChildEnv(workDir, sdkUrl)
|
||||||
|
|
||||||
let proc: ReturnType<typeof Bun.spawn>
|
let proc: ReturnType<typeof Bun.spawn>
|
||||||
try {
|
try {
|
||||||
@ -471,7 +471,10 @@ export class ConversationService {
|
|||||||
return args
|
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,
|
// Provider isolation: when Desktop has its own provider config/index,
|
||||||
// strip inherited provider env vars so the child CLI reads fresh values
|
// strip inherited provider env vars so the child CLI reads fresh values
|
||||||
// from ~/.claude/cc-haha/settings.json instead of stale process.env.
|
// 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 {
|
return {
|
||||||
...cleanEnv,
|
...cleanEnv,
|
||||||
CLAUDE_CODE_ENABLE_TASKS: '1',
|
CLAUDE_CODE_ENABLE_TASKS: '1',
|
||||||
CALLER_DIR: workDir,
|
CALLER_DIR: workDir,
|
||||||
PWD: 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
|
// Tell the CLI entrypoint to skip project .env loading. Provider env
|
||||||
// should come from Desktop-managed config or inherited launch env, not
|
// should come from Desktop-managed config or inherited launch env, not
|
||||||
// be reintroduced from the repo's .env file.
|
// be reintroduced from the repo's .env file.
|
||||||
|
|||||||
@ -11,6 +11,11 @@
|
|||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: 'user_message'; content: string; attachments?: AttachmentRef[] }
|
| { type: 'user_message'; content: string; attachments?: AttachmentRef[] }
|
||||||
| { type: 'permission_response'; requestId: string; allowed: boolean; rule?: string }
|
| { 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: 'set_permission_mode'; mode: string }
|
||||||
| { type: 'stop_generation' }
|
| { type: 'stop_generation' }
|
||||||
| { type: 'ping' }
|
| { type: 'ping' }
|
||||||
@ -34,6 +39,11 @@ export type ServerMessage =
|
|||||||
| { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown; parentToolUseId?: string }
|
| { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown; parentToolUseId?: string }
|
||||||
| { type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; 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: '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: 'message_complete'; usage: TokenUsage }
|
||||||
| { type: 'thinking'; text: string }
|
| { type: 'thinking'; text: string }
|
||||||
| { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number }
|
| { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number }
|
||||||
@ -62,6 +72,56 @@ export type TeamMemberStatus = {
|
|||||||
currentTask?: string
|
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
|
// Internal types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
ConversationStartupError,
|
ConversationStartupError,
|
||||||
conversationService,
|
conversationService,
|
||||||
} from '../services/conversationService.js'
|
} from '../services/conversationService.js'
|
||||||
|
import { computerUseApprovalService } from '../services/computerUseApprovalService.js'
|
||||||
import { sessionService } from '../services/sessionService.js'
|
import { sessionService } from '../services/sessionService.js'
|
||||||
import { SettingsService } from '../services/settingsService.js'
|
import { SettingsService } from '../services/settingsService.js'
|
||||||
import { ProviderService } from '../services/providerService.js'
|
import { ProviderService } from '../services/providerService.js'
|
||||||
@ -118,6 +119,10 @@ export const handleWebSocket = {
|
|||||||
handlePermissionResponse(ws, message)
|
handlePermissionResponse(ws, message)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'computer_use_permission_response':
|
||||||
|
handleComputerUsePermissionResponse(ws, message)
|
||||||
|
break
|
||||||
|
|
||||||
case 'set_permission_mode':
|
case 'set_permission_mode':
|
||||||
handleSetPermissionMode(ws, message)
|
handleSetPermissionMode(ws, message)
|
||||||
break
|
break
|
||||||
@ -148,6 +153,7 @@ export const handleWebSocket = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[WS] Client disconnected from session: ${sessionId} (${code}: ${reason})`)
|
console.log(`[WS] Client disconnected from session: ${sessionId} (${code}: ${reason})`)
|
||||||
|
computerUseApprovalService.cancelSession(sessionId)
|
||||||
activeSessions.delete(sessionId)
|
activeSessions.delete(sessionId)
|
||||||
cleanupStreamState(sessionId)
|
cleanupStreamState(sessionId)
|
||||||
sessionSlashCommands.delete(sessionId)
|
sessionSlashCommands.delete(sessionId)
|
||||||
@ -300,6 +306,22 @@ function handlePermissionResponse(
|
|||||||
console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`)
|
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(
|
function handleSetPermissionMode(
|
||||||
ws: ServerWebSocket<WebSocketData>,
|
ws: ServerWebSocket<WebSocketData>,
|
||||||
message: Extract<ClientMessage, { type: 'set_permission_mode' }>
|
message: Extract<ClientMessage, { type: 'set_permission_mode' }>
|
||||||
|
|||||||
@ -15,13 +15,14 @@ import type {
|
|||||||
ScreenshotResult,
|
ScreenshotResult,
|
||||||
} from '../../vendor/computer-use-mcp/index.js'
|
} from '../../vendor/computer-use-mcp/index.js'
|
||||||
import { API_RESIZE_PARAMS, targetImageSize } 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 { sleep } from '../sleep.js'
|
||||||
import { CLI_CU_CAPABILITIES, CLI_HOST_BUNDLE_ID } from './common.js'
|
import { CLI_CU_CAPABILITIES, CLI_HOST_BUNDLE_ID } from './common.js'
|
||||||
import { callPythonHelper } from './pythonBridge.js'
|
import { callPythonHelper } from './pythonBridge.js'
|
||||||
|
|
||||||
const SCREENSHOT_JPEG_QUALITY = 0.75
|
const SCREENSHOT_JPEG_QUALITY = 0.75
|
||||||
const MOVE_SETTLE_MS = 50
|
const MOVE_SETTLE_MS = 50
|
||||||
|
const hostBundleId =
|
||||||
|
process.env.CC_HAHA_COMPUTER_USE_HOST_BUNDLE_ID || CLI_HOST_BUNDLE_ID
|
||||||
|
|
||||||
type PythonDisplayGeometry = DisplayGeometry
|
type PythonDisplayGeometry = DisplayGeometry
|
||||||
|
|
||||||
@ -48,14 +49,11 @@ function normalizeDisplayGeometry(display: PythonDisplayGeometry): DisplayGeomet
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readClipboardViaPbpaste(): Promise<string> {
|
async function readClipboardViaPbpaste(): Promise<string> {
|
||||||
const { stdout, code } = await execFileNoThrow('pbpaste', [], { useCwd: false })
|
return callPythonHelper<string>('read_clipboard', {})
|
||||||
if (code !== 0) throw new Error(`pbpaste exited with code ${code}`)
|
|
||||||
return stdout
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeClipboardViaPbcopy(text: string): Promise<void> {
|
async function writeClipboardViaPbcopy(text: string): Promise<void> {
|
||||||
const { code } = await execFileNoThrow('pbcopy', [], { input: text, useCwd: false })
|
await callPythonHelper('write_clipboard', { text })
|
||||||
if (code !== 0) throw new Error(`pbcopy exited with code ${code}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function typeViaClipboard(text: string): Promise<void> {
|
async function typeViaClipboard(text: string): Promise<void> {
|
||||||
@ -66,8 +64,11 @@ async function typeViaClipboard(text: string): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await writeClipboardViaPbcopy(text)
|
await writeClipboardViaPbcopy(text)
|
||||||
await callPythonHelper('key', { keySequence: 'command+v', repeat: 1 })
|
// Give NSPasteboard a beat before paste, then keep the new contents
|
||||||
await sleep(100)
|
// resident long enough for Electron/WebView fields to consume them.
|
||||||
|
await sleep(40)
|
||||||
|
await callPythonHelper('paste_clipboard', {})
|
||||||
|
await sleep(180)
|
||||||
} finally {
|
} finally {
|
||||||
if (typeof saved === 'string') {
|
if (typeof saved === 'string') {
|
||||||
try {
|
try {
|
||||||
@ -88,7 +89,7 @@ export function createCliExecutor(_opts: {
|
|||||||
return {
|
return {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...CLI_CU_CAPABILITIES,
|
...CLI_CU_CAPABILITIES,
|
||||||
hostBundleId: CLI_HOST_BUNDLE_ID,
|
hostBundleId,
|
||||||
},
|
},
|
||||||
|
|
||||||
async prepareForAction(): Promise<string[]> {
|
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
|
// IME/input-source state can corrupt keystroke-by-keystroke typing even for
|
||||||
// plain ASCII. The save/restore + read-back-verify lives in the EXECUTOR,
|
// plain ASCII. The save/restore + read-back-verify lives in the EXECUTOR,
|
||||||
// not here. Here we just route.
|
// not here. Here we just route.
|
||||||
|
const hasNonControlText = /[^\r\n\t]/u.test(text);
|
||||||
const viaClipboard =
|
const viaClipboard =
|
||||||
(text.includes("\n") ||
|
(text.includes("\n") ||
|
||||||
(adapter.executor.capabilities.platform === "darwin" && text.length > 1)) &&
|
(adapter.executor.capabilities.platform === "darwin" &&
|
||||||
|
hasNonControlText)) &&
|
||||||
overrides.grantFlags.clipboardWrite &&
|
overrides.grantFlags.clipboardWrite &&
|
||||||
subGates.clipboardPasteMultiline;
|
subGates.clipboardPasteMultiline;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user