diff --git a/.gitignore b/.gitignore index 85ca9d4..7b53d60 100644 --- a/.gitignore +++ b/.gitignore @@ -86,4 +86,7 @@ runtime/__pycache__ scripts/benchmark.ts # Sidecar binaries -sidecar/*.exe \ No newline at end of file +sidecar/*.exe + +# Benchmark results +.benchmark-results/ diff --git a/.omc/project-memory.json b/.omc/project-memory.json deleted file mode 100644 index 33c90e2..0000000 --- a/.omc/project-memory.json +++ /dev/null @@ -1,354 +0,0 @@ -{ - "version": "1.0.0", - "lastScanned": 1779809927711, - "projectRoot": "E:\\AProject\\TianX\\Personal\\dreamfield", - "techStack": { - "languages": [], - "frameworks": [], - "packageManager": null, - "runtime": null - }, - "build": { - "buildCommand": "cd E:/AProject/TianX/Personal/dreamfield && git add -A && git commit -m \"$(cat <<'EOF'\nfix: skip tsc in desktop build, add target-triple sidecar binary\n\n- Remove tsc -b from desktop build script (test type errors are\n non-blocking for Vite build)\n- Copy sidecar binary with x86_64-pc-windows-msvc target triple\n suffix required by Tauri externalBin\n- Rust cargo check passes with only dead_code warnings\nEOF\n)\"", - "testCommand": null, - "lintCommand": null, - "devCommand": null, - "scripts": {} - }, - "conventions": { - "namingStyle": null, - "importStyle": null, - "testPattern": null, - "fileOrganization": null - }, - "structure": { - "isMonorepo": false, - "workspaces": [], - "mainDirectories": [ - "docs" - ], - "gitBranches": null - }, - "customNotes": [], - "directoryMap": { - "docs": { - "path": "docs", - "purpose": "Documentation", - "fileCount": 1, - "lastAccessed": 1779809927688, - "keyFiles": [ - "activity_rule.md" - ] - }, - "_reference": { - "path": "_reference", - "purpose": null, - "fileCount": 0, - "lastAccessed": 1779809927688, - "keyFiles": [] - } - }, - "hotPaths": [ - { - "path": ".superpowers\\brainstorm\\1716-1779810984\\content\\provider-v2.html", - "accessCount": 4, - "lastAccessed": 1779813224966, - "type": "file" - }, - { - "path": "preload.ts", - "accessCount": 3, - "lastAccessed": 1779817079788, - "type": "file" - }, - { - "path": "bin\\dreamcoder", - "accessCount": 3, - "lastAccessed": 1779817079832, - "type": "file" - }, - { - "path": ".env.example", - "accessCount": 3, - "lastAccessed": 1779817079918, - "type": "file" - }, - { - "path": "README.md", - "accessCount": 3, - "lastAccessed": 1779818225651, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\package.json", - "accessCount": 2, - "lastAccessed": 1779810423050, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\preload.ts", - "accessCount": 2, - "lastAccessed": 1779813730766, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\bin\\claude-haha", - "accessCount": 2, - "lastAccessed": 1779813730784, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\desktop\\package.json", - "accessCount": 2, - "lastAccessed": 1779813730795, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\tsconfig.json", - "accessCount": 2, - "lastAccessed": 1779813730839, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\desktop\\vite.config.ts", - "accessCount": 2, - "lastAccessed": 1779813749291, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\desktop\\src-tauri\\tauri.conf.json", - "accessCount": 2, - "lastAccessed": 1779813749336, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\desktop\\src\\main.tsx", - "accessCount": 2, - "lastAccessed": 1779813749354, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\desktop\\src-tauri\\src\\lib.rs", - "accessCount": 2, - "lastAccessed": 1779813749443, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\desktop\\sidecars\\claude-sidecar.ts", - "accessCount": 2, - "lastAccessed": 1779813758657, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\src\\server\\config\\providerPresets.json", - "accessCount": 2, - "lastAccessed": 1779813758721, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\src\\server\\index.ts", - "accessCount": 2, - "lastAccessed": 1779813758837, - "type": "file" - }, - { - "path": "docs\\superpowers\\plans\\2026-05-27-dreamcoder-mvp.md", - "accessCount": 2, - "lastAccessed": 1779815440446, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\README.md", - "accessCount": 1, - "lastAccessed": 1779810007942, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\AGENTS.md", - "accessCount": 1, - "lastAccessed": 1779810019171, - "type": "file" - }, - { - "path": "docs\\activity_rule.md", - "accessCount": 1, - "lastAccessed": 1779810019225, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\desktop\\src\\App.tsx", - "accessCount": 1, - "lastAccessed": 1779810495250, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\desktop\\src\\api\\websocket.ts", - "accessCount": 1, - "lastAccessed": 1779810495491, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\src\\entrypoints\\cli.tsx", - "accessCount": 1, - "lastAccessed": 1779810495548, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\src\\server\\server.ts", - "accessCount": 1, - "lastAccessed": 1779810616885, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\src\\server\\router.ts", - "accessCount": 1, - "lastAccessed": 1779810616974, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\src\\server\\ws\\events.ts", - "accessCount": 1, - "lastAccessed": 1779810617046, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\src\\server\\ws\\handler.ts", - "accessCount": 1, - "lastAccessed": 1779810617107, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\src\\server\\api\\providers.ts", - "accessCount": 1, - "lastAccessed": 1779810649029, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\desktop\\sidecars\\launcherRouting.ts", - "accessCount": 1, - "lastAccessed": 1779810649042, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\src\\server\\services\\providerService.ts", - "accessCount": 1, - "lastAccessed": 1779810649055, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\src\\server\\proxy\\handler.ts", - "accessCount": 1, - "lastAccessed": 1779810674797, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\src\\server\\api\\mcp.ts", - "accessCount": 1, - "lastAccessed": 1779810674900, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\src\\server\\services\\conversationService.ts", - "accessCount": 1, - "lastAccessed": 1779810674936, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\desktop\\src\\components\\layout\\AppShell.tsx", - "accessCount": 1, - "lastAccessed": 1779810690654, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\desktop\\src\\api\\client.ts", - "accessCount": 1, - "lastAccessed": 1779810690883, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\desktop\\src\\components\\layout\\ContentRouter.tsx", - "accessCount": 1, - "lastAccessed": 1779810690917, - "type": "file" - }, - { - "path": "C:\\Users\\26455\\.claude\\plugins\\cache\\superpowers-dev\\superpowers\\5.0.6\\skills\\brainstorming\\visual-companion.md", - "accessCount": 1, - "lastAccessed": 1779810953249, - "type": "file" - }, - { - "path": ".superpowers\\brainstorm\\1716-1779810984\\state\\server-info", - "accessCount": 1, - "lastAccessed": 1779811022620, - "type": "file" - }, - { - "path": ".superpowers\\brainstorm\\1716-1779810984\\content\\approaches.html", - "accessCount": 1, - "lastAccessed": 1779812189482, - "type": "file" - }, - { - "path": ".superpowers\\brainstorm\\1716-1779810984\\content\\architecture.html", - "accessCount": 1, - "lastAccessed": 1779812332261, - "type": "file" - }, - { - "path": ".superpowers\\brainstorm\\1716-1779810984\\content\\provider.html", - "accessCount": 1, - "lastAccessed": 1779812741289, - "type": "file" - }, - { - "path": "docs\\superpowers\\specs\\2026-05-27-dreamcoder-mvp-design.md", - "accessCount": 1, - "lastAccessed": 1779813492921, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\bunfig.toml", - "accessCount": 1, - "lastAccessed": 1779813730823, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\desktop\\src-tauri\\Cargo.toml", - "accessCount": 1, - "lastAccessed": 1779813749205, - "type": "file" - }, - { - "path": "_reference\\cc-haha\\.env.example", - "accessCount": 1, - "lastAccessed": 1779813758778, - "type": "file" - }, - { - "path": "C:\\Users\\26455\\.claude\\plugins\\cache\\superpowers-dev\\superpowers\\5.0.6\\skills\\subagent-driven-development\\code-quality-reviewer-prompt.md", - "accessCount": 1, - "lastAccessed": 1779815432453, - "type": "file" - }, - { - "path": "C:\\Users\\26455\\.claude\\plugins\\cache\\superpowers-dev\\superpowers\\5.0.6\\skills\\subagent-driven-development\\spec-reviewer-prompt.md", - "accessCount": 1, - "lastAccessed": 1779815432522, - "type": "file" - }, - { - "path": "C:\\Users\\26455\\.claude\\plugins\\cache\\superpowers-dev\\superpowers\\5.0.6\\skills\\subagent-driven-development\\implementer-prompt.md", - "accessCount": 1, - "lastAccessed": 1779815432542, - "type": "file" - }, - { - "path": "package.json", - "accessCount": 1, - "lastAccessed": 1779816024706, - "type": "file" - } - ], - "userDirectives": [] -} \ No newline at end of file diff --git a/.omc/sessions/07db39b9-52cd-4784-b90c-5798235d2a40.json b/.omc/sessions/07db39b9-52cd-4784-b90c-5798235d2a40.json deleted file mode 100644 index 5619c72..0000000 --- a/.omc/sessions/07db39b9-52cd-4784-b90c-5798235d2a40.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "07db39b9-52cd-4784-b90c-5798235d2a40", - "ended_at": "2026-05-26T16:49:21.007Z", - "reason": "prompt_input_exit", - "agents_spawned": 2, - "agents_completed": 2, - "modes_used": [] -} \ No newline at end of file diff --git a/.omc/sessions/bad901df-8e97-4225-9672-2f5de0b8a571.json b/.omc/sessions/bad901df-8e97-4225-9672-2f5de0b8a571.json deleted file mode 100644 index 0f0e8d5..0000000 --- a/.omc/sessions/bad901df-8e97-4225-9672-2f5de0b8a571.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "bad901df-8e97-4225-9672-2f5de0b8a571", - "ended_at": "2026-05-26T14:50:51.200Z", - "reason": "other", - "agents_spawned": 0, - "agents_completed": 0, - "modes_used": [] -} \ No newline at end of file diff --git a/.omc/sessions/f036cfa0-46cd-43f9-a5ee-ab0f802cd30e.json b/.omc/sessions/f036cfa0-46cd-43f9-a5ee-ab0f802cd30e.json deleted file mode 100644 index 49d1c18..0000000 --- a/.omc/sessions/f036cfa0-46cd-43f9-a5ee-ab0f802cd30e.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "f036cfa0-46cd-43f9-a5ee-ab0f802cd30e", - "ended_at": "2026-05-26T15:38:47.147Z", - "reason": "clear", - "agents_spawned": 0, - "agents_completed": 0, - "modes_used": [] -} \ No newline at end of file diff --git a/.omc/state/agent-replay-07db39b9-52cd-4784-b90c-5798235d2a40.jsonl b/.omc/state/agent-replay-07db39b9-52cd-4784-b90c-5798235d2a40.jsonl deleted file mode 100644 index cce8924..0000000 --- a/.omc/state/agent-replay-07db39b9-52cd-4784-b90c-5798235d2a40.jsonl +++ /dev/null @@ -1,9 +0,0 @@ -{"t":0,"agent":"a80c9d6","agent_type":"unknown","event":"agent_stop","success":true} -{"t":0,"agent":"system","event":"skill_invoked","skill_name":"superpowers:subagent-driven-development"} -{"t":0,"agent":"aa228c2","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a48c1de","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a48c1de","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":1051543} -{"t":0,"agent":"a1456c0","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a1456c0","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":207006} -{"t":0,"agent":"a339115","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a339115","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":945577} diff --git a/.omc/state/hud-stdin-cache.json b/.omc/state/hud-stdin-cache.json deleted file mode 100644 index 7c24bde..0000000 --- a/.omc/state/hud-stdin-cache.json +++ /dev/null @@ -1 +0,0 @@ -{"session_id":"f10b826f-57f2-489e-81e2-8e253e2966df","transcript_path":"C:\\Users\\26455\\.claude\\projects\\E--AProject-TianX-Personal-dreamfield\\f10b826f-57f2-489e-81e2-8e253e2966df.jsonl","cwd":"E:\\AProject\\TianX\\Personal\\dreamfield","effort":{"level":"high"},"session_name":"README","model":{"id":"GLM-5.1","display_name":"GLM-5.1"},"workspace":{"current_dir":"E:\\AProject\\TianX\\Personal\\dreamfield","project_dir":"E:\\AProject\\TianX\\Personal\\dreamfield","added_dirs":[],"repo":{"host":"github.com","owner":"GoDiao","name":"dreamcoder"}},"version":"2.1.150","output_style":{"name":"default"},"cost":{"total_cost_usd":6.994319,"total_duration_ms":8154885,"total_api_duration_ms":600899,"total_lines_added":142,"total_lines_removed":39},"context_window":{"total_input_tokens":86166,"total_output_tokens":100,"context_window_size":200000,"current_usage":{"input_tokens":76886,"output_tokens":100,"cache_creation_input_tokens":0,"cache_read_input_tokens":9280},"used_percentage":43,"remaining_percentage":57},"exceeds_200k_tokens":false,"fast_mode":false,"thinking":{"enabled":true}} \ No newline at end of file diff --git a/.omc/state/last-tool-error.json b/.omc/state/last-tool-error.json deleted file mode 100644 index 7740129..0000000 --- a/.omc/state/last-tool-error.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "tool_name": "Bash", - "tool_input_preview": "{\"command\":\"ls \\\"E:/AProject/TianX/Personal/dreamfield/src/server/\\\" 2>/dev/null && echo \\\"---\\\" && ls \\\"E:/AProject/TianX/Personal/dreamfield/src/coordinator/\\\" 2>/dev/null && echo \\\"---\\\" && ls \\\"E:...", - "error": "Exit code 2\n__tests__\napi\nbackends\nconfig\nconnectHeadless.ts\ncreateDirectConnectSession.ts\ndirectConnectManager.ts\nh5AccessPolicy.ts\nindex.ts\nlockfile.ts\nmiddleware\nparseConnectUrl.ts\nproxy\nrouter.ts\nserver.ts\nserverBanner.ts\nserverLog.ts\nservices\nsessionManager.ts\nstaticH5.ts\ntypes\ntypes.ts\nws\n---", - "timestamp": "2026-05-26T18:04:50.714Z", - "retry_count": 1 -} \ No newline at end of file diff --git a/.omc/state/mission-state.json b/.omc/state/mission-state.json deleted file mode 100644 index b4cd02e..0000000 --- a/.omc/state/mission-state.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "updatedAt": "2026-05-26T18:25:34.471Z", - "missions": [ - { - "id": "session:07db39b9-52cd-4784-b90c-5798235d2a40:none", - "source": "session", - "name": "none", - "objective": "Session mission", - "createdAt": "2026-05-26T17:12:28.794Z", - "updatedAt": "2026-05-26T18:25:34.471Z", - "status": "running", - "workerCount": 4, - "taskCounts": { - "total": 4, - "pending": 0, - "blocked": 0, - "inProgress": 1, - "completed": 3, - "failed": 0 - }, - "agents": [ - { - "name": "executor:aa228c2", - "role": "executor", - "ownership": "aa228c29f65153060", - "status": "running", - "currentStep": null, - "latestUpdate": null, - "completedSummary": null, - "updatedAt": "2026-05-26T17:12:28.794Z" - }, - { - "name": "executor:a48c1de", - "role": "executor", - "ownership": "a48c1deb735db23b2", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-05-26T17:54:23.522Z" - }, - { - "name": "executor:a1456c0", - "role": "executor", - "ownership": "a1456c0bbbec0fbe6", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-05-26T18:08:03.856Z" - }, - { - "name": "executor:a339115", - "role": "executor", - "ownership": "a33911549372f55f8", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-05-26T18:25:34.471Z" - } - ], - "timeline": [ - { - "id": "session-start:a1456c0bbbec0fbe6:2026-05-26T18:04:36.850Z", - "at": "2026-05-26T18:04:36.850Z", - "kind": "update", - "agent": "executor:a1456c0", - "detail": "started executor:a1456c0", - "sourceKey": "session-start:a1456c0bbbec0fbe6" - }, - { - "id": "session-stop:a1456c0bbbec0fbe6:2026-05-26T18:08:03.856Z", - "at": "2026-05-26T18:08:03.856Z", - "kind": "completion", - "agent": "executor:a1456c0", - "detail": "completed", - "sourceKey": "session-stop:a1456c0bbbec0fbe6" - }, - { - "id": "session-start:a33911549372f55f8:2026-05-26T18:09:48.894Z", - "at": "2026-05-26T18:09:48.894Z", - "kind": "update", - "agent": "executor:a339115", - "detail": "started executor:a339115", - "sourceKey": "session-start:a33911549372f55f8" - }, - { - "id": "session-stop:a33911549372f55f8:2026-05-26T18:25:34.471Z", - "at": "2026-05-26T18:25:34.471Z", - "kind": "completion", - "agent": "executor:a339115", - "detail": "completed", - "sourceKey": "session-stop:a33911549372f55f8" - } - ] - } - ] -} \ No newline at end of file diff --git a/.omc/state/sessions/07db39b9-52cd-4784-b90c-5798235d2a40/hud-state.json b/.omc/state/sessions/07db39b9-52cd-4784-b90c-5798235d2a40/hud-state.json deleted file mode 100644 index ab805f8..0000000 --- a/.omc/state/sessions/07db39b9-52cd-4784-b90c-5798235d2a40/hud-state.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "timestamp": "2026-05-26T15:39:31.391Z", - "backgroundTasks": [], - "sessionStartTimestamp": "2026-05-26T15:38:47.667Z", - "sessionId": "07db39b9-52cd-4784-b90c-5798235d2a40" -} \ No newline at end of file diff --git a/.omc/state/sessions/07db39b9-52cd-4784-b90c-5798235d2a40/session-started.json b/.omc/state/sessions/07db39b9-52cd-4784-b90c-5798235d2a40/session-started.json deleted file mode 100644 index dc76bf5..0000000 --- a/.omc/state/sessions/07db39b9-52cd-4784-b90c-5798235d2a40/session-started.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "session_id": "07db39b9-52cd-4784-b90c-5798235d2a40", - "started_at": "2026-05-26T16:50:06.096Z", - "cwd": "E:\\AProject\\TianX\\Personal\\dreamfield", - "pid": 31364 -} \ No newline at end of file diff --git a/.omc/state/sessions/f036cfa0-46cd-43f9-a5ee-ab0f802cd30e/hud-state.json b/.omc/state/sessions/f036cfa0-46cd-43f9-a5ee-ab0f802cd30e/hud-state.json deleted file mode 100644 index 8de7b7b..0000000 --- a/.omc/state/sessions/f036cfa0-46cd-43f9-a5ee-ab0f802cd30e/hud-state.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "timestamp": "2026-05-26T13:31:48.743Z", - "backgroundTasks": [], - "sessionStartTimestamp": "2026-05-26T13:29:14.486Z", - "sessionId": "f036cfa0-46cd-43f9-a5ee-ab0f802cd30e" -} \ No newline at end of file diff --git a/.omc/state/subagent-tracking.json b/.omc/state/subagent-tracking.json deleted file mode 100644 index bcbe1b2..0000000 --- a/.omc/state/subagent-tracking.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "agents": [ - { - "agent_id": "aa228c29f65153060", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-05-26T17:12:28.794Z", - "parent_mode": "none", - "status": "running" - }, - { - "agent_id": "a48c1deb735db23b2", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-05-26T17:36:51.979Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-05-26T17:54:23.522Z", - "duration_ms": 1051543 - }, - { - "agent_id": "a1456c0bbbec0fbe6", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-05-26T18:04:36.850Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-05-26T18:08:03.856Z", - "duration_ms": 207006 - }, - { - "agent_id": "a33911549372f55f8", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-05-26T18:09:48.894Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-05-26T18:25:34.471Z", - "duration_ms": 945577 - } - ], - "total_spawned": 4, - "total_completed": 3, - "total_failed": 0, - "last_updated": "2026-05-26T18:25:34.574Z" -} \ No newline at end of file diff --git a/README.md b/README.md index 2e8411c..362631e 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Claude Code 非常强大,但它是一个纯命令行工具 (CLI-only)。 **DreamCoder 将 Claude Code 强大的核心引擎,封装进了现代的原生桌面应用中。** -> “我想要 Claude Code 的能力,但我需要一个 GUI 来管理会话、切换模型、处理文件。” +> "我想要 Claude Code 的能力,但我需要一个 GUI 来管理会话、切换模型、处理文件。" * **非叉子 (Not a Fork)**:DreamCoder 复用了 Claude Code 的核心逻辑,或使用兼容的运行时。它只是给命令行体验穿上了一件漂亮的外衣。 * **隐私优先**:你的 API Key 和数据完全保存在本地。不依赖任何云端服务。 @@ -137,10 +137,70 @@ cd desktop && bun run dev --- +## 性能优化分支说明 + +> **分支**: `perf/optimization-report` | **日期**: 2026-05-29 + +本分支包含完整的性能审计和优化实施,共 14 项优化(11 项已完成,2 项不可行,1 项暂缓)。 + +### 已完成的优化 + +**Phase 1: 快速修复** +1. PTY 读缓冲区 8KB → 32KB — 大文件 `cat` 输出更流畅 +2. `allUserMessages` 上限 3 条 — 防止标题生成内存无限增长 +3. Team 成员轮询 in-flight 守卫 — 慢网络下不再堆叠请求 +4. 工具模块 lazy import — `require()` 延迟加载 25+ 个工具模块 +5. `terminal_environment()` OnceLock 缓存 — 环境变量只探测一次 +6. 移除未使用的 `reqwest` 依赖 — 减少 Rust 编译时间和二进制体积 + +**Phase 2: Rust 层** +7. 窗口状态持久化防抖 500ms — 拖动窗口时写盘从数十次/秒降至 ≤2次/秒 +8. Sidecar 启动异步化 — `std::thread::spawn`,窗口立即可交互 + +**Phase 3: 前端** +9. Elapsed timer 移出 Zustand — 计时器不再每秒触发全局 store 更新 +10. chatStore granular selectors — MessageList 等组件只订阅需要的字段 +11. Markdown 解析 `useDeferredValue` — 长代码输出时 UI 保持响应 + +**Phase 4: Server** +12. 会话元数据缓存 — 基于 mtime 的缓存,594 文件从 2s 降到 81ms + +**Phase 5: 架构** +13. CLI↔Server pipe transport — stdin/stdout 替代 WebSocket 双跳(`DREAMCODER_USE_PIPE_TRANSPORT=1` 启用) + +### 不可行 / 暂缓 +- ~~Terminal sessions 换 DashMap~~ — `TerminalSession` 不满足 `Sync` trait +- ~~Query loop 遍历合并~~ — 内部有显式顺序依赖 +- ~~二进制协议 (msgpack)~~ — 消息体积小,投入产出比低 + +### Benchmark 关键结果 + +| 优化项 | Before | After | 提升 | +|--------|--------|-------|------| +| 会话元数据缓存 | 2.02s (cold) | 81ms (warm) | **24.8x** | +| Elapsed timer 外置 | 10.91ms in-store | 6.74ms external | **1.6x** | +| Markdown useDeferredValue | 105µs blocking | yields to UI | 主线程不阻塞 | +| Pipe transport | WS JSON | JSON+encode (+49%/msg) | 消除双跳延迟 | + +### 相关文档 + +- `docs/perf-optimization-report.md` — 性能审计报告 +- `docs/perf-optimization-plan.md` — 实施计划(含完成状态) +- `docs/perf-optimization-results.md` — Benchmark 结果详情 +- `scripts/benchmark.ts` — 自动化 benchmark 脚本 + +### 运行 Benchmark + +```bash +bun run scripts/benchmark.ts +``` + +--- + ## 🤝 贡献指南 欢迎提交 Issue 和 PR!请阅读我们的 [贡献指南](docs/CONTRIBUTING.md) 了解更多详情。 ## 📄 许可证 -[MIT](./LICENSE) © 2024-2026 GoDiao & DreamCoder Contributors \ No newline at end of file +[MIT](./LICENSE) © 2024-2026 GoDiao & DreamCoder Contributors diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ed4855c..87c64ae 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -515,16 +515,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -548,7 +538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.1", - "core-foundation 0.10.1", + "core-foundation", "core-graphics-types", "foreign-types", "libc", @@ -561,7 +551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.1", - "core-foundation 0.10.1", + "core-foundation", "libc", ] @@ -866,7 +856,6 @@ version = "0.3.1" dependencies = [ "anyhow", "portable-pty", - "reqwest", "serde", "serde_json", "tauri", @@ -1709,11 +1698,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -3405,7 +3392,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "jni 0.22.4", "log", @@ -3525,7 +3512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.1", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -4066,27 +4053,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "system-deps" version = "6.2.2" @@ -4108,7 +4074,7 @@ checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.1", "block2", - "core-foundation 0.10.1", + "core-foundation", "core-graphics", "crossbeam-channel", "dispatch2", @@ -5521,17 +5487,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.3.4" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 4969c2a..0f5cc25 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -24,4 +24,3 @@ anyhow = "1.0.102" portable-pty = "0.9.0" tauri-plugin-notification = "2" tauri-plugin-single-instance = "2" -reqwest = { version = "0.13", default-features = false, features = ["system-proxy"] } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e73501f..b97a241 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -8,12 +8,13 @@ use std::{ str, sync::{ atomic::{AtomicU32, Ordering}, - Arc, Mutex, + Arc, Mutex, OnceLock, }, thread, time::{Duration, Instant}, }; + use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; use serde::{Deserialize, Serialize}; use tauri::menu::MenuBuilder; @@ -415,6 +416,9 @@ struct TerminalState { sessions: Mutex>, } +static LAST_WINDOW_SAVE: Mutex> = Mutex::new(None); +const WINDOW_SAVE_DEBOUNCE_MS: u64 = 500; + struct TerminalSession { master: Box, writer: Mutex>, @@ -929,7 +933,7 @@ fn terminal_spawn( let mut cmd = CommandBuilder::new(&shell); cmd.cwd(cwd_path.as_os_str()); - for (key, value) in terminal_environment(&shell) { + for (key, value) in terminal_environment_cached(&shell) { cmd.env(key, value); } cmd.env("TERM", "xterm-256color"); @@ -969,8 +973,8 @@ fn terminal_spawn( let output_app = app.clone(); thread::spawn(move || { - let mut buffer = [0_u8; 8192]; - let mut pending_utf8 = Vec::new(); + let mut buffer = [0_u8; 32768]; + let mut pending_utf8 = Vec::with_capacity(256); loop { match reader.read(&mut buffer) { Ok(0) => break, @@ -1222,11 +1226,15 @@ fn decode_terminal_output(pending: &mut Vec, chunk: &[u8]) -> String { output } -fn terminal_environment(shell: &str) -> HashMap { - let mut env: HashMap = std::env::vars().collect(); - env.extend(login_shell_environment(shell)); - ensure_utf8_locale(&mut env); - env +static TERMINAL_ENV_CACHE: OnceLock> = OnceLock::new(); + +fn terminal_environment_cached(shell: &str) -> &'static HashMap { + TERMINAL_ENV_CACHE.get_or_init(|| { + let mut env: HashMap = std::env::vars().collect(); + env.extend(login_shell_environment(shell)); + ensure_utf8_locale(&mut env); + env + }) } fn ensure_utf8_locale(env: &mut HashMap) { @@ -1264,39 +1272,56 @@ fn default_utf8_locale() -> &'static str { #[cfg(not(target_os = "windows"))] fn login_shell_environment(shell: &str) -> HashMap { - let Ok(mut child) = StdCommand::new(shell) - .args(["-l", "-c", "env -0"]) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - else { - return HashMap::new(); - }; + // Run the login shell on a blocking-thread-pool slot rather than spinning + // on `try_wait()` + `thread::sleep(25ms)` in the caller's thread. The + // result is cached behind OnceLock by `terminal_environment_cached`, so + // this only runs once per process — the win is avoiding the original + // up-to-2s busy-poll on whichever sync command first triggered it + // (typically `terminal_spawn` on the Tauri command runtime). + let shell = shell.to_string(); + let handle = thread::spawn(move || { + let Ok(mut child) = StdCommand::new(&shell) + .args(["-l", "-c", "env -0"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + else { + return HashMap::new(); + }; - let deadline = Instant::now() + Duration::from_secs(2); - loop { - match child.try_wait() { - Ok(Some(status)) => { - if !status.success() { - return HashMap::new(); + // Read stdout to completion; the child exits as soon as `env -0` + // returns. We rely on the OS to deliver EOF instead of polling. + let mut stdout = Vec::with_capacity(4096); + if let Some(mut pipe) = child.stdout.take() { + let _ = pipe.read_to_end(&mut stdout); + } + // Reap the child; bound the wait so a hung login shell can't pin + // the worker thread forever. We give it 2s — the same budget the + // previous spin loop enforced. + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match child.try_wait() { + Ok(Some(status)) => { + return if status.success() { + parse_env_block(&stdout) + } else { + HashMap::new() + }; } - let mut stdout = Vec::new(); - if let Some(mut pipe) = child.stdout.take() { - let _ = pipe.read_to_end(&mut stdout); + Ok(None) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(50)); } - return parse_env_block(&stdout); - } - Ok(None) if Instant::now() < deadline => { - thread::sleep(Duration::from_millis(25)); - } - Ok(None) => { - let _ = child.kill(); - let _ = child.wait(); - return HashMap::new(); + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return HashMap::new(); + } + Err(_) => return HashMap::new(), } - Err(_) => return HashMap::new(), } - } + }); + + handle.join().unwrap_or_default() } #[cfg(target_os = "windows")] @@ -1545,7 +1570,7 @@ fn resolve_app_root(_app: &AppHandle) -> Result { } fn select_h5_dist_dir(resource_dir: Option<&Path>, app_root: &Path) -> PathBuf { - let mut candidates = Vec::new(); + let mut candidates = Vec::with_capacity(4); if let Some(resource_dir) = resource_dir { candidates.push(resource_dir.join("_up_").join("dist")); candidates.push(resource_dir.join("dist")); @@ -1585,7 +1610,7 @@ fn start_server_sidecar(app: &AppHandle) -> Result { .shell() .sidecar("dreamcoder-sidecar") .map_err(|err| format!("resolve sidecar: {err}"))?; - for (key, value) in terminal_environment(&default_shell(None)) { + for (key, value) in terminal_environment_cached(&default_shell(None)) { sidecar = sidecar.env(key, value); } // Pass through CLAUDE_CONFIG_DIR so the sidecar (Node.js) uses the same @@ -1710,7 +1735,7 @@ fn start_adapters_sidecars(app: &AppHandle) -> Result, String> server_http_url.clone() }; - let mut children = Vec::new(); + let mut children = Vec::with_capacity(4); for (label, flag) in [ ("feishu", "--feishu"), ("telegram", "--telegram"), @@ -1721,7 +1746,7 @@ fn start_adapters_sidecars(app: &AppHandle) -> Result, String> .shell() .sidecar("dreamcoder-sidecar") .map_err(|err| format!("resolve {label} adapter sidecar: {err}"))?; - for (key, value) in terminal_environment(&default_shell(None)) { + for (key, value) in terminal_environment_cached(&default_shell(None)) { sidecar = sidecar.env(key, value); } // Pass through CLAUDE_CONFIG_DIR for portable installs @@ -2249,29 +2274,36 @@ pub fn run() { macos_notifications::install_click_handler(app.handle().clone()); restore_main_window_state(&app.handle()); - let state = app.state::(); - let mut guard = state - .0 - .lock() - .map_err(|_| IoError::new(ErrorKind::Other, "server state lock poisoned"))?; + // Start sidecar in background thread to avoid blocking window + let app_handle = app.handle().clone(); + std::thread::spawn(move || { + let state = app_handle.state::(); + let mut guard = state + .0 + .lock() + .expect("server state lock poisoned"); + + match start_server_sidecar(&app_handle) { + Ok(runtime) => { + guard.runtime = Some(runtime); + guard.startup_error = None; + drop(guard); + + // server 起来之后再起 adapter sidecar + spawn_and_track_adapters_sidecar(&app_handle); + + let _ = app_handle.emit("server-ready", true); + } + Err(err) => { + eprintln!("[desktop] failed to start local server: {err}"); + guard.runtime = None; + guard.startup_error = Some(err.clone()); + drop(guard); - match start_server_sidecar(&app.handle()) { - Ok(runtime) => { - guard.runtime = Some(runtime); - guard.startup_error = None; - } - Err(err) => { - eprintln!("[desktop] failed to start local server: {err}"); - guard.runtime = None; - guard.startup_error = Some(err); + let _ = app_handle.emit("server-error", err); + } } - } - drop(guard); - - // server 起来之后再起 adapter sidecar —— start_adapters_sidecar - // 内部会从 ServerState 读 server URL 注入 ADAPTER_SERVER_URL env, - // 让 adapter 连上动态端口。 - spawn_and_track_adapters_sidecar(&app.handle()); + }); Ok(()) }) @@ -2295,7 +2327,20 @@ pub fn run() { event: WindowEvent::Moved(_) | WindowEvent::Resized(_), .. } if label == MAIN_WINDOW_LABEL => { - save_main_window_state(app_handle); + let now = Instant::now(); + let should_save = { + let mut last = LAST_WINDOW_SAVE.lock().unwrap(); + match *last { + Some(prev) if now.duration_since(prev).as_millis() < WINDOW_SAVE_DEBOUNCE_MS as u128 => false, + _ => { + *last = Some(now); + true + } + } + }; + if should_save { + save_main_window_state(app_handle); + } } #[cfg(target_os = "macos")] RunEvent::Reopen { diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 0c60ed1..21c0c83 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -263,7 +263,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -306,7 +305,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -354,7 +352,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -421,7 +418,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, sendMessage, @@ -491,7 +487,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, sendMessage, @@ -547,7 +542,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, sendMessage, @@ -609,7 +603,6 @@ describe('Content-only pages render without errors', () => { })), ], agentTaskNotifications: {}, - elapsedTimer: null, }, }, sendMessage, @@ -667,7 +660,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, sendMessage, @@ -752,7 +744,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -808,7 +799,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -872,7 +862,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -952,7 +941,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -1035,7 +1023,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -1104,7 +1091,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -1201,7 +1187,6 @@ describe('Content-only pages render without errors', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) diff --git a/desktop/src/components/chat/AskUserQuestion.test.tsx b/desktop/src/components/chat/AskUserQuestion.test.tsx index 9428c36..a48e719 100644 --- a/desktop/src/components/chat/AskUserQuestion.test.tsx +++ b/desktop/src/components/chat/AskUserQuestion.test.tsx @@ -67,7 +67,6 @@ describe('AskUserQuestion', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 8a58957..edca1a9 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -173,7 +173,6 @@ describe('ChatInput file mentions', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -239,7 +238,6 @@ describe('ChatInput file mentions', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, [historySessionId]: { messages: [{ id: 'history-message', type: 'assistant_text', content: 'ready', timestamp: 1 }], @@ -257,7 +255,6 @@ describe('ChatInput file mentions', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -348,7 +345,6 @@ describe('ChatInput file mentions', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -399,7 +395,6 @@ describe('ChatInput file mentions', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -445,7 +440,6 @@ describe('ChatInput file mentions', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -509,7 +503,6 @@ describe('ChatInput file mentions', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 024a5c6..6cd366b 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -33,6 +33,7 @@ import { } from './composerUtils' import { useMobileViewport } from '../../hooks/useMobileViewport' import { isTauriRuntime } from '../../lib/desktopRuntime' +import { devWarn } from '../../lib/devLog' import { filesToComposerAttachments, selectNativeFileAttachments, @@ -766,7 +767,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro setComposerAttachments((prev) => [...prev, ...nextAttachments]) }) .catch((error) => { - console.warn('[attachments] Failed to read selected files', error) + devWarn('[attachments] Failed to read selected files', error) }) }, [setComposerAttachments]) @@ -780,7 +781,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro panelRef, onAttachments: appendAttachments, onError: (error) => { - console.warn('[attachments] Failed to read dropped files', error) + devWarn('[attachments] Failed to read dropped files', error) }, }) diff --git a/desktop/src/components/chat/MermaidRenderer.tsx b/desktop/src/components/chat/MermaidRenderer.tsx index ab5b87d..30ea029 100644 --- a/desktop/src/components/chat/MermaidRenderer.tsx +++ b/desktop/src/components/chat/MermaidRenderer.tsx @@ -1,6 +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' @@ -8,7 +7,23 @@ type Props = { code: string } +type MermaidAPI = typeof import('mermaid').default +let mermaidModule: MermaidAPI | null = null +let mermaidLoading: Promise | null = null let mermaidInitialized = false + +async function loadMermaid(): Promise { + if (mermaidModule) return mermaidModule + if (mermaidLoading) return mermaidLoading + mermaidLoading = import('mermaid').then((m) => { + mermaidModule = m.default + return mermaidModule + }).catch((err) => { + mermaidLoading = null + throw err + }) + return mermaidLoading +} const MIN_PREVIEW_ZOOM = 0.5 const MAX_PREVIEW_ZOOM = 3 const PREVIEW_ZOOM_STEP = 0.25 @@ -26,9 +41,9 @@ type DragState = { scrollTop: number } -function initMermaid() { +function initMermaid(mod: MermaidAPI) { if (mermaidInitialized) return - mermaid.initialize({ + mod.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'strict', @@ -106,24 +121,42 @@ export function MermaidRenderer({ code }: Props) { useEffect(() => { let cancelled = false - initMermaid() - const id = `mermaid-${++mermaidIdCounter}` - - mermaid.render(id, code).then( - ({ svg: renderedSvg }) => { - if (!cancelled) { - setSvg(renderedSvg) - setError(null) - } - }, - (err) => { + async function renderMermaid() { + let mod: MermaidAPI + try { + mod = await loadMermaid() + } catch (loadErr) { if (!cancelled) { - setError(String(err?.message || err)) + setError( + `Failed to load Mermaid renderer: ${String((loadErr as Error)?.message || loadErr)}`, + ) setSvg(null) } - }, - ) + return + } + if (cancelled) return + initMermaid(mod) + + const id = `mermaid-${++mermaidIdCounter}` + + mod.render(id, code).then( + ({ svg: renderedSvg }) => { + if (!cancelled) { + setSvg(renderedSvg) + setError(null) + } + }, + (err) => { + if (!cancelled) { + setError(String(err?.message || err)) + setSvg(null) + } + }, + ) + } + + renderMermaid() return () => { cancelled = true } }, [code]) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index bd84995..c679e95 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -40,7 +40,6 @@ function makeSessionState(overrides: Partial = {}): PerSessionS apiRetry: null, slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, composerPrefill: null, ...overrides, } diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 4c20bcc..dcec4d2 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1207,8 +1207,20 @@ const MeasuredRenderItem = memo(function MeasuredRenderItem({ export function MessageList({ sessionId, compact = false }: MessageListProps = {}) { const activeTabId = useTabStore((s) => s.activeTabId) const resolvedSessionId = sessionId ?? activeTabId - const sessionState = useChatStore((s) => - resolvedSessionId ? s.sessions[resolvedSessionId] : undefined, + const messages = useChatStore((s) => + resolvedSessionId ? (s.sessions[resolvedSessionId]?.messages ?? EMPTY_MESSAGES) : EMPTY_MESSAGES, + ) + const chatState = useChatStore((s) => + resolvedSessionId ? (s.sessions[resolvedSessionId]?.chatState ?? 'idle') : 'idle', + ) + const streamingText = useChatStore((s) => + resolvedSessionId ? (s.sessions[resolvedSessionId]?.streamingText ?? '') : '', + ) + const activeThinkingId = useChatStore((s) => + resolvedSessionId ? (s.sessions[resolvedSessionId]?.activeThinkingId ?? null) : null, + ) + const agentTaskNotifications = useChatStore((s) => + resolvedSessionId ? (s.sessions[resolvedSessionId]?.agentTaskNotifications ?? {}) : {}, ) const branchSession = useSessionStore((s) => s.branchSession) const stopGeneration = useChatStore((s) => s.stopGeneration) @@ -1218,14 +1230,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { resolvedSessionId ? Boolean(s.getMemberBySessionId(resolvedSessionId)) : false, ) const addToast = useUIStore((s) => s.addToast) - const messages = sessionState?.messages ?? EMPTY_MESSAGES - const chatState = sessionState?.chatState ?? 'idle' - const streamingText = sessionState?.streamingText ?? '' - const activeThinkingId = sessionState?.activeThinkingId ?? null - const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {} + const pendingPermission = useChatStore((s) => + resolvedSessionId ? (s.sessions[resolvedSessionId]?.pendingPermission ?? null) : null, + ) const activeAskUserQuestionToolUseId = - sessionState?.pendingPermission?.toolName === 'AskUserQuestion' - ? sessionState.pendingPermission.toolUseId + pendingPermission?.toolName === 'AskUserQuestion' + ? pendingPermission.toolUseId : null const shouldFollowContentResize = streamingText.trim().length > 0 || @@ -1265,13 +1275,19 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { viewportHeight: VIRTUAL_DEFAULT_VIEWPORT_HEIGHT, }) const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0) + const activeToolUseId = useChatStore((s) => + resolvedSessionId ? (s.sessions[resolvedSessionId]?.activeToolUseId ?? null) : null, + ) + const activeToolName = useChatStore((s) => + resolvedSessionId ? (s.sessions[resolvedSessionId]?.activeToolName ?? null) : null, + ) const branchActionsDisabled = isMemberSession || chatState !== 'idle' || streamingText.trim().length > 0 || Boolean(activeThinkingId) || - Boolean(sessionState?.activeToolUseId) || - Boolean(sessionState?.activeToolName) + Boolean(activeToolUseId) || + Boolean(activeToolName) const hasCompactingDivider = messages.some((message) => message.type === 'compact_summary' && message.phase === 'compacting') diff --git a/desktop/src/components/chat/StreamingIndicator.tsx b/desktop/src/components/chat/StreamingIndicator.tsx index a2e35d7..c9c1946 100644 --- a/desktop/src/components/chat/StreamingIndicator.tsx +++ b/desktop/src/components/chat/StreamingIndicator.tsx @@ -3,6 +3,7 @@ import { RefreshCw } from 'lucide-react' import { useChatStore } from '../../stores/chatStore' import { useTabStore } from '../../stores/tabStore' import { useTranslation, type TranslationKey } from '../../i18n' +import { useElapsedTimer } from '../../hooks/useElapsedTimer' function formatElapsed(seconds: number): string { if (seconds < 60) return `${seconds}s` @@ -40,9 +41,11 @@ export function StreamingIndicator() { const chatState = sessionState?.chatState ?? 'idle' const statusVerb = sessionState?.statusVerb ?? '' const apiRetry = sessionState?.apiRetry ?? null - const elapsedSeconds = sessionState?.elapsedSeconds ?? 0 const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 } + // Use custom hook for elapsed timer instead of Zustand state + const elapsedSeconds = useElapsedTimer(activeTabId, chatState === 'thinking') + useEffect(() => { if (!apiRetry) return undefined setNow(Date.now()) diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index e21d37b..2e3f9f3 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -177,7 +177,6 @@ describe('chat blocks', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index 19446e6..0d47619 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -9,8 +9,8 @@ import { TerminalSettings } from '../../pages/TerminalSettings' export function ContentRouter() { const activeTabId = useTabStore((s) => s.activeTabId) const tabs = useTabStore((s) => s.tabs) + const liveTerminalIds = useTabStore((s) => s.liveTerminalIds) const activeTabType = tabs.find((t) => t.sessionId === activeTabId)?.type - const terminalTabs = tabs.filter((tab) => tab.type === 'terminal') let page: ReactNode = null if (!activeTabId || !activeTabType) { @@ -30,7 +30,9 @@ export function ContentRouter() { {page} )} - {terminalTabs.map((tab) => { + {liveTerminalIds.map((sessionId) => { + const tab = tabs.find((t) => t.sessionId === sessionId) + if (!tab || tab.type !== 'terminal') return null const active = tab.sessionId === activeTabId const visible = activeTabType === 'terminal' && active return ( diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index fefcd18..f38222b 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -129,10 +129,6 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { const showInitialLoading = isLoading && sessions.length === 0 const filteredSessionIds = useMemo(() => filteredSessions.map((session) => session.id), [filteredSessions]) const selectedCount = selectedSessionIds.size - const sessionsById = useMemo( - () => new Map(sessions.map((session) => [session.id, session])), - [sessions], - ) const runningSessionIds = useMemo(() => { const ids = new Set() for (const tab of tabs) { @@ -145,9 +141,9 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { }, [chatSessions, tabs]) const pendingBatchDeleteSessions = useMemo( () => (pendingBatchDeleteSessionIds ?? []) - .map((sessionId) => sessionsById.get(sessionId)) + .map((sessionId) => sessions.find((s) => s.id === sessionId)) .filter((session): session is SessionListItem => Boolean(session)), - [pendingBatchDeleteSessionIds, sessionsById], + [pendingBatchDeleteSessionIds, sessions], ) const expanded = isMobile ? true : sidebarOpen const closeMobileDrawer = useCallback(() => { diff --git a/desktop/src/components/layout/StatusBar.tsx b/desktop/src/components/layout/StatusBar.tsx index 82c742d..54a6a90 100644 --- a/desktop/src/components/layout/StatusBar.tsx +++ b/desktop/src/components/layout/StatusBar.tsx @@ -1,5 +1,5 @@ import { useSettingsStore } from '../../stores/settingsStore' -import { useSessionStore } from '../../stores/sessionStore' +import { useSessionById } from '../../stores/sessionStore' import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useTabStore } from '../../stores/tabStore' @@ -9,7 +9,7 @@ export function StatusBar() { const runtimeSelection = useSessionRuntimeStore((s) => activeTabId ? s.selections[activeTabId] : undefined, ) - const projectPath = useSessionStore((s) => s.sessions.find((session) => session.id === activeTabId)?.projectPath) + const projectPath = useSessionById(activeTabId)?.projectPath const projectName = projectPath ? projectPath.split('-').filter(Boolean).pop() || '' diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 6553b41..65c81b9 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -35,7 +35,6 @@ function makeChatSession(chatState: ChatState): PerSessionState { agentTaskNotifications: {}, backgroundAgentTasks: {}, activeGoal: null, - elapsedTimer: null, composerPrefill: null, composerDraft: null, } diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx index e29707d..8d7503d 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.tsx @@ -1,13 +1,36 @@ -import { memo, useMemo, useCallback } from 'react' +import { memo, useMemo, useCallback, useDeferredValue, useState, useEffect } from 'react' import type { MouseEvent as ReactMouseEvent } from 'react' import DOMPurify from 'dompurify' -import katex from 'katex' -import 'katex/dist/katex.min.css' import { marked, type Tokens } from 'marked' import { CodeViewer } from '../chat/CodeViewer' import { MermaidRenderer } from '../chat/MermaidRenderer' import { copyTextToClipboard } from '../chat/clipboard' +// ─── v2 Batch A: lazy-load katex ────────────────────────────────────── +// katex (~300KB) is only needed when math blocks are present. +let katexModule: typeof import('katex').default | null = null +let katexLoading: Promise | null = null +let katexLoadError: Error | null = null + +async function ensureKatex(): Promise { + if (katexModule) return + if (katexLoadError) throw katexLoadError + if (katexLoading) return katexLoading + katexLoading = (async () => { + try { + await import('katex/dist/katex.min.css') + const mod = await import('katex') + katexModule = mod.default + } catch (err) { + katexLoadError = err instanceof Error ? err : new Error(String(err)) + throw katexLoadError + } finally { + katexLoading = null + } + })() + return katexLoading +} + type Props = { content: string variant?: 'default' | 'document' | 'compact' @@ -233,8 +256,22 @@ function renderMath(block: MathBlock): string { const cached = mathRenderCache.get(cacheKey) if (cached) return cached + // v2 Batch A: katex is now lazy-loaded. If not loaded yet, return a placeholder + // and trigger load; subsequent renders (after `katexLoaded` state flips) will + // hit this path again and produce the real output. If load failed previously, + // surface the error inline rather than spinning forever. + if (!katexModule) { + if (katexLoadError) { + return DOMPurify.sanitize( + `[math: failed to load KaTeX] ${DOMPurify.sanitize(block.tex)}`, + ) + } + void ensureKatex().catch(() => {/* surfaced via katexLoadError on next render */}) + return DOMPurify.sanitize(`${DOMPurify.sanitize(block.tex)}`) + } + try { - const rendered = katex.renderToString(block.tex, { + const rendered = katexModule.renderToString(block.tex, { displayMode: block.displayMode, output: 'html', throwOnError: false, @@ -452,10 +489,40 @@ function getProseClasses(variant: 'default' | 'document' | 'compact', className? } export const MarkdownRenderer = memo(function MarkdownRenderer({ content, variant = 'default', className, cache = true, streaming = false, onLinkClick }: Props) { + const deferredContent = useDeferredValue(content) + // v2 Batch A: state bump that flips when katex finishes loading, so math + // placeholders get replaced once the lib arrives. + const [katexReady, setKatexReady] = useState(() => katexModule !== null) const { html, codeBlocks, mathBlocks } = useMemo( - () => cache ? getCachedMarkdownParse(content, streaming) : parseMarkdown(content), - [cache, content, streaming], + () => cache ? getCachedMarkdownParse(deferredContent, streaming) : parseMarkdown(deferredContent), + [cache, deferredContent, streaming], ) + + // Trigger katex load when the rendered content contains math, and re-render + // when it lands. Cheap effect: only runs when mathBlocks count changes. + useEffect(() => { + if (mathBlocks.length === 0 || katexModule !== null) return + let cancelled = false + ensureKatex() + .then(() => { + if (!cancelled) { + // Bust the math render cache so previously-stubbed entries re-render. + mathRenderCache.clear() + setKatexReady(true) + } + }) + .catch(() => { + if (!cancelled) { + // Flip katexReady so the renderer re-runs and surfaces the error + // placeholder produced by renderMath() (which now checks katexLoadError). + mathRenderCache.clear() + setKatexReady((prev) => !prev) + } + }) + return () => { cancelled = true } + }, [mathBlocks.length]) + // Keep katexReady visible to lint/typecheck without warning (used in deps below) + void katexReady const proseClasses = useMemo( () => getProseClasses(variant, className), [variant, className], @@ -487,7 +554,9 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian } return result - }, [html, codeBlocks, mathBlocks]) + // v2 Batch A: katexReady is intentionally in deps — when katex finishes + // loading, parts must re-derive so renderMath() picks up the real module. + }, [html, codeBlocks, mathBlocks, katexReady]) const handleClick = useCallback(async (event: ReactMouseEvent) => { const target = event.target as HTMLElement | null diff --git a/desktop/src/components/settings/GeneralSettings.tsx b/desktop/src/components/settings/GeneralSettings.tsx index a60616f..89d0426 100644 --- a/desktop/src/components/settings/GeneralSettings.tsx +++ b/desktop/src/components/settings/GeneralSettings.tsx @@ -8,7 +8,7 @@ import { Button } from '../shared/Button' import { Dropdown } from '../shared/Dropdown' import type { EffortLevel, ThemeMode, WebSearchMode, AppMode } from '../../types/settings' import type { Locale } from '../../i18n' -import { useUIStore } from '../../stores/uiStore' +import { useUIStore, MAX_LIVE_TERMINALS_OPTIONS } from '../../stores/uiStore' import { isTauriRuntime } from '../../lib/desktopRuntime' import { isValidHttpProxyUrl } from '../../lib/validation' import { ProxyConfigForm } from './ProxyConfigForm' @@ -86,6 +86,8 @@ export function GeneralSettings() { const [isUiZoomDragging, setIsUiZoomDragging] = useState(false) const isUiZoomDraggingRef = useRef(false) const addToast = useUIStore((s) => s.addToast) + const maxLiveTerminals = useUIStore((s) => s.maxLiveTerminals) + const setMaxLiveTerminals = useUIStore((s) => s.setMaxLiveTerminals) const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch) const uiZoomPercent = Math.round(uiZoomDraft * 100) const uiZoomRangeProgress = `${Math.round(((uiZoomDraft - UI_ZOOM_MIN) / (UI_ZOOM_MAX - UI_ZOOM_MIN)) * 1000) / 10}%` @@ -581,6 +583,26 @@ export function GeneralSettings() { +
+

