cowork-os 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (526) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1638 -0
  3. package/bin/cowork.js +42 -0
  4. package/build/entitlements.mac.plist +16 -0
  5. package/build/icon.icns +0 -0
  6. package/build/icon.png +0 -0
  7. package/dist/electron/electron/activity/ActivityRepository.js +190 -0
  8. package/dist/electron/electron/agent/browser/browser-service.js +639 -0
  9. package/dist/electron/electron/agent/context-manager.js +225 -0
  10. package/dist/electron/electron/agent/custom-skill-loader.js +566 -0
  11. package/dist/electron/electron/agent/daemon.js +975 -0
  12. package/dist/electron/electron/agent/executor.js +3561 -0
  13. package/dist/electron/electron/agent/llm/anthropic-provider.js +155 -0
  14. package/dist/electron/electron/agent/llm/bedrock-provider.js +202 -0
  15. package/dist/electron/electron/agent/llm/gemini-provider.js +375 -0
  16. package/dist/electron/electron/agent/llm/index.js +34 -0
  17. package/dist/electron/electron/agent/llm/ollama-provider.js +263 -0
  18. package/dist/electron/electron/agent/llm/openai-oauth.js +101 -0
  19. package/dist/electron/electron/agent/llm/openai-provider.js +657 -0
  20. package/dist/electron/electron/agent/llm/openrouter-provider.js +232 -0
  21. package/dist/electron/electron/agent/llm/pricing.js +160 -0
  22. package/dist/electron/electron/agent/llm/provider-factory.js +880 -0
  23. package/dist/electron/electron/agent/llm/types.js +178 -0
  24. package/dist/electron/electron/agent/queue-manager.js +378 -0
  25. package/dist/electron/electron/agent/sandbox/docker-sandbox.js +402 -0
  26. package/dist/electron/electron/agent/sandbox/macos-sandbox.js +407 -0
  27. package/dist/electron/electron/agent/sandbox/runner.js +410 -0
  28. package/dist/electron/electron/agent/sandbox/sandbox-factory.js +228 -0
  29. package/dist/electron/electron/agent/sandbox/security-utils.js +258 -0
  30. package/dist/electron/electron/agent/search/brave-provider.js +119 -0
  31. package/dist/electron/electron/agent/search/google-provider.js +100 -0
  32. package/dist/electron/electron/agent/search/index.js +28 -0
  33. package/dist/electron/electron/agent/search/provider-factory.js +395 -0
  34. package/dist/electron/electron/agent/search/serpapi-provider.js +112 -0
  35. package/dist/electron/electron/agent/search/tavily-provider.js +90 -0
  36. package/dist/electron/electron/agent/search/types.js +40 -0
  37. package/dist/electron/electron/agent/security/index.js +12 -0
  38. package/dist/electron/electron/agent/security/input-sanitizer.js +303 -0
  39. package/dist/electron/electron/agent/security/output-filter.js +217 -0
  40. package/dist/electron/electron/agent/skill-eligibility.js +281 -0
  41. package/dist/electron/electron/agent/skill-registry.js +396 -0
  42. package/dist/electron/electron/agent/skills/document.js +878 -0
  43. package/dist/electron/electron/agent/skills/image-generator.js +225 -0
  44. package/dist/electron/electron/agent/skills/organizer.js +141 -0
  45. package/dist/electron/electron/agent/skills/presentation.js +367 -0
  46. package/dist/electron/electron/agent/skills/spreadsheet.js +165 -0
  47. package/dist/electron/electron/agent/tools/browser-tools.js +523 -0
  48. package/dist/electron/electron/agent/tools/builtin-settings.js +384 -0
  49. package/dist/electron/electron/agent/tools/canvas-tools.js +530 -0
  50. package/dist/electron/electron/agent/tools/cron-tools.js +577 -0
  51. package/dist/electron/electron/agent/tools/edit-tools.js +194 -0
  52. package/dist/electron/electron/agent/tools/file-tools.js +719 -0
  53. package/dist/electron/electron/agent/tools/glob-tools.js +283 -0
  54. package/dist/electron/electron/agent/tools/grep-tools.js +387 -0
  55. package/dist/electron/electron/agent/tools/image-tools.js +111 -0
  56. package/dist/electron/electron/agent/tools/mention-tools.js +282 -0
  57. package/dist/electron/electron/agent/tools/node-tools.js +476 -0
  58. package/dist/electron/electron/agent/tools/registry.js +2719 -0
  59. package/dist/electron/electron/agent/tools/search-tools.js +91 -0
  60. package/dist/electron/electron/agent/tools/shell-tools.js +574 -0
  61. package/dist/electron/electron/agent/tools/skill-tools.js +274 -0
  62. package/dist/electron/electron/agent/tools/system-tools.js +578 -0
  63. package/dist/electron/electron/agent/tools/web-fetch-tools.js +444 -0
  64. package/dist/electron/electron/agent/tools/x-tools.js +264 -0
  65. package/dist/electron/electron/agents/AgentRoleRepository.js +420 -0
  66. package/dist/electron/electron/agents/HeartbeatService.js +356 -0
  67. package/dist/electron/electron/agents/MentionRepository.js +197 -0
  68. package/dist/electron/electron/agents/TaskSubscriptionRepository.js +168 -0
  69. package/dist/electron/electron/agents/WorkingStateRepository.js +229 -0
  70. package/dist/electron/electron/canvas/canvas-manager.js +714 -0
  71. package/dist/electron/electron/canvas/canvas-preload.js +53 -0
  72. package/dist/electron/electron/canvas/canvas-protocol.js +195 -0
  73. package/dist/electron/electron/canvas/canvas-store.js +174 -0
  74. package/dist/electron/electron/canvas/index.js +13 -0
  75. package/dist/electron/electron/control-plane/client.js +364 -0
  76. package/dist/electron/electron/control-plane/handlers.js +572 -0
  77. package/dist/electron/electron/control-plane/index.js +41 -0
  78. package/dist/electron/electron/control-plane/node-manager.js +264 -0
  79. package/dist/electron/electron/control-plane/protocol.js +194 -0
  80. package/dist/electron/electron/control-plane/remote-client.js +437 -0
  81. package/dist/electron/electron/control-plane/server.js +640 -0
  82. package/dist/electron/electron/control-plane/settings.js +369 -0
  83. package/dist/electron/electron/control-plane/ssh-tunnel.js +549 -0
  84. package/dist/electron/electron/cron/index.js +30 -0
  85. package/dist/electron/electron/cron/schedule.js +190 -0
  86. package/dist/electron/electron/cron/service.js +614 -0
  87. package/dist/electron/electron/cron/store.js +155 -0
  88. package/dist/electron/electron/cron/types.js +82 -0
  89. package/dist/electron/electron/cron/webhook.js +258 -0
  90. package/dist/electron/electron/database/SecureSettingsRepository.js +444 -0
  91. package/dist/electron/electron/database/TaskLabelRepository.js +120 -0
  92. package/dist/electron/electron/database/repositories.js +1781 -0
  93. package/dist/electron/electron/database/schema.js +978 -0
  94. package/dist/electron/electron/extensions/index.js +33 -0
  95. package/dist/electron/electron/extensions/loader.js +313 -0
  96. package/dist/electron/electron/extensions/registry.js +485 -0
  97. package/dist/electron/electron/extensions/types.js +11 -0
  98. package/dist/electron/electron/gateway/channel-registry.js +1102 -0
  99. package/dist/electron/electron/gateway/channels/bluebubbles-client.js +479 -0
  100. package/dist/electron/electron/gateway/channels/bluebubbles.js +432 -0
  101. package/dist/electron/electron/gateway/channels/discord.js +975 -0
  102. package/dist/electron/electron/gateway/channels/email-client.js +593 -0
  103. package/dist/electron/electron/gateway/channels/email.js +443 -0
  104. package/dist/electron/electron/gateway/channels/google-chat.js +631 -0
  105. package/dist/electron/electron/gateway/channels/imessage-client.js +363 -0
  106. package/dist/electron/electron/gateway/channels/imessage.js +465 -0
  107. package/dist/electron/electron/gateway/channels/index.js +36 -0
  108. package/dist/electron/electron/gateway/channels/line-client.js +470 -0
  109. package/dist/electron/electron/gateway/channels/line.js +479 -0
  110. package/dist/electron/electron/gateway/channels/matrix-client.js +432 -0
  111. package/dist/electron/electron/gateway/channels/matrix.js +592 -0
  112. package/dist/electron/electron/gateway/channels/mattermost-client.js +394 -0
  113. package/dist/electron/electron/gateway/channels/mattermost.js +496 -0
  114. package/dist/electron/electron/gateway/channels/signal-client.js +500 -0
  115. package/dist/electron/electron/gateway/channels/signal.js +582 -0
  116. package/dist/electron/electron/gateway/channels/slack.js +415 -0
  117. package/dist/electron/electron/gateway/channels/teams.js +596 -0
  118. package/dist/electron/electron/gateway/channels/telegram.js +1390 -0
  119. package/dist/electron/electron/gateway/channels/twitch-client.js +502 -0
  120. package/dist/electron/electron/gateway/channels/twitch.js +396 -0
  121. package/dist/electron/electron/gateway/channels/types.js +8 -0
  122. package/dist/electron/electron/gateway/channels/whatsapp.js +953 -0
  123. package/dist/electron/electron/gateway/context-policy.js +268 -0
  124. package/dist/electron/electron/gateway/index.js +1063 -0
  125. package/dist/electron/electron/gateway/infrastructure.js +496 -0
  126. package/dist/electron/electron/gateway/router.js +2700 -0
  127. package/dist/electron/electron/gateway/security.js +375 -0
  128. package/dist/electron/electron/gateway/session.js +115 -0
  129. package/dist/electron/electron/gateway/tunnel.js +503 -0
  130. package/dist/electron/electron/guardrails/guardrail-manager.js +348 -0
  131. package/dist/electron/electron/hooks/gmail-watcher.js +300 -0
  132. package/dist/electron/electron/hooks/index.js +46 -0
  133. package/dist/electron/electron/hooks/mappings.js +381 -0
  134. package/dist/electron/electron/hooks/server.js +480 -0
  135. package/dist/electron/electron/hooks/settings.js +447 -0
  136. package/dist/electron/electron/hooks/types.js +41 -0
  137. package/dist/electron/electron/ipc/canvas-handlers.js +158 -0
  138. package/dist/electron/electron/ipc/handlers.js +3138 -0
  139. package/dist/electron/electron/ipc/mission-control-handlers.js +141 -0
  140. package/dist/electron/electron/main.js +448 -0
  141. package/dist/electron/electron/mcp/client/MCPClientManager.js +330 -0
  142. package/dist/electron/electron/mcp/client/MCPServerConnection.js +437 -0
  143. package/dist/electron/electron/mcp/client/transports/SSETransport.js +304 -0
  144. package/dist/electron/electron/mcp/client/transports/StdioTransport.js +307 -0
  145. package/dist/electron/electron/mcp/client/transports/WebSocketTransport.js +329 -0
  146. package/dist/electron/electron/mcp/host/MCPHostServer.js +354 -0
  147. package/dist/electron/electron/mcp/host/ToolAdapter.js +100 -0
  148. package/dist/electron/electron/mcp/registry/MCPRegistryManager.js +497 -0
  149. package/dist/electron/electron/mcp/settings.js +446 -0
  150. package/dist/electron/electron/mcp/types.js +59 -0
  151. package/dist/electron/electron/memory/MemoryService.js +435 -0
  152. package/dist/electron/electron/notifications/index.js +17 -0
  153. package/dist/electron/electron/notifications/service.js +118 -0
  154. package/dist/electron/electron/notifications/store.js +144 -0
  155. package/dist/electron/electron/preload.js +842 -0
  156. package/dist/electron/electron/reports/StandupReportService.js +272 -0
  157. package/dist/electron/electron/security/concurrency.js +293 -0
  158. package/dist/electron/electron/security/index.js +15 -0
  159. package/dist/electron/electron/security/policy-manager.js +435 -0
  160. package/dist/electron/electron/settings/appearance-manager.js +193 -0
  161. package/dist/electron/electron/settings/personality-manager.js +724 -0
  162. package/dist/electron/electron/settings/x-manager.js +58 -0
  163. package/dist/electron/electron/tailscale/exposure.js +188 -0
  164. package/dist/electron/electron/tailscale/index.js +28 -0
  165. package/dist/electron/electron/tailscale/settings.js +205 -0
  166. package/dist/electron/electron/tailscale/tailscale.js +355 -0
  167. package/dist/electron/electron/tray/QuickInputWindow.js +568 -0
  168. package/dist/electron/electron/tray/TrayManager.js +895 -0
  169. package/dist/electron/electron/tray/index.js +9 -0
  170. package/dist/electron/electron/updater/index.js +6 -0
  171. package/dist/electron/electron/updater/update-manager.js +418 -0
  172. package/dist/electron/electron/utils/env-migration.js +209 -0
  173. package/dist/electron/electron/utils/process.js +102 -0
  174. package/dist/electron/electron/utils/rate-limiter.js +104 -0
  175. package/dist/electron/electron/utils/validation.js +419 -0
  176. package/dist/electron/electron/utils/x-cli.js +177 -0
  177. package/dist/electron/electron/voice/VoiceService.js +507 -0
  178. package/dist/electron/electron/voice/index.js +14 -0
  179. package/dist/electron/electron/voice/voice-settings-manager.js +359 -0
  180. package/dist/electron/shared/channelMessages.js +170 -0
  181. package/dist/electron/shared/types.js +1185 -0
  182. package/package.json +159 -0
  183. package/resources/skills/1password.json +10 -0
  184. package/resources/skills/add-documentation.json +31 -0
  185. package/resources/skills/analyze-csv.json +17 -0
  186. package/resources/skills/apple-notes.json +10 -0
  187. package/resources/skills/apple-reminders.json +10 -0
  188. package/resources/skills/auto-commenter.json +10 -0
  189. package/resources/skills/bear-notes.json +10 -0
  190. package/resources/skills/bird.json +35 -0
  191. package/resources/skills/blogwatcher.json +10 -0
  192. package/resources/skills/blucli.json +10 -0
  193. package/resources/skills/bluebubbles.json +10 -0
  194. package/resources/skills/camsnap.json +10 -0
  195. package/resources/skills/clean-imports.json +18 -0
  196. package/resources/skills/code-review.json +18 -0
  197. package/resources/skills/coding-agent.json +10 -0
  198. package/resources/skills/compare-files.json +23 -0
  199. package/resources/skills/convert-code.json +34 -0
  200. package/resources/skills/create-changelog.json +24 -0
  201. package/resources/skills/debug-error.json +17 -0
  202. package/resources/skills/dependency-check.json +10 -0
  203. package/resources/skills/discord.json +10 -0
  204. package/resources/skills/eightctl.json +10 -0
  205. package/resources/skills/explain-code.json +29 -0
  206. package/resources/skills/extract-todos.json +18 -0
  207. package/resources/skills/food-order.json +10 -0
  208. package/resources/skills/gemini.json +10 -0
  209. package/resources/skills/generate-readme.json +10 -0
  210. package/resources/skills/gifgrep.json +10 -0
  211. package/resources/skills/git-commit.json +10 -0
  212. package/resources/skills/github.json +10 -0
  213. package/resources/skills/gog.json +10 -0
  214. package/resources/skills/goplaces.json +10 -0
  215. package/resources/skills/himalaya.json +10 -0
  216. package/resources/skills/imsg.json +10 -0
  217. package/resources/skills/karpathy-guidelines.json +12 -0
  218. package/resources/skills/last30days.json +26 -0
  219. package/resources/skills/local-places.json +10 -0
  220. package/resources/skills/mcporter.json +10 -0
  221. package/resources/skills/model-usage.json +10 -0
  222. package/resources/skills/nano-banana-pro.json +10 -0
  223. package/resources/skills/nano-pdf.json +10 -0
  224. package/resources/skills/notion.json +10 -0
  225. package/resources/skills/obsidian.json +10 -0
  226. package/resources/skills/openai-image-gen.json +10 -0
  227. package/resources/skills/openai-whisper-api.json +10 -0
  228. package/resources/skills/openai-whisper.json +10 -0
  229. package/resources/skills/openhue.json +10 -0
  230. package/resources/skills/oracle.json +10 -0
  231. package/resources/skills/ordercli.json +10 -0
  232. package/resources/skills/peekaboo.json +10 -0
  233. package/resources/skills/project-structure.json +10 -0
  234. package/resources/skills/proofread.json +17 -0
  235. package/resources/skills/refactor-code.json +31 -0
  236. package/resources/skills/rename-symbol.json +23 -0
  237. package/resources/skills/sag.json +10 -0
  238. package/resources/skills/security-audit.json +18 -0
  239. package/resources/skills/session-logs.json +10 -0
  240. package/resources/skills/sherpa-onnx-tts.json +10 -0
  241. package/resources/skills/skill-creator.json +15 -0
  242. package/resources/skills/skill-hub.json +29 -0
  243. package/resources/skills/slack.json +10 -0
  244. package/resources/skills/songsee.json +10 -0
  245. package/resources/skills/sonoscli.json +10 -0
  246. package/resources/skills/spotify-player.json +10 -0
  247. package/resources/skills/startup-cfo.json +55 -0
  248. package/resources/skills/summarize-folder.json +18 -0
  249. package/resources/skills/summarize.json +10 -0
  250. package/resources/skills/things-mac.json +10 -0
  251. package/resources/skills/tmux.json +10 -0
  252. package/resources/skills/translate.json +36 -0
  253. package/resources/skills/trello.json +10 -0
  254. package/resources/skills/video-frames.json +10 -0
  255. package/resources/skills/voice-call.json +10 -0
  256. package/resources/skills/wacli.json +10 -0
  257. package/resources/skills/weather.json +10 -0
  258. package/resources/skills/write-tests.json +31 -0
  259. package/src/electron/activity/ActivityRepository.ts +238 -0
  260. package/src/electron/agent/browser/browser-service.ts +721 -0
  261. package/src/electron/agent/context-manager.ts +257 -0
  262. package/src/electron/agent/custom-skill-loader.ts +634 -0
  263. package/src/electron/agent/daemon.ts +1097 -0
  264. package/src/electron/agent/executor.ts +4017 -0
  265. package/src/electron/agent/llm/anthropic-provider.ts +175 -0
  266. package/src/electron/agent/llm/bedrock-provider.ts +236 -0
  267. package/src/electron/agent/llm/gemini-provider.ts +422 -0
  268. package/src/electron/agent/llm/index.ts +9 -0
  269. package/src/electron/agent/llm/ollama-provider.ts +347 -0
  270. package/src/electron/agent/llm/openai-oauth.ts +127 -0
  271. package/src/electron/agent/llm/openai-provider.ts +686 -0
  272. package/src/electron/agent/llm/openrouter-provider.ts +273 -0
  273. package/src/electron/agent/llm/pricing.ts +180 -0
  274. package/src/electron/agent/llm/provider-factory.ts +971 -0
  275. package/src/electron/agent/llm/types.ts +291 -0
  276. package/src/electron/agent/queue-manager.ts +408 -0
  277. package/src/electron/agent/sandbox/docker-sandbox.ts +453 -0
  278. package/src/electron/agent/sandbox/macos-sandbox.ts +426 -0
  279. package/src/electron/agent/sandbox/runner.ts +453 -0
  280. package/src/electron/agent/sandbox/sandbox-factory.ts +337 -0
  281. package/src/electron/agent/sandbox/security-utils.ts +251 -0
  282. package/src/electron/agent/search/brave-provider.ts +141 -0
  283. package/src/electron/agent/search/google-provider.ts +131 -0
  284. package/src/electron/agent/search/index.ts +6 -0
  285. package/src/electron/agent/search/provider-factory.ts +450 -0
  286. package/src/electron/agent/search/serpapi-provider.ts +138 -0
  287. package/src/electron/agent/search/tavily-provider.ts +108 -0
  288. package/src/electron/agent/search/types.ts +118 -0
  289. package/src/electron/agent/security/index.ts +20 -0
  290. package/src/electron/agent/security/input-sanitizer.ts +380 -0
  291. package/src/electron/agent/security/output-filter.ts +259 -0
  292. package/src/electron/agent/skill-eligibility.ts +334 -0
  293. package/src/electron/agent/skill-registry.ts +457 -0
  294. package/src/electron/agent/skills/document.ts +1070 -0
  295. package/src/electron/agent/skills/image-generator.ts +272 -0
  296. package/src/electron/agent/skills/organizer.ts +131 -0
  297. package/src/electron/agent/skills/presentation.ts +418 -0
  298. package/src/electron/agent/skills/spreadsheet.ts +166 -0
  299. package/src/electron/agent/tools/browser-tools.ts +546 -0
  300. package/src/electron/agent/tools/builtin-settings.ts +422 -0
  301. package/src/electron/agent/tools/canvas-tools.ts +572 -0
  302. package/src/electron/agent/tools/cron-tools.ts +723 -0
  303. package/src/electron/agent/tools/edit-tools.ts +196 -0
  304. package/src/electron/agent/tools/file-tools.ts +811 -0
  305. package/src/electron/agent/tools/glob-tools.ts +303 -0
  306. package/src/electron/agent/tools/grep-tools.ts +432 -0
  307. package/src/electron/agent/tools/image-tools.ts +126 -0
  308. package/src/electron/agent/tools/mention-tools.ts +371 -0
  309. package/src/electron/agent/tools/node-tools.ts +550 -0
  310. package/src/electron/agent/tools/registry.ts +3052 -0
  311. package/src/electron/agent/tools/search-tools.ts +111 -0
  312. package/src/electron/agent/tools/shell-tools.ts +651 -0
  313. package/src/electron/agent/tools/skill-tools.ts +340 -0
  314. package/src/electron/agent/tools/system-tools.ts +665 -0
  315. package/src/electron/agent/tools/web-fetch-tools.ts +528 -0
  316. package/src/electron/agent/tools/x-tools.ts +267 -0
  317. package/src/electron/agents/AgentRoleRepository.ts +557 -0
  318. package/src/electron/agents/HeartbeatService.ts +469 -0
  319. package/src/electron/agents/MentionRepository.ts +242 -0
  320. package/src/electron/agents/TaskSubscriptionRepository.ts +231 -0
  321. package/src/electron/agents/WorkingStateRepository.ts +278 -0
  322. package/src/electron/canvas/canvas-manager.ts +818 -0
  323. package/src/electron/canvas/canvas-preload.ts +102 -0
  324. package/src/electron/canvas/canvas-protocol.ts +174 -0
  325. package/src/electron/canvas/canvas-store.ts +200 -0
  326. package/src/electron/canvas/index.ts +8 -0
  327. package/src/electron/control-plane/client.ts +527 -0
  328. package/src/electron/control-plane/handlers.ts +723 -0
  329. package/src/electron/control-plane/index.ts +51 -0
  330. package/src/electron/control-plane/node-manager.ts +322 -0
  331. package/src/electron/control-plane/protocol.ts +269 -0
  332. package/src/electron/control-plane/remote-client.ts +517 -0
  333. package/src/electron/control-plane/server.ts +853 -0
  334. package/src/electron/control-plane/settings.ts +401 -0
  335. package/src/electron/control-plane/ssh-tunnel.ts +624 -0
  336. package/src/electron/cron/index.ts +9 -0
  337. package/src/electron/cron/schedule.ts +217 -0
  338. package/src/electron/cron/service.ts +743 -0
  339. package/src/electron/cron/store.ts +165 -0
  340. package/src/electron/cron/types.ts +291 -0
  341. package/src/electron/cron/webhook.ts +303 -0
  342. package/src/electron/database/SecureSettingsRepository.ts +514 -0
  343. package/src/electron/database/TaskLabelRepository.ts +148 -0
  344. package/src/electron/database/repositories.ts +2397 -0
  345. package/src/electron/database/schema.ts +1017 -0
  346. package/src/electron/extensions/index.ts +18 -0
  347. package/src/electron/extensions/loader.ts +336 -0
  348. package/src/electron/extensions/registry.ts +546 -0
  349. package/src/electron/extensions/types.ts +372 -0
  350. package/src/electron/gateway/channel-registry.ts +1267 -0
  351. package/src/electron/gateway/channels/bluebubbles-client.ts +641 -0
  352. package/src/electron/gateway/channels/bluebubbles.ts +509 -0
  353. package/src/electron/gateway/channels/discord.ts +1150 -0
  354. package/src/electron/gateway/channels/email-client.ts +708 -0
  355. package/src/electron/gateway/channels/email.ts +516 -0
  356. package/src/electron/gateway/channels/google-chat.ts +760 -0
  357. package/src/electron/gateway/channels/imessage-client.ts +473 -0
  358. package/src/electron/gateway/channels/imessage.ts +520 -0
  359. package/src/electron/gateway/channels/index.ts +21 -0
  360. package/src/electron/gateway/channels/line-client.ts +598 -0
  361. package/src/electron/gateway/channels/line.ts +559 -0
  362. package/src/electron/gateway/channels/matrix-client.ts +632 -0
  363. package/src/electron/gateway/channels/matrix.ts +655 -0
  364. package/src/electron/gateway/channels/mattermost-client.ts +526 -0
  365. package/src/electron/gateway/channels/mattermost.ts +550 -0
  366. package/src/electron/gateway/channels/signal-client.ts +722 -0
  367. package/src/electron/gateway/channels/signal.ts +666 -0
  368. package/src/electron/gateway/channels/slack.ts +458 -0
  369. package/src/electron/gateway/channels/teams.ts +681 -0
  370. package/src/electron/gateway/channels/telegram.ts +1727 -0
  371. package/src/electron/gateway/channels/twitch-client.ts +665 -0
  372. package/src/electron/gateway/channels/twitch.ts +468 -0
  373. package/src/electron/gateway/channels/types.ts +1002 -0
  374. package/src/electron/gateway/channels/whatsapp.ts +1101 -0
  375. package/src/electron/gateway/context-policy.ts +382 -0
  376. package/src/electron/gateway/index.ts +1274 -0
  377. package/src/electron/gateway/infrastructure.ts +645 -0
  378. package/src/electron/gateway/router.ts +3206 -0
  379. package/src/electron/gateway/security.ts +422 -0
  380. package/src/electron/gateway/session.ts +144 -0
  381. package/src/electron/gateway/tunnel.ts +626 -0
  382. package/src/electron/guardrails/guardrail-manager.ts +380 -0
  383. package/src/electron/hooks/gmail-watcher.ts +355 -0
  384. package/src/electron/hooks/index.ts +30 -0
  385. package/src/electron/hooks/mappings.ts +404 -0
  386. package/src/electron/hooks/server.ts +574 -0
  387. package/src/electron/hooks/settings.ts +466 -0
  388. package/src/electron/hooks/types.ts +245 -0
  389. package/src/electron/ipc/canvas-handlers.ts +223 -0
  390. package/src/electron/ipc/handlers.ts +3661 -0
  391. package/src/electron/ipc/mission-control-handlers.ts +182 -0
  392. package/src/electron/main.ts +496 -0
  393. package/src/electron/mcp/client/MCPClientManager.ts +406 -0
  394. package/src/electron/mcp/client/MCPServerConnection.ts +514 -0
  395. package/src/electron/mcp/client/transports/SSETransport.ts +360 -0
  396. package/src/electron/mcp/client/transports/StdioTransport.ts +355 -0
  397. package/src/electron/mcp/client/transports/WebSocketTransport.ts +384 -0
  398. package/src/electron/mcp/host/MCPHostServer.ts +388 -0
  399. package/src/electron/mcp/host/ToolAdapter.ts +140 -0
  400. package/src/electron/mcp/registry/MCPRegistryManager.ts +565 -0
  401. package/src/electron/mcp/settings.ts +468 -0
  402. package/src/electron/mcp/types.ts +371 -0
  403. package/src/electron/memory/MemoryService.ts +523 -0
  404. package/src/electron/notifications/index.ts +16 -0
  405. package/src/electron/notifications/service.ts +161 -0
  406. package/src/electron/notifications/store.ts +163 -0
  407. package/src/electron/preload.ts +2845 -0
  408. package/src/electron/reports/StandupReportService.ts +356 -0
  409. package/src/electron/security/concurrency.ts +333 -0
  410. package/src/electron/security/index.ts +17 -0
  411. package/src/electron/security/policy-manager.ts +539 -0
  412. package/src/electron/settings/appearance-manager.ts +182 -0
  413. package/src/electron/settings/personality-manager.ts +800 -0
  414. package/src/electron/settings/x-manager.ts +62 -0
  415. package/src/electron/tailscale/exposure.ts +262 -0
  416. package/src/electron/tailscale/index.ts +34 -0
  417. package/src/electron/tailscale/settings.ts +218 -0
  418. package/src/electron/tailscale/tailscale.ts +379 -0
  419. package/src/electron/tray/QuickInputWindow.ts +609 -0
  420. package/src/electron/tray/TrayManager.ts +1005 -0
  421. package/src/electron/tray/index.ts +6 -0
  422. package/src/electron/updater/index.ts +1 -0
  423. package/src/electron/updater/update-manager.ts +447 -0
  424. package/src/electron/utils/env-migration.ts +203 -0
  425. package/src/electron/utils/process.ts +124 -0
  426. package/src/electron/utils/rate-limiter.ts +130 -0
  427. package/src/electron/utils/validation.ts +493 -0
  428. package/src/electron/utils/x-cli.ts +198 -0
  429. package/src/electron/voice/VoiceService.ts +583 -0
  430. package/src/electron/voice/index.ts +9 -0
  431. package/src/electron/voice/voice-settings-manager.ts +403 -0
  432. package/src/renderer/App.tsx +775 -0
  433. package/src/renderer/components/ActivityFeed.tsx +407 -0
  434. package/src/renderer/components/ActivityFeedItem.tsx +285 -0
  435. package/src/renderer/components/AgentRoleCard.tsx +343 -0
  436. package/src/renderer/components/AgentRoleEditor.tsx +805 -0
  437. package/src/renderer/components/AgentSquadSettings.tsx +295 -0
  438. package/src/renderer/components/AgentWorkingStatePanel.tsx +411 -0
  439. package/src/renderer/components/AppearanceSettings.tsx +122 -0
  440. package/src/renderer/components/ApprovalDialog.tsx +100 -0
  441. package/src/renderer/components/BlueBubblesSettings.tsx +505 -0
  442. package/src/renderer/components/BuiltinToolsSettings.tsx +307 -0
  443. package/src/renderer/components/CanvasPreview.tsx +1189 -0
  444. package/src/renderer/components/CommandOutput.tsx +202 -0
  445. package/src/renderer/components/ContextPolicySettings.tsx +523 -0
  446. package/src/renderer/components/ControlPlaneSettings.tsx +1134 -0
  447. package/src/renderer/components/DisclaimerModal.tsx +124 -0
  448. package/src/renderer/components/DiscordSettings.tsx +436 -0
  449. package/src/renderer/components/EmailSettings.tsx +606 -0
  450. package/src/renderer/components/ExtensionsSettings.tsx +542 -0
  451. package/src/renderer/components/FileViewer.tsx +224 -0
  452. package/src/renderer/components/GoogleChatSettings.tsx +535 -0
  453. package/src/renderer/components/GuardrailSettings.tsx +487 -0
  454. package/src/renderer/components/HooksSettings.tsx +581 -0
  455. package/src/renderer/components/ImessageSettings.tsx +484 -0
  456. package/src/renderer/components/LineSettings.tsx +483 -0
  457. package/src/renderer/components/MCPRegistryBrowser.tsx +386 -0
  458. package/src/renderer/components/MCPSettings.tsx +943 -0
  459. package/src/renderer/components/MainContent.tsx +2433 -0
  460. package/src/renderer/components/MatrixSettings.tsx +510 -0
  461. package/src/renderer/components/MattermostSettings.tsx +473 -0
  462. package/src/renderer/components/MemorySettings.tsx +247 -0
  463. package/src/renderer/components/MentionBadge.tsx +87 -0
  464. package/src/renderer/components/MentionInput.tsx +409 -0
  465. package/src/renderer/components/MentionList.tsx +476 -0
  466. package/src/renderer/components/MissionControlPanel.tsx +1995 -0
  467. package/src/renderer/components/NodesSettings.tsx +316 -0
  468. package/src/renderer/components/NotificationPanel.tsx +481 -0
  469. package/src/renderer/components/Onboarding/AwakeningOrb.tsx +44 -0
  470. package/src/renderer/components/Onboarding/Onboarding.tsx +443 -0
  471. package/src/renderer/components/Onboarding/TypewriterText.tsx +102 -0
  472. package/src/renderer/components/Onboarding/index.ts +3 -0
  473. package/src/renderer/components/OnboardingModal.tsx +698 -0
  474. package/src/renderer/components/PairingCodeDisplay.tsx +324 -0
  475. package/src/renderer/components/PersonalitySettings.tsx +597 -0
  476. package/src/renderer/components/QueueSettings.tsx +119 -0
  477. package/src/renderer/components/QuickTaskFAB.tsx +71 -0
  478. package/src/renderer/components/RightPanel.tsx +413 -0
  479. package/src/renderer/components/ScheduledTasksSettings.tsx +1328 -0
  480. package/src/renderer/components/SearchSettings.tsx +328 -0
  481. package/src/renderer/components/Settings.tsx +1504 -0
  482. package/src/renderer/components/Sidebar.tsx +344 -0
  483. package/src/renderer/components/SignalSettings.tsx +673 -0
  484. package/src/renderer/components/SkillHubBrowser.tsx +458 -0
  485. package/src/renderer/components/SkillParameterModal.tsx +185 -0
  486. package/src/renderer/components/SkillsSettings.tsx +451 -0
  487. package/src/renderer/components/SlackSettings.tsx +442 -0
  488. package/src/renderer/components/StandupReportViewer.tsx +614 -0
  489. package/src/renderer/components/TaskBoard.tsx +498 -0
  490. package/src/renderer/components/TaskBoardCard.tsx +357 -0
  491. package/src/renderer/components/TaskBoardColumn.tsx +211 -0
  492. package/src/renderer/components/TaskLabelManager.tsx +472 -0
  493. package/src/renderer/components/TaskQueuePanel.tsx +144 -0
  494. package/src/renderer/components/TaskQuickActions.tsx +492 -0
  495. package/src/renderer/components/TaskTimeline.tsx +216 -0
  496. package/src/renderer/components/TaskView.tsx +162 -0
  497. package/src/renderer/components/TeamsSettings.tsx +518 -0
  498. package/src/renderer/components/TelegramSettings.tsx +421 -0
  499. package/src/renderer/components/Toast.tsx +76 -0
  500. package/src/renderer/components/TraySettings.tsx +189 -0
  501. package/src/renderer/components/TwitchSettings.tsx +511 -0
  502. package/src/renderer/components/UpdateSettings.tsx +295 -0
  503. package/src/renderer/components/VoiceIndicator.tsx +270 -0
  504. package/src/renderer/components/VoiceSettings.tsx +867 -0
  505. package/src/renderer/components/WhatsAppSettings.tsx +721 -0
  506. package/src/renderer/components/WorkingStateEditor.tsx +309 -0
  507. package/src/renderer/components/WorkingStateHistory.tsx +481 -0
  508. package/src/renderer/components/WorkspaceSelector.tsx +150 -0
  509. package/src/renderer/components/XSettings.tsx +311 -0
  510. package/src/renderer/global.d.ts +9 -0
  511. package/src/renderer/hooks/useAgentContext.ts +153 -0
  512. package/src/renderer/hooks/useOnboardingFlow.ts +548 -0
  513. package/src/renderer/hooks/useVoiceInput.ts +268 -0
  514. package/src/renderer/index.html +12 -0
  515. package/src/renderer/main.tsx +10 -0
  516. package/src/renderer/public/cowork-os-logo.png +0 -0
  517. package/src/renderer/quick-input.html +164 -0
  518. package/src/renderer/styles/index.css +14504 -0
  519. package/src/renderer/utils/agentMessages.ts +749 -0
  520. package/src/renderer/utils/voice-directives.ts +169 -0
  521. package/src/shared/channelMessages.ts +213 -0
  522. package/src/shared/types.ts +3608 -0
  523. package/tsconfig.electron.json +26 -0
  524. package/tsconfig.json +26 -0
  525. package/tsconfig.node.json +10 -0
  526. package/vite.config.ts +23 -0
