@nac3/forge-cli 0.2.1-alpha.60 → 1.0.0

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 (301) hide show
  1. package/README.md +5 -1
  2. package/dist/agents/bridge.d.ts.map +1 -1
  3. package/dist/agents/bridge.js +27 -0
  4. package/dist/agents/bridge.js.map +1 -1
  5. package/dist/agents/config_resolver.d.ts.map +1 -1
  6. package/dist/agents/config_resolver.js +15 -15
  7. package/dist/agents/config_resolver.js.map +1 -1
  8. package/dist/agents/runner.d.ts +8 -0
  9. package/dist/agents/runner.d.ts.map +1 -1
  10. package/dist/agents/runner.js +58 -11
  11. package/dist/agents/runner.js.map +1 -1
  12. package/dist/agents/system_prompt.d.ts.map +1 -1
  13. package/dist/agents/system_prompt.js +2 -1
  14. package/dist/agents/system_prompt.js.map +1 -1
  15. package/dist/bin/yf.d.ts.map +1 -1
  16. package/dist/bin/yf.js +18 -0
  17. package/dist/bin/yf.js.map +1 -1
  18. package/dist/chat/claude.d.ts +13 -0
  19. package/dist/chat/claude.d.ts.map +1 -1
  20. package/dist/chat/claude.js +45 -10
  21. package/dist/chat/claude.js.map +1 -1
  22. package/dist/chat/claude_cli.d.ts +10 -0
  23. package/dist/chat/claude_cli.d.ts.map +1 -1
  24. package/dist/chat/claude_cli.js +97 -16
  25. package/dist/chat/claude_cli.js.map +1 -1
  26. package/dist/chat/completion_hallucination_detector.d.ts +11 -0
  27. package/dist/chat/completion_hallucination_detector.d.ts.map +1 -1
  28. package/dist/chat/completion_hallucination_detector.js +37 -2
  29. package/dist/chat/completion_hallucination_detector.js.map +1 -1
  30. package/dist/chat/doctrine_digest.d.ts.map +1 -1
  31. package/dist/chat/doctrine_digest.js +10 -6
  32. package/dist/chat/doctrine_digest.js.map +1 -1
  33. package/dist/chat/panel.d.ts.map +1 -1
  34. package/dist/chat/panel.js +885 -42
  35. package/dist/chat/panel.js.map +1 -1
  36. package/dist/chat/refusal_detector.d.ts.map +1 -1
  37. package/dist/chat/refusal_detector.js +2 -1
  38. package/dist/chat/refusal_detector.js.map +1 -1
  39. package/dist/chat/server.d.ts +1 -0
  40. package/dist/chat/server.d.ts.map +1 -1
  41. package/dist/chat/server.js +890 -62
  42. package/dist/chat/server.js.map +1 -1
  43. package/dist/chat/session_projects.d.ts +31 -0
  44. package/dist/chat/session_projects.d.ts.map +1 -0
  45. package/dist/chat/session_projects.js +36 -0
  46. package/dist/chat/session_projects.js.map +1 -0
  47. package/dist/chat/session_store.d.ts +32 -0
  48. package/dist/chat/session_store.d.ts.map +1 -0
  49. package/dist/chat/session_store.js +58 -0
  50. package/dist/chat/session_store.js.map +1 -0
  51. package/dist/chat/tooling_section.d.ts +11 -0
  52. package/dist/chat/tooling_section.d.ts.map +1 -0
  53. package/dist/chat/tooling_section.js +70 -0
  54. package/dist/chat/tooling_section.js.map +1 -0
  55. package/dist/chat/tools/app_backend_dispatch.d.ts.map +1 -1
  56. package/dist/chat/tools/app_backend_dispatch.js +44 -1
  57. package/dist/chat/tools/app_backend_dispatch.js.map +1 -1
  58. package/dist/chat/tools/fs_editor.d.ts.map +1 -1
  59. package/dist/chat/tools/fs_editor.js +53 -3
  60. package/dist/chat/tools/fs_editor.js.map +1 -1
  61. package/dist/chat/tools/graph.js +2 -2
  62. package/dist/chat/tools/graph.js.map +1 -1
  63. package/dist/chat/tools/keys.d.ts.map +1 -1
  64. package/dist/chat/tools/keys.js +5 -18
  65. package/dist/chat/tools/keys.js.map +1 -1
  66. package/dist/chat/tools/nac3_invoke.d.ts.map +1 -1
  67. package/dist/chat/tools/nac3_invoke.js +16 -7
  68. package/dist/chat/tools/nac3_invoke.js.map +1 -1
  69. package/dist/chat/tools/shell.d.ts +6 -0
  70. package/dist/chat/tools/shell.d.ts.map +1 -1
  71. package/dist/chat/tools/shell.js +39 -0
  72. package/dist/chat/tools/shell.js.map +1 -1
  73. package/dist/chat/tools/workflow.d.ts +20 -0
  74. package/dist/chat/tools/workflow.d.ts.map +1 -1
  75. package/dist/chat/tools/workflow.js +98 -0
  76. package/dist/chat/tools/workflow.js.map +1 -1
  77. package/dist/chat/tools.d.ts.map +1 -1
  78. package/dist/chat/tools.js +79 -1
  79. package/dist/chat/tools.js.map +1 -1
  80. package/dist/chat/turn_control.d.ts +42 -0
  81. package/dist/chat/turn_control.d.ts.map +1 -0
  82. package/dist/chat/turn_control.js +53 -0
  83. package/dist/chat/turn_control.js.map +1 -0
  84. package/dist/commands/brain.js +1 -1
  85. package/dist/commands/chat.d.ts.map +1 -1
  86. package/dist/commands/chat.js +61 -10
  87. package/dist/commands/chat.js.map +1 -1
  88. package/dist/commands/graph.js +9 -9
  89. package/dist/commands/graph.js.map +1 -1
  90. package/dist/commands/keys_setup.d.ts +10 -28
  91. package/dist/commands/keys_setup.d.ts.map +1 -1
  92. package/dist/commands/keys_setup.js +54 -228
  93. package/dist/commands/keys_setup.js.map +1 -1
  94. package/dist/commands/license.d.ts.map +1 -1
  95. package/dist/commands/license.js +52 -28
  96. package/dist/commands/license.js.map +1 -1
  97. package/dist/commands/mcp.d.ts.map +1 -1
  98. package/dist/commands/mcp.js +62 -6
  99. package/dist/commands/mcp.js.map +1 -1
  100. package/dist/commands/mobile.d.ts +1 -1
  101. package/dist/commands/mobile.d.ts.map +1 -1
  102. package/dist/commands/mobile.js +32 -28
  103. package/dist/commands/mobile.js.map +1 -1
  104. package/dist/commands/onboard.d.ts +19 -0
  105. package/dist/commands/onboard.d.ts.map +1 -0
  106. package/dist/commands/onboard.js +126 -0
  107. package/dist/commands/onboard.js.map +1 -0
  108. package/dist/commands/pending.d.ts.map +1 -1
  109. package/dist/commands/pending.js +25 -1
  110. package/dist/commands/pending.js.map +1 -1
  111. package/dist/commands/projects.js +25 -1
  112. package/dist/commands/projects.js.map +1 -1
  113. package/dist/commands/review-screens.js +28 -1
  114. package/dist/commands/review-screens.js.map +1 -1
  115. package/dist/commands/workflow.d.ts +18 -0
  116. package/dist/commands/workflow.d.ts.map +1 -0
  117. package/dist/commands/workflow.js +253 -0
  118. package/dist/commands/workflow.js.map +1 -0
  119. package/dist/core/brain_config.d.ts +2 -0
  120. package/dist/core/brain_config.d.ts.map +1 -1
  121. package/dist/core/brain_config.js +18 -3
  122. package/dist/core/brain_config.js.map +1 -1
  123. package/dist/core/cf_tunnel.d.ts.map +1 -1
  124. package/dist/core/cf_tunnel.js +8 -2
  125. package/dist/core/cf_tunnel.js.map +1 -1
  126. package/dist/core/cost_router.d.ts.map +1 -1
  127. package/dist/core/cost_router.js +11 -2
  128. package/dist/core/cost_router.js.map +1 -1
  129. package/dist/core/desktop_open.d.ts +16 -0
  130. package/dist/core/desktop_open.d.ts.map +1 -0
  131. package/dist/core/desktop_open.js +114 -0
  132. package/dist/core/desktop_open.js.map +1 -0
  133. package/dist/core/keys_envelope.d.ts.map +1 -1
  134. package/dist/core/keys_envelope.js +5 -1
  135. package/dist/core/keys_envelope.js.map +1 -1
  136. package/dist/core/limits_config.d.ts +5 -0
  137. package/dist/core/limits_config.d.ts.map +1 -1
  138. package/dist/core/limits_config.js +5 -0
  139. package/dist/core/limits_config.js.map +1 -1
  140. package/dist/core/logger.d.ts.map +1 -1
  141. package/dist/core/logger.js +3 -0
  142. package/dist/core/logger.js.map +1 -1
  143. package/dist/core/pairing.d.ts +6 -0
  144. package/dist/core/pairing.d.ts.map +1 -1
  145. package/dist/core/pairing.js +41 -2
  146. package/dist/core/pairing.js.map +1 -1
  147. package/dist/core/provider_keys.d.ts.map +1 -1
  148. package/dist/core/provider_keys.js +25 -12
  149. package/dist/core/provider_keys.js.map +1 -1
  150. package/dist/core/provider_models.js +1 -1
  151. package/dist/deploy/cloudflare.d.ts.map +1 -1
  152. package/dist/deploy/cloudflare.js +13 -2
  153. package/dist/deploy/cloudflare.js.map +1 -1
  154. package/dist/design/yujin_design_resolver.d.ts +16 -0
  155. package/dist/design/yujin_design_resolver.d.ts.map +1 -1
  156. package/dist/design/yujin_design_resolver.js +103 -20
  157. package/dist/design/yujin_design_resolver.js.map +1 -1
  158. package/dist/docs/doctrine/default-design-system.md +32 -0
  159. package/dist/docs/doctrine/office-tools.md +20 -0
  160. package/dist/docs/doctrine/verb-composition.md +16 -0
  161. package/dist/docs/doctrine/workflow-kinds.md +39 -22
  162. package/dist/docs/doctrine/workflow.md +12 -4
  163. package/dist/license/auto_sync.d.ts +22 -0
  164. package/dist/license/auto_sync.d.ts.map +1 -0
  165. package/dist/license/auto_sync.js +91 -0
  166. package/dist/license/auto_sync.js.map +1 -0
  167. package/dist/license/handle_key.d.ts +32 -0
  168. package/dist/license/handle_key.d.ts.map +1 -0
  169. package/dist/license/handle_key.js +56 -0
  170. package/dist/license/handle_key.js.map +1 -0
  171. package/dist/license/hito4_client.d.ts.map +1 -1
  172. package/dist/license/hito4_client.js +63 -25
  173. package/dist/license/hito4_client.js.map +1 -1
  174. package/dist/llm/gemini_client.d.ts.map +1 -1
  175. package/dist/llm/gemini_client.js +5 -2
  176. package/dist/llm/gemini_client.js.map +1 -1
  177. package/dist/llm/managed_client.d.ts.map +1 -1
  178. package/dist/llm/managed_client.js +14 -9
  179. package/dist/llm/managed_client.js.map +1 -1
  180. package/dist/mcp/plan_bridges.d.ts +17 -0
  181. package/dist/mcp/plan_bridges.d.ts.map +1 -0
  182. package/dist/mcp/plan_bridges.js +119 -0
  183. package/dist/mcp/plan_bridges.js.map +1 -0
  184. package/dist/migrate/ai-apply.d.ts.map +1 -1
  185. package/dist/migrate/ai-apply.js +25 -2
  186. package/dist/migrate/ai-apply.js.map +1 -1
  187. package/dist/nac3/internal_manifest.d.ts.map +1 -1
  188. package/dist/nac3/internal_manifest.js +200 -10
  189. package/dist/nac3/internal_manifest.js.map +1 -1
  190. package/dist/nac3/verb_tier_map.d.ts.map +1 -1
  191. package/dist/nac3/verb_tier_map.js +3 -0
  192. package/dist/nac3/verb_tier_map.js.map +1 -1
  193. package/dist/onboarding/concierge_client.d.ts +34 -0
  194. package/dist/onboarding/concierge_client.d.ts.map +1 -0
  195. package/dist/onboarding/concierge_client.js +80 -0
  196. package/dist/onboarding/concierge_client.js.map +1 -0
  197. package/dist/onboarding/next_step.d.ts +32 -0
  198. package/dist/onboarding/next_step.d.ts.map +1 -0
  199. package/dist/onboarding/next_step.js +60 -0
  200. package/dist/onboarding/next_step.js.map +1 -0
  201. package/dist/pending/store.d.ts +7 -0
  202. package/dist/pending/store.d.ts.map +1 -1
  203. package/dist/pending/store.js +19 -0
  204. package/dist/pending/store.js.map +1 -1
  205. package/dist/pending/types.d.ts +20 -0
  206. package/dist/pending/types.d.ts.map +1 -1
  207. package/dist/pending/types.js.map +1 -1
  208. package/dist/reader/parsers/epub.d.ts.map +1 -1
  209. package/dist/reader/parsers/epub.js +6 -1
  210. package/dist/reader/parsers/epub.js.map +1 -1
  211. package/dist/relay/board_snapshot.d.ts +69 -0
  212. package/dist/relay/board_snapshot.d.ts.map +1 -0
  213. package/dist/relay/board_snapshot.js +149 -0
  214. package/dist/relay/board_snapshot.js.map +1 -0
  215. package/dist/relay/command_consumer.d.ts +91 -0
  216. package/dist/relay/command_consumer.d.ts.map +1 -0
  217. package/dist/relay/command_consumer.js +129 -0
  218. package/dist/relay/command_consumer.js.map +1 -0
  219. package/dist/relay/mock_relay.d.ts +34 -0
  220. package/dist/relay/mock_relay.d.ts.map +1 -0
  221. package/dist/relay/mock_relay.js +93 -0
  222. package/dist/relay/mock_relay.js.map +1 -0
  223. package/dist/relay/relay_client.d.ts +28 -0
  224. package/dist/relay/relay_client.d.ts.map +1 -0
  225. package/dist/relay/relay_client.js +80 -0
  226. package/dist/relay/relay_client.js.map +1 -0
  227. package/dist/relay/relay_publisher.d.ts +59 -0
  228. package/dist/relay/relay_publisher.d.ts.map +1 -0
  229. package/dist/relay/relay_publisher.js +101 -0
  230. package/dist/relay/relay_publisher.js.map +1 -0
  231. package/dist/relay/types.d.ts +54 -0
  232. package/dist/relay/types.d.ts.map +1 -0
  233. package/dist/relay/types.js +8 -0
  234. package/dist/relay/types.js.map +1 -0
  235. package/dist/semantic_graph/ast_extract.d.ts +5 -0
  236. package/dist/semantic_graph/ast_extract.d.ts.map +1 -1
  237. package/dist/semantic_graph/ast_extract.js +46 -0
  238. package/dist/semantic_graph/ast_extract.js.map +1 -1
  239. package/dist/semantic_graph/derive.d.ts +12 -0
  240. package/dist/semantic_graph/derive.d.ts.map +1 -1
  241. package/dist/semantic_graph/derive.js +325 -6
  242. package/dist/semantic_graph/derive.js.map +1 -1
  243. package/dist/semantic_graph/store.d.ts +9 -0
  244. package/dist/semantic_graph/store.d.ts.map +1 -1
  245. package/dist/semantic_graph/store.js +23 -1
  246. package/dist/semantic_graph/store.js.map +1 -1
  247. package/dist/semantic_graph/types.d.ts +1 -1
  248. package/dist/semantic_graph/types.d.ts.map +1 -1
  249. package/dist/support/github_dispatcher.d.ts.map +1 -1
  250. package/dist/support/github_dispatcher.js +30 -2
  251. package/dist/support/github_dispatcher.js.map +1 -1
  252. package/dist/tasks/supervisor.d.ts +88 -0
  253. package/dist/tasks/supervisor.d.ts.map +1 -0
  254. package/dist/tasks/supervisor.js +308 -0
  255. package/dist/tasks/supervisor.js.map +1 -0
  256. package/dist/tasks/task_type.d.ts +11 -0
  257. package/dist/tasks/task_type.d.ts.map +1 -0
  258. package/dist/tasks/task_type.js +21 -0
  259. package/dist/tasks/task_type.js.map +1 -0
  260. package/dist/vault/store.d.ts.map +1 -1
  261. package/dist/vault/store.js +6 -1
  262. package/dist/vault/store.js.map +1 -1
  263. package/dist/version.d.ts +3 -5
  264. package/dist/version.d.ts.map +1 -1
  265. package/dist/version.js +3 -5
  266. package/dist/version.js.map +1 -1
  267. package/dist/voice/providers/google.d.ts.map +1 -1
  268. package/dist/voice/providers/google.js +6 -4
  269. package/dist/voice/providers/google.js.map +1 -1
  270. package/dist/workflow/config.d.ts +43 -0
  271. package/dist/workflow/config.d.ts.map +1 -0
  272. package/dist/workflow/config.js +199 -0
  273. package/dist/workflow/config.js.map +1 -0
  274. package/dist/workflow/instances.d.ts +40 -0
  275. package/dist/workflow/instances.d.ts.map +1 -0
  276. package/dist/workflow/instances.js +122 -0
  277. package/dist/workflow/instances.js.map +1 -0
  278. package/dist/workflow/multi_view.d.ts +27 -0
  279. package/dist/workflow/multi_view.d.ts.map +1 -0
  280. package/dist/workflow/multi_view.js +76 -0
  281. package/dist/workflow/multi_view.js.map +1 -0
  282. package/dist/workflow/orchestrator.d.ts +111 -0
  283. package/dist/workflow/orchestrator.d.ts.map +1 -0
  284. package/dist/workflow/orchestrator.js +241 -0
  285. package/dist/workflow/orchestrator.js.map +1 -0
  286. package/docs/doctrine/default-design-system.md +32 -0
  287. package/docs/doctrine/office-tools.md +20 -0
  288. package/docs/doctrine/verb-composition.md +16 -0
  289. package/docs/doctrine/workflow-kinds.md +39 -22
  290. package/docs/doctrine/workflow.md +12 -4
  291. package/package.json +2 -1
  292. package/src/i18n/catalogs/ar.json +223 -210
  293. package/src/i18n/catalogs/de.json +223 -210
  294. package/src/i18n/catalogs/en.json +223 -210
  295. package/src/i18n/catalogs/es.json +223 -210
  296. package/src/i18n/catalogs/fr.json +223 -210
  297. package/src/i18n/catalogs/hi.json +223 -210
  298. package/src/i18n/catalogs/it.json +223 -210
  299. package/src/i18n/catalogs/ja.json +223 -210
  300. package/src/i18n/catalogs/pt.json +223 -210
  301. package/src/i18n/catalogs/zh.json +223 -210