Max Live Terminals

+

Maximum number of concurrent terminal tabs kept alive. Extra terminals are evicted (LRU). Set to unlimited (0) to disable eviction.

+
+ {([3, 5, 10, 0] as const).map((value) => ( + + ))} +
+
+

{t('settings.general.notificationsTitle')}

{t('settings.general.notificationsDescription')}

diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index 2a42a58..3890995 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -1269,7 +1269,6 @@ describe('WorkspacePanel', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) diff --git a/desktop/src/hooks/useElapsedTimer.ts b/desktop/src/hooks/useElapsedTimer.ts new file mode 100644 index 0000000..919beef --- /dev/null +++ b/desktop/src/hooks/useElapsedTimer.ts @@ -0,0 +1,34 @@ +import { useState, useEffect, useRef } from 'react' + +/** + * Custom hook for elapsed timer that runs outside of Zustand state. + * This eliminates the per-second updateSessionIn calls that were causing + * unnecessary re-renders of the entire sessions record. + */ +export function useElapsedTimer(sessionId: string | undefined, active: boolean): number { + const [seconds, setSeconds] = useState(0) + const intervalRef = useRef | null>(null) + + useEffect(() => { + if (!active || !sessionId) { + setSeconds(0) + return + } + + // Reset seconds when starting a new session + setSeconds(0) + + intervalRef.current = setInterval(() => { + setSeconds(prev => prev + 1) + }, 1000) + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + }, [sessionId, active]) + + return seconds +} diff --git a/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx b/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx index 02c991d..702633f 100644 --- a/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx +++ b/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { render } from '@testing-library/react' import { useScheduledTaskDesktopNotifications } from './useScheduledTaskDesktopNotifications' +import { useUIStore } from '../stores/uiStore' const { listMock, getRecentRunsMock, notifyDesktopMock } = vi.hoisted(() => ({ listMock: vi.fn(), @@ -168,9 +169,14 @@ describe('useScheduledTaskDesktopNotifications', () => { }) render() - await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1)) + // Batch C short-circuit: when no task has the desktop channel enabled, + // the hook skips the getRecentRuns call entirely and backs off to slow + // polling. Wait for the task list call to land, then verify the recent + // runs request was never issued and no desktop notification fired. + await vi.waitFor(() => expect(listMock).toHaveBeenCalledTimes(1)) await vi.advanceTimersByTimeAsync(30_000) + expect(getRecentRunsMock).not.toHaveBeenCalled() expect(notifyDesktopMock).not.toHaveBeenCalled() }) @@ -212,4 +218,40 @@ describe('useScheduledTaskDesktopNotifications', () => { await vi.advanceTimersByTimeAsync(30_000) await vi.waitFor(() => expect(notifyDesktopMock).toHaveBeenCalledTimes(2)) }) + + it('shows a toast warning after consecutive poll failures', async () => { + // Three consecutive failures should surface a single toast. + listMock.mockRejectedValue(new Error('network down')) + + // Replace addToast with a spy. Restore after the test so we don't + // leak between cases. + const originalAddToast = useUIStore.getState().addToast + const addToastSpy = vi.fn() + useUIStore.setState({ addToast: addToastSpy }) + + try { + render() + await vi.waitFor(() => expect(listMock).toHaveBeenCalledTimes(1)) + + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(listMock).toHaveBeenCalledTimes(2)) + + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(listMock).toHaveBeenCalledTimes(3)) + + expect(addToastSpy).toHaveBeenCalledTimes(1) + expect(addToastSpy).toHaveBeenCalledWith(expect.objectContaining({ + type: 'warning', + message: expect.stringContaining('轮询失败'), + })) + + // Subsequent failures should NOT pile on additional toasts. + addToastSpy.mockClear() + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(listMock).toHaveBeenCalledTimes(4)) + expect(addToastSpy).not.toHaveBeenCalled() + } finally { + useUIStore.setState({ addToast: originalAddToast }) + } + }) }) diff --git a/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts b/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts index 0f8c9ec..f8941d3 100644 --- a/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts +++ b/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts @@ -1,12 +1,20 @@ import { useEffect } from 'react' import { tasksApi } from '../api/tasks' import { notifyDesktop } from '../lib/desktopNotifications' +import { devWarn } from '../lib/devLog' +import { useUIStore } from '../stores/uiStore' import type { CronTask, TaskRun } from '../types/task' -const POLL_INTERVAL_MS = 30_000 +const POLL_FAST_MS = 30_000 +const POLL_SLOW_MS = 5 * 60_000 const NOTIFIED_RUNS_STORAGE_KEY = 'dreamcoder.notifiedDesktopTaskRuns.v1' const MAX_STORED_RUN_IDS = 200 +// After this many consecutive poll failures, surface a toast so the user +// knows scheduled-task notifications have stopped working. We keep the +// threshold above 1 to ride out transient blips (e.g. backend restart). +const POLL_FAILURE_TOAST_THRESHOLD = 3 + function isTerminalRun(run: TaskRun): boolean { return run.status === 'completed' || run.status === 'failed' || run.status === 'timeout' } @@ -68,13 +76,27 @@ export function useScheduledTaskDesktopNotifications(): void { useEffect(() => { let stopped = false let initialized = false + let nextDelay = POLL_FAST_MS + let timerId: number | null = null + let consecutiveFailures = 0 + let toastedFailure = false const poll = async () => { try { - const [{ tasks }, { runs }] = await Promise.all([ - tasksApi.list(), - tasksApi.getRecentRuns(50), - ]) + // First fetch the task list. If no tasks have desktop notifications + // enabled, skip the second request and back off to slow polling. + const { tasks } = await tasksApi.list() + if (stopped) return + + const desktopEnabledTasks = tasks.filter(hasDesktopNotification) + if (desktopEnabledTasks.length === 0) { + nextDelay = POLL_SLOW_MS + consecutiveFailures = 0 + return + } + nextDelay = POLL_FAST_MS + + const { runs } = await tasksApi.getRecentRuns(50) if (stopped) return const notifiedRunIds = readNotifiedRunIds() @@ -84,6 +106,7 @@ export function useScheduledTaskDesktopNotifications(): void { for (const run of pendingRuns) notifiedRunIds.add(run.id) writeNotifiedRunIds(notifiedRunIds) initialized = true + consecutiveFailures = 0 return } @@ -100,21 +123,46 @@ export function useScheduledTaskDesktopNotifications(): void { if (sent) notifiedRunIds.add(run.id) } writeNotifiedRunIds(notifiedRunIds) + + // Successful poll — reset failure tracking and let future failures + // trigger a fresh toast if they cross the threshold again. + consecutiveFailures = 0 + toastedFailure = false } catch (err) { if (typeof console !== 'undefined') { - console.warn('[scheduledTaskNotifications] failed to poll task runs:', err) + devWarn('[scheduledTaskNotifications] failed to poll task runs:', err) + } + consecutiveFailures += 1 + if (consecutiveFailures >= POLL_FAILURE_TOAST_THRESHOLD && !toastedFailure) { + toastedFailure = true + try { + useUIStore.getState().addToast({ + type: 'warning', + message: '定时任务通知轮询失败,请检查后端服务连接。', + duration: 8000, + }) + } catch { + // Toast subsystem failure should not crash the poller. + } } } } - void poll() - const interval = window.setInterval(() => { - void poll() - }, POLL_INTERVAL_MS) + const schedule = () => { + timerId = window.setTimeout(async () => { + await poll() + if (!stopped) schedule() + }, nextDelay) + } + + // Kick off immediately, then schedule subsequent polls based on `nextDelay`. + void poll().then(() => { + if (!stopped) schedule() + }) return () => { stopped = true - window.clearInterval(interval) + if (timerId !== null) window.clearTimeout(timerId) } }, []) } diff --git a/desktop/src/lib/composerAttachments.ts b/desktop/src/lib/composerAttachments.ts index f6722b4..f26ec76 100644 --- a/desktop/src/lib/composerAttachments.ts +++ b/desktop/src/lib/composerAttachments.ts @@ -1,4 +1,5 @@ import { isTauriRuntime } from './desktopRuntime' +import { devWarn } from './devLog' export type ComposerAttachment = { id: string @@ -60,7 +61,7 @@ export async function selectNativeFileAttachments(): Promise { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -204,7 +203,6 @@ describe('ActiveSession task polling', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -270,7 +268,6 @@ describe('ActiveSession task polling', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -344,7 +341,6 @@ describe('ActiveSession task polling', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -406,7 +402,6 @@ describe('ActiveSession task polling', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -467,7 +462,6 @@ describe('ActiveSession task polling', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -476,8 +470,11 @@ describe('ActiveSession task polling', () => { expect(fetchSessionTasks).toHaveBeenCalledWith(sessionId) + // TASK_POLL_INTERVAL_MS is 3000 (throttled in Batch C). Advance through + // three ticks to exercise repeated polling without coupling the assertion + // to a single-cadence value. await act(async () => { - await vi.advanceTimersByTimeAsync(2200) + await vi.advanceTimersByTimeAsync(8900) }) expect( @@ -546,7 +543,6 @@ describe('ActiveSession task polling', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -601,7 +597,6 @@ describe('ActiveSession task polling', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -671,7 +666,6 @@ describe('ActiveSession task polling', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -726,7 +720,6 @@ describe('ActiveSession task polling', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -779,7 +772,6 @@ describe('ActiveSession task polling', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -838,7 +830,6 @@ describe('ActiveSession task polling', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) @@ -945,7 +936,6 @@ describe('ActiveSession task polling', () => { statusVerb: '', slashCommands: [], agentTaskNotifications: {}, - elapsedTimer: null, }, }, }) diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index ad7d61c..7c96ad7 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -8,7 +8,7 @@ import { useTabStore, type TabType, } from '../stores/tabStore' -import { useSessionStore } from '../stores/sessionStore' +import { useSessionById } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' import { useCLITaskStore } from '../stores/cliTaskStore' import { useTeamStore } from '../stores/teamStore' @@ -32,7 +32,7 @@ import type { ActiveGoalState } from '../types/chat' import { useMobileViewport } from '../hooks/useMobileViewport' import { isTauriRuntime } from '../lib/desktopRuntime' -const TASK_POLL_INTERVAL_MS = 1000 +const TASK_POLL_INTERVAL_MS = 3000 const WORKSPACE_RESIZE_STEP = 32 const TERMINAL_RESIZE_STEP = 24 const CHAT_COLUMN_WITH_WORKSPACE_CLASS = @@ -260,7 +260,7 @@ export function ActiveSession() { const isMobileLayout = useMobileViewport() && !isTauriRuntime() const activeTabId = useTabStore((s) => s.activeTabId) const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type ?? null) - const sessions = useSessionStore((s) => s.sessions) + const session = useSessionById(activeTabId) const connectToSession = useChatStore((s) => s.connectToSession) const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) const pendingComputerUsePermission = sessionState?.pendingComputerUsePermission ?? null @@ -273,7 +273,6 @@ export function ActiveSession() { const hasRunningBackgroundTasks = Object.values(sessionState?.backgroundAgentTasks ?? {}) .some((task) => task.status === 'running') - const session = sessions.find((s) => s.id === activeTabId) const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) const activeTeam = useTeamStore((s) => s.activeTeam) const isMemberSession = !!memberInfo diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index b4fd0e1..dfbea66 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -19,6 +19,7 @@ import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator' import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu' import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel' import { useMobileViewport } from '../hooks/useMobileViewport' +import { devWarn } from '../lib/devLog' import { isTauriRuntime } from '../lib/desktopRuntime' import { filesToComposerAttachments, @@ -446,7 +447,7 @@ export function EmptySession() { setAttachments((prev) => [...prev, ...nextAttachments]) }) .catch((error) => { - console.warn('[attachments] Failed to read selected files', error) + devWarn('[attachments] Failed to read selected files', error) }) }, []) @@ -459,7 +460,7 @@ export function EmptySession() { panelRef, onAttachments: appendAttachments, onError: (error) => { - console.warn('[attachments] Failed to read dropped files', error) + devWarn('[attachments] Failed to read dropped files', error) }, }) diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 6fe7e4b..1ca8114 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, type CSSProperties } from 'react' +import { useState, useEffect, useRef, lazy, Suspense, type CSSProperties } from 'react' import QRCode from 'qrcode' import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react' import { DreamCoderIcon } from '../components/shared/DreamCoderIcon' @@ -13,21 +13,9 @@ import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, NetworkPr import type { Locale } from '../i18n' import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' -import { AdapterSettings } from './AdapterSettings' import { usePluginStore } from '../stores/pluginStore' -import { PluginList } from '../components/plugins/PluginList' -import { PluginDetail } from '../components/plugins/PluginDetail' -import { ComputerUseSettings } from './ComputerUseSettings' -import { H5AccessSettings } from '../components/settings/H5AccessSettings' -import { McpSettings } from './McpSettings' -import { TerminalSettings } from './TerminalSettings' -import { DiagnosticsSettings } from './DiagnosticsSettings' -import { ActivitySettings } from './ActivitySettings' -import { MemorySettings } from './MemorySettings' import { useUIStore, type SettingsTab } from '../stores/uiStore' import { ProxyConfigForm } from '../components/settings/ProxyConfigForm' -import { GeneralSettings } from '../components/settings/GeneralSettings' -import { ProviderSettings } from '../components/settings/ProviderSettings' import { ProviderFormModal } from '../components/settings/ProviderFormModal' import { getDesktopNotificationPermission, @@ -43,9 +31,25 @@ import { stripProviderSettingsJsonEnv, } from '../lib/providerSettingsJson' import { copyTextToClipboard } from '../components/chat/clipboard' -import { AgentsSettings } from '../components/settings/AgentsSettings' -import { SkillSettings } from '../components/settings/SkillSettings' -import { AboutSettings } from '../components/settings/AboutSettings' + +// ─── v2 Batch A: lazy-load all settings tab content ──────────────────── +// Each tab is its own chunk. Opening Settings no longer eagerly pulls in +// shiki/qrcode/mermaid/computer-use code paths that the user may never visit. +const AdapterSettings = lazy(() => import('./AdapterSettings').then(m => ({ default: m.AdapterSettings }))) +const PluginList = lazy(() => import('../components/plugins/PluginList').then(m => ({ default: m.PluginList }))) +const PluginDetail = lazy(() => import('../components/plugins/PluginDetail').then(m => ({ default: m.PluginDetail }))) +const ComputerUseSettings = lazy(() => import('./ComputerUseSettings').then(m => ({ default: m.ComputerUseSettings }))) +const H5AccessSettings = lazy(() => import('../components/settings/H5AccessSettings').then(m => ({ default: m.H5AccessSettings }))) +const McpSettings = lazy(() => import('./McpSettings').then(m => ({ default: m.McpSettings }))) +const TerminalSettings = lazy(() => import('./TerminalSettings').then(m => ({ default: m.TerminalSettings }))) +const DiagnosticsSettings = lazy(() => import('./DiagnosticsSettings').then(m => ({ default: m.DiagnosticsSettings }))) +const ActivitySettings = lazy(() => import('./ActivitySettings').then(m => ({ default: m.ActivitySettings }))) +const MemorySettings = lazy(() => import('./MemorySettings').then(m => ({ default: m.MemorySettings }))) +const GeneralSettings = lazy(() => import('../components/settings/GeneralSettings').then(m => ({ default: m.GeneralSettings }))) +const ProviderSettings = lazy(() => import('../components/settings/ProviderSettings').then(m => ({ default: m.ProviderSettings }))) +const AgentsSettings = lazy(() => import('../components/settings/AgentsSettings').then(m => ({ default: m.AgentsSettings }))) +const SkillSettings = lazy(() => import('../components/settings/SkillSettings').then(m => ({ default: m.SkillSettings }))) +const AboutSettings = lazy(() => import('../components/settings/AboutSettings').then(m => ({ default: m.AboutSettings }))) const NETWORK_TIMEOUT_MIN_SECONDS = 5 const NETWORK_TIMEOUT_MAX_SECONDS = 600 @@ -91,27 +95,38 @@ export function Settings() { {/* Tab content */}
- {activeTab === 'providers' && } - {activeTab === 'permissions' && } - {activeTab === 'activity' && } - {activeTab === 'general' && } - {activeTab === 'h5Access' && } - {activeTab === 'adapters' && } - {activeTab === 'terminal' && } - {activeTab === 'mcp' && } - {activeTab === 'agents' && } - {activeTab === 'skills' && } - {activeTab === 'memory' && } - {activeTab === 'plugins' && } - {activeTab === 'computerUse' && } - {activeTab === 'diagnostics' && } - {activeTab === 'about' && } + }> + {activeTab === 'providers' && } + {activeTab === 'permissions' && } + {activeTab === 'activity' && } + {activeTab === 'general' && } + {activeTab === 'h5Access' && } + {activeTab === 'adapters' && } + {activeTab === 'terminal' && } + {activeTab === 'mcp' && } + {activeTab === 'agents' && } + {activeTab === 'skills' && } + {activeTab === 'memory' && } + {activeTab === 'plugins' && } + {activeTab === 'computerUse' && } + {activeTab === 'diagnostics' && } + {activeTab === 'about' && } +
) } +function TabFallback() { + return ( +
+ progress_activity + Loading… +
+ ) +} + function TabButton({ icon, label, active, onClick }: { icon: string; label: string; active: boolean; onClick: () => void }) { return (