mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
chore: comprehensive code review fixes — security, perf, leaks, quality, docs
- security: XSS sanitization with DOMPurify in Markdown/Mermaid/PermissionDialog; path whitelist in filesystem API; fake keys in test/config files - perf: fine-grained Zustand selectors in Sidebar/StatusBar/ContentRouter; 50ms throttle on streaming deltas; React.memo + useMemo in MessageList; useRef for frequent keyboard shortcut state; AbortController 30s timeout - leaks: WS session TTL timers (5-min cleanup on close); batch splice for sdkMessages/stderrLines; folderPath validation in cronScheduler - quality: optimistic update rollback in settingsStore; error state in providerStore/teamStore; i18n for all hardcoded English strings - docs: desktop architecture and features docs updated; VitePress nav fixed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a0fa8751b3
commit
8888e1c3fb
6
.gitignore
vendored
6
.gitignore
vendored
@ -41,6 +41,12 @@ docs/superpowers/
|
||||
# Debug / temp screenshots (root dir only)
|
||||
/*.png
|
||||
|
||||
# Private keys & credentials
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
credentials*.json
|
||||
|
||||
# TS build cache
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
"name": "claude-code-desktop",
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"lucide-react": "^0.469.0",
|
||||
"marked": "^15.0.7",
|
||||
"mermaid": "^11.14.0",
|
||||
@ -420,6 +422,8 @@
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/dompurify": ["@types/dompurify@3.2.0", "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
|
||||
@ -16,6 +16,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"lucide-react": "^0.469.0",
|
||||
"marked": "^15.0.7",
|
||||
"mermaid": "^11.14.0",
|
||||
|
||||
@ -11,7 +11,26 @@
|
||||
"identifier": "shell:allow-execute",
|
||||
"allow": [
|
||||
{
|
||||
"args": true,
|
||||
"args": [
|
||||
"server",
|
||||
{ "validator": "regex", "value": "^(--port|--host|--config)=.+$" }
|
||||
],
|
||||
"name": "binaries/claude-sidecar",
|
||||
"sidecar": true
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"adapters",
|
||||
{ "validator": "regex", "value": "^(feishu|telegram|lark|slack)(:.+)?$" }
|
||||
],
|
||||
"name": "binaries/claude-sidecar",
|
||||
"sidecar": true
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"cli",
|
||||
{ "validator": "regex", "value": "^.+$" }
|
||||
],
|
||||
"name": "binaries/claude-sidecar",
|
||||
"sidecar": true
|
||||
}
|
||||
@ -21,7 +40,26 @@
|
||||
"identifier": "shell:allow-spawn",
|
||||
"allow": [
|
||||
{
|
||||
"args": true,
|
||||
"args": [
|
||||
"server",
|
||||
{ "validator": "regex", "value": "^(--port|--host|--config)=.+$" }
|
||||
],
|
||||
"name": "binaries/claude-sidecar",
|
||||
"sidecar": true
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"adapters",
|
||||
{ "validator": "regex", "value": "^(feishu|telegram|lark|slack)(:.+)?$" }
|
||||
],
|
||||
"name": "binaries/claude-sidecar",
|
||||
"sidecar": true
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"cli",
|
||||
{ "validator": "regex", "value": "^.+$" }
|
||||
],
|
||||
"name": "binaries/claude-sidecar",
|
||||
"sidecar": true
|
||||
}
|
||||
@ -31,7 +69,26 @@
|
||||
"identifier": "shell:allow-kill",
|
||||
"allow": [
|
||||
{
|
||||
"args": true,
|
||||
"args": [
|
||||
"server",
|
||||
{ "validator": "regex", "value": "^(--port|--host|--config)=.+$" }
|
||||
],
|
||||
"name": "binaries/claude-sidecar",
|
||||
"sidecar": true
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"adapters",
|
||||
{ "validator": "regex", "value": "^(feishu|telegram|lark|slack)(:.+)?$" }
|
||||
],
|
||||
"name": "binaries/claude-sidecar",
|
||||
"sidecar": true
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"cli",
|
||||
{ "validator": "regex", "value": "^.+$" }
|
||||
],
|
||||
"name": "binaries/claude-sidecar",
|
||||
"sidecar": true
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
|
||||
"productName": "Claude Code Haha",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.claude-code.desktop",
|
||||
"identifier": "com.claude-code-haha.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
@ -25,7 +25,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:* ws://localhost:* http://localhost:*; media-src 'self' blob:"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@ -60,8 +60,8 @@ describe('Content-only pages render without errors', () => {
|
||||
},
|
||||
})
|
||||
const { container } = render(<ActiveSession />)
|
||||
// Should have the title area and chat input
|
||||
expect(container.innerHTML).toContain('Untitled Session')
|
||||
// With empty messages, the hero is shown
|
||||
expect(container.innerHTML).toContain('New session')
|
||||
// ChatInput has a textarea
|
||||
expect(container.querySelector('textarea')).toBeInTheDocument()
|
||||
expect(container.innerHTML).not.toContain('Preview')
|
||||
|
||||
@ -30,19 +30,28 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000)
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json().catch(() => res.text())
|
||||
throw new ApiError(res.status, errorBody)
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json().catch(() => res.text())
|
||||
throw new ApiError(res.status, errorBody)
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json() as Promise<T>
|
||||
} catch (err) {
|
||||
clearTimeout(timeout)
|
||||
throw err
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
|
||||
@ -54,8 +54,7 @@ export function ChatInput() {
|
||||
const slashCommands = sessionState?.slashCommands ?? []
|
||||
const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null)
|
||||
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
|
||||
const messages = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.messages ?? [] : [])
|
||||
const hasMessages = messages.length > 0
|
||||
const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false)
|
||||
|
||||
const isActive = chatState !== 'idle'
|
||||
const isWorkspaceMissing = activeSession?.workDirExists === false
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import mermaid from 'mermaid'
|
||||
import { Modal } from '../shared/Modal'
|
||||
import { CopyButton } from '../shared/CopyButton'
|
||||
@ -280,7 +281,7 @@ export function MermaidRenderer({ code }: Props) {
|
||||
className="flex items-center justify-center overflow-auto bg-white p-4 cursor-pointer"
|
||||
style={{ maxHeight: 400 }}
|
||||
onClick={handlePreview}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -346,7 +347,7 @@ export function MermaidRenderer({ code }: Props) {
|
||||
style={previewCanvasStyle}
|
||||
data-dragging={isDraggingPreview ? 'true' : 'false'}
|
||||
aria-label="Mermaid preview canvas"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { useRef, useEffect, useMemo, memo } from 'react'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@ -77,28 +77,31 @@ export function MessageList() {
|
||||
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
|
||||
}, [messages.length, streamingText])
|
||||
|
||||
const toolUseIds = new Set<string>()
|
||||
const toolResultMap = new Map<string, ToolResult>()
|
||||
const childToolCallsByParent = new Map<string, ToolCall[]>()
|
||||
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(() => {
|
||||
const toolUseIds = new Set<string>()
|
||||
const toolResultMap = new Map<string, ToolResult>()
|
||||
const childToolCallsByParent = new Map<string, ToolCall[]>()
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'tool_use') {
|
||||
toolUseIds.add(msg.toolUseId)
|
||||
if (msg.parentToolUseId) {
|
||||
const siblings = childToolCallsByParent.get(msg.parentToolUseId)
|
||||
if (siblings) {
|
||||
siblings.push(msg)
|
||||
} else {
|
||||
childToolCallsByParent.set(msg.parentToolUseId, [msg])
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'tool_use') {
|
||||
toolUseIds.add(msg.toolUseId)
|
||||
if (msg.parentToolUseId) {
|
||||
const siblings = childToolCallsByParent.get(msg.parentToolUseId)
|
||||
if (siblings) {
|
||||
siblings.push(msg)
|
||||
} else {
|
||||
childToolCallsByParent.set(msg.parentToolUseId, [msg])
|
||||
}
|
||||
}
|
||||
}
|
||||
if (msg.type === 'tool_result' && msg.toolUseId) {
|
||||
toolResultMap.set(msg.toolUseId, msg)
|
||||
}
|
||||
}
|
||||
if (msg.type === 'tool_result' && msg.toolUseId) {
|
||||
toolResultMap.set(msg.toolUseId, msg)
|
||||
}
|
||||
}
|
||||
|
||||
const renderItems = buildRenderItems(messages, toolUseIds)
|
||||
const renderItems = buildRenderItems(messages, toolUseIds)
|
||||
return { toolUseIds, toolResultMap, childToolCallsByParent, renderItems }
|
||||
}, [messages])
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
@ -157,7 +160,7 @@ export function MessageList() {
|
||||
)
|
||||
}
|
||||
|
||||
function MessageBlock({
|
||||
const MessageBlock = memo(function MessageBlock({
|
||||
message,
|
||||
activeThinkingId,
|
||||
agentTaskNotifications,
|
||||
@ -234,4 +237,4 @@ function MessageBlock({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TranslationKey } from '../../i18n'
|
||||
import { Button } from '../shared/Button'
|
||||
import { DiffViewer } from './DiffViewer'
|
||||
|
||||
@ -33,7 +34,7 @@ const TOOL_META: Record<string, { icon: string; label: string; color: string }>
|
||||
/**
|
||||
* Extract human-readable detail lines from tool input.
|
||||
*/
|
||||
function extractToolDetails(toolName: string, input: unknown, t: (key: any, params?: any) => string): { primary: string; secondary?: string } {
|
||||
function extractToolDetails(toolName: string, input: unknown, t: (key: TranslationKey, params?: Record<string, string | number>) => string): { primary: string; secondary?: string } {
|
||||
const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
|
||||
|
||||
switch (toolName) {
|
||||
@ -69,7 +70,7 @@ function extractToolDetails(toolName: string, input: unknown, t: (key: any, para
|
||||
}
|
||||
}
|
||||
|
||||
function getPermissionTitle(toolName: string, input: unknown, t: (key: any, params?: any) => string) {
|
||||
function getPermissionTitle(toolName: string, input: unknown, t: (key: TranslationKey, params?: Record<string, string | number>) => string) {
|
||||
const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
|
||||
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
|
||||
const fileName = filePath ? filePath.split('/').pop() || filePath : ''
|
||||
|
||||
@ -4,6 +4,7 @@ import { DiffViewer } from './DiffViewer'
|
||||
import { TerminalChrome } from './TerminalChrome'
|
||||
import { CopyButton } from '../shared/CopyButton'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TranslationKey } from '../../i18n'
|
||||
import { InlineImageGallery } from './InlineImageGallery'
|
||||
import type { AgentTaskNotification } from '../../types/chat'
|
||||
|
||||
@ -99,7 +100,7 @@ function renderPreview(
|
||||
toolName: string,
|
||||
obj: Record<string, unknown>,
|
||||
result?: { content: unknown; isError: boolean } | null,
|
||||
t?: (key: any, params?: any) => string,
|
||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
) {
|
||||
const filePath = typeof obj.file_path === 'string' ? obj.file_path : 'file'
|
||||
|
||||
@ -153,7 +154,7 @@ function renderPreview(
|
||||
return null
|
||||
}
|
||||
|
||||
function renderDetails(toolName: string, obj: Record<string, unknown>, t?: (key: any, params?: any) => string) {
|
||||
function renderDetails(toolName: string, obj: Record<string, unknown>, t?: (key: TranslationKey, params?: Record<string, string | number>) => string) {
|
||||
if (toolName === 'Edit' || toolName === 'Write') {
|
||||
return null
|
||||
}
|
||||
@ -173,7 +174,7 @@ function renderDetails(toolName: string, obj: Record<string, unknown>, t?: (key:
|
||||
)
|
||||
}
|
||||
|
||||
function getToolResultSummary(toolName: string, content: unknown, t?: (key: any, params?: any) => string): string {
|
||||
function getToolResultSummary(toolName: string, content: unknown, t?: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
|
||||
if (toolName === 'Bash') return ''
|
||||
|
||||
const text = extractTextContent(content)
|
||||
@ -190,7 +191,7 @@ function getToolResultSummary(toolName: string, content: unknown, t?: (key: any,
|
||||
return `${compact.slice(0, 36)}…`
|
||||
}
|
||||
|
||||
function getToolSummary(toolName: string, obj: Record<string, unknown>, t?: (key: any, params?: any) => string): string {
|
||||
function getToolSummary(toolName: string, obj: Record<string, unknown>, t?: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
|
||||
switch (toolName) {
|
||||
case 'Bash':
|
||||
return typeof obj.command === 'string' ? obj.command : ''
|
||||
@ -229,7 +230,7 @@ function extractTextContent(content: unknown): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function changedLineSummary(oldString: string, newString: string, t?: (key: any, params?: any) => string): string {
|
||||
function changedLineSummary(oldString: string, newString: string, t?: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
|
||||
const oldLines = oldString.split('\n')
|
||||
const newLines = newString.split('\n')
|
||||
let changed = 0
|
||||
|
||||
@ -3,6 +3,7 @@ import { ToolCallBlock } from './ToolCallBlock'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { Modal } from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TranslationKey } from '../../i18n'
|
||||
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
@ -17,7 +18,7 @@ type Props = {
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
const TOOL_VERBS: Record<string, (count: number, t: (key: any, params?: any) => string) => string> = {
|
||||
const TOOL_VERBS: Record<string, (count: number, t: (key: TranslationKey, params?: Record<string, string | number>) => string) => string> = {
|
||||
Read: (n, t) => n === 1 ? t('toolGroup.readOne') : t('toolGroup.readMany', { count: n }),
|
||||
Write: (n, t) => n === 1 ? t('toolGroup.createdOne') : t('toolGroup.createdMany', { count: n }),
|
||||
Edit: (n, t) => n === 1 ? t('toolGroup.editedOne') : t('toolGroup.editedMany', { count: n }),
|
||||
@ -29,7 +30,7 @@ const TOOL_VERBS: Record<string, (count: number, t: (key: any, params?: any) =>
|
||||
WebFetch: (n, t) => n === 1 ? t('toolGroup.fetchedOne') : t('toolGroup.fetchedMany', { count: n }),
|
||||
}
|
||||
|
||||
function generateSummary(toolCalls: ToolCall[], t: (key: any, params?: any) => string): string {
|
||||
function generateSummary(toolCalls: ToolCall[], t: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
|
||||
const counts = new Map<string, number>()
|
||||
for (const tc of toolCalls) {
|
||||
counts.set(tc.toolName, (counts.get(tc.toolName) ?? 0) + 1)
|
||||
@ -476,7 +477,7 @@ function getAgentStatus({
|
||||
|
||||
function getAgentStatusLabel(
|
||||
status: AgentStatus,
|
||||
t: (key: any, params?: any) => string,
|
||||
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'failed':
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
@ -175,7 +176,7 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed mb-3" dangerouslySetInnerHTML={{ __html: t('permMode.enableBypassBody') }} />
|
||||
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed mb-3" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(t('permMode.enableBypassBody')) }} />
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-surface-container)] border border-[var(--color-border)]" title={workDir}>
|
||||
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)] shrink-0">folder</span>
|
||||
<code className="text-xs font-[var(--font-mono)] text-[var(--color-text-primary)] truncate">{workDir}</code>
|
||||
|
||||
@ -10,11 +10,13 @@ import { initializeDesktopServerUrl } from '../../lib/desktopRuntime'
|
||||
import { TabBar } from './TabBar'
|
||||
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function AppShell() {
|
||||
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [startupError, setStartupError] = useState<string | null>(null)
|
||||
const t = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@ -72,7 +74,7 @@ export function AppShell() {
|
||||
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
|
||||
<div className="max-w-xl rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6">
|
||||
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
Local server failed to start
|
||||
{t('app.serverFailed')}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
|
||||
{startupError}
|
||||
@ -85,7 +87,7 @@ export function AppShell() {
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] text-[var(--color-text-secondary)]">
|
||||
Launching local workspace...
|
||||
{t('app.launching')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -8,20 +8,20 @@ import { AgentTranscript } from '../../pages/AgentTranscript'
|
||||
|
||||
export function ContentRouter() {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const activeTab = useTabStore((s) => s.tabs.find((t) => t.sessionId === s.activeTabId))
|
||||
const activeTabType = useTabStore((s) => s.tabs.find((t) => t.sessionId === s.activeTabId)?.type)
|
||||
const viewingAgentId = useTeamStore((s) => s.viewingAgentId)
|
||||
|
||||
// No tabs open — show empty session
|
||||
if (!activeTabId || !activeTab) {
|
||||
if (!activeTabId || !activeTabType) {
|
||||
return <EmptySession />
|
||||
}
|
||||
|
||||
// Special tabs
|
||||
if (activeTab.type === 'settings') {
|
||||
if (activeTabType === 'settings') {
|
||||
return <Settings />
|
||||
}
|
||||
|
||||
if (activeTab.type === 'scheduled') {
|
||||
if (activeTabType === 'scheduled') {
|
||||
return <ScheduledTasks />
|
||||
}
|
||||
|
||||
|
||||
@ -13,14 +13,12 @@ type TimeGroup = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'older'
|
||||
const TIME_GROUP_ORDER: TimeGroup[] = ['today', 'yesterday', 'last7days', 'last30days', 'older']
|
||||
|
||||
export function Sidebar() {
|
||||
const {
|
||||
sessions,
|
||||
selectedProjects,
|
||||
error,
|
||||
fetchSessions,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
} = useSessionStore()
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const selectedProjects = useSessionStore((s) => s.selectedProjects)
|
||||
const error = useSessionStore((s) => s.error)
|
||||
const fetchSessions = useSessionStore((s) => s.fetchSessions)
|
||||
const deleteSession = useSessionStore((s) => s.deleteSession)
|
||||
const renameSession = useSessionStore((s) => s.renameSession)
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
|
||||
|
||||
@ -5,12 +5,10 @@ import { useTabStore } from '../../stores/tabStore'
|
||||
export function StatusBar() {
|
||||
const { currentModel } = useSettingsStore()
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const projectPath = useSessionStore((s) => s.sessions.find((session) => session.id === activeTabId)?.projectPath)
|
||||
|
||||
const activeSession = sessions.find((s) => s.id === activeTabId)
|
||||
|
||||
const projectName = activeSession?.projectPath
|
||||
? activeSession.projectPath.split('-').filter(Boolean).pop() || ''
|
||||
const projectName = projectPath
|
||||
? projectPath.split('-').filter(Boolean).pop() || ''
|
||||
: ''
|
||||
|
||||
return (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked, type Tokens } from 'marked'
|
||||
import { CodeViewer } from '../chat/CodeViewer'
|
||||
import { MermaidRenderer } from '../chat/MermaidRenderer'
|
||||
@ -169,10 +170,11 @@ export function MarkdownRenderer({ content, variant = 'default', className }: Pr
|
||||
}, [])
|
||||
|
||||
if (codeBlocks.length === 0) {
|
||||
const cleanHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['use'], ADD_ATTR: ['xlink:href'] })
|
||||
return (
|
||||
<div
|
||||
className={proseClasses}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
dangerouslySetInnerHTML={{ __html: cleanHtml }}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
)
|
||||
@ -182,7 +184,7 @@ export function MarkdownRenderer({ content, variant = 'default', className }: Pr
|
||||
<div className={proseClasses} onClick={handleClick}>
|
||||
{parts.map((part, i) =>
|
||||
part.type === 'html' ? (
|
||||
<div key={i} dangerouslySetInnerHTML={{ __html: part.content }} />
|
||||
<div key={i} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(part.content, { ADD_TAGS: ['use'], ADD_ATTR: ['xlink:href'] }) }} />
|
||||
) : shouldRenderAsMermaid(part.block) ? (
|
||||
<MermaidRenderer key={part.block.id} code={part.block.code} />
|
||||
) : (
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type UpdateInfo = {
|
||||
version: string
|
||||
@ -17,6 +18,7 @@ try {
|
||||
export function UpdateChecker() {
|
||||
const [update, setUpdate] = useState<UpdateInfo | null>(null)
|
||||
const addToast = useUIStore((s) => s.addToast)
|
||||
const t = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return
|
||||
@ -29,7 +31,7 @@ export function UpdateChecker() {
|
||||
setUpdate({ version: available.version, downloading: false, progress: 0 })
|
||||
addToast({
|
||||
type: 'info',
|
||||
message: `New version v${available.version} available`,
|
||||
message: t('update.newVersion', { version: available.version }),
|
||||
duration: 0, // persist until dismissed
|
||||
})
|
||||
}
|
||||
@ -71,7 +73,7 @@ export function UpdateChecker() {
|
||||
} catch (err) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: `Update failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
message: t('update.failed', { error: err instanceof Error ? err.message : String(err) }),
|
||||
})
|
||||
setUpdate((u) => u && { ...u, downloading: false })
|
||||
}
|
||||
@ -81,7 +83,7 @@ export function UpdateChecker() {
|
||||
<div className="fixed top-4 right-4 z-[200] max-w-xs">
|
||||
<div className="bg-[var(--color-surface-container-low)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-dropdown)] p-4">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
v{update.version} available
|
||||
{t('update.available', { version: update.version })}
|
||||
</p>
|
||||
{update.downloading ? (
|
||||
<div className="mt-2">
|
||||
@ -91,7 +93,7 @@ export function UpdateChecker() {
|
||||
style={{ width: `${Math.min(update.progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">Downloading...</p>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">{t('update.downloading')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex gap-2">
|
||||
@ -99,13 +101,13 @@ export function UpdateChecker() {
|
||||
onClick={handleUpdate}
|
||||
className="px-3 py-1 text-xs font-medium rounded-[var(--radius-md)] bg-[var(--color-text-accent)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Update now
|
||||
{t('update.now')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUpdate(null)}
|
||||
className="px-3 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
>
|
||||
Later
|
||||
{t('update.later')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -48,7 +48,7 @@ export function DayOfWeekPicker({ selected, onChange }: Props) {
|
||||
}
|
||||
`}
|
||||
>
|
||||
{t(DAY_KEYS[day] as any)}
|
||||
{t(DAY_KEYS[day]!)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -141,7 +141,7 @@ export function TaskRunsPanel({ taskId, onClose, refreshKey }: Props) {
|
||||
|
||||
{/* Status text */}
|
||||
<span className="text-xs font-medium" style={{ color: cfg.color }}>
|
||||
{t(`tasks.runStatus.${run.status}` as any)}
|
||||
{t(`tasks.runStatus.${run.status}` as any)} {/* dynamic key */}
|
||||
</span>
|
||||
|
||||
{/* Time */}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
@ -13,6 +13,13 @@ export function useKeyboardShortcuts() {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const chatState = useChatStore((s) => activeTabId ? s.sessions[activeTabId]?.chatState ?? 'idle' : 'idle')
|
||||
|
||||
const activeModalRef = useRef(activeModal)
|
||||
activeModalRef.current = activeModal
|
||||
const chatStateRef = useRef(chatState)
|
||||
chatStateRef.current = chatState
|
||||
const activeTabIdRef = useRef(activeTabId)
|
||||
activeTabIdRef.current = activeTabId
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const meta = e.metaKey || e.ctrlKey
|
||||
@ -33,21 +40,21 @@ export function useKeyboardShortcuts() {
|
||||
|
||||
// Escape — Close modal or clear state
|
||||
if (e.key === 'Escape') {
|
||||
if (activeModal) {
|
||||
if (activeModalRef.current) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
// Cmd+. — Stop generation
|
||||
if (meta && e.key === '.') {
|
||||
if (chatState !== 'idle' && activeTabId) {
|
||||
if (chatStateRef.current !== 'idle' && activeTabIdRef.current) {
|
||||
e.preventDefault()
|
||||
stopGeneration(activeTabId)
|
||||
stopGeneration(activeTabIdRef.current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [activeModal, activeTabId, chatState, closeModal, setActiveSession, setActiveView, stopGeneration])
|
||||
}, [closeModal, setActiveSession, setActiveView, stopGeneration])
|
||||
}
|
||||
|
||||
@ -516,6 +516,29 @@ export const en = {
|
||||
'scheduledPage.connectedLocal': 'Connected to local node',
|
||||
'scheduledPage.thisMonth': '{count} this month',
|
||||
|
||||
// ─── Update Checker ──────────────────────────────────────
|
||||
'update.available': 'v{version} available',
|
||||
'update.newVersion': 'New version v{version} available',
|
||||
'update.downloading': 'Downloading...',
|
||||
'update.now': 'Update now',
|
||||
'update.later': 'Later',
|
||||
'update.failed': 'Update failed: {error}',
|
||||
|
||||
// ─── Active Session ──────────────────────────────────────
|
||||
'session.untitled': 'Untitled Session',
|
||||
'session.active': 'session active',
|
||||
'session.lastUpdated': 'last updated {time}',
|
||||
'session.messages': '{count} messages',
|
||||
'session.workspaceUnavailable': 'Workspace unavailable: {dir}',
|
||||
'session.timeJustNow': 'just now',
|
||||
'session.timeMinutes': '{n}m ago',
|
||||
'session.timeHours': '{n}h ago',
|
||||
'session.timeDays': '{n}d ago',
|
||||
|
||||
// ─── App Shell ──────────────────────────────────────
|
||||
'app.serverFailed': 'Local server failed to start',
|
||||
'app.launching': 'Launching local workspace...',
|
||||
|
||||
// ─── Error Codes ──────────────────────────────────────
|
||||
'error.CLI_NOT_RUNNING': 'CLI process is not running. The session may have ended or the process crashed.',
|
||||
'error.CLI_START_FAILED': 'Failed to start CLI process.',
|
||||
|
||||
@ -518,6 +518,29 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'scheduledPage.connectedLocal': '已连接到本地节点',
|
||||
'scheduledPage.thisMonth': '本月 {count}',
|
||||
|
||||
// ─── 更新检查 ──────────────────────────────────────
|
||||
'update.available': 'v{version} 可用',
|
||||
'update.newVersion': '新版本 v{version} 可用',
|
||||
'update.downloading': '下载中...',
|
||||
'update.now': '立即更新',
|
||||
'update.later': '稍后',
|
||||
'update.failed': '更新失败: {error}',
|
||||
|
||||
// ─── 活跃会话 ──────────────────────────────────────
|
||||
'session.untitled': '未命名会话',
|
||||
'session.active': '会话活跃中',
|
||||
'session.lastUpdated': '最后更新 {time}',
|
||||
'session.messages': '{count} 条消息',
|
||||
'session.workspaceUnavailable': '工作目录不可用: {dir}',
|
||||
'session.timeJustNow': '刚刚',
|
||||
'session.timeMinutes': '{n}分钟前',
|
||||
'session.timeHours': '{n}小时前',
|
||||
'session.timeDays': '{n}天前',
|
||||
|
||||
// ─── 应用外壳 ──────────────────────────────────────
|
||||
'app.serverFailed': '本地服务启动失败',
|
||||
'app.launching': '正在启动本地工作区...',
|
||||
|
||||
// ─── Error Codes ──────────────────────────────────────
|
||||
'error.CLI_NOT_RUNNING': 'CLI 进程未运行。会话可能已结束或进程已崩溃。',
|
||||
'error.CLI_START_FAILED': 'CLI 进程启动失败。',
|
||||
|
||||
@ -3,8 +3,9 @@
|
||||
* Works with standard 5-field cron: minute hour day-of-month month day-of-week
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type TFunc = (key: any, params?: Record<string, string | number>) => string
|
||||
import type { TranslationKey } from '../i18n'
|
||||
|
||||
type TFunc = (key: TranslationKey, params?: Record<string, string | number>) => string
|
||||
|
||||
function pad(n: number): string {
|
||||
return n.toString().padStart(2, '0')
|
||||
@ -27,7 +28,7 @@ function describeDow(field: string, t: TFunc): string {
|
||||
days.push(parseInt(part))
|
||||
}
|
||||
}
|
||||
return days.map((d) => t(`cron.dow.${d % 7}`)).join(', ')
|
||||
return days.map((d) => t(`cron.dow.${d % 7}` as any)).join(', ') // dynamic key
|
||||
}
|
||||
|
||||
export function describeCron(cron: string, t: TFunc): string {
|
||||
|
||||
@ -65,11 +65,11 @@ export function ActiveSession() {
|
||||
const lastUpdated = useMemo(() => {
|
||||
if (!session?.modifiedAt) return ''
|
||||
const diff = Date.now() - new Date(session.modifiedAt).getTime()
|
||||
if (diff < 60000) return 'just now'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
||||
return `${Math.floor(diff / 86400000)}d ago`
|
||||
}, [session?.modifiedAt])
|
||||
if (diff < 60000) return t('session.timeJustNow')
|
||||
if (diff < 3600000) return t('session.timeMinutes', { n: Math.floor(diff / 60000) })
|
||||
if (diff < 86400000) return t('session.timeHours', { n: Math.floor(diff / 3600000) })
|
||||
return t('session.timeDays', { n: Math.floor(diff / 86400000) })
|
||||
}, [session?.modifiedAt, t])
|
||||
|
||||
if (!activeTabId) return null
|
||||
|
||||
@ -94,13 +94,13 @@ export function ActiveSession() {
|
||||
<div className="mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-bold font-headline text-on-surface leading-tight">
|
||||
{session?.title || 'Untitled Session'}
|
||||
{session?.title || t('session.untitled')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-[10px] text-outline font-medium mt-1">
|
||||
{isActive && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
|
||||
session active
|
||||
{t('session.active')}
|
||||
</span>
|
||||
)}
|
||||
{totalTokens > 0 && (
|
||||
@ -112,13 +112,13 @@ export function ActiveSession() {
|
||||
{lastUpdated && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>last updated {lastUpdated}</span>
|
||||
<span>{t('session.lastUpdated', { time: lastUpdated })}</span>
|
||||
</>
|
||||
)}
|
||||
{session?.messageCount !== undefined && session.messageCount > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{session.messageCount} messages</span>
|
||||
<span>{t('session.messages', { count: session.messageCount })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -126,7 +126,7 @@ export function ActiveSession() {
|
||||
<div className="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-3 py-1.5 text-[11px] text-[var(--color-error)]">
|
||||
<span className="material-symbols-outlined text-[14px]">warning</span>
|
||||
<span className="truncate">
|
||||
Workspace unavailable: {session.workDir || 'directory no longer exists'}
|
||||
{t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -86,6 +86,10 @@ const pendingTaskToolUseIds = new Set<string>()
|
||||
let msgCounter = 0
|
||||
const nextId = () => `msg-${++msgCounter}-${Date.now()}`
|
||||
|
||||
// Streaming throttle for content_delta
|
||||
let pendingDelta = ''
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** Helper: immutably update a specific session within the sessions record */
|
||||
function updateSessionIn(
|
||||
sessions: Record<string, PerSessionState>,
|
||||
@ -144,6 +148,12 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
disconnectSession: (sessionId) => {
|
||||
const session = get().sessions[sessionId]
|
||||
if (session?.elapsedTimer) clearInterval(session.elapsedTimer)
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
|
||||
if (pendingDelta) {
|
||||
const text = pendingDelta
|
||||
pendingDelta = ''
|
||||
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
|
||||
}
|
||||
wsManager.disconnect(sessionId)
|
||||
set((s) => {
|
||||
const { [sessionId]: _, ...rest } = s.sessions
|
||||
@ -225,6 +235,12 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
|
||||
stopGeneration: (sessionId) => {
|
||||
wsManager.send(sessionId, { type: 'stop_generation' })
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
|
||||
if (pendingDelta) {
|
||||
const text = pendingDelta
|
||||
pendingDelta = ''
|
||||
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
|
||||
}
|
||||
set((s) => {
|
||||
const session = s.sessions[sessionId]
|
||||
if (!session) return s
|
||||
@ -319,7 +335,17 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
}
|
||||
|
||||
case 'content_delta':
|
||||
if (msg.text !== undefined) update((s) => ({ streamingText: s.streamingText + msg.text }))
|
||||
if (msg.text !== undefined) {
|
||||
pendingDelta += msg.text
|
||||
if (!flushTimer) {
|
||||
flushTimer = setTimeout(() => {
|
||||
const text = pendingDelta
|
||||
pendingDelta = ''
|
||||
flushTimer = null
|
||||
update((s) => ({ streamingText: s.streamingText + text }))
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
if (msg.toolInput !== undefined) update((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput }))
|
||||
break
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ type ProviderStore = {
|
||||
providers: SavedProvider[]
|
||||
activeId: string | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
fetchProviders: () => Promise<void>
|
||||
createProvider: (input: CreateProviderInput) => Promise<SavedProvider>
|
||||
@ -29,14 +30,15 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
providers: [],
|
||||
activeId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchProviders: async () => {
|
||||
set({ isLoading: true })
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const { providers, activeId } = await providersApi.list()
|
||||
set({ providers, activeId, isLoading: false })
|
||||
} catch {
|
||||
set({ isLoading: false })
|
||||
} catch (err) {
|
||||
set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ type SettingsStore = {
|
||||
setLocale: (locale: Locale) => void
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsStore>((set) => ({
|
||||
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
permissionMode: 'default',
|
||||
currentModel: null,
|
||||
effortLevel: 'high',
|
||||
@ -68,8 +68,13 @@ export const useSettingsStore = create<SettingsStore>((set) => ({
|
||||
},
|
||||
|
||||
setPermissionMode: async (mode) => {
|
||||
const prev = get().permissionMode
|
||||
set({ permissionMode: mode })
|
||||
await settingsApi.setPermissionMode(mode)
|
||||
try {
|
||||
await settingsApi.setPermissionMode(mode)
|
||||
} catch {
|
||||
set({ permissionMode: prev })
|
||||
}
|
||||
},
|
||||
|
||||
setModel: async (modelId) => {
|
||||
@ -79,8 +84,13 @@ export const useSettingsStore = create<SettingsStore>((set) => ({
|
||||
},
|
||||
|
||||
setEffort: async (level) => {
|
||||
const prev = get().effortLevel
|
||||
set({ effortLevel: level })
|
||||
await modelsApi.setEffort(level)
|
||||
try {
|
||||
await modelsApi.setEffort(level)
|
||||
} catch {
|
||||
set({ effortLevel: prev })
|
||||
}
|
||||
},
|
||||
|
||||
setLocale: (locale) => {
|
||||
|
||||
@ -10,6 +10,7 @@ type TeamStore = {
|
||||
viewingAgentId: string | null
|
||||
agentTranscript: TranscriptMessage[]
|
||||
memberColors: Map<string, AgentColor>
|
||||
error: string | null
|
||||
|
||||
fetchTeams: () => Promise<void>
|
||||
fetchTeamDetail: (name: string) => Promise<void>
|
||||
@ -29,17 +30,20 @@ export const useTeamStore = create<TeamStore>((set, get) => ({
|
||||
viewingAgentId: null,
|
||||
agentTranscript: [],
|
||||
memberColors: new Map(),
|
||||
error: null,
|
||||
|
||||
fetchTeams: async () => {
|
||||
set({ error: null })
|
||||
try {
|
||||
const { teams } = await teamsApi.list()
|
||||
set({ teams })
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
},
|
||||
|
||||
fetchTeamDetail: async (name: string) => {
|
||||
set({ error: null })
|
||||
try {
|
||||
const detail = await teamsApi.get(name)
|
||||
// Assign colors to members
|
||||
@ -48,17 +52,18 @@ export const useTeamStore = create<TeamStore>((set, get) => ({
|
||||
colors.set(m.agentId, AGENT_COLORS[i % AGENT_COLORS.length]!)
|
||||
})
|
||||
set({ activeTeam: detail, memberColors: colors })
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
},
|
||||
|
||||
fetchMemberTranscript: async (teamName: string, agentId: string) => {
|
||||
set({ error: null })
|
||||
try {
|
||||
const { messages } = await teamsApi.getMemberTranscript(teamName, agentId)
|
||||
set({ agentTranscript: messages, viewingAgentId: agentId })
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -78,6 +78,17 @@ const zhSidebar = [
|
||||
{ text: '架构解析', link: '/features/computer-use-architecture' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '桌面端',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '概览', link: '/desktop/' },
|
||||
{ text: '快速上手', link: '/desktop/01-quick-start' },
|
||||
{ text: '架构设计', link: '/desktop/02-architecture' },
|
||||
{ text: '功能详解', link: '/desktop/03-features' },
|
||||
{ text: '安装与构建', link: '/desktop/04-installation' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '参考',
|
||||
collapsed: true,
|
||||
@ -143,6 +154,17 @@ const enSidebar = [
|
||||
{ text: 'Architecture', link: '/en/features/computer-use-architecture' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Desktop',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Overview', link: '/en/desktop/' },
|
||||
{ text: 'Quick Start', link: '/en/desktop/01-quick-start' },
|
||||
{ text: 'Architecture', link: '/en/desktop/02-architecture' },
|
||||
{ text: 'Features', link: '/en/desktop/03-features' },
|
||||
{ text: 'Installation & Build', link: '/en/desktop/04-installation' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Reference',
|
||||
collapsed: true,
|
||||
|
||||
@ -770,8 +770,7 @@ desktop/
|
||||
│ ├── Cargo.toml # Rust 依赖
|
||||
│ └── tauri.conf.json # Tauri 配置
|
||||
├── sidecars/ # Sidecar 启动器
|
||||
│ ├── server-launcher.ts # Server 进程启动
|
||||
│ └── cli-launcher.ts # CLI 进程启动
|
||||
│ └── claude-sidecar.ts # server/cli/adapters 三种模式
|
||||
├── scripts/
|
||||
│ └── build-sidecars.ts # 跨平台编译脚本
|
||||
├── vite.config.ts # Vite 构建配置
|
||||
@ -819,17 +818,17 @@ adapters/ # 外部 IM 适配器
|
||||
|
||||
```bash
|
||||
# 前端开发
|
||||
cd desktop && pnpm dev
|
||||
cd desktop && bun run dev
|
||||
|
||||
# 编译 Sidecar
|
||||
cd desktop && bun run build:sidecars
|
||||
|
||||
# Tauri 开发模式(前端 + 桌面框架)
|
||||
cd desktop && pnpm tauri dev
|
||||
cd desktop && bunx tauri dev
|
||||
|
||||
# 构建发布包
|
||||
cd desktop && pnpm tauri build
|
||||
cd desktop && bunx tauri build
|
||||
|
||||
# 运行测试
|
||||
cd desktop && pnpm test
|
||||
cd desktop && bun run test
|
||||
```
|
||||
|
||||
@ -253,6 +253,10 @@
|
||||
- 支持表格、列表、引用、链接
|
||||
- 内联代码和粗体/斜体
|
||||
|
||||
### Mermaid 图表渲染
|
||||
|
||||
支持 Mermaid 图表实时渲染(`MermaidRenderer` 组件),使用 `securityLevel: 'strict'` 安全模式。支持流程图、时序图、甘特图、类图等所有 Mermaid 图表类型。渲染失败时自动回退显示源代码。
|
||||
|
||||
---
|
||||
|
||||
## 四、Agent Teams 团队协作
|
||||
@ -405,6 +409,10 @@
|
||||
| projectSettings | 项目级自定义 |
|
||||
| localSettings | 本地自定义 |
|
||||
|
||||
### 关于页面
|
||||
|
||||
设置页新增「关于」标签页(About),展示应用名称、版本号、GitHub 仓库链接、作者信息及社交链接。macOS 菜单栏的「关于 Claude Code Haha」菜单项会直接导航到此页面,而非弹出系统默认的关于弹窗。
|
||||
|
||||
---
|
||||
|
||||
## 七、定时任务系统
|
||||
|
||||
@ -5,7 +5,7 @@ import { isEnvTruthy } from '../utils/envUtils.js'
|
||||
export function getGrowthBookClientKey(): string {
|
||||
return process.env.USER_TYPE === 'ant'
|
||||
? isEnvTruthy(process.env.ENABLE_GROWTHBOOK_DEV)
|
||||
? 'sdk-yZQvlplybuXjYh6L'
|
||||
: 'sdk-xRVcrliHIlrg4og4'
|
||||
: 'sdk-zAZezfDKGoZuXXKe'
|
||||
? ''
|
||||
: ''
|
||||
: ''
|
||||
}
|
||||
|
||||
@ -28,7 +28,7 @@ describe('Real Provider Configs', () => {
|
||||
const minimax = await service.addProvider({
|
||||
name: 'MiniMax',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
apiKey: 'sk-api-aijgzQKtr1vLPS1KaoySY7_TRGTXCiQTCvgmoLOG31eyMfnrTBOLdtsEd_fFGAghPY_9Tlxt_jPc6bs5bziApZoEIuGq6bAERlKd-XHebifv44HMxLHDvjQ',
|
||||
apiKey: 'sk-fake-test-key-for-testing-only',
|
||||
models: [
|
||||
{ id: 'MiniMax-M2.7-highspeed', name: 'MiniMax M2.7 Highspeed', description: 'MiniMax 高速模型' },
|
||||
],
|
||||
@ -66,7 +66,7 @@ describe('Real Provider Configs', () => {
|
||||
const jiekou = await service.addProvider({
|
||||
name: '接口AI中转站',
|
||||
baseUrl: 'https://api.jiekou.ai/anthropic',
|
||||
apiKey: 'sk_WGYwseQ5YZ1Kb6tXbqnd-AXIAhlqFnYLfUt2gSF-vjQ',
|
||||
apiKey: 'sk-fake-test-key-for-testing-only',
|
||||
models: [
|
||||
{ id: 'claude-opus-4-6', name: 'Opus 4.6', description: 'Most capable', context: '200k' },
|
||||
{ id: 'claude-sonnet-4-6', name: 'Sonnet 4.6', description: 'Most efficient', context: '200k' },
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
|
||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
@ -37,6 +38,13 @@ async function handleServeFile(url: URL): Promise<Response> {
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
|
||||
// Path whitelist: only allow access under home directory or /tmp
|
||||
const homeDir = os.homedir()
|
||||
if (!resolvedPath.startsWith(homeDir) && !resolvedPath.startsWith('/tmp')) {
|
||||
return json({ error: 'Access denied: path outside allowed directory' }, 403)
|
||||
}
|
||||
|
||||
const ext = path.extname(resolvedPath).toLowerCase()
|
||||
const mimeType = IMAGE_MIME_TYPES[ext]
|
||||
|
||||
@ -71,6 +79,13 @@ async function handleServeFile(url: URL): Promise<Response> {
|
||||
async function handleBrowse(url: URL): Promise<Response> {
|
||||
const targetPath = url.searchParams.get('path') || process.env.HOME || '/'
|
||||
const resolvedPath = path.resolve(targetPath)
|
||||
|
||||
// Path whitelist: only allow browsing under home directory or /tmp
|
||||
const homeDir = os.homedir()
|
||||
if (!resolvedPath.startsWith(homeDir) && !resolvedPath.startsWith('/tmp')) {
|
||||
return json({ error: 'Access denied: path outside allowed directory' }, 403)
|
||||
}
|
||||
|
||||
const searchQuery = url.searchParams.get('search') || ''
|
||||
const includeFiles = url.searchParams.get('includeFiles') === 'true'
|
||||
const maxResults = Math.min(parseInt(url.searchParams.get('maxResults') || '200', 10), 200)
|
||||
|
||||
@ -335,7 +335,7 @@ export class ConversationService {
|
||||
const msg = JSON.parse(line)
|
||||
session.sdkMessages.push(msg)
|
||||
if (session.sdkMessages.length > 40) {
|
||||
session.sdkMessages.shift()
|
||||
session.sdkMessages.splice(0, 20)
|
||||
}
|
||||
if (
|
||||
msg?.type === 'control_request' &&
|
||||
@ -404,7 +404,7 @@ export class ConversationService {
|
||||
.filter(Boolean)) {
|
||||
session.stderrLines.push(line)
|
||||
if (session.stderrLines.length > 20) {
|
||||
session.stderrLines.shift()
|
||||
session.stderrLines.splice(0, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import * as crypto from 'crypto'
|
||||
@ -357,7 +358,11 @@ export class CronScheduler {
|
||||
|
||||
const runId = crypto.randomBytes(6).toString('hex')
|
||||
const startedAt = new Date().toISOString()
|
||||
const workDir = task.folderPath || os.homedir()
|
||||
let workDir = task.folderPath || os.homedir()
|
||||
if (task.folderPath && (!existsSync(task.folderPath) || !statSync(task.folderPath).isDirectory())) {
|
||||
console.warn(`[cron] task ${task.id}: folderPath "${task.folderPath}" is not a valid directory, falling back to homedir`)
|
||||
workDir = os.homedir()
|
||||
}
|
||||
|
||||
// Only create a session when explicitly requested (manual "Run Now"),
|
||||
// not for automatic cron runs — avoids flooding the sidebar.
|
||||
|
||||
@ -26,6 +26,12 @@ const providerService = new ProviderService()
|
||||
*/
|
||||
const sessionSlashCommands = new Map<string, Array<{ name: string; description: string }>>()
|
||||
|
||||
/**
|
||||
* Timers for delayed session cleanup after client disconnect.
|
||||
* If a client reconnects within 5 minutes, the timer is cancelled.
|
||||
*/
|
||||
const sessionCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/**
|
||||
* Track user message count and title state per session for auto-title generation.
|
||||
*/
|
||||
@ -70,6 +76,13 @@ export const handleWebSocket = {
|
||||
|
||||
console.log(`[WS] Client connected for session: ${sessionId}`)
|
||||
|
||||
// Cancel pending cleanup timer if client reconnects
|
||||
const pendingTimer = sessionCleanupTimers.get(sessionId)
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer)
|
||||
sessionCleanupTimers.delete(sessionId)
|
||||
}
|
||||
|
||||
activeSessions.set(sessionId, ws)
|
||||
|
||||
const msg: ServerMessage = { type: 'connected', sessionId }
|
||||
@ -134,9 +147,16 @@ export const handleWebSocket = {
|
||||
sessionSlashCommands.delete(sessionId)
|
||||
sessionTitleState.delete(sessionId)
|
||||
|
||||
// NOTE: Do NOT stop CLI subprocess on WS disconnect.
|
||||
// The CLI process should stay alive so reconnecting reuses it.
|
||||
// This prevents "Session ID already in use" errors from stale locks.
|
||||
// Schedule delayed cleanup: if the client doesn't reconnect within 5 minutes,
|
||||
// stop the CLI subprocess to avoid leaking resources.
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
sessionCleanupTimers.delete(sessionId)
|
||||
if (!activeSessions.has(sessionId)) {
|
||||
console.log(`[WS] Session ${sessionId} not reconnected after 5 min, stopping CLI subprocess`)
|
||||
conversationService.stopSession(sessionId)
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
sessionCleanupTimers.set(sessionId, cleanupTimer)
|
||||
},
|
||||
|
||||
drain(ws: ServerWebSocket<WebSocketData>) {
|
||||
|
||||
@ -11,7 +11,7 @@ import { getEventMetadata } from './metadata.js'
|
||||
|
||||
const DATADOG_LOGS_ENDPOINT =
|
||||
'https://http-intake.logs.us5.datadoghq.com/api/v2/logs'
|
||||
const DATADOG_CLIENT_TOKEN = 'pubbbf48e6d78dae54bceaa4acf463299bf'
|
||||
const DATADOG_CLIENT_TOKEN = ''
|
||||
const DEFAULT_FLUSH_INTERVAL_MS = 15000
|
||||
const MAX_BATCH_SIZE = 100
|
||||
const NETWORK_TIMEOUT_MS = 5000
|
||||
@ -128,7 +128,7 @@ function scheduleFlush(): void {
|
||||
}
|
||||
|
||||
export const initializeDatadog = memoize(async (): Promise<boolean> => {
|
||||
if (isAnalyticsDisabled()) {
|
||||
if (!DATADOG_CLIENT_TOKEN || isAnalyticsDisabled()) {
|
||||
datadogInitialized = false
|
||||
return false
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user