diff --git a/.gitignore b/.gitignore index 4713b5e6..4f794fa5 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/desktop/bun.lock b/desktop/bun.lock index 525141c9..52d20da9 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -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=="], diff --git a/desktop/package.json b/desktop/package.json index 84de5264..05779397 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index b445af1e..0e039140 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -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 } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 9c9104a7..744fda3c 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -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": { diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 9729651f..fcdf668e 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -60,8 +60,8 @@ describe('Content-only pages render without errors', () => { }, }) const { container } = render() - // 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') diff --git a/desktop/src/api/client.ts b/desktop/src/api/client.ts index 94ccbd5e..3d9d07e0 100644 --- a/desktop/src/api/client.ts +++ b/desktop/src/api/client.ts @@ -30,19 +30,28 @@ async function request(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 + } catch (err) { + clearTimeout(timeout) + throw err } - - if (res.status === 204) return undefined as T - return res.json() as Promise } export const api = { diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 900efe42..191fea48 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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(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 diff --git a/desktop/src/components/chat/MermaidRenderer.tsx b/desktop/src/components/chat/MermaidRenderer.tsx index ba110ab7..22978b95 100644 --- a/desktop/src/components/chat/MermaidRenderer.tsx +++ b/desktop/src/components/chat/MermaidRenderer.tsx @@ -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 } }) }} /> @@ -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 } }) }} /> diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 9fe316fb..d13c5c56 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -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() - const toolResultMap = new Map() - const childToolCallsByParent = new Map() + const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(() => { + const toolUseIds = new Set() + const toolResultMap = new Map() + const childToolCallsByParent = new Map() - 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 (
@@ -157,7 +160,7 @@ export function MessageList() { ) } -function MessageBlock({ +const MessageBlock = memo(function MessageBlock({ message, activeThinkingId, agentTaskNotifications, @@ -234,4 +237,4 @@ function MessageBlock({
) } -} +}) diff --git a/desktop/src/components/chat/PermissionDialog.tsx b/desktop/src/components/chat/PermissionDialog.tsx index fd0c1db2..dce69385 100644 --- a/desktop/src/components/chat/PermissionDialog.tsx +++ b/desktop/src/components/chat/PermissionDialog.tsx @@ -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 /** * 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): { primary: string; secondary?: string } { const obj = (input && typeof input === 'object') ? input as Record : {} 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) { const obj = (input && typeof input === 'object') ? input as Record : {} const filePath = typeof obj.file_path === 'string' ? obj.file_path : '' const fileName = filePath ? filePath.split('/').pop() || filePath : '' diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx index 4649313a..f8e53ee2 100644 --- a/desktop/src/components/chat/ToolCallBlock.tsx +++ b/desktop/src/components/chat/ToolCallBlock.tsx @@ -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, result?: { content: unknown; isError: boolean } | null, - t?: (key: any, params?: any) => string, + t?: (key: TranslationKey, params?: Record) => 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, t?: (key: any, params?: any) => string) { +function renderDetails(toolName: string, obj: Record, t?: (key: TranslationKey, params?: Record) => string) { if (toolName === 'Edit' || toolName === 'Write') { return null } @@ -173,7 +174,7 @@ function renderDetails(toolName: string, obj: Record, 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 { 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, t?: (key: any, params?: any) => string): string { +function getToolSummary(toolName: string, obj: Record, t?: (key: TranslationKey, params?: Record) => 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 { const oldLines = oldString.split('\n') const newLines = newString.split('\n') let changed = 0 diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx index aa8f4467..8b712ba2 100644 --- a/desktop/src/components/chat/ToolCallGroup.tsx +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -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 @@ -17,7 +18,7 @@ type Props = { isStreaming?: boolean } -const TOOL_VERBS: Record string) => string> = { +const TOOL_VERBS: Record) => 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 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 { const counts = new Map() 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 { switch (status) { case 'failed': diff --git a/desktop/src/components/controls/PermissionModeSelector.tsx b/desktop/src/components/controls/PermissionModeSelector.tsx index bc2fa751..dbffda49 100644 --- a/desktop/src/components/controls/PermissionModeSelector.tsx +++ b/desktop/src/components/controls/PermissionModeSelector.tsx @@ -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 */}
-

+

folder {workDir} diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index eab928e0..323a7fe0 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -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(null) + const t = useTranslation() useEffect(() => { let cancelled = false @@ -72,7 +74,7 @@ export function AppShell() {

- Local server failed to start + {t('app.serverFailed')}

{startupError} @@ -85,7 +87,7 @@ export function AppShell() { if (!ready) { return (

- Launching local workspace... + {t('app.launching')}
) } diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index 41f4387a..b9a9dfe5 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -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 } // Special tabs - if (activeTab.type === 'settings') { + if (activeTabType === 'settings') { return } - if (activeTab.type === 'scheduled') { + if (activeTabType === 'scheduled') { return } diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index ba356f5a..f2c24a1c 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -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) diff --git a/desktop/src/components/layout/StatusBar.tsx b/desktop/src/components/layout/StatusBar.tsx index e3121f16..e95ad7b0 100644 --- a/desktop/src/components/layout/StatusBar.tsx +++ b/desktop/src/components/layout/StatusBar.tsx @@ -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 ( diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx index abe90780..6bb60abd 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.tsx @@ -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 (
) @@ -182,7 +184,7 @@ export function MarkdownRenderer({ content, variant = 'default', className }: Pr
{parts.map((part, i) => part.type === 'html' ? ( -
+
) : shouldRenderAsMermaid(part.block) ? ( ) : ( diff --git a/desktop/src/components/shared/UpdateChecker.tsx b/desktop/src/components/shared/UpdateChecker.tsx index 91f5635b..771acbff 100644 --- a/desktop/src/components/shared/UpdateChecker.tsx +++ b/desktop/src/components/shared/UpdateChecker.tsx @@ -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(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() {

- v{update.version} available + {t('update.available', { version: update.version })}

{update.downloading ? (
@@ -91,7 +93,7 @@ export function UpdateChecker() { style={{ width: `${Math.min(update.progress, 100)}%` }} />
-

Downloading...

+

{t('update.downloading')}

) : (
@@ -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')}
)} diff --git a/desktop/src/components/tasks/DayOfWeekPicker.tsx b/desktop/src/components/tasks/DayOfWeekPicker.tsx index 6a9ef7ea..577b8484 100644 --- a/desktop/src/components/tasks/DayOfWeekPicker.tsx +++ b/desktop/src/components/tasks/DayOfWeekPicker.tsx @@ -48,7 +48,7 @@ export function DayOfWeekPicker({ selected, onChange }: Props) { } `} > - {t(DAY_KEYS[day] as any)} + {t(DAY_KEYS[day]!)} ) })} diff --git a/desktop/src/components/tasks/TaskRunsPanel.tsx b/desktop/src/components/tasks/TaskRunsPanel.tsx index 93685cf5..582e8c9d 100644 --- a/desktop/src/components/tasks/TaskRunsPanel.tsx +++ b/desktop/src/components/tasks/TaskRunsPanel.tsx @@ -141,7 +141,7 @@ export function TaskRunsPanel({ taskId, onClose, refreshKey }: Props) { {/* Status text */} - {t(`tasks.runStatus.${run.status}` as any)} + {t(`tasks.runStatus.${run.status}` as any)} {/* dynamic key */} {/* Time */} diff --git a/desktop/src/hooks/useKeyboardShortcuts.ts b/desktop/src/hooks/useKeyboardShortcuts.ts index 21f2afdd..2fc633b9 100644 --- a/desktop/src/hooks/useKeyboardShortcuts.ts +++ b/desktop/src/hooks/useKeyboardShortcuts.ts @@ -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]) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index b639e840..1b222e73 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index e1b0e921..6c6f58a4 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -518,6 +518,29 @@ export const zh: Record = { '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 进程启动失败。', diff --git a/desktop/src/lib/cronDescribe.ts b/desktop/src/lib/cronDescribe.ts index 1365f6db..7383cf68 100644 --- a/desktop/src/lib/cronDescribe.ts +++ b/desktop/src/lib/cronDescribe.ts @@ -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 +import type { TranslationKey } from '../i18n' + +type TFunc = (key: TranslationKey, params?: Record) => 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 { diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 3aeb1eeb..6b2907d9 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -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() {

- {session?.title || 'Untitled Session'} + {session?.title || t('session.untitled')}

{isActive && ( - session active + {t('session.active')} )} {totalTokens > 0 && ( @@ -112,13 +112,13 @@ export function ActiveSession() { {lastUpdated && ( <> · - last updated {lastUpdated} + {t('session.lastUpdated', { time: lastUpdated })} )} {session?.messageCount !== undefined && session.messageCount > 0 && ( <> · - {session.messageCount} messages + {t('session.messages', { count: session.messageCount })} )}
@@ -126,7 +126,7 @@ export function ActiveSession() {
warning - Workspace unavailable: {session.workDir || 'directory no longer exists'} + {t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
)} diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 1c2094f8..5dbc627f 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -86,6 +86,10 @@ const pendingTaskToolUseIds = new Set() let msgCounter = 0 const nextId = () => `msg-${++msgCounter}-${Date.now()}` +// Streaming throttle for content_delta +let pendingDelta = '' +let flushTimer: ReturnType | null = null + /** Helper: immutably update a specific session within the sessions record */ function updateSessionIn( sessions: Record, @@ -144,6 +148,12 @@ export const useChatStore = create((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((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((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 diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts index 27b3ee93..fb02d73a 100644 --- a/desktop/src/stores/providerStore.ts +++ b/desktop/src/stores/providerStore.ts @@ -14,6 +14,7 @@ type ProviderStore = { providers: SavedProvider[] activeId: string | null isLoading: boolean + error: string | null fetchProviders: () => Promise createProvider: (input: CreateProviderInput) => Promise @@ -29,14 +30,15 @@ export const useProviderStore = create((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) }) } }, diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 950cc894..b8831a96 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -31,7 +31,7 @@ type SettingsStore = { setLocale: (locale: Locale) => void } -export const useSettingsStore = create((set) => ({ +export const useSettingsStore = create((set, get) => ({ permissionMode: 'default', currentModel: null, effortLevel: 'high', @@ -68,8 +68,13 @@ export const useSettingsStore = create((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((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) => { diff --git a/desktop/src/stores/teamStore.ts b/desktop/src/stores/teamStore.ts index 437e1d45..3ad9a04f 100644 --- a/desktop/src/stores/teamStore.ts +++ b/desktop/src/stores/teamStore.ts @@ -10,6 +10,7 @@ type TeamStore = { viewingAgentId: string | null agentTranscript: TranscriptMessage[] memberColors: Map + error: string | null fetchTeams: () => Promise fetchTeamDetail: (name: string) => Promise @@ -29,17 +30,20 @@ export const useTeamStore = create((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((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) }) } }, diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index ca2558aa..19abb51d 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -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, diff --git a/docs/desktop/02-architecture.md b/docs/desktop/02-architecture.md index 84a03a5c..7cbdd096 100644 --- a/docs/desktop/02-architecture.md +++ b/docs/desktop/02-architecture.md @@ -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 ``` diff --git a/docs/desktop/03-features.md b/docs/desktop/03-features.md index c88ade5f..4e333119 100644 --- a/docs/desktop/03-features.md +++ b/docs/desktop/03-features.md @@ -253,6 +253,10 @@ - 支持表格、列表、引用、链接 - 内联代码和粗体/斜体 +### Mermaid 图表渲染 + +支持 Mermaid 图表实时渲染(`MermaidRenderer` 组件),使用 `securityLevel: 'strict'` 安全模式。支持流程图、时序图、甘特图、类图等所有 Mermaid 图表类型。渲染失败时自动回退显示源代码。 + --- ## 四、Agent Teams 团队协作 @@ -405,6 +409,10 @@ | projectSettings | 项目级自定义 | | localSettings | 本地自定义 | +### 关于页面 + +设置页新增「关于」标签页(About),展示应用名称、版本号、GitHub 仓库链接、作者信息及社交链接。macOS 菜单栏的「关于 Claude Code Haha」菜单项会直接导航到此页面,而非弹出系统默认的关于弹窗。 + --- ## 七、定时任务系统 diff --git a/src/constants/keys.ts b/src/constants/keys.ts index 773fdd14..5971afa1 100644 --- a/src/constants/keys.ts +++ b/src/constants/keys.ts @@ -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' + ? '' + : '' + : '' } diff --git a/src/server/__tests__/providers-real.test.ts b/src/server/__tests__/providers-real.test.ts index dea45f60..31655950 100644 --- a/src/server/__tests__/providers-real.test.ts +++ b/src/server/__tests__/providers-real.test.ts @@ -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' }, diff --git a/src/server/api/filesystem.ts b/src/server/api/filesystem.ts index 2e387d62..94b51b05 100644 --- a/src/server/api/filesystem.ts +++ b/src/server/api/filesystem.ts @@ -5,6 +5,7 @@ import * as path from 'path' import * as fs from 'fs' +import * as os from 'os' const IMAGE_MIME_TYPES: Record = { '.png': 'image/png', @@ -37,6 +38,13 @@ async function handleServeFile(url: URL): Promise { } 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 { async function handleBrowse(url: URL): Promise { 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) diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 00bf9c23..fdabcf7f 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -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) } } } diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts index aa29324f..332d0c04 100644 --- a/src/server/services/cronScheduler.ts +++ b/src/server/services/cronScheduler.ts @@ -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. diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 1076955d..6975e215 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -26,6 +26,12 @@ const providerService = new ProviderService() */ const sessionSlashCommands = new Map>() +/** + * Timers for delayed session cleanup after client disconnect. + * If a client reconnects within 5 minutes, the timer is cancelled. + */ +const sessionCleanupTimers = new Map>() + /** * 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) { diff --git a/src/services/analytics/datadog.ts b/src/services/analytics/datadog.ts index 2f8bdf31..1d0e457e 100644 --- a/src/services/analytics/datadog.ts +++ b/src/services/analytics/datadog.ts @@ -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 => { - if (isAnalyticsDisabled()) { + if (!DATADOG_CLIENT_TOKEN || isAnalyticsDisabled()) { datadogInitialized = false return false }