@@ -34,6 +34,19 @@ export function renderPanelHtml(cfg) {
34
34
  const tr = {
35
35
  inputPlaceholder: tForLang(lang, 'panel.input.placeholder'),
36
36
  sendButton: tForLang(lang, 'panel.button.send'),
37
+ stopButton: tForLang(lang, 'panel.button.stop'),
38
+ statusStopping: tForLang(lang, 'panel.status.stopping'),
39
+ statusNoActiveTurn: tForLang(lang, 'panel.status.noActiveTurn'),
40
+ statusInterruptFailed: tForLang(lang, 'panel.status.interruptFailed'),
41
+ statusTurnInterrupted: tForLang(lang, 'panel.status.turnInterrupted'),
42
+ labelInterrupted: tForLang(lang, 'panel.label.interrupted'),
43
+ queueLabel: tForLang(lang, 'panel.queue.label'),
44
+ queueRemove: tForLang(lang, 'panel.queue.remove'),
45
+ projectSwitchHint: tForLang(lang, 'panel.project.switchHint'),
46
+ projectTitle: tForLang(lang, 'panel.project.title'),
47
+ projectAdd: tForLang(lang, 'panel.project.add'),
48
+ projectAddPrompt: tForLang(lang, 'panel.project.addPrompt'),
49
+ projectSwitching: tForLang(lang, 'panel.project.switching'),
37
50
  micButton: tForLang(lang, 'panel.button.mic'),
38
51
  micButtonRec: tForLang(lang, 'panel.button.mic.recording'),
39
52
  ttsButton: tForLang(lang, 'panel.button.tts'),
@@ -308,6 +321,26 @@ body {
308
321
  /* alpha.59i -- shell kill button on active shell.exec tabs */
309
322
  #yf-full .pz-tab-kill { margin-left: 4px; width: 18px; height: 18px; border-radius: 50%; border: 1px solid rgba(208,64,64,0.5); background: rgba(208,64,64,0.15); color: #ff8a8a; cursor: pointer; font-size: 10px; line-height: 1; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
310
323
  #yf-full .pz-tab-kill:hover { background: rgba(208,64,64,0.35); border-color: rgba(208,64,64,0.8); }
324
+ /* PND-007 -- Tareas tab */
325
+ #yf-full .pz-tasks-wrap { flex: 1; overflow-y: auto; padding: 14px 16px; }
326
+ #yf-full .pz-tasks-new { display: flex; gap: 8px; margin-bottom: 14px; }
327
+ #yf-full .pz-tasks-input { flex: 1; font: inherit; font-size: 13px; padding: 7px 11px; border: 1px solid var(--line, rgba(0,0,0,0.14)); border-radius: 8px; background: var(--bg-1, #fafafa); color: var(--ink, #1a1a1a); }
328
+ #yf-full .pz-tasks-run { font: inherit; font-size: 12px; font-weight: 600; padding: 7px 14px; border: none; border-radius: 8px; background: #4f5b87; color: white; cursor: pointer; }
329
+ #yf-full .pz-tasks-run:hover { background: #3f4a73; }
330
+ #yf-full .pz-tasks-empty { color: var(--ink-subtle, #9a9a9a); font-size: 13px; padding: 20px 4px; text-align: center; }
331
+ #yf-full .pz-task { display: flex; align-items: center; gap: 12px; padding: 11px 12px; border: 1px solid var(--line, rgba(0,0,0,0.10)); border-radius: 10px; margin-bottom: 8px; background: var(--bg-1, #fcfcfc); }
332
+ #yf-full .pz-task-ic { font-size: 20px; width: 26px; text-align: center; flex-shrink: 0; }
333
+ #yf-full .pz-task-main { flex: 1; min-width: 0; }
334
+ #yf-full .pz-task-label { font-size: 13px; font-weight: 600; color: var(--ink, #1a1a1a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
335
+ #yf-full .pz-task-cmd { font-size: 11px; color: var(--ink-subtle, #9a9a9a); font-family: ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; }
336
+ #yf-full .pz-task-side { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
337
+ #yf-full .pz-task-badge { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; border: 1px solid; border-radius: 6px; padding: 2px 7px; }
338
+ #yf-full .pz-task-kill, #yf-full .pz-task-log { font: inherit; font-size: 11px; padding: 3px 9px; border-radius: 6px; border: 1px solid var(--line, rgba(0,0,0,0.16)); background: transparent; color: var(--ink-muted, #6a6a6a); cursor: pointer; }
339
+ #yf-full .pz-task-kill { border-color: rgba(208,64,64,0.5); color: #c8472e; }
340
+ #yf-full .pz-task-del { font: inherit; font-size: 14px; line-height: 1; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--line, rgba(0,0,0,0.16)); background: transparent; color: var(--ink-subtle, #9a9a9a); cursor: pointer; }
341
+ #yf-full .pz-task-del:hover { border-color: rgba(208,64,64,0.5); color: #c8472e; }
342
+ #yf-full .pz-task-logbox:not(:empty) { margin: -2px 0 10px 38px; }
343
+ #yf-full .pz-task-logpre { font-family: ui-monospace, monospace; font-size: 11px; line-height: 1.5; color: var(--ink-muted, #555); background: var(--bg-2, #f4f4f4); border-radius: 8px; padding: 10px 12px; overflow-x: auto; white-space: pre-wrap; max-height: 220px; }
311
344
  #yf-full .pz-tab-body {
312
345
  flex: 1; overflow: hidden; min-height: 0;
313
346
  display: flex; flex-direction: column;
@@ -569,6 +602,7 @@ body {
569
602
  #yf-full .pz-cw-row.k-bash { border-left-color: #6f8b5e; }
570
603
  #yf-full .pz-cw-row.k-read, #yf-full .pz-cw-row.k-search { border-left-color: var(--indigo, #4f5b87); }
571
604
  #yf-full .pz-cw-head { display: flex; align-items: baseline; gap: 7px; }
605
+ #yf-full .pz-cw-ts { font-family: ui-monospace, Menlo, monospace; font-size: 10.5px; color: var(--ink-subtle, #a89f88); flex: 0 0 auto; font-variant-numeric: tabular-nums; }
572
606
  #yf-full .pz-cw-ic { color: var(--ink-muted, #8a7f68); font-size: 12px; width: 14px; flex: 0 0 auto; text-align: center; }
573
607
  #yf-full .pz-cw-target { font-family: ui-monospace, Menlo, monospace; font-size: 13px; color: var(--ink, #1a1a1a); font-weight: 600; word-break: break-all; }
574
608
  #yf-full .pz-cw-path { font-size: 10px; color: var(--ink-subtle, #a89f88); margin-left: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 45%; }
@@ -588,6 +622,22 @@ body {
588
622
  background: var(--bg-1, #f5f0e6); border: 1px solid var(--border, #d4cbb8); border-radius: 8px;
589
623
  padding: 14px 16px; margin-bottom: 14px;
590
624
  }
625
+ /* PND-094 ext -- multi-workflow: cada workflow es una card separada. */
626
+ #yf-full .pz-wfl-card { margin-bottom: 26px; padding-bottom: 6px; border-bottom: 1px dashed var(--border, #d4cbb8); }
627
+ #yf-full .pz-wfl-card:last-child { border-bottom: none; }
628
+ /* selector entre workflows abiertos (solo con >1). */
629
+ #yf-full .pz-wfl-sel { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 16px; }
630
+ #yf-full .pz-wfl-chip {
631
+ font-size: 12px; color: var(--ink-muted, #6f6650); cursor: pointer;
632
+ background: var(--bg-1, #f5f0e6); border: 1px solid var(--border, #d4cbb8);
633
+ border-radius: 14px; padding: 4px 12px; font-family: inherit;
634
+ }
635
+ #yf-full .pz-wfl-chip:hover { border-color: var(--indigo, #4f5b87); }
636
+ #yf-full .pz-wfl-chip.active {
637
+ color: #fff; background: var(--indigo, #4f5b87); border-color: var(--indigo, #4f5b87); font-weight: 600;
638
+ }
639
+ #yf-full .pz-wfl-adhoc { color: var(--amber, #c8a04d); font-weight: 700; }
640
+ #yf-full .pz-wfl-empty-stage { opacity: 0.5; font-style: italic; }
591
641
  #yf-full .pz-wfl-title { font-family: var(--font-kanji, "Noto Serif JP", serif); font-size: 18px; color: var(--ink, #1a1a1a); }
592
642
  #yf-full .pz-wfl-kind {
593
643
  display: inline-block; font-size: 11px; color: var(--indigo, #4f5b87);
@@ -624,7 +674,7 @@ body {
624
674
  #yf-full .pz-wfl-hang { position: relative; margin: 0 0 0 14px; padding-top: 12px; }
625
675
  #yf-full .pz-wfl-hang::before { content: ''; position: absolute; left: 0; top: -2px; bottom: 12px; width: 2px; background: #d4cbb8; }
626
676
  #yf-full .pz-wfl-task {
627
- position: relative; display: flex; align-items: center; gap: 6px;
677
+ position: relative; display: flex; align-items: flex-start; gap: 6px;
628
678
  background: var(--bg-1, #f5f0e6); border: 1px solid var(--border, #d4cbb8); border-radius: 6px;
629
679
  padding: 5px 8px; margin-bottom: 7px; font-size: 12px; color: var(--ink-muted, #6f6650);
630
680
  }
@@ -634,7 +684,14 @@ body {
634
684
  #yf-full .pz-wfl-check { width: 14px; text-align: center; color: #c8b89a; flex: 0 0 auto; }
635
685
  #yf-full .pz-wfl-task.wfl-done .pz-wfl-check { color: #6f8b5e; }
636
686
  #yf-full .pz-wfl-task.wfl-active .pz-wfl-check { color: var(--amber, #c8a04d); }
637
- #yf-full .pz-wfl-task-title { line-height: 1.2; }
687
+ /* el titulo es el item flexible: min-width:0 + word-break para que una
688
+ * palabra larga (p.ej. MobileManagedBrainClient) corte dentro de la card
689
+ * y no se desborde por la derecha. */
690
+ #yf-full .pz-wfl-task-title { flex: 1 1 auto; min-width: 0; line-height: 1.2; overflow-wrap: anywhere; word-break: break-word; }
691
+ /* Pestana BPMN 2.0 (flujos de proceso). */
692
+ #yf-full .pz-body.pz-bpmn-wrap { background: var(--bg-1, #f5f0e6); padding: 12px; overflow: auto; }
693
+ #yf-full .pz-bpmn-svg { display: block; width: 100%; height: auto; }
694
+ #yf-full .pz-bpmn-empty { color: var(--ink-muted, #8a7f68); font-style: italic; padding: 28px; text-align: center; }
638
695
  #yf-full .pz-body a {
639
696
  color: #2563eb; text-decoration: underline;
640
697
  }
@@ -813,6 +870,26 @@ body {
813
870
  .header .brand .t { display: flex; flex-direction: column; line-height: 1.1; }
814
871
  .header .brand .t .name { font-size: 14px; font-weight: 600; }
815
872
  .header .brand .t .proj { font-size: 11px; opacity: 0.8; }
873
+ .header .brand .t .proj-switch { cursor: pointer; display: inline-flex; align-items: center;
874
+ align-self: flex-start; gap: 4px; padding: 1px 6px; margin-left: -4px; border-radius: 6px;
875
+ border: 1px solid transparent; transition: background 0.12s, border-color 0.12s; }
876
+ .header .brand .t .proj-switch:hover,
877
+ .header .brand .t .proj-switch:focus-visible {
878
+ opacity: 1; background: rgba(255,255,255,0.16); border-color: rgba(255,255,255,0.28); outline: none; }
879
+ .proj-menu { position: fixed; z-index: 9999; min-width: 240px; max-width: 420px;
880
+ max-height: 60vh; overflow-y: auto; background: white; color: var(--ink);
881
+ border: 1px solid var(--border); border-radius: 8px;
882
+ box-shadow: 0 6px 24px rgba(0,0,0,0.18); padding: 4px; }
883
+ .proj-menu .proj-menu-title { font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
884
+ color: var(--ink-muted); padding: 6px 8px 2px; }
885
+ .proj-menu .proj-row { display: flex; flex-direction: column; gap: 1px;
886
+ padding: 6px 8px; border-radius: 6px; cursor: pointer; }
887
+ .proj-menu .proj-row:hover { background: var(--bg2, #f1efe7); }
888
+ .proj-menu .proj-row.on { background: rgba(80,90,160,0.08); }
889
+ .proj-menu .proj-row .proj-row-name { font-size: 13px; font-weight: 600; }
890
+ .proj-menu .proj-row .proj-row-path { font-size: 10px; color: var(--ink-muted);
891
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
892
+ .proj-menu .proj-add { color: var(--indigo); font-weight: 600; border-top: 1px solid var(--border-subtle); margin-top: 2px; }
816
893
  .header .actions { display: flex; gap: 4px; }
817
894
  .header .actions button {
818
895
  background: rgba(255,255,255,0.1); color: white;
@@ -1263,7 +1340,7 @@ body {
1263
1340
  /* ---- input bar ---- */
1264
1341
  .bar { border-top: 1px solid var(--border-subtle); background: white;
1265
1342
  padding: 8px; display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
1266
- .bar input { flex: 1; padding: 8px 10px;
1343
+ .bar input { flex: 1; min-width: 0; padding: 8px 10px;
1267
1344
  border: 1px solid var(--border); border-radius: 6px; font-size: 14px; font-family: inherit; }
1268
1345
  .bar input:focus { outline: none; border-color: var(--indigo); box-shadow: 0 0 0 3px rgba(79,91,135,0.12); }
1269
1346
  .bar .mic, .bar .send {
@@ -1284,9 +1361,25 @@ body {
1284
1361
  cursor: pointer; font-size: 14px;
1285
1362
  }
1286
1363
  .bar .tts-toggle.on { background: var(--indigo); color: white; }
1287
- .bar .send { background: var(--indigo); padding: 0 12px; width: auto; border-radius: 6px;
1288
- font-size: 13px; font-weight: 600; }
1364
+ .bar .send { background: var(--indigo); padding: 0 9px; width: auto; min-width: 0;
1365
+ flex-shrink: 0; white-space: nowrap; border-radius: 6px; font-size: 12px; font-weight: 600; }
1289
1366
  .bar .send:disabled { background: #aaa; cursor: not-allowed; }
1367
+ .bar .stop { background: var(--err, #c8472e); color: white; padding: 0 9px; width: auto;
1368
+ min-width: 0; flex-shrink: 0; white-space: nowrap; border-radius: 6px; font-size: 12px;
1369
+ font-weight: 600; border: none; cursor: pointer; height: 36px; }
1370
+
1371
+ /* ---- message queue (task #28) ---- */
1372
+ .queue { display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
1373
+ padding: 6px 12px 0; }
1374
+ .queue .queue-label { font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
1375
+ color: var(--ink-muted); margin-right: 2px; }
1376
+ .queue .queue-chip { display: inline-flex; align-items: center; gap: 5px;
1377
+ background: var(--bg2, #f1efe7); border: 1px solid var(--border); border-radius: 12px;
1378
+ padding: 2px 9px; font-size: 12px; color: var(--ink); cursor: pointer; max-width: 240px;
1379
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1380
+ .queue .queue-chip:hover { border-color: var(--err, #c8472e); }
1381
+ .queue .queue-x { color: var(--ink-muted); font-weight: 700; }
1382
+ .queue .queue-chip:hover .queue-x { color: var(--err, #c8472e); }
1290
1383
 
1291
1384
  /* ---- status row ---- */
1292
1385
  .status { font-size: 11px; color: var(--ink-muted); padding: 4px 12px; text-align: center; flex-shrink: 0; }
@@ -1713,7 +1806,7 @@ ${renderYujinSplash()}
1713
1806
  <div class="header">
1714
1807
  <div class="brand">
1715
1808
  <span class="k">${escapeHtml(tr.brandKanji)}</span>
1716
- <div class="t"><span class="name">Yujin Forge</span><span class="proj" id="proj-mini" data-nac-id="yujin.panel.project-mini"></span></div>
1809
+ <div class="t"><span class="name">Yujin Forge</span><span class="proj proj-switch" id="proj-mini" data-proj-switch role="button" tabindex="0" title="${escapeHtml(tr.projectSwitchHint)}" data-nac-id="yujin.panel.project-mini"></span></div>
1717
1810
  </div>
1718
1811
  <div class="actions">
1719
1812
  <button class="keys-btn" data-vault-open aria-label="${escapeHtml(tr.keysButton)}" title="${escapeHtml(tr.keysButton)}">${escapeHtml(tr.keysButton)}</button>
@@ -1723,12 +1816,14 @@ ${renderYujinSplash()}
1723
1816
  </div>
1724
1817
  <div id="stream-mini" data-nac-id="yujin.chat.stream-mini" class="stream"></div>
1725
1818
  <div class="status" id="status-mini" data-nac-id="yujin.chat.status-mini"></div>
1819
+ <div id="queue-mini" class="queue" style="display:none"></div>
1726
1820
  <form id="bar-mini" data-nac-id="yujin.chat.bar-mini" class="bar" onsubmit="return false">
1727
1821
  <button type="button" class="mic" data-mic data-mic-btn aria-label="${escapeHtml(tr.micButton)}" title="${escapeHtml(tr.micButton)}">🎙</button>
1728
1822
  <button type="button" class="tts-toggle" data-tts-toggle aria-label="${escapeHtml(tr.ttsButton)}" title="${escapeHtml(tr.ttsButton)}">🔊</button>
1729
1823
  <button type="button" class="clip" data-attach-clip aria-label="Adjuntar archivo" title="Adjuntar archivo">📎</button>
1730
1824
  <input type="text" placeholder="${escapeHtml(tr.inputPlaceholder)}" aria-label="${escapeHtml(tr.inputPlaceholder)}" autocomplete="off">
1731
1825
  <button type="submit" class="send">${escapeHtml(tr.sendButton)}</button>
1826
+ <button type="button" class="stop" data-stop aria-label="${escapeHtml(tr.stopButton)}" title="${escapeHtml(tr.stopButton)}" style="display:none">&#9632; ${escapeHtml(tr.stopButton)}</button>
1732
1827
  </form>
1733
1828
  <input type="file" id="attach-file-input" multiple style="display:none" data-nac-id="yujin.chat.attach-input">
1734
1829
  </div>
@@ -1883,7 +1978,7 @@ ${renderYujinSplash()}
1883
1978
  <div class="header">
1884
1979
  <div class="brand">
1885
1980
  <span class="k">${escapeHtml(tr.brandKanji)}</span>
1886
- <div class="t"><span class="name">Yujin Forge</span><span class="proj" id="proj-full" data-nac-id="yujin.panel.project-full"></span></div>
1981
+ <div class="t"><span class="name">Yujin Forge</span><span class="proj proj-switch" id="proj-full" data-proj-switch role="button" tabindex="0" title="${escapeHtml(tr.projectSwitchHint)}" data-nac-id="yujin.panel.project-full"></span></div>
1887
1982
  </div>
1888
1983
  <div class="actions">
1889
1984
  <button class="agents-btn"
@@ -2343,18 +2438,17 @@ ${renderYujinSplash()}
2343
2438
  </div>
2344
2439
  </div>
2345
2440
 
2346
- <!-- Vincular telefono (dogfood 2026-06-10) -- genera un QR que
2347
- Forge Mobile escanea; el usuario solo tipea el codigo. -->
2441
+ <!-- Vincular telefono (dogfood 2026-06-10; Option 3 2026-06-16 --
2442
+ code-less: el QR es autocontenido, se escanea y listo). -->
2348
2443
  <div class="set-section">
2349
2444
  <div class="set-section-title">Vincular telefono</div>
2350
2445
  <div class="set-row">
2351
- <span class="set-lbl" style="opacity:0.7;font-size:11px;">Abri Forge Mobile, escanea este QR y escribi el codigo.</span>
2446
+ <span class="set-lbl" style="opacity:0.7;font-size:11px;">Abri Forge Mobile, toca "Vincular" y escanea este QR. Listo.</span>
2352
2447
  <button type="button" class="set-btn" id="set-pair-generate">Generar QR</button>
2353
2448
  </div>
2354
2449
  <div id="set-pair-result" class="hidden" style="text-align:center;padding:8px 0;">
2355
- <div id="set-pair-qr" style="display:inline-block;background:#f5f0e6;padding:8px;border-radius:8px;width:200px;height:200px;"></div>
2356
- <div style="margin-top:8px;font-size:13px;">Codigo: <strong id="set-pair-code" style="font-size:18px;letter-spacing:3px;font-family:ui-monospace,Menlo,monospace;"></strong></div>
2357
- <div id="set-pair-expiry" style="font-size:11px;opacity:0.6;margin-top:2px;"></div>
2450
+ <div id="set-pair-qr" style="display:inline-block;background:#ffffff;padding:10px;border-radius:8px;width:220px;height:220px;"></div>
2451
+ <div id="set-pair-expiry" style="font-size:11px;opacity:0.6;margin-top:6px;"></div>
2358
2452
  </div>
2359
2453
  <div id="set-pair-error" class="hidden" style="font-size:12px;color:var(--err,#c0392b);padding:6px 0;"></div>
2360
2454
  </div>
@@ -2472,12 +2566,14 @@ ${renderYujinSplash()}
2472
2566
  <div class="chat-col">
2473
2567
  <div id="stream-full" data-nac-id="yujin.chat.stream-full" class="stream"></div>
2474
2568
  <div class="status" id="status-full" data-nac-id="yujin.chat.status-full"></div>
2569
+ <div id="queue-full" class="queue" style="display:none"></div>
2475
2570
  <form id="bar-full" data-nac-id="yujin.chat.bar-full" class="bar" onsubmit="return false">
2476
2571
  <button type="button" class="mic" data-mic data-mic-btn aria-label="${escapeHtml(tr.micButton)}" title="${escapeHtml(tr.micButton)}">🎙</button>
2477
2572
  <button type="button" class="tts-toggle" data-tts-toggle aria-label="${escapeHtml(tr.ttsButton)}" title="${escapeHtml(tr.ttsButton)}">🔊</button>
2478
2573
  <button type="button" class="clip" data-attach-clip aria-label="Adjuntar archivo" title="Adjuntar archivo">📎</button>
2479
2574
  <input type="text" placeholder="${escapeHtml(tr.inputPlaceholder)}" aria-label="${escapeHtml(tr.inputPlaceholder)}" autocomplete="off">
2480
2575
  <button type="submit" class="send">${escapeHtml(tr.sendButton)}</button>
2576
+ <button type="button" class="stop" data-stop aria-label="${escapeHtml(tr.stopButton)}" title="${escapeHtml(tr.stopButton)}" style="display:none">&#9632; ${escapeHtml(tr.stopButton)}</button>
2481
2577
  </form>
2482
2578
  </div>
2483
2579
  <div class="pizarra" data-nac-id="yujin.pizarron" data-nac-role="region">
@@ -2601,9 +2697,109 @@ function showMode(mode) {
2601
2697
  }
2602
2698
 
2603
2699
  function setProj() {
2604
- const txt = CONFIG.projectName + ' ' + 'v' + CONFIG.version;
2605
- $('#proj-mini').textContent = txt;
2606
- $('#proj-full').textContent = txt;
2700
+ /* Project selector (task #33) -- render the name + a caret so it reads
2701
+ * as a dropdown (not a passive version stamp). The version moves to the
2702
+ * tooltip. */
2703
+ const label = CONFIG.projectName + ' ▾';
2704
+ const title = CONFIG.projectName + ' v' + CONFIG.version + ' — ' + (CONFIG.i18n.projectSwitchHint || '');
2705
+ for (const id of ['#proj-mini', '#proj-full']) {
2706
+ const el = $(id);
2707
+ if (!el) continue;
2708
+ el.textContent = label;
2709
+ el.setAttribute('title', title);
2710
+ }
2711
+ }
2712
+
2713
+ /* Project selector (task #33) -- the project label in the header is a
2714
+ * switcher. Clicking it lists every known project (~/.yujin-forge/
2715
+ * projects.json) and switching HOT-reanchors the live session: the
2716
+ * pizarron, graph + startup tabs follow immediately. */
2717
+ function closeProjectMenu() {
2718
+ const m = document.getElementById('proj-menu');
2719
+ if (m && m.parentNode) m.parentNode.removeChild(m);
2720
+ document.removeEventListener('click', onProjMenuOutside, true);
2721
+ }
2722
+ function onProjMenuOutside(e) {
2723
+ const m = document.getElementById('proj-menu');
2724
+ if (m && !m.contains(e.target) && !(e.target.closest && e.target.closest('[data-proj-switch]'))) {
2725
+ closeProjectMenu();
2726
+ }
2727
+ }
2728
+ async function openProjectMenu(anchor) {
2729
+ if (document.getElementById('proj-menu')) { closeProjectMenu(); return; }
2730
+ let reg = { projects: [], active_slug: null };
2731
+ try {
2732
+ const r = await fetch('/api/forge/projects');
2733
+ const j = await r.json();
2734
+ if (j && j.ok && j.registry) reg = j.registry;
2735
+ else if (j && Array.isArray(j.projects)) reg = j;
2736
+ } catch (e) { /* show just the add option */ }
2737
+ const menu = document.createElement('div');
2738
+ menu.id = 'proj-menu';
2739
+ menu.className = 'proj-menu';
2740
+ const rows = (reg.projects || []).slice().sort(function (a, b) {
2741
+ return String(b.last_active_at || '').localeCompare(String(a.last_active_at || ''));
2742
+ }).map(function (p) {
2743
+ const active = p.slug === reg.active_slug;
2744
+ return '<div class="proj-row' + (active ? ' on' : '') + '" data-proj-slug="' + escapeHtml(p.slug) + '" role="button" tabindex="0">'
2745
+ + '<span class="proj-row-name">' + (active ? '&#9679; ' : '') + escapeHtml(p.slug) + '</span>'
2746
+ + '<span class="proj-row-path">' + escapeHtml(String(p.path || '')) + '</span>'
2747
+ + '</div>';
2748
+ }).join('');
2749
+ menu.innerHTML = '<div class="proj-menu-title">' + escapeHtml(CONFIG.i18n.projectTitle) + '</div>'
2750
+ + (rows || '')
2751
+ + '<div class="proj-row proj-add" data-proj-add role="button" tabindex="0">' + escapeHtml(CONFIG.i18n.projectAdd) + '</div>';
2752
+ document.body.appendChild(menu);
2753
+ /* position under the anchor */
2754
+ try {
2755
+ const rect = anchor.getBoundingClientRect();
2756
+ menu.style.top = (rect.bottom + 4) + 'px';
2757
+ menu.style.left = Math.max(8, rect.left) + 'px';
2758
+ } catch (e) { /* default top-left */ }
2759
+ menu.querySelectorAll('[data-proj-slug]').forEach(function (row) {
2760
+ row.addEventListener('click', function () { switchProject(row.getAttribute('data-proj-slug')); });
2761
+ });
2762
+ const addRow = menu.querySelector('[data-proj-add]');
2763
+ if (addRow) addRow.addEventListener('click', addProjectViaPrompt);
2764
+ setTimeout(function () { document.addEventListener('click', onProjMenuOutside, true); }, 0);
2765
+ }
2766
+ async function switchProject(slug) {
2767
+ if (!slug) return;
2768
+ closeProjectMenu();
2769
+ setStatus(CONFIG.i18n.projectSwitching);
2770
+ try {
2771
+ const r = await fetch('/api/forge/projects/active', {
2772
+ method: 'POST', headers: { 'content-type': 'application/json' },
2773
+ body: JSON.stringify({ slug: slug }),
2774
+ });
2775
+ const j = await r.json();
2776
+ if (j && j.ok) {
2777
+ /* optimistic label update; the system.reanchor event reloads the
2778
+ * startup tabs + the graph follows server-side. */
2779
+ CONFIG.projectName = slug;
2780
+ setProj();
2781
+ setStatus('');
2782
+ } else {
2783
+ setStatus((j && j.error) || 'No se pudo cambiar de proyecto.', true);
2784
+ }
2785
+ } catch (e) {
2786
+ setStatus('No se pudo cambiar de proyecto.', true);
2787
+ }
2788
+ }
2789
+ async function addProjectViaPrompt() {
2790
+ closeProjectMenu();
2791
+ const path = prompt(CONFIG.i18n.projectAddPrompt, '');
2792
+ if (!path || !path.trim()) return;
2793
+ try {
2794
+ const r = await fetch('/api/forge/projects/add', {
2795
+ method: 'POST', headers: { 'content-type': 'application/json' },
2796
+ body: JSON.stringify({ path: path.trim() }),
2797
+ });
2798
+ const j = await r.json();
2799
+ if (!j || !j.ok) { setStatus((j && j.error) || 'No se pudo anadir el proyecto.', true); return; }
2800
+ /* reopen the menu so the user can pick the freshly added project */
2801
+ openProjectMenu($('#proj-full') || $('#proj-mini'));
2802
+ } catch (e) { setStatus('No se pudo anadir el proyecto.', true); }
2607
2803
  }
2608
2804
 
2609
2805
  function escapeHtml(s) {
@@ -3524,10 +3720,8 @@ async function pairGenerate() {
3524
3720
  const j = await r.json();
3525
3721
  if (j && j.ok) {
3526
3722
  const qr = document.getElementById('set-pair-qr');
3527
- const code = document.getElementById('set-pair-code');
3528
3723
  const exp = document.getElementById('set-pair-expiry');
3529
3724
  if (qr) qr.innerHTML = j.qr_svg || '';
3530
- if (code) code.textContent = j.code || '';
3531
3725
  if (exp && j.expires_at) {
3532
3726
  const mins = Math.max(0, Math.round((new Date(j.expires_at).getTime() - Date.now()) / 60000));
3533
3727
  exp.textContent = 'Expira en ~' + mins + ' min. Generalo de nuevo si caduca.';
@@ -4677,8 +4871,23 @@ function renderPizarronCardBody(it) {
4677
4871
  * estetica sumi-e. El modelo manda markdown (seguro: no se
4678
4872
  * ejecuta HTML/JS), el panel le da el look de documento. */
4679
4873
  if (it.kind === 'doc') {
4874
+ /* Default de documentos = HTML (pedido de Pablo). El brain genera
4875
+ * HTML por defecto; se renderiza sanitizado DENTRO de la hoja Yujin.
4876
+ * Backward-compat: si el content es markdown (no empieza con un tag
4877
+ * de bloque), cae a renderMdRich, asi los docs viejos siguen bien. */
4878
+ const dc = String(it.content || '');
4879
+ /* Pablo 2026-06-15 -- el heuristico viejo solo miraba si EMPEZABA
4880
+ * con un tag de bloque, asi que un doc HTML con un \n o una linea
4881
+ * de texto antes del primer tag caia a markdown y se veia "feo"
4882
+ * (tags como texto). Ahora: es HTML si empieza con un tag de bloque
4883
+ * O si contiene un tag de CIERRE de bloque en cualquier parte (senal
4884
+ * fuerte que el markdown no tiene). */
4885
+ const looksHtml =
4886
+ /^\\s*<(?:!doctype|html|head|body|div|section|article|main|header|h[1-6]|p|ul|ol|table|figure|blockquote|pre)\\b/i.test(dc)
4887
+ || /<\\/(?:h[1-6]|div|section|article|main|header|body|html|p|ul|ol|li|table|tr|td|figure|blockquote|pre)\\s*>/i.test(dc);
4888
+ const inner = looksHtml ? sanitizeHtml(dc) : renderMdRich(dc);
4680
4889
  return '<div class="pz-body pz-doc-wrap" data-nac-id="yujin.pizarron.item.' + it.id + '.body" data-nac-role="region" data-nac-action="scroll" tabindex="0">'
4681
- + '<article class="pz-doc-page pz-sumi">' + renderMdRich(it.content) + '</article>'
4890
+ + '<article class="pz-doc-page pz-sumi">' + inner + '</article>'
4682
4891
  + '</div>';
4683
4892
  }
4684
4893
  /* Lienzo Coding (dogfood 2026-06-10) -- mientras el brain codea
@@ -4697,6 +4906,17 @@ function renderPizarronCardBody(it) {
4697
4906
  : '<div class="pz-coding-detail" data-pz-coding-detail>' + renderCodingDetail() + '</div>')
4698
4907
  + '</div>';
4699
4908
  }
4909
+ /* PND-007 -- Tareas: tareas de fondo que sobreviven al turno. Se
4910
+ * llena en vivo en initTasksTab() (poll a /api/forge/task). */
4911
+ if (it.kind === 'tasks') {
4912
+ return '<div class="pz-body pz-tasks-wrap pz-sumi" data-nac-id="yujin.pizarron.item.' + it.id + '.body" data-nac-role="region" tabindex="0">'
4913
+ + '<div class="pz-tasks-new">'
4914
+ + '<input type="text" class="pz-tasks-input" data-pz-tasks-cmd placeholder="comando (ej: pnpm install)..." />'
4915
+ + '<button type="button" class="pz-tasks-run" data-pz-tasks-run>Lanzar</button>'
4916
+ + '</div>'
4917
+ + '<div class="pz-tasks-list" data-pz-tasks-list><div class="pz-tasks-empty">cargando...</div></div>'
4918
+ + '</div>';
4919
+ }
4700
4920
  /* PND-094 -- vista de workflow: fases con nombre (nivel 1) + sus
4701
4921
  * tareas debajo (nivel 2), con avance por tarea y por fase. */
4702
4922
  if (it.kind === 'workflow') {
@@ -4728,9 +4948,15 @@ function renderPizarronCardBody(it) {
4728
4948
  }
4729
4949
  if (it.kind === 'bpmn' || it.kind === 'flow') {
4730
4950
  const trimmed = it.content.trim();
4951
+ /* SVG crudo -> passthrough (compat). */
4731
4952
  if (trimmed.startsWith('<svg')) {
4732
4953
  return bodyOpen + sanitizeHtml(trimmed) + '</div>';
4733
4954
  }
4955
+ /* Modelo JSON {nodes,flows,lanes?} -> renderer BPMN 2.0 propio. */
4956
+ if (trimmed.charAt(0) === '{') {
4957
+ return '<div class="pz-body pz-bpmn-wrap" data-nac-id="yujin.pizarron.item.' + it.id + '.body" data-nac-role="region" data-nac-action="scroll" tabindex="0">'
4958
+ + renderBpmnView(trimmed) + '</div>';
4959
+ }
4734
4960
  return bodyOpen + '<pre class="pz-code"><code>' + escapeHtml(it.content) + '</code></pre></div>';
4735
4961
  }
4736
4962
  return bodyOpen + '<pre><code>' + escapeHtml(it.content) + '</code></pre></div>';
@@ -4821,6 +5047,188 @@ function renderPizarron() {
4821
5047
  if (it.kind === 'coding') {
4822
5048
  try { initCodingTab(it); } catch (e) { /* render best-effort */ }
4823
5049
  }
5050
+ if (it.kind === 'tasks') {
5051
+ try { initTasksTab(it); } catch (e) { /* render best-effort */ }
5052
+ }
5053
+ if (it.kind === 'workflow') {
5054
+ try { initWorkflowTab(it); } catch (e) { /* render best-effort */ }
5055
+ }
5056
+ }
5057
+
5058
+ /* Selector multi-workflow: persiste el indice elegido por tab id (igual
5059
+ * que el filtro del grafo) para que un refresh del board no resetee la
5060
+ * seleccion. Con un solo workflow no hay barra de chips: no-op. */
5061
+ var _wflSelByTab = {};
5062
+ function initWorkflowTab(it) {
5063
+ var body = document.querySelector('[data-nac-id="yujin.pizarron.item.' + it.id + '.body"]');
5064
+ if (!body) return;
5065
+ var root = body.querySelector('[data-pz-wfl-root]');
5066
+ if (!root) return;
5067
+ var chips = root.querySelectorAll('[data-pz-wfl-chip]');
5068
+ var cards = root.querySelectorAll('[data-pz-wfl-card]');
5069
+ if (chips.length <= 1) return; /* un solo workflow: sin selector */
5070
+ var show = function (idx) {
5071
+ for (var i = 0; i < cards.length; i++) {
5072
+ cards[i].style.display = (String(cards[i].getAttribute('data-wfl-idx')) === String(idx)) ? '' : 'none';
5073
+ }
5074
+ for (var j = 0; j < chips.length; j++) {
5075
+ var on = String(chips[j].getAttribute('data-wfl-idx')) === String(idx);
5076
+ if (on) chips[j].classList.add('active'); else chips[j].classList.remove('active');
5077
+ }
5078
+ _wflSelByTab[it.id] = String(idx);
5079
+ };
5080
+ for (var k = 0; k < chips.length; k++) {
5081
+ chips[k].addEventListener('click', function () { show(this.getAttribute('data-wfl-idx')); });
5082
+ }
5083
+ var saved = _wflSelByTab[it.id];
5084
+ show(saved != null ? saved : '0');
5085
+ }
5086
+
5087
+ /* ====================================================================
5088
+ * PND-007 -- Tareas tab. Polls /api/forge/task; renders survivable
5089
+ * background tasks with a per-type icon, status, a log peek + kill, and
5090
+ * a small launcher. Each task TYPE (install/build/server/...) gets its
5091
+ * own sumi-e glyph.
5092
+ * ================================================================== */
5093
+ var yfTasksTimer = null;
5094
+
5095
+ function taskTypeIcon(type) {
5096
+ switch (String(type || 'other')) {
5097
+ case 'install': return { g: '↓', c: '#4f5b87' }; /* down arrow */
5098
+ case 'build': return { g: '⚒', c: '#c8a04d' }; /* hammer+pick */
5099
+ case 'server': return { g: '◉', c: '#3fa85e' }; /* fisheye */
5100
+ case 'test': return { g: '✓', c: '#6f8b5e' }; /* check */
5101
+ case 'deploy': return { g: '☁', c: '#8a7bb0' }; /* cloud */
5102
+ case 'git': return { g: '⎇', c: '#b07b4f' }; /* alternation */
5103
+ case 'script': return { g: '›', c: '#7a8aa0' }; /* chevron */
5104
+ default: return { g: '●', c: '#9a9a9a' }; /* disc */
5105
+ }
5106
+ }
5107
+
5108
+ function taskStatusBadge(s) {
5109
+ if (s === 'running') return { t: 'corriendo', c: '#c8a04d' };
5110
+ if (s === 'done') return { t: 'terminada', c: '#6f8b5e' };
5111
+ if (s === 'failed') return { t: 'fallo', c: '#c8472e' };
5112
+ if (s === 'killed') return { t: 'detenida', c: '#9a9a9a' };
5113
+ if (s === 'exited') return { t: 'salio', c: '#9a9a9a' };
5114
+ return { t: String(s || ''), c: '#9a9a9a' };
5115
+ }
5116
+
5117
+ function renderTasksList(tasks) {
5118
+ if (!tasks || tasks.length === 0) {
5119
+ return '<div class="pz-tasks-empty">Sin tareas de fondo. Lo que lances aca sobrevive al turno.</div>';
5120
+ }
5121
+ var html = '';
5122
+ for (var i = 0; i < tasks.length; i++) {
5123
+ var t = tasks[i];
5124
+ var ic = taskTypeIcon(t.type);
5125
+ var sb = taskStatusBadge(t.status);
5126
+ var cmdline = escapeHtml(String(t.cmd || '') + (t.args && t.args.length ? ' ' + t.args.join(' ') : '')).slice(0, 200);
5127
+ html += '<div class="pz-task" data-pz-task="' + escapeHtml(t.id) + '">'
5128
+ + '<div class="pz-task-ic" style="color:' + ic.c + '">' + ic.g + '</div>'
5129
+ + '<div class="pz-task-main">'
5130
+ + '<div class="pz-task-label">' + escapeHtml(String(t.label || t.cmd || 'tarea')) + '</div>'
5131
+ + '<div class="pz-task-cmd">' + cmdline + '</div>'
5132
+ + '</div>'
5133
+ + '<div class="pz-task-side">'
5134
+ + '<span class="pz-task-badge" style="color:' + sb.c + ';border-color:' + sb.c + '">' + sb.t + '</span>'
5135
+ + (t.status === 'running'
5136
+ ? '<button type="button" class="pz-task-kill" data-pz-task-kill="' + escapeHtml(t.id) + '">stop</button>'
5137
+ : '<button type="button" class="pz-task-log" data-pz-task-log="' + escapeHtml(t.id) + '">log</button>')
5138
+ + '<button type="button" class="pz-task-del" data-pz-task-del="' + escapeHtml(t.id) + '" title="Eliminar tarea" aria-label="Eliminar tarea">&times;</button>'
5139
+ + '</div>'
5140
+ + '</div>'
5141
+ + '<div class="pz-task-logbox" data-pz-task-logbox="' + escapeHtml(t.id) + '"></div>';
5142
+ }
5143
+ return html;
5144
+ }
5145
+
5146
+ async function refreshTasksTab() {
5147
+ var list = document.querySelector('[data-pz-tasks-list]');
5148
+ if (!list) return; /* tab closed -> stop touching the DOM */
5149
+ try {
5150
+ var r = await fetch('/api/forge/task');
5151
+ var data = await r.json();
5152
+ if (data && data.ok) list.innerHTML = renderTasksList(data.tasks || []);
5153
+ } catch (e) { /* best-effort */ }
5154
+ }
5155
+
5156
+ async function tasksTabLaunch() {
5157
+ var inp = document.querySelector('[data-pz-tasks-cmd]');
5158
+ if (!inp) return;
5159
+ var raw = String(inp.value || '').trim();
5160
+ if (!raw) return;
5161
+ /* split a simple "cmd arg arg" string (no shell parsing).
5162
+ * NOTE: this code rides inside the panel's outer template literal, so
5163
+ * a regex backslash must be DOUBLED -- /\s+/ would be mangled to /s+/
5164
+ * (split on the letter 's': "pnpm install" -> "pnpm in"+"tall"). */
5165
+ var parts = raw.split(/\\s+/);
5166
+ var cmd = parts.shift();
5167
+ try {
5168
+ await fetch('/api/forge/task', {
5169
+ method: 'POST', headers: { 'content-type': 'application/json' },
5170
+ body: JSON.stringify({ cmd: cmd, args: parts, label: raw.slice(0, 60) }),
5171
+ });
5172
+ inp.value = '';
5173
+ await refreshTasksTab();
5174
+ } catch (e) { /* best-effort */ }
5175
+ }
5176
+
5177
+ async function tasksTabShowLog(id) {
5178
+ var box = document.querySelector('[data-pz-task-logbox="' + id + '"]');
5179
+ if (!box) return;
5180
+ if (box.getAttribute('data-open') === '1') { box.innerHTML = ''; box.removeAttribute('data-open'); return; }
5181
+ try {
5182
+ var r = await fetch('/api/forge/task/log?id=' + encodeURIComponent(id) + '&lines=40');
5183
+ var data = await r.json();
5184
+ box.innerHTML = '<pre class="pz-task-logpre">' + escapeHtml(String((data && data.tail) || '(sin salida)')) + '</pre>';
5185
+ box.setAttribute('data-open', '1');
5186
+ } catch (e) { /* best-effort */ }
5187
+ }
5188
+
5189
+ async function tasksTabKill(id) {
5190
+ try {
5191
+ await fetch('/api/forge/task/kill', {
5192
+ method: 'POST', headers: { 'content-type': 'application/json' },
5193
+ body: JSON.stringify({ id: id }),
5194
+ });
5195
+ await refreshTasksTab();
5196
+ } catch (e) { /* best-effort */ }
5197
+ }
5198
+
5199
+ async function tasksTabDelete(id) {
5200
+ /* Remove the task from the list (kills it first if still running). */
5201
+ try {
5202
+ await fetch('/api/forge/task/delete', {
5203
+ method: 'POST', headers: { 'content-type': 'application/json' },
5204
+ body: JSON.stringify({ id: id }),
5205
+ });
5206
+ await refreshTasksTab();
5207
+ } catch (e) { /* best-effort */ }
5208
+ }
5209
+
5210
+ function initTasksTab(it) {
5211
+ var wrap = document.querySelector('.pz-tasks-wrap');
5212
+ if (!wrap) return;
5213
+ var runBtn = wrap.querySelector('[data-pz-tasks-run]');
5214
+ if (runBtn) runBtn.addEventListener('click', function () { tasksTabLaunch().catch(function () {}); });
5215
+ var inp = wrap.querySelector('[data-pz-tasks-cmd]');
5216
+ if (inp) inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') tasksTabLaunch().catch(function () {}); });
5217
+ /* delegated clicks for per-task log/kill */
5218
+ wrap.addEventListener('click', function (e) {
5219
+ var tgt = e.target;
5220
+ if (!tgt || !tgt.getAttribute) return;
5221
+ var killId = tgt.getAttribute('data-pz-task-kill');
5222
+ if (killId) { tasksTabKill(killId).catch(function () {}); return; }
5223
+ var delId = tgt.getAttribute('data-pz-task-del');
5224
+ if (delId) { tasksTabDelete(delId).catch(function () {}); return; }
5225
+ var logId = tgt.getAttribute('data-pz-task-log');
5226
+ if (logId) { tasksTabShowLog(logId).catch(function () {}); return; }
5227
+ });
5228
+ refreshTasksTab().catch(function () {});
5229
+ if (!yfTasksTimer) {
5230
+ yfTasksTimer = setInterval(function () { refreshTasksTab().catch(function () {}); }, 4000);
5231
+ }
4824
5232
  }
4825
5233
 
4826
5234
  /* ====================================================================
@@ -4842,6 +5250,68 @@ function renderPizarron() {
4842
5250
  function renderWorkflowView(content) {
4843
5251
  let v;
4844
5252
  try { v = JSON.parse(content || '{}'); } catch (e) { v = null; }
5253
+ /* PND-094 ext -- multi-workflow: content { workflows: [card...] }. Each
5254
+ * card = one coexisting WorkflowInstance with its stages -> ordered
5255
+ * task pendings. Falls through to the legacy single-workflow render
5256
+ * below when content is the old { stages, ... } shape. */
5257
+ if (v && Array.isArray(v.workflows)) {
5258
+ if (v.workflows.length === 0) return '<div class="pz-wfl-empty">Sin workflows abiertos.</div>';
5259
+ var kindLbl = { greenfield: 'App nueva', corrective: 'Correctivo', evolutive: 'Evolutivo' };
5260
+ var ic2 = function (closed) { return closed ? '✓' : '○'; };
5261
+ var multi = v.workflows.length > 1;
5262
+ /* Selector entre los workflows abiertos: con >1 se muestra una barra
5263
+ * de chips (uno por workflow) y SOLO se ve el seleccionado. El estado
5264
+ * lo persiste initWorkflowTab por tab (sobrevive a los refreshes). */
5265
+ var sel = '';
5266
+ if (multi) {
5267
+ sel = '<div class="pz-wfl-sel" data-pz-wfl-sel>';
5268
+ for (var ci = 0; ci < v.workflows.length; ci++) {
5269
+ var cw = v.workflows[ci];
5270
+ sel += '<button type="button" class="pz-wfl-chip' + (ci === 0 ? ' active' : '') + '"'
5271
+ + ' data-pz-wfl-chip data-wfl-idx="' + ci + '">'
5272
+ + escapeHtml(cw.code) + ' &middot; ' + escapeHtml(cw.name || '') + '</button>';
5273
+ }
5274
+ sel += '</div>';
5275
+ }
5276
+ var out = '<div data-pz-wfl-root>' + sel;
5277
+ for (var wi = 0; wi < v.workflows.length; wi++) {
5278
+ var wf = v.workflows[wi];
5279
+ var wpct = wf.total > 0 ? Math.round((wf.done / wf.total) * 100) : 0;
5280
+ out += '<div class="pz-wfl-card" data-pz-wfl-card data-wfl-idx="' + wi + '"'
5281
+ + (multi && wi !== 0 ? ' style="display:none"' : '') + '>'
5282
+ + '<div class="pz-wfl-head">'
5283
+ + '<div class="pz-wfl-title">' + escapeHtml(wf.code + ' -- ' + (wf.name || '')) + '</div>'
5284
+ + '<div class="pz-wfl-kind">' + escapeHtml(kindLbl[wf.kind] || wf.kind || '') + (wf.status === 'closed' ? ' (cerrado)' : '') + '</div>'
5285
+ + '<div class="pz-wfl-overall">' + (wf.done || 0) + '/' + (wf.total || 0) + ' tareas &middot; ' + wpct + '%</div>'
5286
+ + '<div class="pz-wfl-bar"><div class="pz-wfl-fill" style="width:' + wpct + '%"></div></div>'
5287
+ + '</div>';
5288
+ out += '<div class="pz-wfl-flow">';
5289
+ for (var si2 = 0; si2 < wf.stages.length; si2++) {
5290
+ var st2 = wf.stages[si2];
5291
+ var tasks2 = st2.tasks || [];
5292
+ var sdone = 0; for (var ti = 0; ti < tasks2.length; ti++) if (tasks2[ti].closed) sdone++;
5293
+ var sst = tasks2.length === 0 ? 'pending' : (sdone === tasks2.length ? 'done' : (sdone > 0 ? 'active' : 'pending'));
5294
+ var last2 = si2 === wf.stages.length - 1;
5295
+ out += '<div class="pz-wfl-col">'
5296
+ + '<div class="pz-wfl-phase wfl-' + sst + (last2 ? ' wfl-last' : '') + '">'
5297
+ + '<div class="pz-wfl-phase-title">' + escapeHtml(st2.title || st2.id || '') + '</div>'
5298
+ + '<div class="pz-wfl-phase-count">' + sdone + '/' + tasks2.length + '</div>'
5299
+ + '</div><div class="pz-wfl-hang">';
5300
+ for (var k2 = 0; k2 < tasks2.length; k2++) {
5301
+ var tk = tasks2[k2];
5302
+ out += '<div class="pz-wfl-task wfl-' + (tk.closed ? 'done' : 'pending') + '" title="' + escapeHtml(tk.id) + '">'
5303
+ + '<span class="pz-wfl-check">' + ic2(tk.closed) + '</span>'
5304
+ + '<span class="pz-wfl-task-title">' + escapeHtml((tk.id ? tk.id + ' ' : '') + (tk.title || '')) + (tk.ad_hoc ? ' <span class="pz-wfl-adhoc">+</span>' : '') + '</span>'
5305
+ + '</div>';
5306
+ }
5307
+ if (tasks2.length === 0) out += '<div class="pz-wfl-task wfl-pending pz-wfl-empty-stage">sin tareas</div>';
5308
+ out += '</div></div>';
5309
+ }
5310
+ out += '</div></div>';
5311
+ }
5312
+ out += '</div>';
5313
+ return out;
5314
+ }
4845
5315
  if (!v || !Array.isArray(v.stages)) {
4846
5316
  return '<div class="pz-wfl-empty">Sin estado de workflow todavia.</div>';
4847
5317
  }
@@ -4882,6 +5352,161 @@ function renderWorkflowView(content) {
4882
5352
  return h;
4883
5353
  }
4884
5354
 
5355
+ /* Renderer BPMN 2.0 (pestana canonica de flujos). El content es un
5356
+ * modelo JSON { lanes?:[{id,label}], nodes:[{id,type,label,lane?,
5357
+ * gateway?}], flows:[{from,to,label?}] }. Layout en capas izq->der
5358
+ * (longest-path), shapes BPMN (eventos=circulos, tareas=rect
5359
+ * redondeado, gateways=rombo con simbolo), flujos ortogonales con
5360
+ * flecha + label, swimlanes opcionales. Estatico, estetica sumi-e. */
5361
+ function bpmnWrap(s, maxChars, maxLines) {
5362
+ var words = String(s == null ? '' : s).split(/\s+/);
5363
+ var lines = []; var cur = '';
5364
+ for (var i = 0; i < words.length; i++) {
5365
+ var w = words[i];
5366
+ if (!cur) { cur = w; }
5367
+ else if ((cur + ' ' + w).length <= maxChars) { cur += ' ' + w; }
5368
+ else { lines.push(cur); cur = w; if (lines.length === maxLines - 1) break; }
5369
+ }
5370
+ if (cur && lines.length < maxLines) lines.push(cur);
5371
+ if (lines.length === maxLines && (cur || words.length > 0)) {
5372
+ var last = lines[maxLines - 1];
5373
+ if (last && last.length > maxChars) lines[maxLines - 1] = last.slice(0, maxChars - 1) + '…';
5374
+ }
5375
+ return lines;
5376
+ }
5377
+
5378
+ function renderBpmnView(content) {
5379
+ var m;
5380
+ try { m = JSON.parse(content); } catch (e) {
5381
+ return '<div class="pz-bpmn-empty">El flujo BPMN espera JSON { nodes, flows, lanes? }. No pude parsear el content.</div>';
5382
+ }
5383
+ if (!m || !Array.isArray(m.nodes) || m.nodes.length === 0) {
5384
+ return '<div class="pz-bpmn-empty">Sin nodos en el flujo.</div>';
5385
+ }
5386
+ var nodes = m.nodes.filter(function (n) { return n && n.id; });
5387
+ var flows = Array.isArray(m.flows) ? m.flows.filter(function (f) { return f && f.from && f.to; }) : [];
5388
+ var lanes = Array.isArray(m.lanes) ? m.lanes.filter(function (l) { return l && l.id; }) : [];
5389
+ var byId = {}; nodes.forEach(function (n) { byId[n.id] = n; });
5390
+ /* capas: longest-path desde las fuentes (DAG; cap por #nodos si hay ciclos). */
5391
+ var layer = {}; nodes.forEach(function (n) { layer[n.id] = 0; });
5392
+ for (var it = 0; it < nodes.length; it++) {
5393
+ var changed = false;
5394
+ for (var k = 0; k < flows.length; k++) {
5395
+ var f = flows[k];
5396
+ if (byId[f.from] && byId[f.to] && layer[f.to] < layer[f.from] + 1) { layer[f.to] = layer[f.from] + 1; changed = true; }
5397
+ }
5398
+ if (!changed) break;
5399
+ }
5400
+ var maxCol = 0; nodes.forEach(function (n) { if (layer[n.id] > maxCol) maxCol = layer[n.id]; });
5401
+ var hasLanes = lanes.length > 0;
5402
+ var laneIdx = {}; lanes.forEach(function (l, i) { laneIdx[l.id] = i; });
5403
+ /* dimensiones */
5404
+ var COL_W = 178, LANE_H = 116, ROW_H = 96, PAD = 30, LANE_LBL = hasLanes ? 30 : 0, TOPPAD = hasLanes ? 8 : 26;
5405
+ /* posiciones (centro de cada nodo) */
5406
+ var pos = {};
5407
+ if (hasLanes) {
5408
+ var subCount = {}; /* nodos por (lane,col) para apilar */
5409
+ nodes.forEach(function (n) {
5410
+ var li = (n.lane && laneIdx[n.lane] != null) ? laneIdx[n.lane] : 0;
5411
+ var key = li + ':' + layer[n.id];
5412
+ var sub = subCount[key] || 0; subCount[key] = sub + 1;
5413
+ pos[n.id] = { li: li, col: layer[n.id], sub: sub };
5414
+ });
5415
+ nodes.forEach(function (n) {
5416
+ var p = pos[n.id]; var key = p.li + ':' + p.col; var cnt = subCount[key];
5417
+ var bandTop = TOPPAD + p.li * LANE_H;
5418
+ n._x = PAD + LANE_LBL + p.col * COL_W + COL_W / 2;
5419
+ n._y = bandTop + (p.sub + 1) * (LANE_H / (cnt + 1));
5420
+ });
5421
+ } else {
5422
+ var cols = {}; nodes.forEach(function (n) { (cols[layer[n.id]] = cols[layer[n.id]] || []).push(n); });
5423
+ var maxRows = 0; for (var c in cols) if (cols[c].length > maxRows) maxRows = cols[c].length;
5424
+ var diagramH = maxRows * ROW_H;
5425
+ for (var cc = 0; cc <= maxCol; cc++) {
5426
+ var arr = cols[cc] || [];
5427
+ var startY = TOPPAD + (diagramH - arr.length * ROW_H) / 2;
5428
+ arr.forEach(function (n, i) {
5429
+ n._x = PAD + cc * COL_W + COL_W / 2;
5430
+ n._y = startY + i * ROW_H + ROW_H / 2;
5431
+ });
5432
+ }
5433
+ }
5434
+ var W = PAD + LANE_LBL + (maxCol + 1) * COL_W + PAD;
5435
+ var H = hasLanes ? (TOPPAD + lanes.length * LANE_H + PAD) : (TOPPAD + (function () { var mx = 0; nodes.forEach(function (n) { if (n._y > mx) mx = n._y; }); return mx + ROW_H / 2; })());
5436
+ var P = { ink: '#1a1a1a', indigo: '#4f5b87', amber: '#c8a04d', tea: '#6f8b5e', vermilion: '#c8472e', line: '#b8ad95', washi: '#f5f0e6', bg2: '#ece6d8' };
5437
+ /* Auto-resize fix (Pablo 2026-06-16): a wide-flat flow (W >> H) used to
5438
+ * be scaled DOWN to the tab width (width:100%), shrinking the nodes to an
5439
+ * unreadable band with dead space below. min-width = the intrinsic width
5440
+ * keeps nodes at their designed size: a narrow flow still fills the width
5441
+ * (scaled up), a wide one renders 1:1 and the container scrolls. */
5442
+ var svg = '<svg class="pz-bpmn-svg" viewBox="0 0 ' + Math.round(W) + ' ' + Math.round(H) + '"'
5443
+ + ' style="min-width:' + Math.round(W) + 'px"'
5444
+ + ' preserveAspectRatio="xMinYMin meet" xmlns="http://www.w3.org/2000/svg">';
5445
+ svg += '<defs><marker id="bpmn-arrow" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto" markerUnits="strokeWidth">'
5446
+ + '<path d="M0,0 L7,3 L0,6 z" fill="' + P.ink + '"/></marker></defs>';
5447
+ /* swimlanes */
5448
+ if (hasLanes) {
5449
+ for (var li2 = 0; li2 < lanes.length; li2++) {
5450
+ var ly = TOPPAD + li2 * LANE_H;
5451
+ svg += '<rect x="' + PAD + '" y="' + ly + '" width="' + (W - 2 * PAD) + '" height="' + LANE_H + '" fill="' + (li2 % 2 ? 'rgba(0,0,0,0.025)' : 'transparent') + '" stroke="' + P.line + '" stroke-width="1"/>';
5452
+ svg += '<text x="' + (PAD + 14) + '" y="' + (ly + LANE_H / 2) + '" transform="rotate(-90 ' + (PAD + 14) + ' ' + (ly + LANE_H / 2) + ')" text-anchor="middle" font-size="11" fill="' + P.indigo + '" font-weight="600">' + escapeHtml(String(lanes[li2].label || lanes[li2].id)) + '</text>';
5453
+ }
5454
+ }
5455
+ /* flujos (debajo de los nodos) */
5456
+ flows.forEach(function (f) {
5457
+ var a = byId[f.from], b = byId[f.to]; if (!a || !b) return;
5458
+ var ar = bpmnHalf(a), br = bpmnHalf(b);
5459
+ var sx, sy = a._y, tx, ty = b._y;
5460
+ var forward = (b._x >= a._x);
5461
+ sx = a._x + (forward ? ar : -ar); tx = b._x - (forward ? br : -br);
5462
+ var midx = (sx + tx) / 2;
5463
+ var d;
5464
+ if (Math.abs(sy - ty) < 2) { d = 'M' + sx + ',' + sy + ' L' + tx + ',' + ty; }
5465
+ else if (forward) { d = 'M' + sx + ',' + sy + ' L' + midx + ',' + sy + ' L' + midx + ',' + ty + ' L' + tx + ',' + ty; }
5466
+ else { /* loop hacia atras: por abajo */ var down = Math.max(a._y, b._y) + 46; d = 'M' + (a._x) + ',' + (a._y + ar) + ' L' + a._x + ',' + down + ' L' + b._x + ',' + down + ' L' + b._x + ',' + (b._y + br); }
5467
+ svg += '<path d="' + d + '" fill="none" stroke="' + P.ink + '" stroke-width="1.4" marker-end="url(#bpmn-arrow)" opacity="0.8"/>';
5468
+ if (f.label) {
5469
+ var lx = forward ? midx : ((a._x + b._x) / 2), lyy = forward ? (sy + ty) / 2 - 4 : (Math.max(a._y, b._y) + 42);
5470
+ svg += '<rect x="' + (lx - String(f.label).length * 3.1 - 3) + '" y="' + (lyy - 9) + '" width="' + (String(f.label).length * 6.2 + 6) + '" height="13" fill="' + P.washi + '" opacity="0.9"/>';
5471
+ svg += '<text x="' + lx + '" y="' + (lyy + 1) + '" text-anchor="middle" font-size="10" fill="' + P.ink + '">' + escapeHtml(String(f.label)) + '</text>';
5472
+ }
5473
+ });
5474
+ /* nodos */
5475
+ nodes.forEach(function (n) {
5476
+ var x = n._x, y = n._y, type = String(n.type || 'task');
5477
+ if (/start|^event$|end|intermediate/.test(type)) {
5478
+ var r = 18, sw = type.indexOf('end') >= 0 ? 3.4 : 1.6;
5479
+ var stroke = type.indexOf('start') >= 0 ? P.tea : (type.indexOf('end') >= 0 ? P.vermilion : P.indigo);
5480
+ svg += '<circle cx="' + x + '" cy="' + y + '" r="' + r + '" fill="' + P.washi + '" stroke="' + stroke + '" stroke-width="' + sw + '"/>';
5481
+ if (type.indexOf('intermediate') >= 0) svg += '<circle cx="' + x + '" cy="' + y + '" r="' + (r - 3.5) + '" fill="none" stroke="' + stroke + '" stroke-width="1.2"/>';
5482
+ if (n.label) svg += '<text x="' + x + '" y="' + (y + r + 13) + '" text-anchor="middle" font-size="10.5" fill="' + P.ink + '">' + escapeHtml(String(n.label).slice(0, 22)) + '</text>';
5483
+ } else if (/gateway|decision|fork|join|merge/.test(type)) {
5484
+ var g = 23, gk = String(n.gateway || (/parallel|fork|join/.test(type) ? 'parallel' : 'exclusive'));
5485
+ svg += '<path d="M' + x + ',' + (y - g) + ' L' + (x + g) + ',' + y + ' L' + x + ',' + (y + g) + ' L' + (x - g) + ',' + y + ' z" fill="' + P.washi + '" stroke="' + P.amber + '" stroke-width="1.8"/>';
5486
+ var sym = gk.charAt(0) === 'p' ? '+' : (gk.charAt(0) === 'i' ? 'O' : '\\u00d7');
5487
+ svg += '<text x="' + x + '" y="' + (y + 5) + '" text-anchor="middle" font-size="16" fill="' + P.amber + '" font-weight="700">' + sym + '</text>';
5488
+ if (n.label) svg += '<text x="' + x + '" y="' + (y + g + 13) + '" text-anchor="middle" font-size="10.5" fill="' + P.ink + '">' + escapeHtml(String(n.label).slice(0, 22)) + '</text>';
5489
+ } else {
5490
+ var tw = 118, th = 54;
5491
+ svg += '<rect x="' + (x - tw / 2) + '" y="' + (y - th / 2) + '" width="' + tw + '" height="' + th + '" rx="9" fill="' + P.bg2 + '" stroke="' + P.indigo + '" stroke-width="1.4"/>';
5492
+ var ic = { userTask: '\\u263a', serviceTask: '\\u2699', scriptTask: '\\u25b8', manualTask: '\\u270b', sendTask: '\\u2709', receiveTask: '\\u2709' }[type];
5493
+ if (ic) svg += '<text x="' + (x - tw / 2 + 9) + '" y="' + (y - th / 2 + 14) + '" font-size="11" fill="' + P.indigo + '">' + ic + '</text>';
5494
+ var ls = bpmnWrap(n.label || n.id, 17, 3);
5495
+ var ty0 = y - (ls.length - 1) * 7;
5496
+ for (var li3 = 0; li3 < ls.length; li3++) svg += '<text x="' + x + '" y="' + (ty0 + li3 * 14 + 4) + '" text-anchor="middle" font-size="11.5" fill="' + P.ink + '">' + escapeHtml(ls[li3]) + '</text>';
5497
+ }
5498
+ });
5499
+ svg += '</svg>';
5500
+ return svg;
5501
+ }
5502
+ /* mitad del ancho/alto del nodo para anclar los flujos al borde. */
5503
+ function bpmnHalf(n) {
5504
+ var type = String(n.type || 'task');
5505
+ if (/start|^event$|end|intermediate/.test(type)) return 18;
5506
+ if (/gateway|decision|fork|join|merge/.test(type)) return 23;
5507
+ return 59;
5508
+ }
5509
+
4885
5510
  const CODING_TAB_ID = 'coding_canvas';
4886
5511
  const yfCoding = {
4887
5512
  strokes: [], texts: [], fade: 0, phase: 'draw', /* draw | fadeout */
@@ -4934,8 +5559,12 @@ function renderCodingDetail() {
4934
5559
  var item = feed[i];
4935
5560
  var d = item.detail || { kind: 'other', target: item.text, path: null, diff: null, body: null };
4936
5561
  var icon = CODING_ICON[d.kind] || '·';
5562
+ var ts = '';
5563
+ try { ts = new Date(item.t).toTimeString().slice(0, 8); } catch (e) { ts = ''; }
4937
5564
  h += '<div class="pz-cw-row k-' + escapeHtml(d.kind || 'other') + '">'
4938
- + '<div class="pz-cw-head"><span class="pz-cw-ic">' + icon + '</span>'
5565
+ + '<div class="pz-cw-head">'
5566
+ + '<span class="pz-cw-ts">' + ts + '</span>'
5567
+ + '<span class="pz-cw-ic">' + icon + '</span>'
4939
5568
  + '<span class="pz-cw-target">' + escapeHtml(d.target || item.text || '') + '</span>'
4940
5569
  + (d.path ? '<span class="pz-cw-path">' + escapeHtml(d.path) + '</span>' : '')
4941
5570
  + '</div>';
@@ -5276,10 +5905,11 @@ function unmarkStartupTabClosed(key) {
5276
5905
  * titulo real lo recordamos del ultimo fetch; el fallback cubre las que
5277
5906
  * nunca se vieron. */
5278
5907
  var yfStartupTitles = {};
5279
- var NATIVE_TAB_KEYS = ['startup_graph', 'startup_graph_sem', 'startup_pendings', 'startup_workflow', 'coding_canvas'];
5908
+ var NATIVE_TAB_KEYS = ['startup_graph', 'startup_graph_sem', 'startup_pendings', 'startup_workflow', 'startup_tasks', 'coding_canvas'];
5280
5909
  var NATIVE_TAB_FALLBACK = {
5281
5910
  startup_graph: 'Grafo', startup_graph_sem: 'Grafo semantico',
5282
- startup_pendings: 'Pendientes', startup_workflow: 'Workflow', coding_canvas: 'Coding',
5911
+ startup_pendings: 'Pendientes', startup_workflow: 'Workflow',
5912
+ startup_tasks: 'Tareas', coding_canvas: 'Coding',
5283
5913
  };
5284
5914
  function nativeTabLabel(key) { return yfStartupTitles[key] || NATIVE_TAB_FALLBACK[key] || key; }
5285
5915
 
@@ -5305,8 +5935,15 @@ function pizarronUpsertById(item) {
5305
5935
  const wasEditing = existing.isEditing;
5306
5936
  /* No pisar si el usuario esta editando esa tab. */
5307
5937
  if (!wasEditing) {
5938
+ /* Solo re-renderizar si el contenido/tipo/titulo CAMBIO. Antes se
5939
+ * re-renderizaba en cada refresh (cada 12s), lo que reiniciaba el
5940
+ * grafo: reseteaba los filtros de grupo + reposicionaba el layout
5941
+ * aunque nada hubiera cambiado. */
5942
+ const changed = existing.content !== item.content
5943
+ || existing.kind !== item.kind
5944
+ || existing.title !== item.title;
5308
5945
  Object.assign(existing, item, { id: existing.id, isEditing: false });
5309
- if (wasActive) renderPizarron();
5946
+ if (wasActive && changed) renderPizarron();
5310
5947
  }
5311
5948
  return existing.id;
5312
5949
  }
@@ -5391,6 +6028,33 @@ function graphNodeColor(n) {
5391
6028
  return '#8a7bb0'; /* violeta tenue */
5392
6029
  }
5393
6030
 
6031
+ /* High-level group of a node, for the filter legend -- SAME meaning as
6032
+ * the server's nodeGroup (board_snapshot.ts) so desktop + mobile agree.
6033
+ * indexOf-only (NO regex: this lives inside the panel template literal,
6034
+ * where a regex backslash would be mangled). */
6035
+ const PZ_GROUP_COLORS = {
6036
+ backend: '#5b6aa6', frontend: '#6f8b5e', mobile: '#c2914a', tests: '#b06a6a',
6037
+ scripts: '#8a7bb0', libs: '#9a9a9a', tools: '#4a8a8a', protocol: '#b0894a',
6038
+ };
6039
+ /* Filtro de grupos por tab de grafo (id -> {group:boolean}). Se conserva
6040
+ * entre re-renders para que un refresh con datos nuevos NO reinicie la
6041
+ * seleccion del usuario. */
6042
+ const _graphFilterByTab = {};
6043
+ function graphNodeGroup(n) {
6044
+ const id = String((n && n.id) || '');
6045
+ const s = (id + ' ' + String((n && (n.label || n.name)) || '')).toLowerCase();
6046
+ if (id.indexOf('tool.ci.') === 0) return 'tools';
6047
+ if (id.indexOf('tool.npm.') === 0 || id.indexOf('tool.script.') === 0) return 'scripts';
6048
+ if ((n && n.kind === 'library') || id.indexOf('lib.') === 0) return 'libs';
6049
+ const has = (w) => s.indexOf(w) >= 0;
6050
+ if (has('test') || has('spec') || has('vitest') || has('playwright') || has('.qa') || has('qa.')) return 'tests';
6051
+ if (has('mobile') || has('forge-rn') || has('nac3-rn') || has('expo') || has('metro')) return 'mobile';
6052
+ if (has('panel') || has('pizarron') || has('chat') || has('reader') || has('design')
6053
+ || has('figma') || has('webview') || has('component') || has('frontend')) return 'frontend';
6054
+ if (n && n.kind === 'protocol') return 'protocol';
6055
+ return 'backend';
6056
+ }
6057
+
5394
6058
  function initGraphTab(it) {
5395
6059
  const canvas = document.querySelector('[data-pz-graph-canvas]');
5396
6060
  const detail = document.querySelector('[data-pz-graph-detail]');
@@ -5414,8 +6078,10 @@ function initGraphTab(it) {
5414
6078
  label: String(n.label != null ? n.label : n.id != null ? n.id : i),
5415
6079
  weight: typeof n.weight === 'number' ? n.weight : 0.5,
5416
6080
  kind: n.kind || '',
6081
+ group: graphNodeGroup(n),
5417
6082
  detail: n.detail || '',
5418
- color: graphNodeColor(n),
6083
+ confidence: n.confidence || 'auto',
6084
+ color: PZ_GROUP_COLORS[graphNodeGroup(n)] || graphNodeColor(n),
5419
6085
  x: W / 2 + Math.cos(ang) * Math.min(W, H) * 0.32,
5420
6086
  y: H / 2 + Math.sin(ang) * Math.min(W, H) * 0.32,
5421
6087
  vx: 0, vy: 0, fixed: false,
@@ -5470,6 +6136,15 @@ function initGraphTab(it) {
5470
6136
  const circle = document.createElementNS(SVGNS, 'circle');
5471
6137
  circle.setAttribute('r', String(r));
5472
6138
  circle.setAttribute('fill', n.color);
6139
+ /* PND graph -- nodos 'claimed' (declarados en chat, sin verificacion
6140
+ * automatica) se dibujan atenuados + con borde punteado para
6141
+ * distinguirlos de lo derivado del codigo (auto). Antes se ocultaban. */
6142
+ if (n.confidence === 'claimed') {
6143
+ circle.setAttribute('fill-opacity', '0.45');
6144
+ circle.setAttribute('stroke', n.color);
6145
+ circle.setAttribute('stroke-width', '1.5');
6146
+ circle.setAttribute('stroke-dasharray', '3 2');
6147
+ }
5473
6148
  g.appendChild(circle);
5474
6149
  const label = document.createElementNS(SVGNS, 'text');
5475
6150
  label.setAttribute('class', 'pz-node-label');
@@ -5482,6 +6157,48 @@ function initGraphTab(it) {
5482
6157
  return g;
5483
6158
  });
5484
6159
 
6160
+ /* ---- Filtro por grupo (legend toggleable) -- desktop a la par del
6161
+ * mobile. Colorea por backend/frontend/mobile/tests/tools/scripts/
6162
+ * libs/protocol y permite mostrar/ocultar cada grupo. ---- */
6163
+ /* Restaurar la seleccion previa de este tab (si existe) para que un
6164
+ * refresh no la pierda; grupos nuevos arrancan visibles. */
6165
+ const savedVis = _graphFilterByTab[it.id] || {};
6166
+ const vis = {};
6167
+ const counts = {};
6168
+ nodes.forEach((n) => {
6169
+ vis[n.group] = Object.prototype.hasOwnProperty.call(savedVis, n.group) ? !!savedVis[n.group] : true;
6170
+ counts[n.group] = (counts[n.group] || 0) + 1;
6171
+ });
6172
+ _graphFilterByTab[it.id] = vis; /* el toggle muta este mismo objeto */
6173
+ const legend = document.createElement('div');
6174
+ legend.setAttribute('style', 'position:absolute;top:6px;left:6px;right:6px;display:flex;flex-wrap:wrap;gap:5px;z-index:6;pointer-events:auto;');
6175
+ Object.keys(counts).sort().forEach((k) => {
6176
+ const chip = document.createElement('button');
6177
+ chip.type = 'button';
6178
+ chip.setAttribute('data-g', k);
6179
+ chip.setAttribute('style', 'display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:11px;color:#3a3a3a;'
6180
+ + 'border:1px solid #d9d2c4;border-radius:14px;padding:3px 9px;background:#fff;cursor:pointer;opacity:' + (vis[k] ? '1' : '0.4') + ';');
6181
+ chip.innerHTML = '<span style="width:9px;height:9px;border-radius:50%;background:' + (PZ_GROUP_COLORS[k] || '#999') + '"></span>'
6182
+ + escapeHtml(k) + ' ' + counts[k];
6183
+ chip.addEventListener('click', () => {
6184
+ vis[k] = !vis[k];
6185
+ chip.style.opacity = vis[k] ? '1' : '0.4';
6186
+ applyGraphFilter();
6187
+ });
6188
+ legend.appendChild(chip);
6189
+ });
6190
+ /* el canvas es position:relative en CSS; si no, el absolute cuelga del svg */
6191
+ canvas.style.position = canvas.style.position || 'relative';
6192
+ canvas.appendChild(legend);
6193
+ function applyGraphFilter() {
6194
+ for (let i = 0; i < nodeEls.length; i++) nodeEls[i].style.display = vis[nodes[i].group] ? '' : 'none';
6195
+ for (let k = 0; k < edges.length; k++) {
6196
+ const show = vis[nodes[edges[k].from].group] && vis[nodes[edges[k].to].group];
6197
+ edgeEls[k].style.display = show ? '' : 'none';
6198
+ }
6199
+ }
6200
+ applyGraphFilter();
6201
+
5485
6202
  function showDetail(n) {
5486
6203
  if (!detail) return;
5487
6204
  nodeEls.forEach((g) => g.classList.toggle('selected', g._node === n));
@@ -5681,13 +6398,67 @@ function setStatus(text, isErr) {
5681
6398
 
5682
6399
  function setBusy(b) {
5683
6400
  state.busy = b;
5684
- for (const sel of ['#bar-mini .send', '#bar-full .send', '#bar-mini input', '#bar-full input', '#bar-mini .clip', '#bar-full .clip']) {
6401
+ /* Queue (task #28) -- the input + Send stay LIVE during a turn so the
6402
+ * user can type-while-working (like Claude Code): a send mid-turn is
6403
+ * queued and runs automatically when the turn finishes. Only the clip
6404
+ * (attachments mid-turn) is gated. */
6405
+ for (const sel of ['#bar-mini .clip', '#bar-full .clip']) {
6406
+ const el = $(sel); if (el) el.disabled = b;
6407
+ }
6408
+ for (const sel of ['#bar-mini input', '#bar-full input', '#bar-mini .send', '#bar-full .send']) {
6409
+ const el = $(sel); if (el) el.disabled = false;
6410
+ }
6411
+ /* Interrupt (task #27) -- a Detener button appears alongside Send
6412
+ * while a turn is in flight, so the user can stop a long / runaway
6413
+ * turn (a controlled fall: applied tool calls stay, the partial
6414
+ * answer is kept). */
6415
+ for (const sel of ['#bar-mini .stop', '#bar-full .stop']) {
5685
6416
  const el = $(sel);
5686
- if (el) el.disabled = b;
6417
+ if (el) { el.style.display = b ? '' : 'none'; el.disabled = false; }
5687
6418
  }
6419
+ renderQueue();
5688
6420
  renderStream();
5689
6421
  }
5690
6422
 
6423
+ /* Queue (task #28) -- render the pending-message chips above the
6424
+ * composer. Each chip is removable before it runs. */
6425
+ function renderQueue() {
6426
+ const q = state.queue || [];
6427
+ for (const id of ['#queue-mini', '#queue-full']) {
6428
+ const el = $(id);
6429
+ if (!el) continue;
6430
+ if (!q.length) { el.style.display = 'none'; el.innerHTML = ''; continue; }
6431
+ el.style.display = '';
6432
+ el.innerHTML = '<span class="queue-label">' + escapeHtml(CONFIG.i18n.queueLabel) + '</span>'
6433
+ + q.map(function (txt, i) {
6434
+ const short = txt.length > 42 ? txt.slice(0, 42) + '...' : txt;
6435
+ return '<span class="queue-chip" data-qrm="' + i + '" role="button" tabindex="0"'
6436
+ + ' title="' + escapeHtml(CONFIG.i18n.queueRemove) + ': ' + escapeHtml(txt) + '">'
6437
+ + escapeHtml(short) + ' <span class="queue-x">&#215;</span></span>';
6438
+ }).join('');
6439
+ el.querySelectorAll('[data-qrm]').forEach(function (chip) {
6440
+ chip.addEventListener('click', function () {
6441
+ const idx = parseInt(chip.getAttribute('data-qrm'), 10);
6442
+ if (!isNaN(idx) && state.queue) { state.queue.splice(idx, 1); renderQueue(); }
6443
+ });
6444
+ });
6445
+ }
6446
+ }
6447
+
6448
+ /* Interrupt (task #27) -- POST the stop request. The server aborts the
6449
+ * active turn's signal; the in-flight send() unwinds via the SSE
6450
+ * turn_complete (interrupted:true) or the abort path. */
6451
+ async function interruptTurn() {
6452
+ setStatus(CONFIG.i18n.statusStopping);
6453
+ try {
6454
+ const r = await fetch('/api/chat/interrupt', { method: 'POST' });
6455
+ const j = await r.json().catch(function () { return {}; });
6456
+ if (!j || j.active === false) setStatus(CONFIG.i18n.statusNoActiveTurn);
6457
+ } catch (e) {
6458
+ setStatus(CONFIG.i18n.statusInterruptFailed, true);
6459
+ }
6460
+ }
6461
+
5691
6462
  /* alpha.59e -- consume an SSE response stream from /api/chat.
5692
6463
  * The body is text/event-stream with frames separated by an
5693
6464
  * empty line. We parse each event as it arrives + update the
@@ -5878,7 +6649,18 @@ function handleSseEvent(event, data) {
5878
6649
 
5879
6650
  async function send(text, opts) {
5880
6651
  text = (text || '').trim();
5881
- if (!text || state.busy) return;
6652
+ if (!text) return;
6653
+ /* Queue (task #28) -- a turn is already running: enqueue this message
6654
+ * instead of dropping it. It runs automatically when the current turn
6655
+ * finishes (drained in the finally below). Synthetic 'hidden' turns
6656
+ * (NAC3 approval continuations) and the drain itself bypass the queue.
6657
+ * 'Send now' steering = press Detener, then the queued message runs. */
6658
+ if (state.busy && !(opts && (opts.hidden || opts.fromQueue))) {
6659
+ if (!state.queue) state.queue = [];
6660
+ state.queue.push(text);
6661
+ renderQueue();
6662
+ return;
6663
+ }
5882
6664
  /* alpha.49 -- opts.hidden=true pushes a user message that the
5883
6665
  * LLM sees in context but the UI does NOT render in the bubble
5884
6666
  * stream. Used by the NAC3 approval modal post-action to inject
@@ -5963,7 +6745,12 @@ async function send(text, opts) {
5963
6745
  'yf limits set max_tool_rounds.',
5964
6746
  };
5965
6747
  var friendly = FRIENDLY_ERRORS[data.error];
5966
- setStatus(friendly || data.error || ('HTTP ' + res.status), true);
6748
+ /* Surface the DETAILED message (stderr del subproceso, motivo real
6749
+ * del fallo) en vez del codigo generico -- p.ej. modo Plan
6750
+ * claude_api_error traia el stderr del subproceso escondido.
6751
+ * (Pablo 2026-06-15) */
6752
+ var detail = (data.message && data.message !== data.error) ? data.message : '';
6753
+ setStatus(friendly || detail || data.error || ('HTTP ' + res.status), true);
5967
6754
  // Drop the unanswered user message so the user can retry.
5968
6755
  state.messages.pop();
5969
6756
  return;
@@ -5971,6 +6758,13 @@ async function send(text, opts) {
5971
6758
  /* alpha.58 -- attach brain_routing metadata to the bubble so
5972
6759
  * renderStream can paint a tier badge + escalation arrow. */
5973
6760
  const assistantMsg = { role: 'assistant', content: data.message.text };
6761
+ /* Interrupt (task #27) -- the turn was stopped mid-flight; mark the
6762
+ * partial answer so the user knows it is not the full reply. */
6763
+ if (data.interrupted === true) {
6764
+ assistantMsg.interrupted = true;
6765
+ assistantMsg.content = (assistantMsg.content || '') + '\\n\\n_' + CONFIG.i18n.labelInterrupted + '_';
6766
+ setStatus(CONFIG.i18n.statusTurnInterrupted);
6767
+ }
5974
6768
  if (data.brain_routing && typeof data.brain_routing === 'object') {
5975
6769
  const br = data.brain_routing;
5976
6770
  assistantMsg.brain_tier = br.tier;
@@ -6143,11 +6937,23 @@ async function send(text, opts) {
6143
6937
  * goes back to default state on the next turn. */
6144
6938
  state.streamProgress = null;
6145
6939
  setBusy(false);
6940
+ /* Queue (task #28) -- drain the next queued message, if any. A small
6941
+ * delay lets the DOM settle (busy flag cleared) before the next turn
6942
+ * starts; fromQueue bypasses the re-enqueue guard. */
6943
+ if (state.queue && state.queue.length) {
6944
+ const next = state.queue.shift();
6945
+ renderQueue();
6946
+ setTimeout(function () { send(next, { fromQueue: true }); }, 60);
6947
+ }
6146
6948
  }
6147
6949
  }
6148
6950
 
6149
6951
  function attachBar(form) {
6150
6952
  const input = form.querySelector('input');
6953
+ /* Interrupt (task #27) -- the Detener button shares the composer
6954
+ * row; wire it to the stop endpoint. */
6955
+ const stopBtn = form.querySelector('[data-stop]');
6956
+ if (stopBtn) stopBtn.addEventListener('click', function () { interruptTurn(); });
6151
6957
  form.addEventListener('submit', (e) => {
6152
6958
  e.preventDefault();
6153
6959
  const v = input.value;
@@ -6604,15 +7410,7 @@ async function pizarronPlanHintCheck() {
6604
7410
  try {
6605
7411
  const r = await fetch('/api/forge/brain-config');
6606
7412
  const data = await r.json();
6607
- const isCli = data && data.ok && data.active_chat_provider === 'claude-code-cli';
6608
- if (isCli) {
6609
- /* El bridge esta activo: arrancar el polling de la cola.
6610
- * El aviso queda oculto porque el pizarron SI funciona. */
6611
- if (hint) hint.classList.add('hidden');
6612
- startPanelQueuePolling();
6613
- } else {
6614
- if (hint) hint.classList.add('hidden');
6615
- }
7413
+ if (hint) hint.classList.add('hidden');
6616
7414
  } catch { /* dejar como esta */ }
6617
7415
  }
6618
7416
 
@@ -6655,9 +7453,26 @@ function materializePanelDispatch(d) {
6655
7453
  /* proyecto nuevo: re-mostrar las 4 pestanas canonicas (respeta
6656
7454
  * el switch en el endpoint). Se limpia el set de cerradas. */
6657
7455
  try { localStorage.removeItem(YF_CLOSED_STARTUP_KEY); } catch (e) { /* */ }
7456
+ /* Project selector (task #33) -- update the header label to the new
7457
+ * project name the server reported. */
7458
+ try {
7459
+ if (d.args && d.args.name) { CONFIG.projectName = String(d.args.name); setProj(); }
7460
+ } catch (e) { /* */ }
6658
7461
  loadStartupTabs(false).catch(function () {});
6659
7462
  return;
6660
7463
  }
7464
+ if (d.verb === 'system.remote-turn') {
7465
+ /* UI sync (task #32) -- a turn issued from the phone in the unified
7466
+ * 'notebook' thread ran on the desktop; show it here so the laptop
7467
+ * reflects what was asked + answered from the mobile. */
7468
+ const ra = d.args || {};
7469
+ if (ra.user) state.messages.push({ role: 'user', content: String(ra.user), remote: true });
7470
+ if (typeof ra.reply === 'string' && ra.reply) {
7471
+ state.messages.push({ role: 'assistant', content: ra.reply, remote: true });
7472
+ }
7473
+ renderStream();
7474
+ return;
7475
+ }
6661
7476
  if (!d.args) return;
6662
7477
  const a = d.args;
6663
7478
  if (d.verb === 'pizarron.create-tab') {
@@ -6697,10 +7512,14 @@ function materializePanelDispatch(d) {
6697
7512
  /* ---- PND-069: seccion Brain del panel de settings ----------- */
6698
7513
  const yfBrain = { cfg: null, loading: false };
6699
7514
 
7515
+ /* Strict BYOK: the everyday brain is always the user own key
7516
+ * (Console / Plan / other providers). The yujin_managed virtual
7517
+ * provider is intentionally NOT offered here -- managed access is
7518
+ * reserved for the bounded onboarding concierge, never the daily brain. */
6700
7519
  const BRAIN_PROVIDER_LABELS = {
6701
7520
  anthropic: 'Anthropic', openai: 'OpenAI', google_ai: 'Google AI',
6702
7521
  deepseek: 'DeepSeek', xai: 'xAI', mistral: 'Mistral',
6703
- cohere: 'Cohere', yujin_managed: 'Yujin (managed)',
7522
+ cohere: 'Cohere',
6704
7523
  };
6705
7524
 
6706
7525
  function brainStatus(msg) {
@@ -6805,22 +7624,34 @@ async function brainConfigSave(patch, statusMsg) {
6805
7624
  body: JSON.stringify(patch),
6806
7625
  });
6807
7626
  const data = await r.json();
6808
- if (!r.ok || !data.ok) { brainStatus('error guardando'); return; }
7627
+ if (!r.ok || !data.ok) { brainStatus('error guardando'); return null; }
6809
7628
  yfBrain.cfg = data.config;
6810
7629
  brainStatus(statusMsg || 'guardado');
6811
7630
  setTimeout(() => brainStatus(''), 2500);
7631
+ return data;
6812
7632
  } catch (err) {
6813
7633
  brainStatus('error: ' + (err && err.message ? err.message : String(err)));
7634
+ return null;
6814
7635
  }
6815
7636
  }
6816
7637
 
6817
7638
  function brainConfigBind() {
6818
7639
  document.querySelectorAll('input[name="brain-anthropic-mode"]').forEach((radio) => {
6819
- radio.addEventListener('change', () => {
7640
+ radio.addEventListener('change', async () => {
6820
7641
  if (!radio.checked) return;
6821
7642
  const note = document.querySelector('[data-brain-mode-note]');
6822
- if (note) note.hidden = false;
6823
- brainConfigSave({ anthropic_mode: radio.value }, 'guardado -- reabri el panel para aplicar');
7643
+ const modeLabel = radio.value === 'plan' ? 'Plan (suscripcion)' : 'Console (API key)';
7644
+ const data = await brainConfigSave({ anthropic_mode: radio.value }, 'aplicando ' + modeLabel + '...');
7645
+ /* Hot-swap (server applies live now). Only show the restart hint if
7646
+ * the swap did NOT apply live (e.g. test client). */
7647
+ const live = data && data.applied_live;
7648
+ if (note) {
7649
+ note.hidden = false;
7650
+ note.textContent = live
7651
+ ? modeLabel + ' aplicado. El proximo turno ya usa este modo.'
7652
+ : 'Se aplica al reabrir el panel.';
7653
+ }
7654
+ if (live) brainStatus(modeLabel + ' activo');
6824
7655
  });
6825
7656
  });
6826
7657
  const ecoToggle = document.querySelector('[data-brain-eco-enabled]');
@@ -8356,6 +9187,11 @@ function installSupportCollector() {
8356
9187
  document.addEventListener('DOMContentLoaded', () => {
8357
9188
  installSupportCollector();
8358
9189
  setProj();
9190
+ /* Project selector (task #33) -- the header project label opens the
9191
+ * switcher. */
9192
+ $$('[data-proj-switch]').forEach(function (el) {
9193
+ el.addEventListener('click', function (e) { e.stopPropagation(); openProjectMenu(el); });
9194
+ });
8359
9195
  /* alpha.43 F1 -- prime the NAC3 manifest cache on boot so the
8360
9196
  first chat-response version stamp has something to compare
8361
9197
  against. Silent: if the panel never sent a request before,
@@ -8574,6 +9410,13 @@ document.addEventListener('DOMContentLoaded', () => {
8574
9410
  brainConfigBind();
8575
9411
  panelZoomMaximizeBind();
8576
9412
  pizarronPlanHintCheck();
9413
+ /* Panel-queue polling runs in ALL modes (Pablo 2026-06-15): the queue
9414
+ * carries cross-cutting events -- system.remote-turn (mobile chats in
9415
+ * the unified session) + system.reanchor (project switch) + chat-log --
9416
+ * that must reflect in Console/API mode too, not only Plan/CLI. The bug
9417
+ * was gating the poller on claude-code-cli, so in Console mode the
9418
+ * desktop never saw mobile turns nor a project switch. */
9419
+ startPanelQueuePolling();
8577
9420
  /* Click outside closes. */
8578
9421
  document.addEventListener('click', (e) => {
8579
9422
  if (!settingsPanel || settingsPanel.classList.contains('hidden')) return;