@@ -0,0 +1,2433 @@
1
+ import { useState, useEffect, useRef, useCallback, useMemo, Fragment } from 'react';
2
+ import ReactMarkdown from 'react-markdown';
3
+ import remarkGfm from 'remark-gfm';
4
+ import remarkBreaks from 'remark-breaks';
5
+ import { Task, TaskEvent, Workspace, ApprovalRequest, LLMModelInfo, SuccessCriteria, CustomSkill, EventType, TEMP_WORKSPACE_ID, DEFAULT_QUIRKS, CanvasSession } from '../../shared/types';
6
+ import type { AgentRoleData } from '../../electron/preload';
7
+ import { useVoiceInput } from '../hooks/useVoiceInput';
8
+ import { useAgentContext, type AgentContext } from '../hooks/useAgentContext';
9
+ import { getMessage } from '../utils/agentMessages';
10
+
11
+ // localStorage key for verbose mode
12
+ const VERBOSE_STEPS_KEY = 'cowork:verboseSteps';
13
+ const TASK_TITLE_MAX_LENGTH = 50;
14
+ const TITLE_ELLIPSIS_REGEX = /(\.\.\.|\u2026)$/u;
15
+
16
+ // Important event types shown in non-verbose mode
17
+ // These are high-level steps that represent meaningful progress
18
+ const IMPORTANT_EVENT_TYPES: EventType[] = [
19
+ 'task_created',
20
+ 'task_completed',
21
+ 'task_cancelled',
22
+ 'plan_created',
23
+ 'step_started',
24
+ 'step_completed',
25
+ 'step_failed',
26
+ 'assistant_message',
27
+ 'user_message',
28
+ 'file_created',
29
+ 'file_modified',
30
+ 'file_deleted',
31
+ 'error',
32
+ 'verification_started',
33
+ 'verification_passed',
34
+ 'verification_failed',
35
+ 'retry_started',
36
+ 'approval_requested',
37
+ ];
38
+
39
+ // Helper to check if an event is important (shown in non-verbose mode)
40
+ const isImportantEvent = (event: TaskEvent): boolean => {
41
+ return IMPORTANT_EVENT_TYPES.includes(event.type);
42
+ };
43
+
44
+ const buildTaskTitle = (text: string): string => {
45
+ const trimmed = text.trim();
46
+ if (trimmed.length <= TASK_TITLE_MAX_LENGTH) {
47
+ return trimmed;
48
+ }
49
+ return `${trimmed.slice(0, TASK_TITLE_MAX_LENGTH)}...`;
50
+ };
51
+
52
+ type MentionOption = {
53
+ type: 'agent' | 'everyone';
54
+ id: string;
55
+ label: string;
56
+ description?: string;
57
+ icon?: string;
58
+ color?: string;
59
+ };
60
+
61
+ const normalizeMentionSearch = (value: string): string =>
62
+ value.toLowerCase().replace(/[^a-z0-9]/g, '');
63
+ import { ApprovalDialog } from './ApprovalDialog';
64
+ import { SkillParameterModal } from './SkillParameterModal';
65
+ import { FileViewer } from './FileViewer';
66
+ import { CommandOutput } from './CommandOutput';
67
+ import { CanvasPreview } from './CanvasPreview';
68
+
69
+ // Code block component with copy button
70
+ interface CodeBlockProps {
71
+ children?: React.ReactNode;
72
+ className?: string;
73
+ node?: unknown;
74
+ }
75
+
76
+ function CodeBlock({ children, className, ...props }: CodeBlockProps) {
77
+ const [copied, setCopied] = useState(false);
78
+
79
+ // Check if this is a code block (has language class) vs inline code
80
+ const isCodeBlock = className?.startsWith('language-');
81
+ const language = className?.replace('language-', '') || '';
82
+
83
+ // Get the text content for copying
84
+ const getTextContent = (node: React.ReactNode): string => {
85
+ if (typeof node === 'string') return node;
86
+ if (Array.isArray(node)) return node.map(getTextContent).join('');
87
+ if (node && typeof node === 'object' && 'props' in node) {
88
+ return getTextContent((node as { props: { children?: React.ReactNode } }).props.children);
89
+ }
90
+ return '';
91
+ };
92
+
93
+ const handleCopy = async () => {
94
+ const text = getTextContent(children);
95
+ try {
96
+ await navigator.clipboard.writeText(text);
97
+ setCopied(true);
98
+ setTimeout(() => setCopied(false), 2000);
99
+ } catch (err) {
100
+ console.error('Failed to copy:', err);
101
+ }
102
+ };
103
+
104
+ // For inline code, just render normally
105
+ if (!isCodeBlock) {
106
+ return <code className={className} {...props}>{children}</code>;
107
+ }
108
+
109
+ // For code blocks, wrap with copy button
110
+ return (
111
+ <div className="code-block-wrapper">
112
+ <div className="code-block-header">
113
+ {language && <span className="code-block-language">{language}</span>}
114
+ <button
115
+ className={`code-block-copy ${copied ? 'copied' : ''}`}
116
+ onClick={handleCopy}
117
+ title={copied ? 'Copied!' : 'Copy code'}
118
+ >
119
+ {copied ? (
120
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
121
+ <path d="M20 6L9 17l-5-5" />
122
+ </svg>
123
+ ) : (
124
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
125
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
126
+ <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
127
+ </svg>
128
+ )}
129
+ <span>{copied ? 'Copied!' : 'Copy'}</span>
130
+ </button>
131
+ </div>
132
+ <code className={className} {...props}>{children}</code>
133
+ </div>
134
+ );
135
+ }
136
+
137
+ // Copy button for user messages
138
+ function MessageCopyButton({ text }: { text: string }) {
139
+ const [copied, setCopied] = useState(false);
140
+
141
+ const handleCopy = async () => {
142
+ try {
143
+ await navigator.clipboard.writeText(text);
144
+ setCopied(true);
145
+ setTimeout(() => setCopied(false), 2000);
146
+ } catch (err) {
147
+ console.error('Failed to copy:', err);
148
+ }
149
+ };
150
+
151
+ return (
152
+ <button
153
+ className={`message-copy-btn ${copied ? 'copied' : ''}`}
154
+ onClick={handleCopy}
155
+ title={copied ? 'Copied!' : 'Copy message'}
156
+ >
157
+ {copied ? (
158
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
159
+ <path d="M20 6L9 17l-5-5" />
160
+ </svg>
161
+ ) : (
162
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
163
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
164
+ <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
165
+ </svg>
166
+ )}
167
+ <span>{copied ? 'Copied' : 'Copy'}</span>
168
+ </button>
169
+ );
170
+ }
171
+
172
+ // Global audio state to ensure only one audio plays at a time
173
+ let currentAudioContext: AudioContext | null = null;
174
+ let currentAudioSource: AudioBufferSourceNode | null = null;
175
+ let currentSpeakingCallback: (() => void) | null = null;
176
+
177
+ function stopCurrentAudio() {
178
+ if (currentAudioSource) {
179
+ try {
180
+ currentAudioSource.stop();
181
+ } catch {
182
+ // Already stopped
183
+ }
184
+ currentAudioSource = null;
185
+ }
186
+ if (currentAudioContext) {
187
+ try {
188
+ currentAudioContext.close();
189
+ } catch {
190
+ // Already closed
191
+ }
192
+ currentAudioContext = null;
193
+ }
194
+ if (currentSpeakingCallback) {
195
+ currentSpeakingCallback();
196
+ currentSpeakingCallback = null;
197
+ }
198
+ }
199
+
200
+ // Speak button for assistant messages
201
+ function MessageSpeakButton({ text, voiceEnabled }: { text: string; voiceEnabled: boolean }) {
202
+ const [speaking, setSpeaking] = useState(false);
203
+ const [loading, setLoading] = useState(false);
204
+
205
+ const handleClick = async () => {
206
+ if (!voiceEnabled) return;
207
+
208
+ // If already speaking, stop the audio
209
+ if (speaking) {
210
+ stopCurrentAudio();
211
+ setSpeaking(false);
212
+ return;
213
+ }
214
+
215
+ try {
216
+ setLoading(true);
217
+ // Strip markdown for cleaner speech
218
+ const cleanText = text
219
+ .replace(/```[\s\S]*?```/g, '') // Remove code blocks
220
+ .replace(/`[^`]+`/g, '') // Remove inline code
221
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Keep link text only
222
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, '') // Remove images
223
+ .replace(/^#{1,6}\s+/gm, '') // Remove headers
224
+ .replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold
225
+ .replace(/\*([^*]+)\*/g, '$1') // Remove italic
226
+ .replace(/\[\[speak\]\]([\s\S]*?)\[\[\/speak\]\]/gi, '$1') // Extract speak tags
227
+ .trim();
228
+
229
+ if (cleanText) {
230
+ // Stop any currently playing audio first
231
+ stopCurrentAudio();
232
+
233
+ const result = await window.electronAPI.voiceSpeak(cleanText);
234
+ if (result.success && result.audioData) {
235
+ // Convert number array back to ArrayBuffer and play
236
+ const audioBuffer = new Uint8Array(result.audioData).buffer;
237
+ const audioContext = new AudioContext();
238
+ const decodedAudio = await audioContext.decodeAudioData(audioBuffer);
239
+ const source = audioContext.createBufferSource();
240
+ source.buffer = decodedAudio;
241
+ source.connect(audioContext.destination);
242
+
243
+ // Store references for stopping
244
+ currentAudioContext = audioContext;
245
+ currentAudioSource = source;
246
+ currentSpeakingCallback = () => setSpeaking(false);
247
+
248
+ source.onended = () => {
249
+ setSpeaking(false);
250
+ currentAudioContext = null;
251
+ currentAudioSource = null;
252
+ currentSpeakingCallback = null;
253
+ try {
254
+ audioContext.close();
255
+ } catch {
256
+ // Already closed
257
+ }
258
+ };
259
+
260
+ setLoading(false);
261
+ setSpeaking(true);
262
+ source.start(0);
263
+ return;
264
+ } else if (!result.success) {
265
+ console.error('TTS failed:', result.error);
266
+ }
267
+ }
268
+ } catch (err) {
269
+ console.error('Failed to speak:', err);
270
+ } finally {
271
+ setLoading(false);
272
+ }
273
+ };
274
+
275
+ if (!voiceEnabled) return null;
276
+
277
+ return (
278
+ <button
279
+ className={`message-speak-btn ${speaking ? 'speaking' : ''}`}
280
+ onClick={handleClick}
281
+ title={speaking ? 'Stop speaking' : loading ? 'Loading...' : 'Speak message'}
282
+ disabled={loading}
283
+ >
284
+ {speaking ? (
285
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
286
+ <rect x="4" y="4" width="16" height="16" rx="2" />
287
+ </svg>
288
+ ) : loading ? (
289
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="spin">
290
+ <circle cx="12" cy="12" r="10" strokeDasharray="32" strokeDashoffset="12" />
291
+ </svg>
292
+ ) : (
293
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
294
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
295
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
296
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
297
+ </svg>
298
+ )}
299
+ <span>{speaking ? 'Stop' : loading ? 'Loading' : 'Speak'}</span>
300
+ </button>
301
+ );
302
+ }
303
+
304
+ // Custom components for ReactMarkdown
305
+ const markdownComponents = {
306
+ code: CodeBlock,
307
+ };
308
+
309
+ const userMarkdownPlugins = [remarkGfm, remarkBreaks];
310
+
311
+ // Searchable Model Dropdown Component
312
+ interface ModelDropdownProps {
313
+ models: LLMModelInfo[];
314
+ selectedModel: string;
315
+ onModelChange: (model: string) => void;
316
+ }
317
+
318
+ function ModelDropdown({ models, selectedModel, onModelChange }: ModelDropdownProps) {
319
+ const [isOpen, setIsOpen] = useState(false);
320
+ const [search, setSearch] = useState('');
321
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
322
+ const containerRef = useRef<HTMLDivElement>(null);
323
+ const inputRef = useRef<HTMLInputElement>(null);
324
+ const listRef = useRef<HTMLDivElement>(null);
325
+
326
+ const selectedModelInfo = models.find(m => m.key === selectedModel);
327
+
328
+ const filteredModels = models.filter(model =>
329
+ model.displayName.toLowerCase().includes(search.toLowerCase()) ||
330
+ model.key.toLowerCase().includes(search.toLowerCase()) ||
331
+ model.description.toLowerCase().includes(search.toLowerCase())
332
+ );
333
+
334
+ // Reset highlighted index when search changes
335
+ useEffect(() => {
336
+ setHighlightedIndex(0);
337
+ }, [search]);
338
+
339
+ // Scroll highlighted option into view
340
+ useEffect(() => {
341
+ if (isOpen && listRef.current) {
342
+ const highlightedEl = listRef.current.querySelector(`[data-index="${highlightedIndex}"]`);
343
+ if (highlightedEl) {
344
+ highlightedEl.scrollIntoView({ block: 'nearest' });
345
+ }
346
+ }
347
+ }, [highlightedIndex, isOpen]);
348
+
349
+ // Close on click outside
350
+ useEffect(() => {
351
+ const handleClickOutside = (e: MouseEvent) => {
352
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
353
+ setIsOpen(false);
354
+ setSearch('');
355
+ }
356
+ };
357
+ document.addEventListener('mousedown', handleClickOutside);
358
+ return () => document.removeEventListener('mousedown', handleClickOutside);
359
+ }, []);
360
+
361
+ const handleKeyDown = (e: React.KeyboardEvent) => {
362
+ if (!isOpen) {
363
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
364
+ e.preventDefault();
365
+ setIsOpen(true);
366
+ }
367
+ return;
368
+ }
369
+
370
+ switch (e.key) {
371
+ case 'ArrowDown':
372
+ e.preventDefault();
373
+ setHighlightedIndex(i => Math.min(i + 1, filteredModels.length - 1));
374
+ break;
375
+ case 'ArrowUp':
376
+ e.preventDefault();
377
+ setHighlightedIndex(i => Math.max(i - 1, 0));
378
+ break;
379
+ case 'Enter':
380
+ e.preventDefault();
381
+ if (filteredModels[highlightedIndex]) {
382
+ onModelChange(filteredModels[highlightedIndex].key);
383
+ setIsOpen(false);
384
+ setSearch('');
385
+ }
386
+ break;
387
+ case 'Escape':
388
+ e.preventDefault();
389
+ setIsOpen(false);
390
+ setSearch('');
391
+ break;
392
+ }
393
+ };
394
+
395
+ const handleSelect = (modelKey: string) => {
396
+ onModelChange(modelKey);
397
+ setIsOpen(false);
398
+ setSearch('');
399
+ };
400
+
401
+ return (
402
+ <div className="model-dropdown-container" ref={containerRef}>
403
+ <button
404
+ className={`model-selector ${isOpen ? 'open' : ''}`}
405
+ onClick={() => {
406
+ setIsOpen(!isOpen);
407
+ if (!isOpen) {
408
+ setTimeout(() => inputRef.current?.focus(), 0);
409
+ }
410
+ }}
411
+ onKeyDown={handleKeyDown}
412
+ >
413
+ {selectedModelInfo?.displayName || 'Select Model'}
414
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
415
+ <path d="M6 9l6 6 6-6" />
416
+ </svg>
417
+ </button>
418
+ {isOpen && (
419
+ <div className="model-dropdown">
420
+ <div className="model-dropdown-search">
421
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
422
+ <circle cx="11" cy="11" r="8" />
423
+ <path d="M21 21l-4.35-4.35" />
424
+ </svg>
425
+ <input
426
+ ref={inputRef}
427
+ type="text"
428
+ value={search}
429
+ onChange={(e) => setSearch(e.target.value)}
430
+ onKeyDown={handleKeyDown}
431
+ placeholder="Search models..."
432
+ autoFocus
433
+ />
434
+ </div>
435
+ <div ref={listRef} className="model-dropdown-list">
436
+ {filteredModels.length === 0 ? (
437
+ <div className="model-dropdown-no-results">No models found</div>
438
+ ) : (
439
+ filteredModels.map((model, index) => (
440
+ <button
441
+ key={model.key}
442
+ data-index={index}
443
+ className={`model-dropdown-item ${model.key === selectedModel ? 'selected' : ''} ${index === highlightedIndex ? 'highlighted' : ''}`}
444
+ onClick={() => handleSelect(model.key)}
445
+ onMouseEnter={() => setHighlightedIndex(index)}
446
+ >
447
+ <div className="model-dropdown-item-content">
448
+ <span className="model-dropdown-item-name">{model.displayName}</span>
449
+ <span className="model-dropdown-item-desc">{model.description}</span>
450
+ </div>
451
+ {model.key === selectedModel && (
452
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
453
+ <path d="M20 6L9 17l-5-5" />
454
+ </svg>
455
+ )}
456
+ </button>
457
+ ))
458
+ )}
459
+ </div>
460
+ </div>
461
+ )}
462
+ </div>
463
+ );
464
+ }
465
+
466
+ // Clickable file path component - opens file viewer on click, shows in Finder on right-click
467
+ function ClickableFilePath({
468
+ path,
469
+ workspacePath,
470
+ className = '',
471
+ onOpenViewer
472
+ }: {
473
+ path: string;
474
+ workspacePath?: string;
475
+ className?: string;
476
+ onOpenViewer?: (path: string) => void;
477
+ }) {
478
+ const handleClick = async (e: React.MouseEvent) => {
479
+ e.preventDefault();
480
+ e.stopPropagation();
481
+
482
+ // If viewer callback is provided and we have a workspace, use the in-app viewer
483
+ if (onOpenViewer && workspacePath) {
484
+ onOpenViewer(path);
485
+ return;
486
+ }
487
+
488
+ // Fallback to external app
489
+ try {
490
+ const error = await window.electronAPI.openFile(path, workspacePath);
491
+ if (error) {
492
+ console.error('Failed to open file:', error);
493
+ }
494
+ } catch (err) {
495
+ console.error('Error opening file:', err);
496
+ }
497
+ };
498
+
499
+ const handleContextMenu = async (e: React.MouseEvent) => {
500
+ e.preventDefault();
501
+ e.stopPropagation();
502
+ try {
503
+ await window.electronAPI.showInFinder(path, workspacePath);
504
+ } catch (err) {
505
+ console.error('Error showing in Finder:', err);
506
+ }
507
+ };
508
+
509
+ // Extract filename for display
510
+ const fileName = path.split('/').pop() || path;
511
+
512
+ return (
513
+ <span
514
+ className={`clickable-file-path ${className}`}
515
+ onClick={handleClick}
516
+ onContextMenu={handleContextMenu}
517
+ title={`${path}\n\nClick to preview • Right-click to show in Finder`}
518
+ >
519
+ {fileName}
520
+ </span>
521
+ );
522
+ }
523
+
524
+ interface GoalModeOptions {
525
+ successCriteria?: SuccessCriteria;
526
+ maxAttempts?: number;
527
+ }
528
+
529
+ type SettingsTab = 'appearance' | 'llm' | 'search' | 'telegram' | 'slack' | 'whatsapp' | 'teams' | 'morechannels' | 'updates' | 'guardrails' | 'queue' | 'skills' | 'voice';
530
+
531
+ interface MainContentProps {
532
+ task: Task | undefined;
533
+ selectedTaskId: string | null; // Added to distinguish "no task" from "task not in list"
534
+ workspace: Workspace | null;
535
+ events: TaskEvent[];
536
+ onSendMessage: (message: string) => void;
537
+ onCreateTask?: (title: string, prompt: string, options?: GoalModeOptions) => void;
538
+ onChangeWorkspace?: () => void;
539
+ onSelectWorkspace?: (workspace: Workspace) => void;
540
+ onOpenSettings?: (tab?: SettingsTab) => void;
541
+ onStopTask?: () => void;
542
+ selectedModel: string;
543
+ availableModels: LLMModelInfo[];
544
+ onModelChange: (model: string) => void;
545
+ }
546
+
547
+ // Track active command execution state
548
+ interface ActiveCommand {
549
+ command: string;
550
+ output: string;
551
+ isRunning: boolean;
552
+ exitCode: number | null;
553
+ startTimestamp: number; // When the command started, for positioning in timeline
554
+ }
555
+
556
+ export function MainContent({ task, selectedTaskId, workspace, events, onSendMessage, onCreateTask, onChangeWorkspace, onSelectWorkspace, onOpenSettings, onStopTask, selectedModel, availableModels, onModelChange }: MainContentProps) {
557
+ // Agent personality context for personalized messages
558
+ const agentContext = useAgentContext();
559
+ const [pendingApproval, setPendingApproval] = useState<ApprovalRequest | null>(null);
560
+ const [inputValue, setInputValue] = useState('');
561
+ const [agentRoles, setAgentRoles] = useState<AgentRoleData[]>([]);
562
+ const [mentionQuery, setMentionQuery] = useState('');
563
+ const [mentionTarget, setMentionTarget] = useState<{ start: number; end: number } | null>(null);
564
+ const [mentionOpen, setMentionOpen] = useState(false);
565
+ const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0);
566
+ // Shell permission state - tracks current workspace's shell permission
567
+ const [shellEnabled, setShellEnabled] = useState(workspace?.permissions?.shell ?? false);
568
+ // Active command execution state
569
+ const [activeCommand, setActiveCommand] = useState<ActiveCommand | null>(null);
570
+ // Track dismissed command outputs by task ID (persisted in localStorage)
571
+ const [dismissedCommandOutputs, setDismissedCommandOutputs] = useState<Set<string>>(() => {
572
+ try {
573
+ const saved = localStorage.getItem('dismissedCommandOutputs');
574
+ return saved ? new Set(JSON.parse(saved)) : new Set();
575
+ } catch {
576
+ return new Set();
577
+ }
578
+ });
579
+ // Goal Mode state
580
+ const [goalModeEnabled, setGoalModeEnabled] = useState(false);
581
+ const [verificationCommand, setVerificationCommand] = useState('');
582
+ const [maxAttempts, setMaxAttempts] = useState(3);
583
+ const [showSteps, setShowSteps] = useState(true);
584
+ const [autoScroll, setAutoScroll] = useState(true);
585
+ const [queuedMessage, setQueuedMessage] = useState<string | null>(null);
586
+ // Track toggled events by ID for stable state across filtering
587
+ const [toggledEvents, setToggledEvents] = useState<Set<string>>(new Set());
588
+ const [appVersion, setAppVersion] = useState<string>('');
589
+ const [customSkills, setCustomSkills] = useState<CustomSkill[]>([]);
590
+ const [showSkillsMenu, setShowSkillsMenu] = useState(false);
591
+ const [skillsSearchQuery, setSkillsSearchQuery] = useState('');
592
+ const [selectedSkillForParams, setSelectedSkillForParams] = useState<CustomSkill | null>(null);
593
+
594
+ // Voice input hook
595
+ const [showVoiceNotConfigured, setShowVoiceNotConfigured] = useState(false);
596
+ const voiceInput = useVoiceInput({
597
+ onTranscript: (text) => {
598
+ // Append transcribed text to input
599
+ setInputValue(prev => prev ? `${prev} ${text}` : text);
600
+ },
601
+ onError: (error) => {
602
+ console.error('Voice input error:', error);
603
+ },
604
+ onNotConfigured: () => {
605
+ setShowVoiceNotConfigured(true);
606
+ },
607
+ });
608
+ const [viewerFilePath, setViewerFilePath] = useState<string | null>(null);
609
+ // Canvas sessions state - track active canvas sessions for current task
610
+ const [canvasSessions, setCanvasSessions] = useState<CanvasSession[]>([]);
611
+ // Workspace dropdown state
612
+ const [showWorkspaceDropdown, setShowWorkspaceDropdown] = useState(false);
613
+ const [workspacesList, setWorkspacesList] = useState<Workspace[]>([]);
614
+ // Verbose mode - when false, only show important steps
615
+ const [verboseSteps, setVerboseSteps] = useState(() => {
616
+ const saved = localStorage.getItem(VERBOSE_STEPS_KEY);
617
+ return saved === 'true';
618
+ });
619
+ // Voice state - track if voice is enabled
620
+ const [voiceEnabled, setVoiceEnabled] = useState(false);
621
+ const [voiceResponseMode, setVoiceResponseMode] = useState<'auto' | 'manual' | 'smart'>('manual');
622
+ const lastSpokenMessageRef = useRef<string | null>(null);
623
+ const skillsMenuRef = useRef<HTMLDivElement>(null);
624
+ const workspaceDropdownRef = useRef<HTMLDivElement>(null);
625
+
626
+ // Filter events based on verbose mode
627
+ const filteredEvents = useMemo(() => {
628
+ const visibleEvents = verboseSteps ? events : events.filter(isImportantEvent);
629
+ // Command output is rendered separately via CommandOutput component
630
+ return visibleEvents.filter(event => event.type !== 'command_output');
631
+ }, [events, verboseSteps]);
632
+
633
+ const latestUserMessageTimestamp = useMemo(() => {
634
+ for (let i = events.length - 1; i >= 0; i--) {
635
+ if (events[i].type === 'user_message') {
636
+ return events[i].timestamp;
637
+ }
638
+ }
639
+ return null;
640
+ }, [events]);
641
+
642
+ const latestCanvasSessionId = useMemo(() => {
643
+ if (canvasSessions.length === 0) return null;
644
+ const eligibleSessions = latestUserMessageTimestamp
645
+ ? canvasSessions.filter(session => session.createdAt >= latestUserMessageTimestamp)
646
+ : canvasSessions;
647
+ const pool = eligibleSessions.length > 0 ? eligibleSessions : canvasSessions;
648
+ return pool.reduce((latest, session) => {
649
+ return session.createdAt > latest.createdAt ? session : latest;
650
+ }, pool[0]).id;
651
+ }, [canvasSessions, latestUserMessageTimestamp]);
652
+
653
+ const timelineItems = useMemo(() => {
654
+ const eventItems = filteredEvents.map((event, index) => ({
655
+ kind: 'event' as const,
656
+ event,
657
+ eventIndex: index,
658
+ timestamp: event.timestamp,
659
+ }));
660
+
661
+ const freezeBefore = latestUserMessageTimestamp;
662
+ const canvasItems = canvasSessions
663
+ .map((session) => ({
664
+ kind: 'canvas' as const,
665
+ session,
666
+ timestamp: session.createdAt,
667
+ forceSnapshot: Boolean(
668
+ (freezeBefore && session.createdAt < freezeBefore) ||
669
+ (latestCanvasSessionId && session.id !== latestCanvasSessionId)
670
+ ),
671
+ }))
672
+ .sort((a, b) => a.timestamp - b.timestamp);
673
+
674
+ if (canvasItems.length === 0) return eventItems;
675
+
676
+ const merged: Array<typeof eventItems[number] | typeof canvasItems[number]> = [];
677
+ let canvasIndex = 0;
678
+
679
+ for (const eventItem of eventItems) {
680
+ while (canvasIndex < canvasItems.length && canvasItems[canvasIndex].timestamp <= eventItem.timestamp) {
681
+ merged.push(canvasItems[canvasIndex]);
682
+ canvasIndex += 1;
683
+ }
684
+ merged.push(eventItem);
685
+ }
686
+
687
+ while (canvasIndex < canvasItems.length) {
688
+ merged.push(canvasItems[canvasIndex]);
689
+ canvasIndex += 1;
690
+ }
691
+
692
+ return merged;
693
+ }, [filteredEvents, canvasSessions, latestCanvasSessionId, latestUserMessageTimestamp]);
694
+
695
+ // Find the index where command output should be inserted (after the last event before command started)
696
+ const commandOutputInsertIndex = useMemo(() => {
697
+ if (!activeCommand || !activeCommand.startTimestamp) return -1;
698
+ // Find the last event that started before or at the same time as the command
699
+ for (let i = filteredEvents.length - 1; i >= 0; i--) {
700
+ if (filteredEvents[i].timestamp <= activeCommand.startTimestamp) {
701
+ return i;
702
+ }
703
+ }
704
+ // If no events before command, insert at beginning (index -1 means render before all events)
705
+ return -1;
706
+ }, [filteredEvents, activeCommand]);
707
+
708
+ // Toggle verbose mode and persist to localStorage
709
+ const toggleVerboseSteps = () => {
710
+ setVerboseSteps(prev => {
711
+ const newValue = !prev;
712
+ localStorage.setItem(VERBOSE_STEPS_KEY, String(newValue));
713
+ return newValue;
714
+ });
715
+ };
716
+
717
+ // Load app version
718
+ useEffect(() => {
719
+ window.electronAPI.getAppVersion()
720
+ .then(info => setAppVersion(info.version))
721
+ .catch(err => console.error('Failed to load version:', err));
722
+ }, []);
723
+
724
+ // Load voice settings
725
+ useEffect(() => {
726
+ window.electronAPI.getVoiceSettings()
727
+ .then(settings => {
728
+ setVoiceEnabled(settings.enabled);
729
+ setVoiceResponseMode(settings.responseMode);
730
+ })
731
+ .catch(err => console.error('Failed to load voice settings:', err));
732
+
733
+ // Subscribe to voice state changes
734
+ const unsubscribe = window.electronAPI.onVoiceEvent((event) => {
735
+ if (event.type === 'voice:state-changed' && typeof event.data === 'object' && 'isActive' in event.data) {
736
+ setVoiceEnabled(event.data.isActive);
737
+ }
738
+ });
739
+
740
+ return () => unsubscribe();
741
+ }, []);
742
+
743
+ // Auto-speak new assistant messages based on response mode
744
+ useEffect(() => {
745
+ if (!voiceEnabled || voiceResponseMode === 'manual') return;
746
+
747
+ const assistantMessages = events.filter(e => e.type === 'assistant_message');
748
+ if (assistantMessages.length === 0) return;
749
+
750
+ const lastMessage = assistantMessages[assistantMessages.length - 1];
751
+ const messageText = lastMessage.payload?.message || '';
752
+
753
+ // Skip if already spoken
754
+ if (lastSpokenMessageRef.current === messageText) return;
755
+
756
+ // Check if should speak based on mode
757
+ const hasDirective = /\[\[speak\]\]/i.test(messageText);
758
+
759
+ if (voiceResponseMode === 'auto' || (voiceResponseMode === 'smart' && hasDirective)) {
760
+ // Extract text to speak
761
+ let textToSpeak = messageText;
762
+
763
+ // If smart mode, only speak content within [[speak]] tags
764
+ if (voiceResponseMode === 'smart' && hasDirective) {
765
+ const matches = messageText.match(/\[\[speak\]\]([\s\S]*?)\[\[\/speak\]\]/gi);
766
+ if (matches) {
767
+ textToSpeak = matches
768
+ .map((m: string) => m.replace(/\[\[speak\]\]/gi, '').replace(/\[\[\/speak\]\]/gi, ''))
769
+ .join(' ')
770
+ .trim();
771
+ }
772
+ } else {
773
+ // Strip markdown for cleaner speech
774
+ textToSpeak = textToSpeak
775
+ .replace(/```[\s\S]*?```/g, '')
776
+ .replace(/`[^`]+`/g, '')
777
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
778
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, '')
779
+ .replace(/^#{1,6}\s+/gm, '')
780
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
781
+ .replace(/\*([^*]+)\*/g, '$1')
782
+ .trim();
783
+ }
784
+
785
+ if (textToSpeak) {
786
+ lastSpokenMessageRef.current = messageText;
787
+ window.electronAPI.voiceSpeak(textToSpeak).catch(err => {
788
+ console.error('Failed to auto-speak:', err);
789
+ });
790
+ }
791
+ }
792
+ }, [events, voiceEnabled, voiceResponseMode]);
793
+
794
+ // Load custom skills (task skills only, excludes guidelines)
795
+ useEffect(() => {
796
+ window.electronAPI.listTaskSkills()
797
+ .then(skills => setCustomSkills(skills.filter(s => s.enabled !== false)))
798
+ .catch(err => console.error('Failed to load custom skills:', err));
799
+ }, []);
800
+
801
+ // Load active agent roles for @mention autocomplete
802
+ useEffect(() => {
803
+ window.electronAPI.getAgentRoles()
804
+ .then((roles) => setAgentRoles(roles.filter((role) => role.isActive)))
805
+ .catch(err => console.error('Failed to load agent roles:', err));
806
+ }, []);
807
+
808
+ // Load canvas sessions when task changes
809
+ useEffect(() => {
810
+ if (!task?.id) {
811
+ setCanvasSessions([]);
812
+ return;
813
+ }
814
+
815
+ // Load existing canvas sessions for this task
816
+ window.electronAPI.canvasListSessions(task.id)
817
+ .then(sessions => {
818
+ // Filter to only active/paused sessions
819
+ setCanvasSessions(sessions.filter(s => s.status !== 'closed'));
820
+ })
821
+ .catch(err => console.error('Failed to load canvas sessions:', err));
822
+ }, [task?.id]);
823
+
824
+ // Subscribe to canvas events
825
+ useEffect(() => {
826
+ const unsubscribe = window.electronAPI.onCanvasEvent((event) => {
827
+ // Only process events for the current task
828
+ if (task?.id && event.taskId === task.id) {
829
+ // Don't show preview on session_created - wait until content is actually pushed
830
+ if (event.type === 'content_pushed') {
831
+ // Content has been pushed, now show the preview if not already showing
832
+ // Fetch the session info and add it to the list
833
+ window.electronAPI.canvasGetSession(event.sessionId)
834
+ .then(session => {
835
+ if (session && session.status !== 'closed') {
836
+ setCanvasSessions(prev => {
837
+ // Only add if not already in the list
838
+ if (prev.some(s => s.id === session.id)) {
839
+ return prev;
840
+ }
841
+ return [...prev, session];
842
+ });
843
+ }
844
+ })
845
+ .catch(err => console.error('Failed to get canvas session:', err));
846
+ } else if (event.type === 'session_updated' && event.session) {
847
+ const updatedSession = event.session;
848
+ setCanvasSessions(prev => {
849
+ const exists = prev.some(s => s.id === event.sessionId);
850
+ if (!exists && updatedSession.status !== 'closed') {
851
+ return [...prev, updatedSession];
852
+ }
853
+ return prev.map(s => s.id === event.sessionId ? updatedSession : s);
854
+ });
855
+ } else if (event.type === 'session_closed') {
856
+ setCanvasSessions(prev => prev.filter(s => s.id !== event.sessionId));
857
+ }
858
+ }
859
+ });
860
+
861
+ return unsubscribe;
862
+ }, [task?.id]);
863
+
864
+ // Handle removing a canvas session from the UI
865
+ const handleCanvasClose = useCallback((sessionId: string) => {
866
+ setCanvasSessions(prev => prev.filter(s => s.id !== sessionId));
867
+ }, []);
868
+
869
+ // Handle dismissing command output for current task
870
+ const handleDismissCommandOutput = useCallback(() => {
871
+ if (!task?.id) return;
872
+ setDismissedCommandOutputs(prev => {
873
+ const updated = new Set(prev);
874
+ updated.add(task.id);
875
+ // Persist to localStorage
876
+ localStorage.setItem('dismissedCommandOutputs', JSON.stringify([...updated]));
877
+ return updated;
878
+ });
879
+ setActiveCommand(null);
880
+ }, [task?.id]);
881
+
882
+ // Filter skills based on search query
883
+ const filteredSkills = useMemo(() => {
884
+ if (!skillsSearchQuery.trim()) return customSkills;
885
+ const query = skillsSearchQuery.toLowerCase();
886
+ return customSkills.filter(skill =>
887
+ skill.name.toLowerCase().includes(query) ||
888
+ skill.description?.toLowerCase().includes(query) ||
889
+ skill.category?.toLowerCase().includes(query)
890
+ );
891
+ }, [customSkills, skillsSearchQuery]);
892
+
893
+ // Sync shell permission state when workspace changes
894
+ useEffect(() => {
895
+ setShellEnabled(workspace?.permissions?.shell ?? false);
896
+ }, [workspace?.id, workspace?.permissions?.shell]);
897
+
898
+ // Toggle shell permission for current workspace
899
+ const handleShellToggle = async () => {
900
+ if (!workspace) return;
901
+ const newValue = !shellEnabled;
902
+ setShellEnabled(newValue);
903
+ try {
904
+ await window.electronAPI.updateWorkspacePermissions(workspace.id, { shell: newValue });
905
+ } catch (err) {
906
+ console.error('Failed to update shell permission:', err);
907
+ setShellEnabled(!newValue); // Revert on error
908
+ }
909
+ };
910
+
911
+ // Close skills menu on click outside
912
+ useEffect(() => {
913
+ const handleClickOutside = (e: MouseEvent) => {
914
+ if (skillsMenuRef.current && !skillsMenuRef.current.contains(e.target as Node)) {
915
+ setShowSkillsMenu(false);
916
+ setSkillsSearchQuery('');
917
+ }
918
+ };
919
+ if (showSkillsMenu) {
920
+ document.addEventListener('mousedown', handleClickOutside);
921
+ }
922
+ return () => document.removeEventListener('mousedown', handleClickOutside);
923
+ }, [showSkillsMenu]);
924
+
925
+ // Close workspace dropdown on click outside
926
+ useEffect(() => {
927
+ const handleClickOutside = (e: MouseEvent) => {
928
+ if (workspaceDropdownRef.current && !workspaceDropdownRef.current.contains(e.target as Node)) {
929
+ setShowWorkspaceDropdown(false);
930
+ }
931
+ };
932
+ if (showWorkspaceDropdown) {
933
+ document.addEventListener('mousedown', handleClickOutside);
934
+ }
935
+ return () => document.removeEventListener('mousedown', handleClickOutside);
936
+ }, [showWorkspaceDropdown]);
937
+
938
+ // Handle workspace dropdown toggle - load workspaces when opening
939
+ const handleWorkspaceDropdownToggle = async () => {
940
+ if (!showWorkspaceDropdown) {
941
+ try {
942
+ const workspaces = await window.electronAPI.listWorkspaces();
943
+ // Filter out temp workspace and sort by most recently created
944
+ const filteredWorkspaces = workspaces
945
+ .filter((w: Workspace) => w.id !== TEMP_WORKSPACE_ID)
946
+ .sort((a: Workspace, b: Workspace) => b.createdAt - a.createdAt);
947
+ setWorkspacesList(filteredWorkspaces);
948
+ } catch (error) {
949
+ console.error('Failed to load workspaces:', error);
950
+ }
951
+ }
952
+ setShowWorkspaceDropdown(!showWorkspaceDropdown);
953
+ };
954
+
955
+ // Handle selecting an existing workspace from dropdown
956
+ const handleWorkspaceSelect = (selectedWorkspace: Workspace) => {
957
+ setShowWorkspaceDropdown(false);
958
+ onSelectWorkspace?.(selectedWorkspace);
959
+ };
960
+
961
+ // Handle selecting a new folder via Finder
962
+ const handleSelectNewFolder = () => {
963
+ setShowWorkspaceDropdown(false);
964
+ onChangeWorkspace?.();
965
+ };
966
+
967
+ const handleSkillSelect = (skill: CustomSkill) => {
968
+ setShowSkillsMenu(false);
969
+ setSkillsSearchQuery('');
970
+ // If skill has parameters, show the parameter modal
971
+ if (skill.parameters && skill.parameters.length > 0) {
972
+ setSelectedSkillForParams(skill);
973
+ } else {
974
+ // No parameters, just set the prompt directly
975
+ setInputValue(skill.prompt);
976
+ }
977
+ };
978
+
979
+ const handleSkillParamSubmit = (expandedPrompt: string) => {
980
+ setSelectedSkillForParams(null);
981
+ // Create task directly with the expanded prompt
982
+ if (onCreateTask) {
983
+ const title = buildTaskTitle(expandedPrompt);
984
+ onCreateTask(title, expandedPrompt);
985
+ }
986
+ };
987
+
988
+ const handleSkillParamCancel = () => {
989
+ setSelectedSkillForParams(null);
990
+ };
991
+
992
+ // Toggle an event's expanded state using its ID
993
+ const toggleEventExpanded = (eventId: string) => {
994
+ setToggledEvents(prev => {
995
+ const next = new Set(prev);
996
+ if (next.has(eventId)) {
997
+ next.delete(eventId);
998
+ } else {
999
+ next.add(eventId);
1000
+ }
1001
+ return next;
1002
+ });
1003
+ };
1004
+
1005
+ // Check if an event has details to show
1006
+ const hasEventDetails = (event: TaskEvent): boolean => {
1007
+ return ['plan_created', 'tool_call', 'tool_result', 'assistant_message', 'error'].includes(event.type);
1008
+ };
1009
+
1010
+ // Determine if an event should be expanded by default
1011
+ // Important events (plan, assistant responses, errors) should be expanded
1012
+ // Verbose events (tool calls/results) should be collapsed
1013
+ const shouldDefaultExpand = (event: TaskEvent): boolean => {
1014
+ return ['plan_created', 'assistant_message', 'error'].includes(event.type);
1015
+ };
1016
+
1017
+ // Check if an event is currently expanded using its ID
1018
+ // If the event should default expand, clicking toggles it to collapsed (and vice versa)
1019
+ const isEventExpanded = (event: TaskEvent): boolean => {
1020
+ const defaultExpanded = shouldDefaultExpand(event);
1021
+ const isToggled = toggledEvents.has(event.id);
1022
+ // XOR: if toggled, invert the default state
1023
+ return defaultExpanded ? !isToggled : isToggled;
1024
+ };
1025
+
1026
+ const timelineRef = useRef<HTMLDivElement>(null);
1027
+ const mainBodyRef = useRef<HTMLDivElement>(null);
1028
+ const prevTaskStatusRef = useRef<Task['status'] | undefined>(undefined);
1029
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
1030
+ const mentionContainerRef = useRef<HTMLDivElement>(null);
1031
+ const mentionDropdownRef = useRef<HTMLDivElement>(null);
1032
+ const placeholderMeasureRef = useRef<HTMLSpanElement>(null);
1033
+ const [cursorLeft, setCursorLeft] = useState<number>(0);
1034
+
1035
+ // Auto-resize textarea as content changes
1036
+ const autoResizeTextarea = useCallback(() => {
1037
+ const textarea = textareaRef.current;
1038
+ if (textarea) {
1039
+ textarea.style.height = 'auto';
1040
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
1041
+ }
1042
+ }, []);
1043
+
1044
+ // Auto-resize when input value changes
1045
+ useEffect(() => {
1046
+ autoResizeTextarea();
1047
+ }, [inputValue, autoResizeTextarea]);
1048
+
1049
+ // Calculate cursor position based on placeholder text width
1050
+ const placeholder = agentContext.getPlaceholder();
1051
+ useEffect(() => {
1052
+ if (placeholderMeasureRef.current) {
1053
+ // Measure the placeholder text width
1054
+ const measureEl = placeholderMeasureRef.current;
1055
+ measureEl.textContent = placeholder;
1056
+ // Get the width and add offset for: padding (16px) + prompt (~$ = ~24px) + gap (10px)
1057
+ const padding = 16; // wrapper left padding
1058
+ const promptWidth = 24; // ~$ prompt width
1059
+ const gap = 10;
1060
+ const textWidth = measureEl.offsetWidth;
1061
+ setCursorLeft(padding + promptWidth + gap + textWidth);
1062
+ }
1063
+ }, [placeholder]);
1064
+
1065
+ // Check if user is near the bottom of the scroll container
1066
+ const isNearBottom = useCallback((element: HTMLElement, threshold = 100) => {
1067
+ const { scrollTop, scrollHeight, clientHeight } = element;
1068
+ return scrollHeight - scrollTop - clientHeight < threshold;
1069
+ }, []);
1070
+
1071
+ // Handle scroll events to detect manual scrolling
1072
+ const handleScroll = useCallback(() => {
1073
+ const container = mainBodyRef.current;
1074
+ if (!container) return;
1075
+
1076
+ // If user scrolls to near bottom, re-enable auto-scroll
1077
+ // If user scrolls away from bottom, disable auto-scroll
1078
+ setAutoScroll(isNearBottom(container));
1079
+ }, [isNearBottom]);
1080
+
1081
+ // Auto-scroll to bottom when new events arrive
1082
+ useEffect(() => {
1083
+ if (autoScroll && timelineRef.current && mainBodyRef.current) {
1084
+ // Scroll the main body to show the latest event
1085
+ mainBodyRef.current.scrollTop = mainBodyRef.current.scrollHeight;
1086
+ }
1087
+ }, [events, autoScroll]);
1088
+
1089
+ // Reset auto-scroll when task changes
1090
+ useEffect(() => {
1091
+ setAutoScroll(true);
1092
+ }, [task?.id]);
1093
+
1094
+ // Send queued message when task finishes executing
1095
+ useEffect(() => {
1096
+ const prevStatus = prevTaskStatusRef.current;
1097
+ const currentStatus = task?.status;
1098
+
1099
+ // If task was executing and now it's not, send the queued message
1100
+ if (prevStatus === 'executing' && currentStatus !== 'executing' && queuedMessage) {
1101
+ onSendMessage(queuedMessage);
1102
+ setQueuedMessage(null);
1103
+ }
1104
+
1105
+ prevTaskStatusRef.current = currentStatus;
1106
+ }, [task?.status, queuedMessage, onSendMessage]);
1107
+
1108
+ // Process command_output events to track live command execution
1109
+ useEffect(() => {
1110
+ // Get the last command_output event
1111
+ const commandOutputEvents = events.filter(e => e.type === 'command_output');
1112
+ if (commandOutputEvents.length === 0) {
1113
+ setActiveCommand(null);
1114
+ return;
1115
+ }
1116
+
1117
+ // Build the command state from events
1118
+ let currentCommand: string | null = null;
1119
+ let output = '';
1120
+ let isRunning = false;
1121
+ let exitCode: number | null = null;
1122
+ let startTimestamp: number = 0;
1123
+
1124
+ for (const event of commandOutputEvents) {
1125
+ const payload = event.payload;
1126
+ if (payload.type === 'start') {
1127
+ // New command started
1128
+ currentCommand = payload.command;
1129
+ output = payload.output || '';
1130
+ isRunning = true;
1131
+ exitCode = null;
1132
+ startTimestamp = event.timestamp;
1133
+ } else if (payload.type === 'stdout' || payload.type === 'stderr' || payload.type === 'stdin') {
1134
+ // Append output (stdin shows what user typed)
1135
+ output += payload.output || '';
1136
+ } else if (payload.type === 'end') {
1137
+ // Command finished
1138
+ isRunning = false;
1139
+ exitCode = payload.exitCode;
1140
+ } else if (payload.type === 'error') {
1141
+ // Error output
1142
+ output += payload.output || '';
1143
+ }
1144
+ }
1145
+
1146
+ // Check if this task's command output was dismissed
1147
+ const isDismissed = task?.id ? dismissedCommandOutputs.has(task.id) : false;
1148
+
1149
+ // If a new command is running, clear the dismissed state for this task
1150
+ if (isRunning && task?.id && isDismissed) {
1151
+ setDismissedCommandOutputs(prev => {
1152
+ const updated = new Set(prev);
1153
+ updated.delete(task.id);
1154
+ localStorage.setItem('dismissedCommandOutputs', JSON.stringify([...updated]));
1155
+ return updated;
1156
+ });
1157
+ }
1158
+
1159
+ // Show command output if:
1160
+ // 1. There's a command AND it's not dismissed, OR
1161
+ // 2. Command is currently running (always show while running)
1162
+ const shouldShowOutput = currentCommand && (isRunning || !isDismissed);
1163
+
1164
+ // Limit output size in UI to prevent performance issues (keep last 50KB)
1165
+ const MAX_UI_OUTPUT = 50 * 1024;
1166
+ let truncatedOutput = output;
1167
+ if (output.length > MAX_UI_OUTPUT) {
1168
+ truncatedOutput = '[... earlier output truncated ...]\n\n' + output.slice(-MAX_UI_OUTPUT);
1169
+ }
1170
+
1171
+ if (shouldShowOutput) {
1172
+ setActiveCommand({
1173
+ command: currentCommand!,
1174
+ output: truncatedOutput,
1175
+ isRunning,
1176
+ exitCode,
1177
+ startTimestamp,
1178
+ });
1179
+ } else {
1180
+ setActiveCommand(null);
1181
+ }
1182
+ }, [events, task?.id, task?.status, dismissedCommandOutputs]);
1183
+
1184
+ // Check for approval requests in events
1185
+ useEffect(() => {
1186
+ // Get all approval IDs that have been resolved (granted or denied)
1187
+ const resolvedApprovalIds = new Set(
1188
+ events
1189
+ .filter(e => e.type === 'approval_granted' || e.type === 'approval_denied')
1190
+ .map(e => e.payload?.approvalId || e.payload?.approval?.id)
1191
+ .filter(Boolean)
1192
+ );
1193
+
1194
+ // Find an approval request that hasn't been resolved yet
1195
+ const pendingApprovalEvent = events.find(e => {
1196
+ if (e.type !== 'approval_requested' || !e.payload?.approval) return false;
1197
+ const approvalId = e.payload.approval.id;
1198
+ // Only show if not already resolved
1199
+ return !resolvedApprovalIds.has(approvalId);
1200
+ });
1201
+
1202
+ if (pendingApprovalEvent) {
1203
+ setPendingApproval(pendingApprovalEvent.payload.approval);
1204
+ } else {
1205
+ // No pending approvals - clear the state
1206
+ setPendingApproval(null);
1207
+ }
1208
+ }, [events]);
1209
+
1210
+ const handleApprovalResponse = async (approved: boolean) => {
1211
+ if (!pendingApproval) return;
1212
+ try {
1213
+ await window.electronAPI.respondToApproval({
1214
+ approvalId: pendingApproval.id,
1215
+ approved,
1216
+ });
1217
+ setPendingApproval(null);
1218
+ } catch (error) {
1219
+ console.error('Failed to respond to approval:', error);
1220
+ }
1221
+ };
1222
+
1223
+ const handleSend = () => {
1224
+ if (inputValue.trim()) {
1225
+ // Use selectedTaskId to determine if we should follow-up or create new task
1226
+ // This fixes the bug where old tasks (beyond the 100 most recent) would create new tasks
1227
+ // instead of sending follow-up messages
1228
+ if (!selectedTaskId && onCreateTask) {
1229
+ // No task selected - create new task with optional Goal Mode options
1230
+ const trimmedInput = inputValue.trim();
1231
+ const title = buildTaskTitle(trimmedInput);
1232
+ const options: GoalModeOptions | undefined = goalModeEnabled && verificationCommand
1233
+ ? {
1234
+ successCriteria: { type: 'shell_command' as const, command: verificationCommand },
1235
+ maxAttempts,
1236
+ }
1237
+ : undefined;
1238
+ onCreateTask(title, trimmedInput, options);
1239
+ // Reset Goal Mode state
1240
+ setGoalModeEnabled(false);
1241
+ setVerificationCommand('');
1242
+ setMaxAttempts(3);
1243
+ } else {
1244
+ // Task is selected (even if not in current list) - send follow-up message
1245
+ onSendMessage(inputValue.trim());
1246
+ }
1247
+ setInputValue('');
1248
+ setMentionOpen(false);
1249
+ setMentionQuery('');
1250
+ setMentionTarget(null);
1251
+ }
1252
+ };
1253
+
1254
+ const handleClearQueue = () => {
1255
+ setQueuedMessage(null);
1256
+ };
1257
+
1258
+ const findMentionAtCursor = (value: string, cursor: number | null) => {
1259
+ if (cursor === null) return null;
1260
+ const uptoCursor = value.slice(0, cursor);
1261
+ const atIndex = uptoCursor.lastIndexOf('@');
1262
+ if (atIndex === -1) return null;
1263
+ if (atIndex > 0 && /[a-zA-Z0-9]/.test(uptoCursor[atIndex - 1])) {
1264
+ return null;
1265
+ }
1266
+ const query = uptoCursor.slice(atIndex + 1);
1267
+ if (query.startsWith(' ')) return null;
1268
+ if (query.includes('\n') || query.includes('\r')) return null;
1269
+ return { query, start: atIndex, end: cursor };
1270
+ };
1271
+
1272
+ const mentionOptions = useMemo<MentionOption[]>(() => {
1273
+ if (!mentionOpen) return [];
1274
+ const query = normalizeMentionSearch(mentionQuery);
1275
+ const options: MentionOption[] = [];
1276
+ const includeEveryone = query.length > 0 && ['everybody', 'everyone', 'all'].some((alias) => alias.startsWith(query));
1277
+ if (includeEveryone) {
1278
+ options.push({
1279
+ type: 'everyone',
1280
+ id: 'everyone',
1281
+ label: 'Everybody',
1282
+ description: 'Auto-pick the best agents for this task',
1283
+ icon: '👥',
1284
+ color: '#64748b',
1285
+ });
1286
+ }
1287
+
1288
+ const filteredAgents = agentRoles
1289
+ .filter((role) => role.isActive)
1290
+ .filter((role) => {
1291
+ if (!query) return true;
1292
+ const haystacks = [role.displayName, role.name, role.description ?? ''];
1293
+ return haystacks.some((text) => normalizeMentionSearch(text).includes(query));
1294
+ })
1295
+ .sort((a, b) => {
1296
+ if (a.sortOrder !== b.sortOrder) {
1297
+ return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
1298
+ }
1299
+ return a.displayName.localeCompare(b.displayName);
1300
+ });
1301
+
1302
+ filteredAgents.forEach((role) => {
1303
+ options.push({
1304
+ type: 'agent',
1305
+ id: role.id,
1306
+ label: role.displayName,
1307
+ description: role.description,
1308
+ icon: role.icon,
1309
+ color: role.color,
1310
+ });
1311
+ });
1312
+
1313
+ return options;
1314
+ }, [mentionOpen, mentionQuery, agentRoles]);
1315
+
1316
+ useEffect(() => {
1317
+ if (mentionSelectedIndex >= mentionOptions.length) {
1318
+ setMentionSelectedIndex(0);
1319
+ }
1320
+ }, [mentionOptions, mentionSelectedIndex]);
1321
+
1322
+ useEffect(() => {
1323
+ if (!mentionOpen) return;
1324
+ const handleClickOutside = (e: MouseEvent) => {
1325
+ if (mentionContainerRef.current && !mentionContainerRef.current.contains(e.target as Node)) {
1326
+ setMentionOpen(false);
1327
+ }
1328
+ };
1329
+ document.addEventListener('mousedown', handleClickOutside);
1330
+ return () => document.removeEventListener('mousedown', handleClickOutside);
1331
+ }, [mentionOpen]);
1332
+
1333
+ const updateMentionState = useCallback((value: string, cursor: number | null) => {
1334
+ const mention = findMentionAtCursor(value, cursor);
1335
+ if (!mention) {
1336
+ setMentionOpen(false);
1337
+ setMentionQuery('');
1338
+ setMentionTarget(null);
1339
+ return;
1340
+ }
1341
+ setMentionOpen(true);
1342
+ setMentionQuery(mention.query);
1343
+ setMentionTarget({ start: mention.start, end: mention.end });
1344
+ setMentionSelectedIndex(0);
1345
+ }, []);
1346
+
1347
+ const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
1348
+ const value = e.target.value;
1349
+ setInputValue(value);
1350
+ updateMentionState(value, e.target.selectionStart);
1351
+ };
1352
+
1353
+ const handleInputClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
1354
+ updateMentionState(inputValue, e.currentTarget.selectionStart);
1355
+ };
1356
+
1357
+ const handleInputKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
1358
+ if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) {
1359
+ updateMentionState(inputValue, (e.currentTarget as HTMLTextAreaElement).selectionStart);
1360
+ }
1361
+ };
1362
+
1363
+ const handleMentionSelect = (option: MentionOption) => {
1364
+ if (!mentionTarget) return;
1365
+ const insertText = option.type === 'everyone' ? '@everybody' : `@${option.label}`;
1366
+ const before = inputValue.slice(0, mentionTarget.start);
1367
+ const after = inputValue.slice(mentionTarget.end);
1368
+ const needsSpace = after.length === 0 ? true : !after.startsWith(' ');
1369
+ const nextValue = `${before}${insertText}${needsSpace ? ' ' : ''}${after}`;
1370
+ setInputValue(nextValue);
1371
+ setMentionOpen(false);
1372
+ setMentionQuery('');
1373
+ setMentionTarget(null);
1374
+
1375
+ requestAnimationFrame(() => {
1376
+ const textarea = textareaRef.current;
1377
+ if (textarea) {
1378
+ const cursorPosition = before.length + insertText.length + (needsSpace ? 1 : 0);
1379
+ textarea.focus();
1380
+ textarea.setSelectionRange(cursorPosition, cursorPosition);
1381
+ }
1382
+ });
1383
+ };
1384
+
1385
+ const renderMentionDropdown = () => {
1386
+ if (!mentionOpen || mentionOptions.length === 0) return null;
1387
+ return (
1388
+ <div className="mention-autocomplete-dropdown" ref={mentionDropdownRef}>
1389
+ {mentionOptions.map((option, index) => {
1390
+ const displayLabel = option.type === 'everyone' ? '@everybody' : `@${option.label}`;
1391
+ return (
1392
+ <button
1393
+ key={`${option.type}-${option.id}`}
1394
+ className={`mention-autocomplete-item ${index === mentionSelectedIndex ? 'selected' : ''}`}
1395
+ onMouseDown={(e) => {
1396
+ e.preventDefault();
1397
+ handleMentionSelect(option);
1398
+ }}
1399
+ onMouseEnter={() => setMentionSelectedIndex(index)}
1400
+ >
1401
+ <span
1402
+ className="mention-autocomplete-icon"
1403
+ style={{ backgroundColor: option.color || '#64748b' }}
1404
+ >
1405
+ {option.icon || '👥'}
1406
+ </span>
1407
+ <div className="mention-autocomplete-details">
1408
+ <span className="mention-autocomplete-name">{displayLabel}</span>
1409
+ {option.description && (
1410
+ <span className="mention-autocomplete-desc">{option.description}</span>
1411
+ )}
1412
+ </div>
1413
+ </button>
1414
+ );
1415
+ })}
1416
+ </div>
1417
+ );
1418
+ };
1419
+
1420
+ const handleKeyDown = (e: React.KeyboardEvent) => {
1421
+ if (mentionOpen && mentionOptions.length > 0) {
1422
+ switch (e.key) {
1423
+ case 'ArrowDown':
1424
+ e.preventDefault();
1425
+ setMentionSelectedIndex((prev) => (prev + 1) % mentionOptions.length);
1426
+ return;
1427
+ case 'ArrowUp':
1428
+ e.preventDefault();
1429
+ setMentionSelectedIndex((prev) => (prev - 1 + mentionOptions.length) % mentionOptions.length);
1430
+ return;
1431
+ case 'Enter':
1432
+ case 'Tab':
1433
+ e.preventDefault();
1434
+ handleMentionSelect(mentionOptions[mentionSelectedIndex]);
1435
+ return;
1436
+ case 'Escape':
1437
+ e.preventDefault();
1438
+ setMentionOpen(false);
1439
+ return;
1440
+ }
1441
+ }
1442
+
1443
+ if (e.key === 'Enter' && !e.shiftKey) {
1444
+ e.preventDefault();
1445
+ handleSend();
1446
+ }
1447
+ };
1448
+
1449
+ const handleQuickAction = (action: string) => {
1450
+ setInputValue(action);
1451
+ };
1452
+
1453
+ const formatTime = (timestamp: number) => {
1454
+ return new Date(timestamp).toLocaleTimeString(undefined, {
1455
+ hour: '2-digit',
1456
+ minute: '2-digit',
1457
+ });
1458
+ };
1459
+
1460
+ const getEventDotClass = (type: TaskEvent['type']) => {
1461
+ if (type === 'error' || type === 'verification_failed') return 'error';
1462
+ if (type === 'step_completed' || type === 'task_completed' || type === 'verification_passed') return 'success';
1463
+ if (type === 'step_started' || type === 'executing' || type === 'verification_started' || type === 'retry_started') return 'active';
1464
+ return '';
1465
+ };
1466
+
1467
+ // Get the last assistant message to always show the response
1468
+ const lastAssistantMessage = useMemo(() => {
1469
+ const assistantMessages = events.filter(e => e.type === 'assistant_message');
1470
+ return assistantMessages.length > 0 ? assistantMessages[assistantMessages.length - 1] : null;
1471
+ }, [events]);
1472
+
1473
+ // Welcome/Empty state
1474
+ if (!task) {
1475
+ return (
1476
+ <div className="main-content">
1477
+ <div className="main-body welcome-view">
1478
+ <div className="welcome-content cli-style">
1479
+ {/* Logo */}
1480
+ <div className="welcome-logo">
1481
+ <img src="./cowork-os-logo.png" alt="CoWork OS" className="welcome-logo-img" />
1482
+ </div>
1483
+
1484
+ {/* ASCII Terminal Header */}
1485
+ <div className="cli-header">
1486
+ <pre className="ascii-art">{`
1487
+ ██████╗ ██████╗ ██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗ ██████╗ ███████╗
1488
+ ██╔════╝██╔═══██╗██║ ██║██╔═══██╗██╔══██╗██║ ██╔╝ ██╔═══██╗██╔════╝
1489
+ ██║ ██║ ██║██║ █╗ ██║██║ ██║██████╔╝█████╔╝ ██║ ██║███████╗
1490
+ ██║ ██║ ██║██║███╗██║██║ ██║██╔══██╗██╔═██╗ ██║ ██║╚════██║
1491
+ ╚██████╗╚██████╔╝╚███╔███╔╝╚██████╔╝██║ ██║██║ ██╗ ╚██████╔╝███████║
1492
+ ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝`}</pre>
1493
+ <div className="cli-version">{appVersion ? `v${appVersion}` : ''}</div>
1494
+ </div>
1495
+
1496
+ {/* Terminal Info */}
1497
+ <div className="cli-info">
1498
+ <div className="cli-line">
1499
+ <span className="cli-prompt">$</span>
1500
+ <span className="cli-text" title={agentContext.getMessage('welcome')}>{agentContext.getMessage('welcome')}</span>
1501
+ </div>
1502
+ <div className="cli-line cli-line-secondary">
1503
+ <span className="cli-prompt">&gt;</span>
1504
+ <span className="cli-text">{agentContext.getMessage('welcomeSubtitle')}</span>
1505
+ </div>
1506
+ <div className="cli-line cli-line-disclosure">
1507
+ <span className="cli-prompt">#</span>
1508
+ <span className="cli-text cli-text-muted" title={agentContext.getMessage('disclaimer')}>{agentContext.getMessage('disclaimer')}</span>
1509
+ </div>
1510
+ </div>
1511
+
1512
+ {/* Quick Start */}
1513
+ <div className="cli-commands">
1514
+ <div className="cli-commands-header">
1515
+ <span className="cli-prompt">&gt;</span>
1516
+ <span>QUICK START</span>
1517
+ </div>
1518
+ <div className="quick-start-grid">
1519
+ <button className="quick-start-card" onClick={() => handleQuickAction('Let\'s organize the files in this folder together. Sort them by type and rename them with clear, consistent names.')} title="Let's sort and tidy up the workspace">
1520
+ <span className="quick-start-icon">📁</span>
1521
+ <span className="quick-start-title">Organize files</span>
1522
+ <span className="quick-start-desc">Let's sort and tidy up the workspace</span>
1523
+ </button>
1524
+ <button className="quick-start-card" onClick={() => handleQuickAction('Let\'s write a document together. I\'ll describe what I need and we can create it.')} title="Co-create reports, summaries, or notes">
1525
+ <span className="quick-start-icon">📝</span>
1526
+ <span className="quick-start-title">Write together</span>
1527
+ <span className="quick-start-desc">Co-create reports, summaries, or notes</span>
1528
+ </button>
1529
+ <button className="quick-start-card" onClick={() => handleQuickAction('Let\'s analyze the data files in this folder together. We\'ll summarize the key findings and create a report.')} title="Work through spreadsheets or data files">
1530
+ <span className="quick-start-icon">📊</span>
1531
+ <span className="quick-start-title">Analyze data</span>
1532
+ <span className="quick-start-desc">Work through spreadsheets or data files</span>
1533
+ </button>
1534
+ <button className="quick-start-card" onClick={() => handleQuickAction('Let\'s generate documentation for this project together. We can create a README, API docs, or code comments as needed.')} title="Build documentation for the project">
1535
+ <span className="quick-start-icon">📖</span>
1536
+ <span className="quick-start-title">Generate docs</span>
1537
+ <span className="quick-start-desc">Build documentation for the project</span>
1538
+ </button>
1539
+ <button className="quick-start-card" onClick={() => handleQuickAction('Let\'s research and summarize information from the files in this folder together.')} title="Dig through files and find insights">
1540
+ <span className="quick-start-icon">🔍</span>
1541
+ <span className="quick-start-title">Research together</span>
1542
+ <span className="quick-start-desc">Dig through files and find insights</span>
1543
+ </button>
1544
+ <button className="quick-start-card" onClick={() => handleQuickAction('Let\'s prepare for a meeting together. We\'ll create an agenda, talking points, and organize materials needed.')} title="Get everything ready for a clean meeting">
1545
+ <span className="quick-start-icon">📋</span>
1546
+ <span className="quick-start-title">Meeting prep</span>
1547
+ <span className="quick-start-desc">Get everything ready for a clean meeting</span>
1548
+ </button>
1549
+ </div>
1550
+ </div>
1551
+
1552
+ {/* Input Area */}
1553
+ <div className="welcome-input-container cli-input-container">
1554
+ {showVoiceNotConfigured && (
1555
+ <div className="voice-not-configured-banner">
1556
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1557
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
1558
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
1559
+ <line x1="12" y1="19" x2="12" y2="23" />
1560
+ <line x1="8" y1="23" x2="16" y2="23" />
1561
+ </svg>
1562
+ <span>Voice input is not configured.</span>
1563
+ <button
1564
+ className="voice-settings-link"
1565
+ onClick={() => {
1566
+ setShowVoiceNotConfigured(false);
1567
+ onOpenSettings?.('voice');
1568
+ }}
1569
+ >
1570
+ Open Voice Settings
1571
+ </button>
1572
+ <button
1573
+ className="voice-banner-close"
1574
+ onClick={() => setShowVoiceNotConfigured(false)}
1575
+ title="Dismiss"
1576
+ >
1577
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1578
+ <path d="M18 6L6 18M6 6l12 12" />
1579
+ </svg>
1580
+ </button>
1581
+ </div>
1582
+ )}
1583
+ <div className="cli-input-wrapper">
1584
+ <span className="cli-input-prompt">~$</span>
1585
+ <span ref={placeholderMeasureRef} className="cli-placeholder-measure" aria-hidden="true" />
1586
+ <div className="mention-autocomplete-wrapper" ref={mentionContainerRef}>
1587
+ <textarea
1588
+ ref={textareaRef}
1589
+ className="welcome-input cli-input input-textarea"
1590
+ placeholder={placeholder}
1591
+ value={inputValue}
1592
+ onChange={handleInputChange}
1593
+ onKeyDown={handleKeyDown}
1594
+ onClick={handleInputClick}
1595
+ onKeyUp={handleInputKeyUp}
1596
+ rows={1}
1597
+ />
1598
+ {renderMentionDropdown()}
1599
+ </div>
1600
+ {!inputValue && <span className="cli-cursor" style={{ left: cursorLeft }} />}
1601
+ </div>
1602
+
1603
+ {/* Goal Mode Options */}
1604
+ <div className="goal-mode-section">
1605
+ <label className="goal-mode-toggle">
1606
+ <input
1607
+ type="checkbox"
1608
+ checked={goalModeEnabled}
1609
+ onChange={(e) => setGoalModeEnabled(e.target.checked)}
1610
+ />
1611
+ <span className="goal-mode-label">Goal Mode</span>
1612
+ <span className="goal-mode-hint">Verify & retry until success</span>
1613
+ </label>
1614
+ {goalModeEnabled && (
1615
+ <div className="goal-mode-options">
1616
+ <div className="goal-mode-command">
1617
+ <span className="goal-mode-prompt">$</span>
1618
+ <input
1619
+ type="text"
1620
+ className="goal-mode-input"
1621
+ placeholder="Verification command (e.g., npm test)"
1622
+ value={verificationCommand}
1623
+ onChange={(e) => setVerificationCommand(e.target.value)}
1624
+ />
1625
+ </div>
1626
+ <div className="goal-mode-attempts">
1627
+ <label>
1628
+ Max attempts:
1629
+ <input
1630
+ type="number"
1631
+ min="1"
1632
+ max="10"
1633
+ value={maxAttempts}
1634
+ onChange={(e) => setMaxAttempts(Math.min(10, Math.max(1, Number(e.target.value))))}
1635
+ />
1636
+ </label>
1637
+ </div>
1638
+ </div>
1639
+ )}
1640
+ </div>
1641
+
1642
+ <div className="welcome-input-footer">
1643
+ <div className="input-left-actions">
1644
+ <div className="workspace-dropdown-container" ref={workspaceDropdownRef}>
1645
+ <button className="folder-selector" onClick={handleWorkspaceDropdownToggle}>
1646
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1647
+ <path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
1648
+ </svg>
1649
+ <span>{workspace?.id === TEMP_WORKSPACE_ID ? 'Work in a folder' : (workspace?.name || 'Work in a folder')}</span>
1650
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={showWorkspaceDropdown ? 'chevron-up' : ''}>
1651
+ <path d="M6 9l6 6 6-6" />
1652
+ </svg>
1653
+ </button>
1654
+ {showWorkspaceDropdown && (
1655
+ <div className="workspace-dropdown">
1656
+ {workspacesList.length > 0 && (
1657
+ <>
1658
+ <div className="workspace-dropdown-header">Recent Folders</div>
1659
+ <div className="workspace-dropdown-list">
1660
+ {workspacesList.slice(0, 5).map((w) => (
1661
+ <button
1662
+ key={w.id}
1663
+ className={`workspace-dropdown-item ${workspace?.id === w.id ? 'active' : ''}`}
1664
+ onClick={() => handleWorkspaceSelect(w)}
1665
+ >
1666
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1667
+ <path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
1668
+ </svg>
1669
+ <div className="workspace-item-info">
1670
+ <span className="workspace-item-name">{w.name}</span>
1671
+ <span className="workspace-item-path">{w.path}</span>
1672
+ </div>
1673
+ {workspace?.id === w.id && (
1674
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="check-icon">
1675
+ <path d="M20 6L9 17l-5-5" />
1676
+ </svg>
1677
+ )}
1678
+ </button>
1679
+ ))}
1680
+ </div>
1681
+ <div className="workspace-dropdown-divider" />
1682
+ </>
1683
+ )}
1684
+ <button className="workspace-dropdown-item new-folder" onClick={handleSelectNewFolder}>
1685
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1686
+ <path d="M12 5v14M5 12h14" />
1687
+ </svg>
1688
+ <span>Work in another folder...</span>
1689
+ </button>
1690
+ </div>
1691
+ )}
1692
+ </div>
1693
+ <button
1694
+ className={`shell-toggle ${shellEnabled ? 'enabled' : ''}`}
1695
+ onClick={handleShellToggle}
1696
+ title={shellEnabled ? 'Shell commands enabled - click to disable' : 'Shell commands disabled - click to enable'}
1697
+ >
1698
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1699
+ <path d="M4 17l6-6-6-6M12 19h8" />
1700
+ </svg>
1701
+ <span>Shell {shellEnabled ? 'ON' : 'OFF'}</span>
1702
+ </button>
1703
+ </div>
1704
+ <div className="input-right-actions">
1705
+ <ModelDropdown
1706
+ models={availableModels}
1707
+ selectedModel={selectedModel}
1708
+ onModelChange={onModelChange}
1709
+ />
1710
+ {/* Skills Menu Button */}
1711
+ <div className="skills-menu-container" ref={skillsMenuRef}>
1712
+ <button
1713
+ className={`skills-menu-btn ${showSkillsMenu ? 'active' : ''}`}
1714
+ onClick={() => setShowSkillsMenu(!showSkillsMenu)}
1715
+ title="Custom Skills"
1716
+ >
1717
+ <span>/</span>
1718
+ </button>
1719
+ {showSkillsMenu && (
1720
+ <div className="skills-dropdown">
1721
+ <div className="skills-dropdown-header">Custom Skills</div>
1722
+ <div className="skills-dropdown-search">
1723
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1724
+ <circle cx="11" cy="11" r="8" />
1725
+ <path d="M21 21l-4.35-4.35" />
1726
+ </svg>
1727
+ <input
1728
+ type="text"
1729
+ placeholder="Search skills..."
1730
+ value={skillsSearchQuery}
1731
+ onChange={(e) => setSkillsSearchQuery(e.target.value)}
1732
+ autoFocus
1733
+ />
1734
+ </div>
1735
+ {customSkills.length > 0 ? (
1736
+ filteredSkills.length > 0 ? (
1737
+ <div className="skills-dropdown-list">
1738
+ {filteredSkills.map(skill => (
1739
+ <div
1740
+ key={skill.id}
1741
+ className="skills-dropdown-item"
1742
+ style={{ cursor: 'pointer' }}
1743
+ onClick={() => handleSkillSelect(skill)}
1744
+ >
1745
+ <span className="skills-dropdown-icon">{skill.icon}</span>
1746
+ <div className="skills-dropdown-info">
1747
+ <span className="skills-dropdown-name">{skill.name}</span>
1748
+ <span className="skills-dropdown-desc">{skill.description}</span>
1749
+ </div>
1750
+ </div>
1751
+ ))}
1752
+ </div>
1753
+ ) : (
1754
+ <div className="skills-dropdown-empty">
1755
+ No skills match "{skillsSearchQuery}"
1756
+ </div>
1757
+ )
1758
+ ) : (
1759
+ <div className="skills-dropdown-empty">
1760
+ No custom skills yet.
1761
+ </div>
1762
+ )}
1763
+ <div className="skills-dropdown-footer">
1764
+ <button
1765
+ className="skills-dropdown-create"
1766
+ onClick={() => {
1767
+ setShowSkillsMenu(false);
1768
+ setSkillsSearchQuery('');
1769
+ onOpenSettings?.('skills');
1770
+ }}
1771
+ >
1772
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1773
+ <line x1="12" y1="5" x2="12" y2="19" />
1774
+ <line x1="5" y1="12" x2="19" y2="12" />
1775
+ </svg>
1776
+ <span>Create New Skill</span>
1777
+ </button>
1778
+ </div>
1779
+ </div>
1780
+ )}
1781
+ </div>
1782
+ <button
1783
+ className={`voice-input-btn ${voiceInput.state}`}
1784
+ onClick={voiceInput.toggleRecording}
1785
+ disabled={voiceInput.state === 'processing'}
1786
+ title={
1787
+ voiceInput.state === 'idle' ? 'Start voice input' :
1788
+ voiceInput.state === 'recording' ? 'Stop recording' :
1789
+ 'Processing...'
1790
+ }
1791
+ >
1792
+ {voiceInput.state === 'processing' ? (
1793
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="voice-processing-spin">
1794
+ <circle cx="12" cy="12" r="10" />
1795
+ <path d="M12 6v6l4 2" />
1796
+ </svg>
1797
+ ) : voiceInput.state === 'recording' ? (
1798
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
1799
+ <rect x="6" y="6" width="12" height="12" rx="2" />
1800
+ </svg>
1801
+ ) : (
1802
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1803
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
1804
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
1805
+ <line x1="12" y1="19" x2="12" y2="23" />
1806
+ <line x1="8" y1="23" x2="16" y2="23" />
1807
+ </svg>
1808
+ )}
1809
+ {voiceInput.state === 'recording' && (
1810
+ <span className="voice-recording-indicator" style={{ width: `${voiceInput.audioLevel}%` }} />
1811
+ )}
1812
+ </button>
1813
+ <button
1814
+ className="lets-go-btn lets-go-btn-sm"
1815
+ onClick={handleSend}
1816
+ disabled={!inputValue.trim()}
1817
+ >
1818
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1819
+ <path d="M12 19V5M5 12l7-7 7 7" />
1820
+ </svg>
1821
+ </button>
1822
+ </div>
1823
+ </div>
1824
+ </div>
1825
+ </div>
1826
+ </div>
1827
+
1828
+ {/* Modal for skills with parameters - Welcome View */}
1829
+ {selectedSkillForParams && (
1830
+ <SkillParameterModal
1831
+ skill={selectedSkillForParams}
1832
+ onSubmit={handleSkillParamSubmit}
1833
+ onCancel={handleSkillParamCancel}
1834
+ />
1835
+ )}
1836
+
1837
+ {/* File Viewer Modal - Welcome View */}
1838
+ {viewerFilePath && workspace?.path && (
1839
+ <FileViewer
1840
+ filePath={viewerFilePath}
1841
+ workspacePath={workspace.path}
1842
+ onClose={() => setViewerFilePath(null)}
1843
+ />
1844
+ )}
1845
+ </div>
1846
+ );
1847
+ }
1848
+
1849
+ const trimmedPrompt = task.prompt.trim();
1850
+ const baseTitle = task.title || buildTaskTitle(trimmedPrompt);
1851
+ const normalizedTitle = baseTitle.replace(TITLE_ELLIPSIS_REGEX, '');
1852
+ const titleMatchesPrompt = normalizedTitle.length > 0 && trimmedPrompt.startsWith(normalizedTitle);
1853
+ const isTitleTruncated = titleMatchesPrompt && trimmedPrompt.length > normalizedTitle.length;
1854
+ const headerTitle = isTitleTruncated && !TITLE_ELLIPSIS_REGEX.test(baseTitle)
1855
+ ? `${baseTitle}...`
1856
+ : baseTitle;
1857
+ const headerTooltip = isTitleTruncated ? trimmedPrompt : baseTitle;
1858
+ const latestPauseEvent = [...events].reverse().find(event => event.type === 'task_paused');
1859
+ const latestApprovalEvent = [...events].reverse().find(event => event.type === 'approval_requested');
1860
+
1861
+ // Task view
1862
+ return (
1863
+ <div className="main-content">
1864
+ {/* Header */}
1865
+ <div className="main-header">
1866
+ <div className="main-header-title" title={headerTooltip}>{headerTitle}</div>
1867
+ </div>
1868
+
1869
+ {/* Body */}
1870
+ <div className="main-body" ref={mainBodyRef} onScroll={handleScroll}>
1871
+ <div className="task-content">
1872
+ {/* User Prompt - Right aligned like chat */}
1873
+ <div className="chat-message user-message">
1874
+ <div className="chat-bubble user-bubble markdown-content">
1875
+ <ReactMarkdown remarkPlugins={userMarkdownPlugins} components={markdownComponents}>
1876
+ {task.prompt}
1877
+ </ReactMarkdown>
1878
+ </div>
1879
+ <MessageCopyButton text={task.prompt} />
1880
+ </div>
1881
+
1882
+ {/* View steps toggle - show right after original prompt */}
1883
+ {events.some(e => e.type !== 'user_message' && e.type !== 'assistant_message') && (
1884
+ <div className="timeline-controls">
1885
+ <button
1886
+ className={`view-steps-btn ${showSteps ? 'expanded' : ''}`}
1887
+ onClick={() => setShowSteps(!showSteps)}
1888
+ >
1889
+ {showSteps ? 'Hide steps' : 'View steps'}
1890
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1891
+ <path d="M9 18l6-6-6-6" />
1892
+ </svg>
1893
+ </button>
1894
+ {showSteps && (
1895
+ <button
1896
+ className={`verbose-toggle-btn ${verboseSteps ? 'active' : ''}`}
1897
+ onClick={toggleVerboseSteps}
1898
+ title={verboseSteps ? 'Show important steps only' : 'Show all steps (verbose)'}
1899
+ >
1900
+ {verboseSteps ? 'Verbose' : 'Summary'}
1901
+ </button>
1902
+ )}
1903
+ </div>
1904
+ )}
1905
+
1906
+ {/* Conversation Flow - renders all events in order */}
1907
+ {events.length > 0 && (
1908
+ <div className="conversation-flow" ref={timelineRef}>
1909
+ {/* Render CommandOutput at beginning if it should appear before all events */}
1910
+ {activeCommand && commandOutputInsertIndex === -1 && (
1911
+ <CommandOutput
1912
+ command={activeCommand.command}
1913
+ output={activeCommand.output}
1914
+ isRunning={activeCommand.isRunning}
1915
+ exitCode={activeCommand.exitCode}
1916
+ taskId={task?.id}
1917
+ onClose={handleDismissCommandOutput}
1918
+ />
1919
+ )}
1920
+ {timelineItems.map((item) => {
1921
+ if (item.kind === 'canvas') {
1922
+ return (
1923
+ <CanvasPreview
1924
+ key={item.session.id}
1925
+ session={item.session}
1926
+ onClose={() => handleCanvasClose(item.session.id)}
1927
+ forceSnapshot={item.forceSnapshot}
1928
+ />
1929
+ );
1930
+ }
1931
+
1932
+ const event = item.event;
1933
+ const isUserMessage = event.type === 'user_message';
1934
+ const isAssistantMessage = event.type === 'assistant_message';
1935
+ // Check if CommandOutput should be rendered after this event
1936
+ const shouldRenderCommandOutput = activeCommand && item.eventIndex === commandOutputInsertIndex;
1937
+
1938
+ // Render user messages as chat bubbles on the right
1939
+ if (isUserMessage) {
1940
+ const messageText = event.payload?.message || 'User message';
1941
+ return (
1942
+ <Fragment key={event.id || `event-${item.eventIndex}`}>
1943
+ <div className="chat-message user-message">
1944
+ <div className="chat-bubble user-bubble markdown-content">
1945
+ <ReactMarkdown remarkPlugins={userMarkdownPlugins} components={markdownComponents}>
1946
+ {messageText}
1947
+ </ReactMarkdown>
1948
+ </div>
1949
+ <MessageCopyButton text={messageText} />
1950
+ </div>
1951
+ {shouldRenderCommandOutput && (
1952
+ <CommandOutput
1953
+ command={activeCommand.command}
1954
+ output={activeCommand.output}
1955
+ isRunning={activeCommand.isRunning}
1956
+ exitCode={activeCommand.exitCode}
1957
+ taskId={task?.id}
1958
+ onClose={handleDismissCommandOutput}
1959
+ />
1960
+ )}
1961
+ </Fragment>
1962
+ );
1963
+ }
1964
+
1965
+ // Render assistant messages as chat bubbles on the left
1966
+ if (isAssistantMessage) {
1967
+ const messageText = event.payload?.message || '';
1968
+ const isLastAssistant = event === lastAssistantMessage;
1969
+ return (
1970
+ <Fragment key={event.id || `event-${item.eventIndex}`}>
1971
+ <div className="chat-message assistant-message">
1972
+ <div className="chat-bubble assistant-bubble">
1973
+ {isLastAssistant && (
1974
+ <div className="chat-bubble-header">
1975
+ {task.status === 'completed' && <span className="chat-status">{agentContext.getMessage('taskComplete')}</span>}
1976
+ {task.status === 'paused' && <span className="chat-status">{agentContext.getMessage('taskPaused') || 'Paused'}</span>}
1977
+ {task.status === 'blocked' && <span className="chat-status">{agentContext.getMessage('taskBlocked') || 'Needs approval'}</span>}
1978
+ {task.status === 'executing' && (
1979
+ <span className="chat-status executing">
1980
+ <svg className="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1981
+ <circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
1982
+ <path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
1983
+ </svg>
1984
+ {agentContext.getMessage('taskWorking')}
1985
+ </span>
1986
+ )}
1987
+ </div>
1988
+ )}
1989
+ <div className="chat-bubble-content markdown-content">
1990
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
1991
+ {messageText.replace(/\[\[speak\]\]([\s\S]*?)\[\[\/speak\]\]/gi, '$1')}
1992
+ </ReactMarkdown>
1993
+ </div>
1994
+ </div>
1995
+ <div className="message-actions">
1996
+ <MessageCopyButton text={messageText} />
1997
+ <MessageSpeakButton text={messageText} voiceEnabled={voiceEnabled} />
1998
+ </div>
1999
+ </div>
2000
+ {shouldRenderCommandOutput && (
2001
+ <CommandOutput
2002
+ command={activeCommand.command}
2003
+ output={activeCommand.output}
2004
+ isRunning={activeCommand.isRunning}
2005
+ exitCode={activeCommand.exitCode}
2006
+ taskId={task?.id}
2007
+ onClose={handleDismissCommandOutput}
2008
+ />
2009
+ )}
2010
+ </Fragment>
2011
+ );
2012
+ }
2013
+
2014
+ // Technical events - only show when showSteps is true
2015
+ const alwaysVisibleEvents = new Set(['approval_requested', 'approval_granted', 'approval_denied']);
2016
+ if (!showSteps && !alwaysVisibleEvents.has(event.type)) {
2017
+ // Even if we're not showing steps, we may still need to render CommandOutput here
2018
+ if (shouldRenderCommandOutput) {
2019
+ return (
2020
+ <Fragment key={event.id || `event-${item.eventIndex}`}>
2021
+ <CommandOutput
2022
+ command={activeCommand.command}
2023
+ output={activeCommand.output}
2024
+ isRunning={activeCommand.isRunning}
2025
+ exitCode={activeCommand.exitCode}
2026
+ taskId={task?.id}
2027
+ onClose={handleDismissCommandOutput}
2028
+ />
2029
+ </Fragment>
2030
+ );
2031
+ }
2032
+ return null;
2033
+ }
2034
+
2035
+ const isExpandable = hasEventDetails(event);
2036
+ const isExpanded = isEventExpanded(event);
2037
+
2038
+ return (
2039
+ <Fragment key={event.id || `event-${item.eventIndex}`}>
2040
+ <div className="timeline-event">
2041
+ <div className="event-indicator">
2042
+ <div className={`event-dot ${getEventDotClass(event.type)}`} />
2043
+ </div>
2044
+ <div className="event-content">
2045
+ <div
2046
+ className={`event-header ${isExpandable ? 'expandable' : ''} ${isExpanded ? 'expanded' : ''}`}
2047
+ onClick={isExpandable ? () => toggleEventExpanded(event.id) : undefined}
2048
+ >
2049
+ <div className="event-header-left">
2050
+ {isExpandable && (
2051
+ <svg className="event-expand-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
2052
+ <path d="M9 18l6-6-6-6" />
2053
+ </svg>
2054
+ )}
2055
+ <div className="event-title">{renderEventTitle(event, workspace?.path, setViewerFilePath, agentContext)}</div>
2056
+ </div>
2057
+ <div className="event-time">{formatTime(event.timestamp)}</div>
2058
+ </div>
2059
+ {isExpanded && renderEventDetails(event, voiceEnabled)}
2060
+ </div>
2061
+ </div>
2062
+ {shouldRenderCommandOutput && (
2063
+ <CommandOutput
2064
+ command={activeCommand.command}
2065
+ output={activeCommand.output}
2066
+ isRunning={activeCommand.isRunning}
2067
+ exitCode={activeCommand.exitCode}
2068
+ taskId={task?.id}
2069
+ onClose={handleDismissCommandOutput}
2070
+ />
2071
+ )}
2072
+ </Fragment>
2073
+ );
2074
+ })}
2075
+ </div>
2076
+ )}
2077
+
2078
+ </div>
2079
+ </div>
2080
+
2081
+ {/* Footer with Input */}
2082
+ <div className="main-footer">
2083
+ <div className="input-container">
2084
+ {/* Queued message display */}
2085
+ {queuedMessage && (
2086
+ <div className="queued-message-frame">
2087
+ <div className="queued-message-content">
2088
+ <svg className="queued-message-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
2089
+ <path d="M12 19V5M5 12l7-7 7 7" />
2090
+ </svg>
2091
+ <span className="queued-message-label">Queue:</span>
2092
+ <span className="queued-message-text">{queuedMessage}</span>
2093
+ </div>
2094
+ <button className="queued-message-clear" onClick={handleClearQueue} title="Remove from queue">
2095
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
2096
+ <path d="M18 6L6 18M6 6l12 12" />
2097
+ </svg>
2098
+ </button>
2099
+ </div>
2100
+ )}
2101
+ {showVoiceNotConfigured && (
2102
+ <div className="voice-not-configured-banner">
2103
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
2104
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
2105
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
2106
+ <line x1="12" y1="19" x2="12" y2="23" />
2107
+ <line x1="8" y1="23" x2="16" y2="23" />
2108
+ </svg>
2109
+ <span>Voice input is not configured.</span>
2110
+ <button
2111
+ className="voice-settings-link"
2112
+ onClick={() => {
2113
+ setShowVoiceNotConfigured(false);
2114
+ onOpenSettings?.('voice');
2115
+ }}
2116
+ >
2117
+ Open Voice Settings
2118
+ </button>
2119
+ <button
2120
+ className="voice-banner-close"
2121
+ onClick={() => setShowVoiceNotConfigured(false)}
2122
+ title="Dismiss"
2123
+ >
2124
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
2125
+ <path d="M18 6L6 18M6 6l12 12" />
2126
+ </svg>
2127
+ </button>
2128
+ </div>
2129
+ )}
2130
+ {task.status === 'paused' && (
2131
+ <div className="task-status-banner task-status-banner-paused">
2132
+ <div className="task-status-banner-content">
2133
+ <strong>Paused — waiting on your input</strong>
2134
+ {latestPauseEvent?.payload?.message && (
2135
+ <span className="task-status-banner-detail">{latestPauseEvent.payload.message}</span>
2136
+ )}
2137
+ </div>
2138
+ <button className="btn-secondary" onClick={() => window.electronAPI.resumeTask(task.id)}>
2139
+ Resume
2140
+ </button>
2141
+ </div>
2142
+ )}
2143
+ {task.status === 'blocked' && (
2144
+ <div className="task-status-banner task-status-banner-blocked">
2145
+ <div className="task-status-banner-content">
2146
+ <strong>Blocked — needs approval</strong>
2147
+ {latestApprovalEvent?.payload?.approval?.description && (
2148
+ <span className="task-status-banner-detail">{latestApprovalEvent.payload.approval.description}</span>
2149
+ )}
2150
+ </div>
2151
+ </div>
2152
+ )}
2153
+ <div className="input-row">
2154
+ <div className="mention-autocomplete-wrapper" ref={mentionContainerRef}>
2155
+ <textarea
2156
+ ref={textareaRef}
2157
+ className="input-field input-textarea"
2158
+ placeholder={queuedMessage ? agentContext.getUiCopy('inputPlaceholderQueued') : agentContext.getMessage('placeholderActive')}
2159
+ value={inputValue}
2160
+ onChange={handleInputChange}
2161
+ onKeyDown={handleKeyDown}
2162
+ onClick={handleInputClick}
2163
+ onKeyUp={handleInputKeyUp}
2164
+ rows={1}
2165
+ />
2166
+ {renderMentionDropdown()}
2167
+ </div>
2168
+ <div className="input-actions">
2169
+ <ModelDropdown
2170
+ models={availableModels}
2171
+ selectedModel={selectedModel}
2172
+ onModelChange={onModelChange}
2173
+ />
2174
+ <button
2175
+ className={`voice-input-btn ${voiceInput.state}`}
2176
+ onClick={voiceInput.toggleRecording}
2177
+ disabled={voiceInput.state === 'processing'}
2178
+ title={
2179
+ voiceInput.state === 'idle' ? 'Start voice input' :
2180
+ voiceInput.state === 'recording' ? 'Stop recording' :
2181
+ 'Processing...'
2182
+ }
2183
+ >
2184
+ {voiceInput.state === 'processing' ? (
2185
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="voice-processing-spin">
2186
+ <circle cx="12" cy="12" r="10" />
2187
+ <path d="M12 6v6l4 2" />
2188
+ </svg>
2189
+ ) : voiceInput.state === 'recording' ? (
2190
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
2191
+ <rect x="6" y="6" width="12" height="12" rx="2" />
2192
+ </svg>
2193
+ ) : (
2194
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
2195
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
2196
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
2197
+ <line x1="12" y1="19" x2="12" y2="23" />
2198
+ <line x1="8" y1="23" x2="16" y2="23" />
2199
+ </svg>
2200
+ )}
2201
+ {voiceInput.state === 'recording' && (
2202
+ <span className="voice-recording-indicator" style={{ width: `${voiceInput.audioLevel}%` }} />
2203
+ )}
2204
+ </button>
2205
+ <button
2206
+ className="lets-go-btn lets-go-btn-sm"
2207
+ onClick={handleSend}
2208
+ disabled={!inputValue.trim()}
2209
+ title="Send message"
2210
+ >
2211
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
2212
+ <path d="M12 19V5M5 12l7-7 7 7" />
2213
+ </svg>
2214
+ </button>
2215
+ {task.status === 'executing' && onStopTask && (
2216
+ <button
2217
+ className="stop-btn-simple"
2218
+ onClick={onStopTask}
2219
+ title="Stop task"
2220
+ >
2221
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
2222
+ <rect x="3" y="3" width="18" height="18" rx="2" />
2223
+ </svg>
2224
+ </button>
2225
+ )}
2226
+ </div>
2227
+ </div>
2228
+ <div className="input-below-actions">
2229
+ <button
2230
+ className={`shell-toggle ${shellEnabled ? 'enabled' : ''}`}
2231
+ onClick={handleShellToggle}
2232
+ title={shellEnabled ? 'Shell commands enabled - click to disable' : 'Shell commands disabled - click to enable'}
2233
+ >
2234
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
2235
+ <path d="M4 17l6-6-6-6M12 19h8" />
2236
+ </svg>
2237
+ <span>Shell {shellEnabled ? 'ON' : 'OFF'}</span>
2238
+ </button>
2239
+ </div>
2240
+ </div>
2241
+ <div className="footer-disclaimer">
2242
+ {agentContext.getMessage('disclaimer')}
2243
+ </div>
2244
+ </div>
2245
+
2246
+ {pendingApproval && (
2247
+ <ApprovalDialog
2248
+ approval={pendingApproval}
2249
+ onApprove={() => handleApprovalResponse(true)}
2250
+ onDeny={() => handleApprovalResponse(false)}
2251
+ />
2252
+ )}
2253
+
2254
+ {selectedSkillForParams && (
2255
+ <SkillParameterModal
2256
+ skill={selectedSkillForParams}
2257
+ onSubmit={handleSkillParamSubmit}
2258
+ onCancel={handleSkillParamCancel}
2259
+ />
2260
+ )}
2261
+
2262
+ {/* File Viewer Modal - Task View */}
2263
+ {viewerFilePath && workspace?.path && (
2264
+ <FileViewer
2265
+ filePath={viewerFilePath}
2266
+ workspacePath={workspace.path}
2267
+ onClose={() => setViewerFilePath(null)}
2268
+ />
2269
+ )}
2270
+ </div>
2271
+ );
2272
+ }
2273
+
2274
+ /**
2275
+ * Truncate long text for display, with expand option handled via CSS
2276
+ */
2277
+ function truncateForDisplay(text: string, maxLength: number = 2000): string {
2278
+ if (!text || text.length <= maxLength) return text;
2279
+ return text.slice(0, maxLength) + '\n\n... [content truncated for display]';
2280
+ }
2281
+
2282
+ function renderEventTitle(
2283
+ event: TaskEvent,
2284
+ workspacePath?: string,
2285
+ onOpenViewer?: (path: string) => void,
2286
+ agentCtx?: AgentContext
2287
+ ): React.ReactNode {
2288
+ // Build message context for personalized messages
2289
+ const msgCtx = agentCtx ? {
2290
+ agentName: agentCtx.agentName,
2291
+ userName: agentCtx.userName,
2292
+ personality: agentCtx.personality,
2293
+ persona: agentCtx.persona,
2294
+ emojiUsage: agentCtx.emojiUsage,
2295
+ quirks: agentCtx.quirks,
2296
+ } : {
2297
+ agentName: 'CoWork',
2298
+ userName: undefined,
2299
+ personality: 'professional' as const,
2300
+ persona: undefined,
2301
+ emojiUsage: 'minimal' as const,
2302
+ quirks: DEFAULT_QUIRKS,
2303
+ };
2304
+
2305
+ switch (event.type) {
2306
+ case 'task_created':
2307
+ return getMessage('taskStart', msgCtx);
2308
+ case 'task_completed':
2309
+ return getMessage('taskComplete', msgCtx);
2310
+ case 'plan_created':
2311
+ return getMessage('planCreated', msgCtx);
2312
+ case 'step_started':
2313
+ return getMessage('stepStarted', msgCtx, event.payload.step?.description || 'Getting started...');
2314
+ case 'step_completed':
2315
+ return getMessage('stepCompleted', msgCtx, event.payload.step?.description || event.payload.message);
2316
+ case 'tool_call':
2317
+ return `Using: ${event.payload.tool}`;
2318
+ case 'tool_result': {
2319
+ const result = event.payload.result;
2320
+ const success = result?.success !== false && !result?.error;
2321
+ const status = success ? 'done' : 'issue';
2322
+
2323
+ // Extract useful info from result to show inline
2324
+ let detail = '';
2325
+ if (result) {
2326
+ if (!success && result.error) {
2327
+ // Show error message for failed tools
2328
+ const errorMsg = typeof result.error === 'string' ? result.error : 'Unknown error';
2329
+ detail = `: ${errorMsg.slice(0, 60)}${errorMsg.length > 60 ? '...' : ''}`;
2330
+ } else if (result.path) {
2331
+ detail = ` → ${result.path}`;
2332
+ } else if (result.content && typeof result.content === 'string') {
2333
+ const lines = result.content.split('\n').length;
2334
+ detail = ` → ${lines} lines`;
2335
+ } else if (result.size !== undefined) {
2336
+ detail = ` → ${result.size} bytes`;
2337
+ } else if (result.files) {
2338
+ detail = ` → ${result.files.length} items`;
2339
+ } else if (result.matches) {
2340
+ detail = ` → ${result.matches.length} matches`;
2341
+ } else if (result.exitCode !== undefined) {
2342
+ detail = result.exitCode === 0 ? '' : ` → exit ${result.exitCode}`;
2343
+ }
2344
+ }
2345
+ return `${event.payload.tool} ${status}${detail}`;
2346
+ }
2347
+ case 'assistant_message':
2348
+ return msgCtx.agentName;
2349
+ case 'file_created':
2350
+ return (
2351
+ <span>
2352
+ Created: <ClickableFilePath path={event.payload.path} workspacePath={workspacePath} onOpenViewer={onOpenViewer} />
2353
+ </span>
2354
+ );
2355
+ case 'file_modified':
2356
+ return (
2357
+ <span>
2358
+ Updated: <ClickableFilePath path={event.payload.path || event.payload.from} workspacePath={workspacePath} onOpenViewer={onOpenViewer} />
2359
+ </span>
2360
+ );
2361
+ case 'file_deleted':
2362
+ return `Removed: ${event.payload.path}`;
2363
+ case 'error':
2364
+ return getMessage('error', msgCtx);
2365
+ case 'approval_requested':
2366
+ return `${getMessage('approval', msgCtx)} ${event.payload.approval?.description}`;
2367
+ case 'log':
2368
+ return event.payload.message;
2369
+ // Goal Mode verification events
2370
+ case 'verification_started':
2371
+ return getMessage('verifying', msgCtx);
2372
+ case 'verification_passed':
2373
+ return `${getMessage('verifyPassed', msgCtx)} (attempt ${event.payload.attempt})`;
2374
+ case 'verification_failed':
2375
+ return `${getMessage('verifyFailed', msgCtx)} (attempt ${event.payload.attempt}/${event.payload.maxAttempts})`;
2376
+ case 'retry_started':
2377
+ return getMessage('retrying', msgCtx, String(event.payload.attempt));
2378
+ default:
2379
+ return event.type;
2380
+ }
2381
+ }
2382
+
2383
+ function renderEventDetails(event: TaskEvent, voiceEnabled: boolean) {
2384
+ switch (event.type) {
2385
+ case 'plan_created':
2386
+ return (
2387
+ <div className="event-details">
2388
+ <div style={{ marginBottom: 8, fontWeight: 500 }}>{event.payload.plan?.description}</div>
2389
+ {event.payload.plan?.steps && (
2390
+ <ul style={{ margin: 0, paddingLeft: 18 }}>
2391
+ {event.payload.plan.steps.map((step: any, i: number) => (
2392
+ <li key={i} style={{ marginBottom: 4 }}>{step.description}</li>
2393
+ ))}
2394
+ </ul>
2395
+ )}
2396
+ </div>
2397
+ );
2398
+ case 'tool_call':
2399
+ return (
2400
+ <div className="event-details event-details-scrollable">
2401
+ <pre>{truncateForDisplay(JSON.stringify(event.payload.input, null, 2))}</pre>
2402
+ </div>
2403
+ );
2404
+ case 'tool_result':
2405
+ return (
2406
+ <div className="event-details event-details-scrollable">
2407
+ <pre>{truncateForDisplay(JSON.stringify(event.payload.result, null, 2))}</pre>
2408
+ </div>
2409
+ );
2410
+ case 'assistant_message':
2411
+ return (
2412
+ <div className="event-details assistant-message event-details-scrollable">
2413
+ <div className="markdown-content">
2414
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
2415
+ {event.payload.message.replace(/\[\[speak\]\]([\s\S]*?)\[\[\/speak\]\]/gi, '$1')}
2416
+ </ReactMarkdown>
2417
+ </div>
2418
+ <div className="message-actions">
2419
+ <MessageCopyButton text={event.payload.message} />
2420
+ <MessageSpeakButton text={event.payload.message} voiceEnabled={voiceEnabled} />
2421
+ </div>
2422
+ </div>
2423
+ );
2424
+ case 'error':
2425
+ return (
2426
+ <div className="event-details" style={{ background: 'rgba(239, 68, 68, 0.1)', borderColor: 'rgba(239, 68, 68, 0.2)' }}>
2427
+ {event.payload.error || event.payload.message}
2428
+ </div>
2429
+ );
2430
+ default:
2431
+ return null;
2432
+ }
2433
+ }