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:
程序员阿江(Relakkes) 2026-04-13 22:27:17 +08:00
parent a0fa8751b3
commit 8888e1c3fb
41 changed files with 385 additions and 132 deletions

6
.gitignore vendored
View File

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

View File

@ -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=="],

View File

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

View File

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

View File

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

View File

@ -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')

View File

@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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} />
) : (

View File

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

View File

@ -48,7 +48,7 @@ export function DayOfWeekPicker({ selected, onChange }: Props) {
}
`}
>
{t(DAY_KEYS[day] as any)}
{t(DAY_KEYS[day]!)}
</button>
)
})}

View File

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

View File

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

View File

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

View File

@ -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 进程启动失败。',

View File

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

View File

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

View File

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

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

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

View File

@ -253,6 +253,10 @@
- 支持表格、列表、引用、链接
- 内联代码和粗体/斜体
### Mermaid 图表渲染
支持 Mermaid 图表实时渲染(`MermaidRenderer` 组件),使用 `securityLevel: 'strict'` 安全模式。支持流程图、时序图、甘特图、类图等所有 Mermaid 图表类型。渲染失败时自动回退显示源代码。
---
## 四、Agent Teams 团队协作
@ -405,6 +409,10 @@
| projectSettings | 项目级自定义 |
| localSettings | 本地自定义 |
### 关于页面
设置页新增「关于」标签页About展示应用名称、版本号、GitHub 仓库链接、作者信息及社交链接。macOS 菜单栏的「关于 Claude Code Haha」菜单项会直接导航到此页面而非弹出系统默认的关于弹窗。
---
## 七、定时任务系统

View File

@ -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'
? ''
: ''
: ''
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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>) {

View File

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