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

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 (303) 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 +95 -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 +923 -50
  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 +960 -73
  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/pizarron.md +40 -0
  161. package/dist/docs/doctrine/verb-composition.md +16 -0
  162. package/dist/docs/doctrine/workflow-kinds.md +39 -22
  163. package/dist/docs/doctrine/workflow.md +12 -4
  164. package/dist/license/auto_sync.d.ts +22 -0
  165. package/dist/license/auto_sync.d.ts.map +1 -0
  166. package/dist/license/auto_sync.js +91 -0
  167. package/dist/license/auto_sync.js.map +1 -0
  168. package/dist/license/handle_key.d.ts +32 -0
  169. package/dist/license/handle_key.d.ts.map +1 -0
  170. package/dist/license/handle_key.js +56 -0
  171. package/dist/license/handle_key.js.map +1 -0
  172. package/dist/license/hito4_client.d.ts.map +1 -1
  173. package/dist/license/hito4_client.js +63 -25
  174. package/dist/license/hito4_client.js.map +1 -1
  175. package/dist/llm/gemini_client.d.ts.map +1 -1
  176. package/dist/llm/gemini_client.js +5 -2
  177. package/dist/llm/gemini_client.js.map +1 -1
  178. package/dist/llm/managed_client.d.ts.map +1 -1
  179. package/dist/llm/managed_client.js +14 -9
  180. package/dist/llm/managed_client.js.map +1 -1
  181. package/dist/mcp/plan_bridges.d.ts +17 -0
  182. package/dist/mcp/plan_bridges.d.ts.map +1 -0
  183. package/dist/mcp/plan_bridges.js +119 -0
  184. package/dist/mcp/plan_bridges.js.map +1 -0
  185. package/dist/migrate/ai-apply.d.ts.map +1 -1
  186. package/dist/migrate/ai-apply.js +25 -2
  187. package/dist/migrate/ai-apply.js.map +1 -1
  188. package/dist/nac3/internal_manifest.d.ts.map +1 -1
  189. package/dist/nac3/internal_manifest.js +200 -10
  190. package/dist/nac3/internal_manifest.js.map +1 -1
  191. package/dist/nac3/verb_tier_map.d.ts.map +1 -1
  192. package/dist/nac3/verb_tier_map.js +3 -0
  193. package/dist/nac3/verb_tier_map.js.map +1 -1
  194. package/dist/onboarding/concierge_client.d.ts +34 -0
  195. package/dist/onboarding/concierge_client.d.ts.map +1 -0
  196. package/dist/onboarding/concierge_client.js +80 -0
  197. package/dist/onboarding/concierge_client.js.map +1 -0
  198. package/dist/onboarding/next_step.d.ts +32 -0
  199. package/dist/onboarding/next_step.d.ts.map +1 -0
  200. package/dist/onboarding/next_step.js +60 -0
  201. package/dist/onboarding/next_step.js.map +1 -0
  202. package/dist/pending/store.d.ts +7 -0
  203. package/dist/pending/store.d.ts.map +1 -1
  204. package/dist/pending/store.js +19 -0
  205. package/dist/pending/store.js.map +1 -1
  206. package/dist/pending/types.d.ts +20 -0
  207. package/dist/pending/types.d.ts.map +1 -1
  208. package/dist/pending/types.js.map +1 -1
  209. package/dist/reader/parsers/epub.d.ts.map +1 -1
  210. package/dist/reader/parsers/epub.js +6 -1
  211. package/dist/reader/parsers/epub.js.map +1 -1
  212. package/dist/relay/board_snapshot.d.ts +69 -0
  213. package/dist/relay/board_snapshot.d.ts.map +1 -0
  214. package/dist/relay/board_snapshot.js +149 -0
  215. package/dist/relay/board_snapshot.js.map +1 -0
  216. package/dist/relay/command_consumer.d.ts +97 -0
  217. package/dist/relay/command_consumer.d.ts.map +1 -0
  218. package/dist/relay/command_consumer.js +135 -0
  219. package/dist/relay/command_consumer.js.map +1 -0
  220. package/dist/relay/mock_relay.d.ts +34 -0
  221. package/dist/relay/mock_relay.d.ts.map +1 -0
  222. package/dist/relay/mock_relay.js +93 -0
  223. package/dist/relay/mock_relay.js.map +1 -0
  224. package/dist/relay/relay_client.d.ts +28 -0
  225. package/dist/relay/relay_client.d.ts.map +1 -0
  226. package/dist/relay/relay_client.js +80 -0
  227. package/dist/relay/relay_client.js.map +1 -0
  228. package/dist/relay/relay_publisher.d.ts +59 -0
  229. package/dist/relay/relay_publisher.d.ts.map +1 -0
  230. package/dist/relay/relay_publisher.js +101 -0
  231. package/dist/relay/relay_publisher.js.map +1 -0
  232. package/dist/relay/types.d.ts +54 -0
  233. package/dist/relay/types.d.ts.map +1 -0
  234. package/dist/relay/types.js +8 -0
  235. package/dist/relay/types.js.map +1 -0
  236. package/dist/semantic_graph/ast_extract.d.ts +5 -0
  237. package/dist/semantic_graph/ast_extract.d.ts.map +1 -1
  238. package/dist/semantic_graph/ast_extract.js +46 -0
  239. package/dist/semantic_graph/ast_extract.js.map +1 -1
  240. package/dist/semantic_graph/derive.d.ts +12 -0
  241. package/dist/semantic_graph/derive.d.ts.map +1 -1
  242. package/dist/semantic_graph/derive.js +325 -6
  243. package/dist/semantic_graph/derive.js.map +1 -1
  244. package/dist/semantic_graph/store.d.ts +9 -0
  245. package/dist/semantic_graph/store.d.ts.map +1 -1
  246. package/dist/semantic_graph/store.js +23 -1
  247. package/dist/semantic_graph/store.js.map +1 -1
  248. package/dist/semantic_graph/types.d.ts +1 -1
  249. package/dist/semantic_graph/types.d.ts.map +1 -1
  250. package/dist/support/github_dispatcher.d.ts.map +1 -1
  251. package/dist/support/github_dispatcher.js +30 -2
  252. package/dist/support/github_dispatcher.js.map +1 -1
  253. package/dist/tasks/supervisor.d.ts +88 -0
  254. package/dist/tasks/supervisor.d.ts.map +1 -0
  255. package/dist/tasks/supervisor.js +308 -0
  256. package/dist/tasks/supervisor.js.map +1 -0
  257. package/dist/tasks/task_type.d.ts +11 -0
  258. package/dist/tasks/task_type.d.ts.map +1 -0
  259. package/dist/tasks/task_type.js +21 -0
  260. package/dist/tasks/task_type.js.map +1 -0
  261. package/dist/vault/store.d.ts.map +1 -1
  262. package/dist/vault/store.js +6 -1
  263. package/dist/vault/store.js.map +1 -1
  264. package/dist/version.d.ts +3 -5
  265. package/dist/version.d.ts.map +1 -1
  266. package/dist/version.js +3 -5
  267. package/dist/version.js.map +1 -1
  268. package/dist/voice/providers/google.d.ts.map +1 -1
  269. package/dist/voice/providers/google.js +6 -4
  270. package/dist/voice/providers/google.js.map +1 -1
  271. package/dist/workflow/config.d.ts +43 -0
  272. package/dist/workflow/config.d.ts.map +1 -0
  273. package/dist/workflow/config.js +199 -0
  274. package/dist/workflow/config.js.map +1 -0
  275. package/dist/workflow/instances.d.ts +40 -0
  276. package/dist/workflow/instances.d.ts.map +1 -0
  277. package/dist/workflow/instances.js +122 -0
  278. package/dist/workflow/instances.js.map +1 -0
  279. package/dist/workflow/multi_view.d.ts +27 -0
  280. package/dist/workflow/multi_view.d.ts.map +1 -0
  281. package/dist/workflow/multi_view.js +76 -0
  282. package/dist/workflow/multi_view.js.map +1 -0
  283. package/dist/workflow/orchestrator.d.ts +111 -0
  284. package/dist/workflow/orchestrator.d.ts.map +1 -0
  285. package/dist/workflow/orchestrator.js +241 -0
  286. package/dist/workflow/orchestrator.js.map +1 -0
  287. package/docs/doctrine/default-design-system.md +32 -0
  288. package/docs/doctrine/office-tools.md +20 -0
  289. package/docs/doctrine/pizarron.md +40 -0
  290. package/docs/doctrine/verb-composition.md +16 -0
  291. package/docs/doctrine/workflow-kinds.md +39 -22
  292. package/docs/doctrine/workflow.md +12 -4
  293. package/package.json +2 -1
  294. package/src/i18n/catalogs/ar.json +223 -210
  295. package/src/i18n/catalogs/de.json +223 -210
  296. package/src/i18n/catalogs/en.json +223 -210
  297. package/src/i18n/catalogs/es.json +223 -210
  298. package/src/i18n/catalogs/fr.json +223 -210
  299. package/src/i18n/catalogs/hi.json +223 -210
  300. package/src/i18n/catalogs/it.json +223 -210
  301. package/src/i18n/catalogs/ja.json +223 -210
  302. package/src/i18n/catalogs/pt.json +223 -210
  303. 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;
@@ -1262,10 +1339,17 @@ body {
1262
1339
 
1263
1340
  /* ---- input bar ---- */
1264
1341
  .bar { border-top: 1px solid var(--border-subtle); background: white;
1265
- padding: 8px; display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
1266
- .bar input { flex: 1; padding: 8px 10px;
1342
+ padding: 8px; display: flex; gap: 6px; align-items: flex-end; flex-shrink: 0; }
1343
+ .bar input, .bar .composer-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
- .bar input:focus { outline: none; border-color: var(--indigo); box-shadow: 0 0 0 3px rgba(79,91,135,0.12); }
1345
+ .bar input:focus, .bar .composer-input:focus { outline: none; border-color: var(--indigo); box-shadow: 0 0 0 3px rgba(79,91,135,0.12); }
1346
+ /* Auto-growing composer: one row by default, expands as you type
1347
+ (bubble-like, capped at ~40vh) then scrolls; Enter sends and the
1348
+ submit handler resets the height. Shift+Enter inserts a newline. */
1349
+ .bar .composer-input { resize: none; overflow-y: auto; line-height: 1.4;
1350
+ min-height: 36px; max-height: 40vh; }
1351
+ .bar .composer-input.expanded { box-shadow: 0 6px 24px rgba(26,24,21,0.16);
1352
+ border-color: var(--indigo); }
1269
1353
  .bar .mic, .bar .send {
1270
1354
  border: 0; border-radius: 50%; width: 36px; height: 36px;
1271
1355
  display: flex; align-items: center; justify-content: center;
@@ -1284,9 +1368,25 @@ body {
1284
1368
  cursor: pointer; font-size: 14px;
1285
1369
  }
1286
1370
  .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; }
1371
+ .bar .send { background: var(--indigo); padding: 0 9px; width: auto; min-width: 0;
1372
+ flex-shrink: 0; white-space: nowrap; border-radius: 6px; font-size: 12px; font-weight: 600; }
1289
1373
  .bar .send:disabled { background: #aaa; cursor: not-allowed; }
1374
+ .bar .stop { background: var(--err, #c8472e); color: white; padding: 0 9px; width: auto;
1375
+ min-width: 0; flex-shrink: 0; white-space: nowrap; border-radius: 6px; font-size: 12px;
1376
+ font-weight: 600; border: none; cursor: pointer; height: 36px; }
1377
+
1378
+ /* ---- message queue (task #28) ---- */
1379
+ .queue { display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
1380
+ padding: 6px 12px 0; }
1381
+ .queue .queue-label { font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
1382
+ color: var(--ink-muted); margin-right: 2px; }
1383
+ .queue .queue-chip { display: inline-flex; align-items: center; gap: 5px;
1384
+ background: var(--bg2, #f1efe7); border: 1px solid var(--border); border-radius: 12px;
1385
+ padding: 2px 9px; font-size: 12px; color: var(--ink); cursor: pointer; max-width: 240px;
1386
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1387
+ .queue .queue-chip:hover { border-color: var(--err, #c8472e); }
1388
+ .queue .queue-x { color: var(--ink-muted); font-weight: 700; }
1389
+ .queue .queue-chip:hover .queue-x { color: var(--err, #c8472e); }
1290
1390
 
1291
1391
  /* ---- status row ---- */
1292
1392
  .status { font-size: 11px; color: var(--ink-muted); padding: 4px 12px; text-align: center; flex-shrink: 0; }
@@ -1713,7 +1813,7 @@ ${renderYujinSplash()}
1713
1813
  <div class="header">
1714
1814
  <div class="brand">
1715
1815
  <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>
1816
+ <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
1817
  </div>
1718
1818
  <div class="actions">
1719
1819
  <button class="keys-btn" data-vault-open aria-label="${escapeHtml(tr.keysButton)}" title="${escapeHtml(tr.keysButton)}">${escapeHtml(tr.keysButton)}</button>
@@ -1723,12 +1823,14 @@ ${renderYujinSplash()}
1723
1823
  </div>
1724
1824
  <div id="stream-mini" data-nac-id="yujin.chat.stream-mini" class="stream"></div>
1725
1825
  <div class="status" id="status-mini" data-nac-id="yujin.chat.status-mini"></div>
1826
+ <div id="queue-mini" class="queue" style="display:none"></div>
1726
1827
  <form id="bar-mini" data-nac-id="yujin.chat.bar-mini" class="bar" onsubmit="return false">
1727
1828
  <button type="button" class="mic" data-mic data-mic-btn aria-label="${escapeHtml(tr.micButton)}" title="${escapeHtml(tr.micButton)}">🎙</button>
1728
1829
  <button type="button" class="tts-toggle" data-tts-toggle aria-label="${escapeHtml(tr.ttsButton)}" title="${escapeHtml(tr.ttsButton)}">🔊</button>
1729
1830
  <button type="button" class="clip" data-attach-clip aria-label="Adjuntar archivo" title="Adjuntar archivo">📎</button>
1730
- <input type="text" placeholder="${escapeHtml(tr.inputPlaceholder)}" aria-label="${escapeHtml(tr.inputPlaceholder)}" autocomplete="off">
1831
+ <textarea class="composer-input" rows="1" placeholder="${escapeHtml(tr.inputPlaceholder)}" aria-label="${escapeHtml(tr.inputPlaceholder)}"></textarea>
1731
1832
  <button type="submit" class="send">${escapeHtml(tr.sendButton)}</button>
1833
+ <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
1834
  </form>
1733
1835
  <input type="file" id="attach-file-input" multiple style="display:none" data-nac-id="yujin.chat.attach-input">
1734
1836
  </div>
@@ -1883,7 +1985,7 @@ ${renderYujinSplash()}
1883
1985
  <div class="header">
1884
1986
  <div class="brand">
1885
1987
  <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>
1988
+ <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
1989
  </div>
1888
1990
  <div class="actions">
1889
1991
  <button class="agents-btn"
@@ -2343,18 +2445,17 @@ ${renderYujinSplash()}
2343
2445
  </div>
2344
2446
  </div>
2345
2447
 
2346
- <!-- Vincular telefono (dogfood 2026-06-10) -- genera un QR que
2347
- Forge Mobile escanea; el usuario solo tipea el codigo. -->
2448
+ <!-- Vincular telefono (dogfood 2026-06-10; Option 3 2026-06-16 --
2449
+ code-less: el QR es autocontenido, se escanea y listo). -->
2348
2450
  <div class="set-section">
2349
2451
  <div class="set-section-title">Vincular telefono</div>
2350
2452
  <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>
2453
+ <span class="set-lbl" style="opacity:0.7;font-size:11px;">Abri Forge Mobile, toca "Vincular" y escanea este QR. Listo.</span>
2352
2454
  <button type="button" class="set-btn" id="set-pair-generate">Generar QR</button>
2353
2455
  </div>
2354
2456
  <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>
2457
+ <div id="set-pair-qr" style="display:inline-block;background:#ffffff;padding:10px;border-radius:8px;width:220px;height:220px;"></div>
2458
+ <div id="set-pair-expiry" style="font-size:11px;opacity:0.6;margin-top:6px;"></div>
2358
2459
  </div>
2359
2460
  <div id="set-pair-error" class="hidden" style="font-size:12px;color:var(--err,#c0392b);padding:6px 0;"></div>
2360
2461
  </div>
@@ -2472,12 +2573,14 @@ ${renderYujinSplash()}
2472
2573
  <div class="chat-col">
2473
2574
  <div id="stream-full" data-nac-id="yujin.chat.stream-full" class="stream"></div>
2474
2575
  <div class="status" id="status-full" data-nac-id="yujin.chat.status-full"></div>
2576
+ <div id="queue-full" class="queue" style="display:none"></div>
2475
2577
  <form id="bar-full" data-nac-id="yujin.chat.bar-full" class="bar" onsubmit="return false">
2476
2578
  <button type="button" class="mic" data-mic data-mic-btn aria-label="${escapeHtml(tr.micButton)}" title="${escapeHtml(tr.micButton)}">🎙</button>
2477
2579
  <button type="button" class="tts-toggle" data-tts-toggle aria-label="${escapeHtml(tr.ttsButton)}" title="${escapeHtml(tr.ttsButton)}">🔊</button>
2478
2580
  <button type="button" class="clip" data-attach-clip aria-label="Adjuntar archivo" title="Adjuntar archivo">📎</button>
2479
- <input type="text" placeholder="${escapeHtml(tr.inputPlaceholder)}" aria-label="${escapeHtml(tr.inputPlaceholder)}" autocomplete="off">
2581
+ <textarea class="composer-input" rows="1" placeholder="${escapeHtml(tr.inputPlaceholder)}" aria-label="${escapeHtml(tr.inputPlaceholder)}"></textarea>
2480
2582
  <button type="submit" class="send">${escapeHtml(tr.sendButton)}</button>
2583
+ <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
2584
  </form>
2482
2585
  </div>
2483
2586
  <div class="pizarra" data-nac-id="yujin.pizarron" data-nac-role="region">
@@ -2596,14 +2699,114 @@ function showMode(mode) {
2596
2699
  $('#yf-mini').classList.toggle('hidden', mode !== 'mini');
2597
2700
  $('#yf-full').classList.toggle('hidden', mode !== 'full');
2598
2701
  if (mode !== 'globito') {
2599
- setTimeout(() => $('#bar-' + (mode === 'full' ? 'full' : 'mini') + ' input').focus(), 30);
2702
+ setTimeout(() => $('#bar-' + (mode === 'full' ? 'full' : 'mini') + ' textarea').focus(), 30);
2600
2703
  }
2601
2704
  }
2602
2705
 
2603
2706
  function setProj() {
2604
- const txt = CONFIG.projectName + ' ' + 'v' + CONFIG.version;
2605
- $('#proj-mini').textContent = txt;
2606
- $('#proj-full').textContent = txt;
2707
+ /* Project selector (task #33) -- render the name + a caret so it reads
2708
+ * as a dropdown (not a passive version stamp). The version moves to the
2709
+ * tooltip. */
2710
+ const label = CONFIG.projectName + ' ▾';
2711
+ const title = CONFIG.projectName + ' v' + CONFIG.version + ' — ' + (CONFIG.i18n.projectSwitchHint || '');
2712
+ for (const id of ['#proj-mini', '#proj-full']) {
2713
+ const el = $(id);
2714
+ if (!el) continue;
2715
+ el.textContent = label;
2716
+ el.setAttribute('title', title);
2717
+ }
2718
+ }
2719
+
2720
+ /* Project selector (task #33) -- the project label in the header is a
2721
+ * switcher. Clicking it lists every known project (~/.yujin-forge/
2722
+ * projects.json) and switching HOT-reanchors the live session: the
2723
+ * pizarron, graph + startup tabs follow immediately. */
2724
+ function closeProjectMenu() {
2725
+ const m = document.getElementById('proj-menu');
2726
+ if (m && m.parentNode) m.parentNode.removeChild(m);
2727
+ document.removeEventListener('click', onProjMenuOutside, true);
2728
+ }
2729
+ function onProjMenuOutside(e) {
2730
+ const m = document.getElementById('proj-menu');
2731
+ if (m && !m.contains(e.target) && !(e.target.closest && e.target.closest('[data-proj-switch]'))) {
2732
+ closeProjectMenu();
2733
+ }
2734
+ }
2735
+ async function openProjectMenu(anchor) {
2736
+ if (document.getElementById('proj-menu')) { closeProjectMenu(); return; }
2737
+ let reg = { projects: [], active_slug: null };
2738
+ try {
2739
+ const r = await fetch('/api/forge/projects');
2740
+ const j = await r.json();
2741
+ if (j && j.ok && j.registry) reg = j.registry;
2742
+ else if (j && Array.isArray(j.projects)) reg = j;
2743
+ } catch (e) { /* show just the add option */ }
2744
+ const menu = document.createElement('div');
2745
+ menu.id = 'proj-menu';
2746
+ menu.className = 'proj-menu';
2747
+ const rows = (reg.projects || []).slice().sort(function (a, b) {
2748
+ return String(b.last_active_at || '').localeCompare(String(a.last_active_at || ''));
2749
+ }).map(function (p) {
2750
+ const active = p.slug === reg.active_slug;
2751
+ return '<div class="proj-row' + (active ? ' on' : '') + '" data-proj-slug="' + escapeHtml(p.slug) + '" role="button" tabindex="0">'
2752
+ + '<span class="proj-row-name">' + (active ? '&#9679; ' : '') + escapeHtml(p.slug) + '</span>'
2753
+ + '<span class="proj-row-path">' + escapeHtml(String(p.path || '')) + '</span>'
2754
+ + '</div>';
2755
+ }).join('');
2756
+ menu.innerHTML = '<div class="proj-menu-title">' + escapeHtml(CONFIG.i18n.projectTitle) + '</div>'
2757
+ + (rows || '')
2758
+ + '<div class="proj-row proj-add" data-proj-add role="button" tabindex="0">' + escapeHtml(CONFIG.i18n.projectAdd) + '</div>';
2759
+ document.body.appendChild(menu);
2760
+ /* position under the anchor */
2761
+ try {
2762
+ const rect = anchor.getBoundingClientRect();
2763
+ menu.style.top = (rect.bottom + 4) + 'px';
2764
+ menu.style.left = Math.max(8, rect.left) + 'px';
2765
+ } catch (e) { /* default top-left */ }
2766
+ menu.querySelectorAll('[data-proj-slug]').forEach(function (row) {
2767
+ row.addEventListener('click', function () { switchProject(row.getAttribute('data-proj-slug')); });
2768
+ });
2769
+ const addRow = menu.querySelector('[data-proj-add]');
2770
+ if (addRow) addRow.addEventListener('click', addProjectViaPrompt);
2771
+ setTimeout(function () { document.addEventListener('click', onProjMenuOutside, true); }, 0);
2772
+ }
2773
+ async function switchProject(slug) {
2774
+ if (!slug) return;
2775
+ closeProjectMenu();
2776
+ setStatus(CONFIG.i18n.projectSwitching);
2777
+ try {
2778
+ const r = await fetch('/api/forge/projects/active', {
2779
+ method: 'POST', headers: { 'content-type': 'application/json' },
2780
+ body: JSON.stringify({ slug: slug }),
2781
+ });
2782
+ const j = await r.json();
2783
+ if (j && j.ok) {
2784
+ /* optimistic label update; the system.reanchor event reloads the
2785
+ * startup tabs + the graph follows server-side. */
2786
+ CONFIG.projectName = slug;
2787
+ setProj();
2788
+ setStatus('');
2789
+ } else {
2790
+ setStatus((j && j.error) || 'No se pudo cambiar de proyecto.', true);
2791
+ }
2792
+ } catch (e) {
2793
+ setStatus('No se pudo cambiar de proyecto.', true);
2794
+ }
2795
+ }
2796
+ async function addProjectViaPrompt() {
2797
+ closeProjectMenu();
2798
+ const path = prompt(CONFIG.i18n.projectAddPrompt, '');
2799
+ if (!path || !path.trim()) return;
2800
+ try {
2801
+ const r = await fetch('/api/forge/projects/add', {
2802
+ method: 'POST', headers: { 'content-type': 'application/json' },
2803
+ body: JSON.stringify({ path: path.trim() }),
2804
+ });
2805
+ const j = await r.json();
2806
+ if (!j || !j.ok) { setStatus((j && j.error) || 'No se pudo anadir el proyecto.', true); return; }
2807
+ /* reopen the menu so the user can pick the freshly added project */
2808
+ openProjectMenu($('#proj-full') || $('#proj-mini'));
2809
+ } catch (e) { setStatus('No se pudo anadir el proyecto.', true); }
2607
2810
  }
2608
2811
 
2609
2812
  function escapeHtml(s) {
@@ -3524,10 +3727,8 @@ async function pairGenerate() {
3524
3727
  const j = await r.json();
3525
3728
  if (j && j.ok) {
3526
3729
  const qr = document.getElementById('set-pair-qr');
3527
- const code = document.getElementById('set-pair-code');
3528
3730
  const exp = document.getElementById('set-pair-expiry');
3529
3731
  if (qr) qr.innerHTML = j.qr_svg || '';
3530
- if (code) code.textContent = j.code || '';
3531
3732
  if (exp && j.expires_at) {
3532
3733
  const mins = Math.max(0, Math.round((new Date(j.expires_at).getTime() - Date.now()) / 60000));
3533
3734
  exp.textContent = 'Expira en ~' + mins + ' min. Generalo de nuevo si caduca.';
@@ -4677,8 +4878,23 @@ function renderPizarronCardBody(it) {
4677
4878
  * estetica sumi-e. El modelo manda markdown (seguro: no se
4678
4879
  * ejecuta HTML/JS), el panel le da el look de documento. */
4679
4880
  if (it.kind === 'doc') {
4881
+ /* Default de documentos = HTML (pedido de Pablo). El brain genera
4882
+ * HTML por defecto; se renderiza sanitizado DENTRO de la hoja Yujin.
4883
+ * Backward-compat: si el content es markdown (no empieza con un tag
4884
+ * de bloque), cae a renderMdRich, asi los docs viejos siguen bien. */
4885
+ const dc = String(it.content || '');
4886
+ /* Pablo 2026-06-15 -- el heuristico viejo solo miraba si EMPEZABA
4887
+ * con un tag de bloque, asi que un doc HTML con un \n o una linea
4888
+ * de texto antes del primer tag caia a markdown y se veia "feo"
4889
+ * (tags como texto). Ahora: es HTML si empieza con un tag de bloque
4890
+ * O si contiene un tag de CIERRE de bloque en cualquier parte (senal
4891
+ * fuerte que el markdown no tiene). */
4892
+ const looksHtml =
4893
+ /^\\s*<(?:!doctype|html|head|body|div|section|article|main|header|h[1-6]|p|ul|ol|table|figure|blockquote|pre)\\b/i.test(dc)
4894
+ || /<\\/(?:h[1-6]|div|section|article|main|header|body|html|p|ul|ol|li|table|tr|td|figure|blockquote|pre)\\s*>/i.test(dc);
4895
+ const inner = looksHtml ? sanitizeHtml(dc) : renderMdRich(dc);
4680
4896
  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>'
4897
+ + '<article class="pz-doc-page pz-sumi">' + inner + '</article>'
4682
4898
  + '</div>';
4683
4899
  }
4684
4900
  /* Lienzo Coding (dogfood 2026-06-10) -- mientras el brain codea
@@ -4697,6 +4913,17 @@ function renderPizarronCardBody(it) {
4697
4913
  : '<div class="pz-coding-detail" data-pz-coding-detail>' + renderCodingDetail() + '</div>')
4698
4914
  + '</div>';
4699
4915
  }
4916
+ /* PND-007 -- Tareas: tareas de fondo que sobreviven al turno. Se
4917
+ * llena en vivo en initTasksTab() (poll a /api/forge/task). */
4918
+ if (it.kind === 'tasks') {
4919
+ 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">'
4920
+ + '<div class="pz-tasks-new">'
4921
+ + '<input type="text" class="pz-tasks-input" data-pz-tasks-cmd placeholder="comando (ej: pnpm install)..." />'
4922
+ + '<button type="button" class="pz-tasks-run" data-pz-tasks-run>Lanzar</button>'
4923
+ + '</div>'
4924
+ + '<div class="pz-tasks-list" data-pz-tasks-list><div class="pz-tasks-empty">cargando...</div></div>'
4925
+ + '</div>';
4926
+ }
4700
4927
  /* PND-094 -- vista de workflow: fases con nombre (nivel 1) + sus
4701
4928
  * tareas debajo (nivel 2), con avance por tarea y por fase. */
4702
4929
  if (it.kind === 'workflow') {
@@ -4728,9 +4955,15 @@ function renderPizarronCardBody(it) {
4728
4955
  }
4729
4956
  if (it.kind === 'bpmn' || it.kind === 'flow') {
4730
4957
  const trimmed = it.content.trim();
4958
+ /* SVG crudo -> passthrough (compat). */
4731
4959
  if (trimmed.startsWith('<svg')) {
4732
4960
  return bodyOpen + sanitizeHtml(trimmed) + '</div>';
4733
4961
  }
4962
+ /* Modelo JSON {nodes,flows,lanes?} -> renderer BPMN 2.0 propio. */
4963
+ if (trimmed.charAt(0) === '{') {
4964
+ 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">'
4965
+ + renderBpmnView(trimmed) + '</div>';
4966
+ }
4734
4967
  return bodyOpen + '<pre class="pz-code"><code>' + escapeHtml(it.content) + '</code></pre></div>';
4735
4968
  }
4736
4969
  return bodyOpen + '<pre><code>' + escapeHtml(it.content) + '</code></pre></div>';
@@ -4821,6 +5054,188 @@ function renderPizarron() {
4821
5054
  if (it.kind === 'coding') {
4822
5055
  try { initCodingTab(it); } catch (e) { /* render best-effort */ }
4823
5056
  }
5057
+ if (it.kind === 'tasks') {
5058
+ try { initTasksTab(it); } catch (e) { /* render best-effort */ }
5059
+ }
5060
+ if (it.kind === 'workflow') {
5061
+ try { initWorkflowTab(it); } catch (e) { /* render best-effort */ }
5062
+ }
5063
+ }
5064
+
5065
+ /* Selector multi-workflow: persiste el indice elegido por tab id (igual
5066
+ * que el filtro del grafo) para que un refresh del board no resetee la
5067
+ * seleccion. Con un solo workflow no hay barra de chips: no-op. */
5068
+ var _wflSelByTab = {};
5069
+ function initWorkflowTab(it) {
5070
+ var body = document.querySelector('[data-nac-id="yujin.pizarron.item.' + it.id + '.body"]');
5071
+ if (!body) return;
5072
+ var root = body.querySelector('[data-pz-wfl-root]');
5073
+ if (!root) return;
5074
+ var chips = root.querySelectorAll('[data-pz-wfl-chip]');
5075
+ var cards = root.querySelectorAll('[data-pz-wfl-card]');
5076
+ if (chips.length <= 1) return; /* un solo workflow: sin selector */
5077
+ var show = function (idx) {
5078
+ for (var i = 0; i < cards.length; i++) {
5079
+ cards[i].style.display = (String(cards[i].getAttribute('data-wfl-idx')) === String(idx)) ? '' : 'none';
5080
+ }
5081
+ for (var j = 0; j < chips.length; j++) {
5082
+ var on = String(chips[j].getAttribute('data-wfl-idx')) === String(idx);
5083
+ if (on) chips[j].classList.add('active'); else chips[j].classList.remove('active');
5084
+ }
5085
+ _wflSelByTab[it.id] = String(idx);
5086
+ };
5087
+ for (var k = 0; k < chips.length; k++) {
5088
+ chips[k].addEventListener('click', function () { show(this.getAttribute('data-wfl-idx')); });
5089
+ }
5090
+ var saved = _wflSelByTab[it.id];
5091
+ show(saved != null ? saved : '0');
5092
+ }
5093
+
5094
+ /* ====================================================================
5095
+ * PND-007 -- Tareas tab. Polls /api/forge/task; renders survivable
5096
+ * background tasks with a per-type icon, status, a log peek + kill, and
5097
+ * a small launcher. Each task TYPE (install/build/server/...) gets its
5098
+ * own sumi-e glyph.
5099
+ * ================================================================== */
5100
+ var yfTasksTimer = null;
5101
+
5102
+ function taskTypeIcon(type) {
5103
+ switch (String(type || 'other')) {
5104
+ case 'install': return { g: '↓', c: '#4f5b87' }; /* down arrow */
5105
+ case 'build': return { g: '⚒', c: '#c8a04d' }; /* hammer+pick */
5106
+ case 'server': return { g: '◉', c: '#3fa85e' }; /* fisheye */
5107
+ case 'test': return { g: '✓', c: '#6f8b5e' }; /* check */
5108
+ case 'deploy': return { g: '☁', c: '#8a7bb0' }; /* cloud */
5109
+ case 'git': return { g: '⎇', c: '#b07b4f' }; /* alternation */
5110
+ case 'script': return { g: '›', c: '#7a8aa0' }; /* chevron */
5111
+ default: return { g: '●', c: '#9a9a9a' }; /* disc */
5112
+ }
5113
+ }
5114
+
5115
+ function taskStatusBadge(s) {
5116
+ if (s === 'running') return { t: 'corriendo', c: '#c8a04d' };
5117
+ if (s === 'done') return { t: 'terminada', c: '#6f8b5e' };
5118
+ if (s === 'failed') return { t: 'fallo', c: '#c8472e' };
5119
+ if (s === 'killed') return { t: 'detenida', c: '#9a9a9a' };
5120
+ if (s === 'exited') return { t: 'salio', c: '#9a9a9a' };
5121
+ return { t: String(s || ''), c: '#9a9a9a' };
5122
+ }
5123
+
5124
+ function renderTasksList(tasks) {
5125
+ if (!tasks || tasks.length === 0) {
5126
+ return '<div class="pz-tasks-empty">Sin tareas de fondo. Lo que lances aca sobrevive al turno.</div>';
5127
+ }
5128
+ var html = '';
5129
+ for (var i = 0; i < tasks.length; i++) {
5130
+ var t = tasks[i];
5131
+ var ic = taskTypeIcon(t.type);
5132
+ var sb = taskStatusBadge(t.status);
5133
+ var cmdline = escapeHtml(String(t.cmd || '') + (t.args && t.args.length ? ' ' + t.args.join(' ') : '')).slice(0, 200);
5134
+ html += '<div class="pz-task" data-pz-task="' + escapeHtml(t.id) + '">'
5135
+ + '<div class="pz-task-ic" style="color:' + ic.c + '">' + ic.g + '</div>'
5136
+ + '<div class="pz-task-main">'
5137
+ + '<div class="pz-task-label">' + escapeHtml(String(t.label || t.cmd || 'tarea')) + '</div>'
5138
+ + '<div class="pz-task-cmd">' + cmdline + '</div>'
5139
+ + '</div>'
5140
+ + '<div class="pz-task-side">'
5141
+ + '<span class="pz-task-badge" style="color:' + sb.c + ';border-color:' + sb.c + '">' + sb.t + '</span>'
5142
+ + (t.status === 'running'
5143
+ ? '<button type="button" class="pz-task-kill" data-pz-task-kill="' + escapeHtml(t.id) + '">stop</button>'
5144
+ : '<button type="button" class="pz-task-log" data-pz-task-log="' + escapeHtml(t.id) + '">log</button>')
5145
+ + '<button type="button" class="pz-task-del" data-pz-task-del="' + escapeHtml(t.id) + '" title="Eliminar tarea" aria-label="Eliminar tarea">&times;</button>'
5146
+ + '</div>'
5147
+ + '</div>'
5148
+ + '<div class="pz-task-logbox" data-pz-task-logbox="' + escapeHtml(t.id) + '"></div>';
5149
+ }
5150
+ return html;
5151
+ }
5152
+
5153
+ async function refreshTasksTab() {
5154
+ var list = document.querySelector('[data-pz-tasks-list]');
5155
+ if (!list) return; /* tab closed -> stop touching the DOM */
5156
+ try {
5157
+ var r = await fetch('/api/forge/task');
5158
+ var data = await r.json();
5159
+ if (data && data.ok) list.innerHTML = renderTasksList(data.tasks || []);
5160
+ } catch (e) { /* best-effort */ }
5161
+ }
5162
+
5163
+ async function tasksTabLaunch() {
5164
+ var inp = document.querySelector('[data-pz-tasks-cmd]');
5165
+ if (!inp) return;
5166
+ var raw = String(inp.value || '').trim();
5167
+ if (!raw) return;
5168
+ /* split a simple "cmd arg arg" string (no shell parsing).
5169
+ * NOTE: this code rides inside the panel's outer template literal, so
5170
+ * a regex backslash must be DOUBLED -- /\s+/ would be mangled to /s+/
5171
+ * (split on the letter 's': "pnpm install" -> "pnpm in"+"tall"). */
5172
+ var parts = raw.split(/\\s+/);
5173
+ var cmd = parts.shift();
5174
+ try {
5175
+ await fetch('/api/forge/task', {
5176
+ method: 'POST', headers: { 'content-type': 'application/json' },
5177
+ body: JSON.stringify({ cmd: cmd, args: parts, label: raw.slice(0, 60) }),
5178
+ });
5179
+ inp.value = '';
5180
+ await refreshTasksTab();
5181
+ } catch (e) { /* best-effort */ }
5182
+ }
5183
+
5184
+ async function tasksTabShowLog(id) {
5185
+ var box = document.querySelector('[data-pz-task-logbox="' + id + '"]');
5186
+ if (!box) return;
5187
+ if (box.getAttribute('data-open') === '1') { box.innerHTML = ''; box.removeAttribute('data-open'); return; }
5188
+ try {
5189
+ var r = await fetch('/api/forge/task/log?id=' + encodeURIComponent(id) + '&lines=40');
5190
+ var data = await r.json();
5191
+ box.innerHTML = '<pre class="pz-task-logpre">' + escapeHtml(String((data && data.tail) || '(sin salida)')) + '</pre>';
5192
+ box.setAttribute('data-open', '1');
5193
+ } catch (e) { /* best-effort */ }
5194
+ }
5195
+
5196
+ async function tasksTabKill(id) {
5197
+ try {
5198
+ await fetch('/api/forge/task/kill', {
5199
+ method: 'POST', headers: { 'content-type': 'application/json' },
5200
+ body: JSON.stringify({ id: id }),
5201
+ });
5202
+ await refreshTasksTab();
5203
+ } catch (e) { /* best-effort */ }
5204
+ }
5205
+
5206
+ async function tasksTabDelete(id) {
5207
+ /* Remove the task from the list (kills it first if still running). */
5208
+ try {
5209
+ await fetch('/api/forge/task/delete', {
5210
+ method: 'POST', headers: { 'content-type': 'application/json' },
5211
+ body: JSON.stringify({ id: id }),
5212
+ });
5213
+ await refreshTasksTab();
5214
+ } catch (e) { /* best-effort */ }
5215
+ }
5216
+
5217
+ function initTasksTab(it) {
5218
+ var wrap = document.querySelector('.pz-tasks-wrap');
5219
+ if (!wrap) return;
5220
+ var runBtn = wrap.querySelector('[data-pz-tasks-run]');
5221
+ if (runBtn) runBtn.addEventListener('click', function () { tasksTabLaunch().catch(function () {}); });
5222
+ var inp = wrap.querySelector('[data-pz-tasks-cmd]');
5223
+ if (inp) inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') tasksTabLaunch().catch(function () {}); });
5224
+ /* delegated clicks for per-task log/kill */
5225
+ wrap.addEventListener('click', function (e) {
5226
+ var tgt = e.target;
5227
+ if (!tgt || !tgt.getAttribute) return;
5228
+ var killId = tgt.getAttribute('data-pz-task-kill');
5229
+ if (killId) { tasksTabKill(killId).catch(function () {}); return; }
5230
+ var delId = tgt.getAttribute('data-pz-task-del');
5231
+ if (delId) { tasksTabDelete(delId).catch(function () {}); return; }
5232
+ var logId = tgt.getAttribute('data-pz-task-log');
5233
+ if (logId) { tasksTabShowLog(logId).catch(function () {}); return; }
5234
+ });
5235
+ refreshTasksTab().catch(function () {});
5236
+ if (!yfTasksTimer) {
5237
+ yfTasksTimer = setInterval(function () { refreshTasksTab().catch(function () {}); }, 4000);
5238
+ }
4824
5239
  }
4825
5240
 
4826
5241
  /* ====================================================================
@@ -4842,6 +5257,68 @@ function renderPizarron() {
4842
5257
  function renderWorkflowView(content) {
4843
5258
  let v;
4844
5259
  try { v = JSON.parse(content || '{}'); } catch (e) { v = null; }
5260
+ /* PND-094 ext -- multi-workflow: content { workflows: [card...] }. Each
5261
+ * card = one coexisting WorkflowInstance with its stages -> ordered
5262
+ * task pendings. Falls through to the legacy single-workflow render
5263
+ * below when content is the old { stages, ... } shape. */
5264
+ if (v && Array.isArray(v.workflows)) {
5265
+ if (v.workflows.length === 0) return '<div class="pz-wfl-empty">Sin workflows abiertos.</div>';
5266
+ var kindLbl = { greenfield: 'App nueva', corrective: 'Correctivo', evolutive: 'Evolutivo' };
5267
+ var ic2 = function (closed) { return closed ? '✓' : '○'; };
5268
+ var multi = v.workflows.length > 1;
5269
+ /* Selector entre los workflows abiertos: con >1 se muestra una barra
5270
+ * de chips (uno por workflow) y SOLO se ve el seleccionado. El estado
5271
+ * lo persiste initWorkflowTab por tab (sobrevive a los refreshes). */
5272
+ var sel = '';
5273
+ if (multi) {
5274
+ sel = '<div class="pz-wfl-sel" data-pz-wfl-sel>';
5275
+ for (var ci = 0; ci < v.workflows.length; ci++) {
5276
+ var cw = v.workflows[ci];
5277
+ sel += '<button type="button" class="pz-wfl-chip' + (ci === 0 ? ' active' : '') + '"'
5278
+ + ' data-pz-wfl-chip data-wfl-idx="' + ci + '">'
5279
+ + escapeHtml(cw.code) + ' &middot; ' + escapeHtml(cw.name || '') + '</button>';
5280
+ }
5281
+ sel += '</div>';
5282
+ }
5283
+ var out = '<div data-pz-wfl-root>' + sel;
5284
+ for (var wi = 0; wi < v.workflows.length; wi++) {
5285
+ var wf = v.workflows[wi];
5286
+ var wpct = wf.total > 0 ? Math.round((wf.done / wf.total) * 100) : 0;
5287
+ out += '<div class="pz-wfl-card" data-pz-wfl-card data-wfl-idx="' + wi + '"'
5288
+ + (multi && wi !== 0 ? ' style="display:none"' : '') + '>'
5289
+ + '<div class="pz-wfl-head">'
5290
+ + '<div class="pz-wfl-title">' + escapeHtml(wf.code + ' -- ' + (wf.name || '')) + '</div>'
5291
+ + '<div class="pz-wfl-kind">' + escapeHtml(kindLbl[wf.kind] || wf.kind || '') + (wf.status === 'closed' ? ' (cerrado)' : '') + '</div>'
5292
+ + '<div class="pz-wfl-overall">' + (wf.done || 0) + '/' + (wf.total || 0) + ' tareas &middot; ' + wpct + '%</div>'
5293
+ + '<div class="pz-wfl-bar"><div class="pz-wfl-fill" style="width:' + wpct + '%"></div></div>'
5294
+ + '</div>';
5295
+ out += '<div class="pz-wfl-flow">';
5296
+ for (var si2 = 0; si2 < wf.stages.length; si2++) {
5297
+ var st2 = wf.stages[si2];
5298
+ var tasks2 = st2.tasks || [];
5299
+ var sdone = 0; for (var ti = 0; ti < tasks2.length; ti++) if (tasks2[ti].closed) sdone++;
5300
+ var sst = tasks2.length === 0 ? 'pending' : (sdone === tasks2.length ? 'done' : (sdone > 0 ? 'active' : 'pending'));
5301
+ var last2 = si2 === wf.stages.length - 1;
5302
+ out += '<div class="pz-wfl-col">'
5303
+ + '<div class="pz-wfl-phase wfl-' + sst + (last2 ? ' wfl-last' : '') + '">'
5304
+ + '<div class="pz-wfl-phase-title">' + escapeHtml(st2.title || st2.id || '') + '</div>'
5305
+ + '<div class="pz-wfl-phase-count">' + sdone + '/' + tasks2.length + '</div>'
5306
+ + '</div><div class="pz-wfl-hang">';
5307
+ for (var k2 = 0; k2 < tasks2.length; k2++) {
5308
+ var tk = tasks2[k2];
5309
+ out += '<div class="pz-wfl-task wfl-' + (tk.closed ? 'done' : 'pending') + '" title="' + escapeHtml(tk.id) + '">'
5310
+ + '<span class="pz-wfl-check">' + ic2(tk.closed) + '</span>'
5311
+ + '<span class="pz-wfl-task-title">' + escapeHtml((tk.id ? tk.id + ' ' : '') + (tk.title || '')) + (tk.ad_hoc ? ' <span class="pz-wfl-adhoc">+</span>' : '') + '</span>'
5312
+ + '</div>';
5313
+ }
5314
+ if (tasks2.length === 0) out += '<div class="pz-wfl-task wfl-pending pz-wfl-empty-stage">sin tareas</div>';
5315
+ out += '</div></div>';
5316
+ }
5317
+ out += '</div></div>';
5318
+ }
5319
+ out += '</div>';
5320
+ return out;
5321
+ }
4845
5322
  if (!v || !Array.isArray(v.stages)) {
4846
5323
  return '<div class="pz-wfl-empty">Sin estado de workflow todavia.</div>';
4847
5324
  }
@@ -4882,6 +5359,161 @@ function renderWorkflowView(content) {
4882
5359
  return h;
4883
5360
  }
4884
5361
 
5362
+ /* Renderer BPMN 2.0 (pestana canonica de flujos). El content es un
5363
+ * modelo JSON { lanes?:[{id,label}], nodes:[{id,type,label,lane?,
5364
+ * gateway?}], flows:[{from,to,label?}] }. Layout en capas izq->der
5365
+ * (longest-path), shapes BPMN (eventos=circulos, tareas=rect
5366
+ * redondeado, gateways=rombo con simbolo), flujos ortogonales con
5367
+ * flecha + label, swimlanes opcionales. Estatico, estetica sumi-e. */
5368
+ function bpmnWrap(s, maxChars, maxLines) {
5369
+ var words = String(s == null ? '' : s).split(/\s+/);
5370
+ var lines = []; var cur = '';
5371
+ for (var i = 0; i < words.length; i++) {
5372
+ var w = words[i];
5373
+ if (!cur) { cur = w; }
5374
+ else if ((cur + ' ' + w).length <= maxChars) { cur += ' ' + w; }
5375
+ else { lines.push(cur); cur = w; if (lines.length === maxLines - 1) break; }
5376
+ }
5377
+ if (cur && lines.length < maxLines) lines.push(cur);
5378
+ if (lines.length === maxLines && (cur || words.length > 0)) {
5379
+ var last = lines[maxLines - 1];
5380
+ if (last && last.length > maxChars) lines[maxLines - 1] = last.slice(0, maxChars - 1) + '…';
5381
+ }
5382
+ return lines;
5383
+ }
5384
+
5385
+ function renderBpmnView(content) {
5386
+ var m;
5387
+ try { m = JSON.parse(content); } catch (e) {
5388
+ return '<div class="pz-bpmn-empty">El flujo BPMN espera JSON { nodes, flows, lanes? }. No pude parsear el content.</div>';
5389
+ }
5390
+ if (!m || !Array.isArray(m.nodes) || m.nodes.length === 0) {
5391
+ return '<div class="pz-bpmn-empty">Sin nodos en el flujo.</div>';
5392
+ }
5393
+ var nodes = m.nodes.filter(function (n) { return n && n.id; });
5394
+ var flows = Array.isArray(m.flows) ? m.flows.filter(function (f) { return f && f.from && f.to; }) : [];
5395
+ var lanes = Array.isArray(m.lanes) ? m.lanes.filter(function (l) { return l && l.id; }) : [];
5396
+ var byId = {}; nodes.forEach(function (n) { byId[n.id] = n; });
5397
+ /* capas: longest-path desde las fuentes (DAG; cap por #nodos si hay ciclos). */
5398
+ var layer = {}; nodes.forEach(function (n) { layer[n.id] = 0; });
5399
+ for (var it = 0; it < nodes.length; it++) {
5400
+ var changed = false;
5401
+ for (var k = 0; k < flows.length; k++) {
5402
+ var f = flows[k];
5403
+ if (byId[f.from] && byId[f.to] && layer[f.to] < layer[f.from] + 1) { layer[f.to] = layer[f.from] + 1; changed = true; }
5404
+ }
5405
+ if (!changed) break;
5406
+ }
5407
+ var maxCol = 0; nodes.forEach(function (n) { if (layer[n.id] > maxCol) maxCol = layer[n.id]; });
5408
+ var hasLanes = lanes.length > 0;
5409
+ var laneIdx = {}; lanes.forEach(function (l, i) { laneIdx[l.id] = i; });
5410
+ /* dimensiones */
5411
+ var COL_W = 178, LANE_H = 116, ROW_H = 96, PAD = 30, LANE_LBL = hasLanes ? 30 : 0, TOPPAD = hasLanes ? 8 : 26;
5412
+ /* posiciones (centro de cada nodo) */
5413
+ var pos = {};
5414
+ if (hasLanes) {
5415
+ var subCount = {}; /* nodos por (lane,col) para apilar */
5416
+ nodes.forEach(function (n) {
5417
+ var li = (n.lane && laneIdx[n.lane] != null) ? laneIdx[n.lane] : 0;
5418
+ var key = li + ':' + layer[n.id];
5419
+ var sub = subCount[key] || 0; subCount[key] = sub + 1;
5420
+ pos[n.id] = { li: li, col: layer[n.id], sub: sub };
5421
+ });
5422
+ nodes.forEach(function (n) {
5423
+ var p = pos[n.id]; var key = p.li + ':' + p.col; var cnt = subCount[key];
5424
+ var bandTop = TOPPAD + p.li * LANE_H;
5425
+ n._x = PAD + LANE_LBL + p.col * COL_W + COL_W / 2;
5426
+ n._y = bandTop + (p.sub + 1) * (LANE_H / (cnt + 1));
5427
+ });
5428
+ } else {
5429
+ var cols = {}; nodes.forEach(function (n) { (cols[layer[n.id]] = cols[layer[n.id]] || []).push(n); });
5430
+ var maxRows = 0; for (var c in cols) if (cols[c].length > maxRows) maxRows = cols[c].length;
5431
+ var diagramH = maxRows * ROW_H;
5432
+ for (var cc = 0; cc <= maxCol; cc++) {
5433
+ var arr = cols[cc] || [];
5434
+ var startY = TOPPAD + (diagramH - arr.length * ROW_H) / 2;
5435
+ arr.forEach(function (n, i) {
5436
+ n._x = PAD + cc * COL_W + COL_W / 2;
5437
+ n._y = startY + i * ROW_H + ROW_H / 2;
5438
+ });
5439
+ }
5440
+ }
5441
+ var W = PAD + LANE_LBL + (maxCol + 1) * COL_W + PAD;
5442
+ 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; })());
5443
+ var P = { ink: '#1a1a1a', indigo: '#4f5b87', amber: '#c8a04d', tea: '#6f8b5e', vermilion: '#c8472e', line: '#b8ad95', washi: '#f5f0e6', bg2: '#ece6d8' };
5444
+ /* Auto-resize fix (Pablo 2026-06-16): a wide-flat flow (W >> H) used to
5445
+ * be scaled DOWN to the tab width (width:100%), shrinking the nodes to an
5446
+ * unreadable band with dead space below. min-width = the intrinsic width
5447
+ * keeps nodes at their designed size: a narrow flow still fills the width
5448
+ * (scaled up), a wide one renders 1:1 and the container scrolls. */
5449
+ var svg = '<svg class="pz-bpmn-svg" viewBox="0 0 ' + Math.round(W) + ' ' + Math.round(H) + '"'
5450
+ + ' style="min-width:' + Math.round(W) + 'px"'
5451
+ + ' preserveAspectRatio="xMinYMin meet" xmlns="http://www.w3.org/2000/svg">';
5452
+ svg += '<defs><marker id="bpmn-arrow" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto" markerUnits="strokeWidth">'
5453
+ + '<path d="M0,0 L7,3 L0,6 z" fill="' + P.ink + '"/></marker></defs>';
5454
+ /* swimlanes */
5455
+ if (hasLanes) {
5456
+ for (var li2 = 0; li2 < lanes.length; li2++) {
5457
+ var ly = TOPPAD + li2 * LANE_H;
5458
+ 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"/>';
5459
+ 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>';
5460
+ }
5461
+ }
5462
+ /* flujos (debajo de los nodos) */
5463
+ flows.forEach(function (f) {
5464
+ var a = byId[f.from], b = byId[f.to]; if (!a || !b) return;
5465
+ var ar = bpmnHalf(a), br = bpmnHalf(b);
5466
+ var sx, sy = a._y, tx, ty = b._y;
5467
+ var forward = (b._x >= a._x);
5468
+ sx = a._x + (forward ? ar : -ar); tx = b._x - (forward ? br : -br);
5469
+ var midx = (sx + tx) / 2;
5470
+ var d;
5471
+ if (Math.abs(sy - ty) < 2) { d = 'M' + sx + ',' + sy + ' L' + tx + ',' + ty; }
5472
+ else if (forward) { d = 'M' + sx + ',' + sy + ' L' + midx + ',' + sy + ' L' + midx + ',' + ty + ' L' + tx + ',' + ty; }
5473
+ 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); }
5474
+ svg += '<path d="' + d + '" fill="none" stroke="' + P.ink + '" stroke-width="1.4" marker-end="url(#bpmn-arrow)" opacity="0.8"/>';
5475
+ if (f.label) {
5476
+ var lx = forward ? midx : ((a._x + b._x) / 2), lyy = forward ? (sy + ty) / 2 - 4 : (Math.max(a._y, b._y) + 42);
5477
+ 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"/>';
5478
+ svg += '<text x="' + lx + '" y="' + (lyy + 1) + '" text-anchor="middle" font-size="10" fill="' + P.ink + '">' + escapeHtml(String(f.label)) + '</text>';
5479
+ }
5480
+ });
5481
+ /* nodos */
5482
+ nodes.forEach(function (n) {
5483
+ var x = n._x, y = n._y, type = String(n.type || 'task');
5484
+ if (/start|^event$|end|intermediate/.test(type)) {
5485
+ var r = 18, sw = type.indexOf('end') >= 0 ? 3.4 : 1.6;
5486
+ var stroke = type.indexOf('start') >= 0 ? P.tea : (type.indexOf('end') >= 0 ? P.vermilion : P.indigo);
5487
+ svg += '<circle cx="' + x + '" cy="' + y + '" r="' + r + '" fill="' + P.washi + '" stroke="' + stroke + '" stroke-width="' + sw + '"/>';
5488
+ if (type.indexOf('intermediate') >= 0) svg += '<circle cx="' + x + '" cy="' + y + '" r="' + (r - 3.5) + '" fill="none" stroke="' + stroke + '" stroke-width="1.2"/>';
5489
+ 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>';
5490
+ } else if (/gateway|decision|fork|join|merge/.test(type)) {
5491
+ var g = 23, gk = String(n.gateway || (/parallel|fork|join/.test(type) ? 'parallel' : 'exclusive'));
5492
+ 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"/>';
5493
+ var sym = gk.charAt(0) === 'p' ? '+' : (gk.charAt(0) === 'i' ? 'O' : '\\u00d7');
5494
+ svg += '<text x="' + x + '" y="' + (y + 5) + '" text-anchor="middle" font-size="16" fill="' + P.amber + '" font-weight="700">' + sym + '</text>';
5495
+ 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>';
5496
+ } else {
5497
+ var tw = 118, th = 54;
5498
+ 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"/>';
5499
+ var ic = { userTask: '\\u263a', serviceTask: '\\u2699', scriptTask: '\\u25b8', manualTask: '\\u270b', sendTask: '\\u2709', receiveTask: '\\u2709' }[type];
5500
+ if (ic) svg += '<text x="' + (x - tw / 2 + 9) + '" y="' + (y - th / 2 + 14) + '" font-size="11" fill="' + P.indigo + '">' + ic + '</text>';
5501
+ var ls = bpmnWrap(n.label || n.id, 17, 3);
5502
+ var ty0 = y - (ls.length - 1) * 7;
5503
+ 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>';
5504
+ }
5505
+ });
5506
+ svg += '</svg>';
5507
+ return svg;
5508
+ }
5509
+ /* mitad del ancho/alto del nodo para anclar los flujos al borde. */
5510
+ function bpmnHalf(n) {
5511
+ var type = String(n.type || 'task');
5512
+ if (/start|^event$|end|intermediate/.test(type)) return 18;
5513
+ if (/gateway|decision|fork|join|merge/.test(type)) return 23;
5514
+ return 59;
5515
+ }
5516
+
4885
5517
  const CODING_TAB_ID = 'coding_canvas';
4886
5518
  const yfCoding = {
4887
5519
  strokes: [], texts: [], fade: 0, phase: 'draw', /* draw | fadeout */
@@ -4934,8 +5566,12 @@ function renderCodingDetail() {
4934
5566
  var item = feed[i];
4935
5567
  var d = item.detail || { kind: 'other', target: item.text, path: null, diff: null, body: null };
4936
5568
  var icon = CODING_ICON[d.kind] || '·';
5569
+ var ts = '';
5570
+ try { ts = new Date(item.t).toTimeString().slice(0, 8); } catch (e) { ts = ''; }
4937
5571
  h += '<div class="pz-cw-row k-' + escapeHtml(d.kind || 'other') + '">'
4938
- + '<div class="pz-cw-head"><span class="pz-cw-ic">' + icon + '</span>'
5572
+ + '<div class="pz-cw-head">'
5573
+ + '<span class="pz-cw-ts">' + ts + '</span>'
5574
+ + '<span class="pz-cw-ic">' + icon + '</span>'
4939
5575
  + '<span class="pz-cw-target">' + escapeHtml(d.target || item.text || '') + '</span>'
4940
5576
  + (d.path ? '<span class="pz-cw-path">' + escapeHtml(d.path) + '</span>' : '')
4941
5577
  + '</div>';
@@ -5276,10 +5912,11 @@ function unmarkStartupTabClosed(key) {
5276
5912
  * titulo real lo recordamos del ultimo fetch; el fallback cubre las que
5277
5913
  * nunca se vieron. */
5278
5914
  var yfStartupTitles = {};
5279
- var NATIVE_TAB_KEYS = ['startup_graph', 'startup_graph_sem', 'startup_pendings', 'startup_workflow', 'coding_canvas'];
5915
+ var NATIVE_TAB_KEYS = ['startup_graph', 'startup_graph_sem', 'startup_pendings', 'startup_workflow', 'startup_tasks', 'coding_canvas'];
5280
5916
  var NATIVE_TAB_FALLBACK = {
5281
5917
  startup_graph: 'Grafo', startup_graph_sem: 'Grafo semantico',
5282
- startup_pendings: 'Pendientes', startup_workflow: 'Workflow', coding_canvas: 'Coding',
5918
+ startup_pendings: 'Pendientes', startup_workflow: 'Workflow',
5919
+ startup_tasks: 'Tareas', coding_canvas: 'Coding',
5283
5920
  };
5284
5921
  function nativeTabLabel(key) { return yfStartupTitles[key] || NATIVE_TAB_FALLBACK[key] || key; }
5285
5922
 
@@ -5305,8 +5942,15 @@ function pizarronUpsertById(item) {
5305
5942
  const wasEditing = existing.isEditing;
5306
5943
  /* No pisar si el usuario esta editando esa tab. */
5307
5944
  if (!wasEditing) {
5945
+ /* Solo re-renderizar si el contenido/tipo/titulo CAMBIO. Antes se
5946
+ * re-renderizaba en cada refresh (cada 12s), lo que reiniciaba el
5947
+ * grafo: reseteaba los filtros de grupo + reposicionaba el layout
5948
+ * aunque nada hubiera cambiado. */
5949
+ const changed = existing.content !== item.content
5950
+ || existing.kind !== item.kind
5951
+ || existing.title !== item.title;
5308
5952
  Object.assign(existing, item, { id: existing.id, isEditing: false });
5309
- if (wasActive) renderPizarron();
5953
+ if (wasActive && changed) renderPizarron();
5310
5954
  }
5311
5955
  return existing.id;
5312
5956
  }
@@ -5391,6 +6035,33 @@ function graphNodeColor(n) {
5391
6035
  return '#8a7bb0'; /* violeta tenue */
5392
6036
  }
5393
6037
 
6038
+ /* High-level group of a node, for the filter legend -- SAME meaning as
6039
+ * the server's nodeGroup (board_snapshot.ts) so desktop + mobile agree.
6040
+ * indexOf-only (NO regex: this lives inside the panel template literal,
6041
+ * where a regex backslash would be mangled). */
6042
+ const PZ_GROUP_COLORS = {
6043
+ backend: '#5b6aa6', frontend: '#6f8b5e', mobile: '#c2914a', tests: '#b06a6a',
6044
+ scripts: '#8a7bb0', libs: '#9a9a9a', tools: '#4a8a8a', protocol: '#b0894a',
6045
+ };
6046
+ /* Filtro de grupos por tab de grafo (id -> {group:boolean}). Se conserva
6047
+ * entre re-renders para que un refresh con datos nuevos NO reinicie la
6048
+ * seleccion del usuario. */
6049
+ const _graphFilterByTab = {};
6050
+ function graphNodeGroup(n) {
6051
+ const id = String((n && n.id) || '');
6052
+ const s = (id + ' ' + String((n && (n.label || n.name)) || '')).toLowerCase();
6053
+ if (id.indexOf('tool.ci.') === 0) return 'tools';
6054
+ if (id.indexOf('tool.npm.') === 0 || id.indexOf('tool.script.') === 0) return 'scripts';
6055
+ if ((n && n.kind === 'library') || id.indexOf('lib.') === 0) return 'libs';
6056
+ const has = (w) => s.indexOf(w) >= 0;
6057
+ if (has('test') || has('spec') || has('vitest') || has('playwright') || has('.qa') || has('qa.')) return 'tests';
6058
+ if (has('mobile') || has('forge-rn') || has('nac3-rn') || has('expo') || has('metro')) return 'mobile';
6059
+ if (has('panel') || has('pizarron') || has('chat') || has('reader') || has('design')
6060
+ || has('figma') || has('webview') || has('component') || has('frontend')) return 'frontend';
6061
+ if (n && n.kind === 'protocol') return 'protocol';
6062
+ return 'backend';
6063
+ }
6064
+
5394
6065
  function initGraphTab(it) {
5395
6066
  const canvas = document.querySelector('[data-pz-graph-canvas]');
5396
6067
  const detail = document.querySelector('[data-pz-graph-detail]');
@@ -5414,8 +6085,10 @@ function initGraphTab(it) {
5414
6085
  label: String(n.label != null ? n.label : n.id != null ? n.id : i),
5415
6086
  weight: typeof n.weight === 'number' ? n.weight : 0.5,
5416
6087
  kind: n.kind || '',
6088
+ group: graphNodeGroup(n),
5417
6089
  detail: n.detail || '',
5418
- color: graphNodeColor(n),
6090
+ confidence: n.confidence || 'auto',
6091
+ color: PZ_GROUP_COLORS[graphNodeGroup(n)] || graphNodeColor(n),
5419
6092
  x: W / 2 + Math.cos(ang) * Math.min(W, H) * 0.32,
5420
6093
  y: H / 2 + Math.sin(ang) * Math.min(W, H) * 0.32,
5421
6094
  vx: 0, vy: 0, fixed: false,
@@ -5470,6 +6143,15 @@ function initGraphTab(it) {
5470
6143
  const circle = document.createElementNS(SVGNS, 'circle');
5471
6144
  circle.setAttribute('r', String(r));
5472
6145
  circle.setAttribute('fill', n.color);
6146
+ /* PND graph -- nodos 'claimed' (declarados en chat, sin verificacion
6147
+ * automatica) se dibujan atenuados + con borde punteado para
6148
+ * distinguirlos de lo derivado del codigo (auto). Antes se ocultaban. */
6149
+ if (n.confidence === 'claimed') {
6150
+ circle.setAttribute('fill-opacity', '0.45');
6151
+ circle.setAttribute('stroke', n.color);
6152
+ circle.setAttribute('stroke-width', '1.5');
6153
+ circle.setAttribute('stroke-dasharray', '3 2');
6154
+ }
5473
6155
  g.appendChild(circle);
5474
6156
  const label = document.createElementNS(SVGNS, 'text');
5475
6157
  label.setAttribute('class', 'pz-node-label');
@@ -5482,6 +6164,48 @@ function initGraphTab(it) {
5482
6164
  return g;
5483
6165
  });
5484
6166
 
6167
+ /* ---- Filtro por grupo (legend toggleable) -- desktop a la par del
6168
+ * mobile. Colorea por backend/frontend/mobile/tests/tools/scripts/
6169
+ * libs/protocol y permite mostrar/ocultar cada grupo. ---- */
6170
+ /* Restaurar la seleccion previa de este tab (si existe) para que un
6171
+ * refresh no la pierda; grupos nuevos arrancan visibles. */
6172
+ const savedVis = _graphFilterByTab[it.id] || {};
6173
+ const vis = {};
6174
+ const counts = {};
6175
+ nodes.forEach((n) => {
6176
+ vis[n.group] = Object.prototype.hasOwnProperty.call(savedVis, n.group) ? !!savedVis[n.group] : true;
6177
+ counts[n.group] = (counts[n.group] || 0) + 1;
6178
+ });
6179
+ _graphFilterByTab[it.id] = vis; /* el toggle muta este mismo objeto */
6180
+ const legend = document.createElement('div');
6181
+ legend.setAttribute('style', 'position:absolute;top:6px;left:6px;right:6px;display:flex;flex-wrap:wrap;gap:5px;z-index:6;pointer-events:auto;');
6182
+ Object.keys(counts).sort().forEach((k) => {
6183
+ const chip = document.createElement('button');
6184
+ chip.type = 'button';
6185
+ chip.setAttribute('data-g', k);
6186
+ chip.setAttribute('style', 'display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:11px;color:#3a3a3a;'
6187
+ + 'border:1px solid #d9d2c4;border-radius:14px;padding:3px 9px;background:#fff;cursor:pointer;opacity:' + (vis[k] ? '1' : '0.4') + ';');
6188
+ chip.innerHTML = '<span style="width:9px;height:9px;border-radius:50%;background:' + (PZ_GROUP_COLORS[k] || '#999') + '"></span>'
6189
+ + escapeHtml(k) + ' ' + counts[k];
6190
+ chip.addEventListener('click', () => {
6191
+ vis[k] = !vis[k];
6192
+ chip.style.opacity = vis[k] ? '1' : '0.4';
6193
+ applyGraphFilter();
6194
+ });
6195
+ legend.appendChild(chip);
6196
+ });
6197
+ /* el canvas es position:relative en CSS; si no, el absolute cuelga del svg */
6198
+ canvas.style.position = canvas.style.position || 'relative';
6199
+ canvas.appendChild(legend);
6200
+ function applyGraphFilter() {
6201
+ for (let i = 0; i < nodeEls.length; i++) nodeEls[i].style.display = vis[nodes[i].group] ? '' : 'none';
6202
+ for (let k = 0; k < edges.length; k++) {
6203
+ const show = vis[nodes[edges[k].from].group] && vis[nodes[edges[k].to].group];
6204
+ edgeEls[k].style.display = show ? '' : 'none';
6205
+ }
6206
+ }
6207
+ applyGraphFilter();
6208
+
5485
6209
  function showDetail(n) {
5486
6210
  if (!detail) return;
5487
6211
  nodeEls.forEach((g) => g.classList.toggle('selected', g._node === n));
@@ -5681,13 +6405,67 @@ function setStatus(text, isErr) {
5681
6405
 
5682
6406
  function setBusy(b) {
5683
6407
  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']) {
6408
+ /* Queue (task #28) -- the input + Send stay LIVE during a turn so the
6409
+ * user can type-while-working (like Claude Code): a send mid-turn is
6410
+ * queued and runs automatically when the turn finishes. Only the clip
6411
+ * (attachments mid-turn) is gated. */
6412
+ for (const sel of ['#bar-mini .clip', '#bar-full .clip']) {
6413
+ const el = $(sel); if (el) el.disabled = b;
6414
+ }
6415
+ for (const sel of ['#bar-mini textarea', '#bar-full textarea', '#bar-mini .send', '#bar-full .send']) {
6416
+ const el = $(sel); if (el) el.disabled = false;
6417
+ }
6418
+ /* Interrupt (task #27) -- a Detener button appears alongside Send
6419
+ * while a turn is in flight, so the user can stop a long / runaway
6420
+ * turn (a controlled fall: applied tool calls stay, the partial
6421
+ * answer is kept). */
6422
+ for (const sel of ['#bar-mini .stop', '#bar-full .stop']) {
5685
6423
  const el = $(sel);
5686
- if (el) el.disabled = b;
6424
+ if (el) { el.style.display = b ? '' : 'none'; el.disabled = false; }
5687
6425
  }
6426
+ renderQueue();
5688
6427
  renderStream();
5689
6428
  }
5690
6429
 
6430
+ /* Queue (task #28) -- render the pending-message chips above the
6431
+ * composer. Each chip is removable before it runs. */
6432
+ function renderQueue() {
6433
+ const q = state.queue || [];
6434
+ for (const id of ['#queue-mini', '#queue-full']) {
6435
+ const el = $(id);
6436
+ if (!el) continue;
6437
+ if (!q.length) { el.style.display = 'none'; el.innerHTML = ''; continue; }
6438
+ el.style.display = '';
6439
+ el.innerHTML = '<span class="queue-label">' + escapeHtml(CONFIG.i18n.queueLabel) + '</span>'
6440
+ + q.map(function (txt, i) {
6441
+ const short = txt.length > 42 ? txt.slice(0, 42) + '...' : txt;
6442
+ return '<span class="queue-chip" data-qrm="' + i + '" role="button" tabindex="0"'
6443
+ + ' title="' + escapeHtml(CONFIG.i18n.queueRemove) + ': ' + escapeHtml(txt) + '">'
6444
+ + escapeHtml(short) + ' <span class="queue-x">&#215;</span></span>';
6445
+ }).join('');
6446
+ el.querySelectorAll('[data-qrm]').forEach(function (chip) {
6447
+ chip.addEventListener('click', function () {
6448
+ const idx = parseInt(chip.getAttribute('data-qrm'), 10);
6449
+ if (!isNaN(idx) && state.queue) { state.queue.splice(idx, 1); renderQueue(); }
6450
+ });
6451
+ });
6452
+ }
6453
+ }
6454
+
6455
+ /* Interrupt (task #27) -- POST the stop request. The server aborts the
6456
+ * active turn's signal; the in-flight send() unwinds via the SSE
6457
+ * turn_complete (interrupted:true) or the abort path. */
6458
+ async function interruptTurn() {
6459
+ setStatus(CONFIG.i18n.statusStopping);
6460
+ try {
6461
+ const r = await fetch('/api/chat/interrupt', { method: 'POST' });
6462
+ const j = await r.json().catch(function () { return {}; });
6463
+ if (!j || j.active === false) setStatus(CONFIG.i18n.statusNoActiveTurn);
6464
+ } catch (e) {
6465
+ setStatus(CONFIG.i18n.statusInterruptFailed, true);
6466
+ }
6467
+ }
6468
+
5691
6469
  /* alpha.59e -- consume an SSE response stream from /api/chat.
5692
6470
  * The body is text/event-stream with frames separated by an
5693
6471
  * empty line. We parse each event as it arrives + update the
@@ -5878,7 +6656,18 @@ function handleSseEvent(event, data) {
5878
6656
 
5879
6657
  async function send(text, opts) {
5880
6658
  text = (text || '').trim();
5881
- if (!text || state.busy) return;
6659
+ if (!text) return;
6660
+ /* Queue (task #28) -- a turn is already running: enqueue this message
6661
+ * instead of dropping it. It runs automatically when the current turn
6662
+ * finishes (drained in the finally below). Synthetic 'hidden' turns
6663
+ * (NAC3 approval continuations) and the drain itself bypass the queue.
6664
+ * 'Send now' steering = press Detener, then the queued message runs. */
6665
+ if (state.busy && !(opts && (opts.hidden || opts.fromQueue))) {
6666
+ if (!state.queue) state.queue = [];
6667
+ state.queue.push(text);
6668
+ renderQueue();
6669
+ return;
6670
+ }
5882
6671
  /* alpha.49 -- opts.hidden=true pushes a user message that the
5883
6672
  * LLM sees in context but the UI does NOT render in the bubble
5884
6673
  * stream. Used by the NAC3 approval modal post-action to inject
@@ -5963,7 +6752,12 @@ async function send(text, opts) {
5963
6752
  'yf limits set max_tool_rounds.',
5964
6753
  };
5965
6754
  var friendly = FRIENDLY_ERRORS[data.error];
5966
- setStatus(friendly || data.error || ('HTTP ' + res.status), true);
6755
+ /* Surface the DETAILED message (stderr del subproceso, motivo real
6756
+ * del fallo) en vez del codigo generico -- p.ej. modo Plan
6757
+ * claude_api_error traia el stderr del subproceso escondido.
6758
+ * (Pablo 2026-06-15) */
6759
+ var detail = (data.message && data.message !== data.error) ? data.message : '';
6760
+ setStatus(friendly || detail || data.error || ('HTTP ' + res.status), true);
5967
6761
  // Drop the unanswered user message so the user can retry.
5968
6762
  state.messages.pop();
5969
6763
  return;
@@ -5971,6 +6765,13 @@ async function send(text, opts) {
5971
6765
  /* alpha.58 -- attach brain_routing metadata to the bubble so
5972
6766
  * renderStream can paint a tier badge + escalation arrow. */
5973
6767
  const assistantMsg = { role: 'assistant', content: data.message.text };
6768
+ /* Interrupt (task #27) -- the turn was stopped mid-flight; mark the
6769
+ * partial answer so the user knows it is not the full reply. */
6770
+ if (data.interrupted === true) {
6771
+ assistantMsg.interrupted = true;
6772
+ assistantMsg.content = (assistantMsg.content || '') + '\\n\\n_' + CONFIG.i18n.labelInterrupted + '_';
6773
+ setStatus(CONFIG.i18n.statusTurnInterrupted);
6774
+ }
5974
6775
  if (data.brain_routing && typeof data.brain_routing === 'object') {
5975
6776
  const br = data.brain_routing;
5976
6777
  assistantMsg.brain_tier = br.tier;
@@ -6143,15 +6944,50 @@ async function send(text, opts) {
6143
6944
  * goes back to default state on the next turn. */
6144
6945
  state.streamProgress = null;
6145
6946
  setBusy(false);
6947
+ /* Queue (task #28) -- drain the next queued message, if any. A small
6948
+ * delay lets the DOM settle (busy flag cleared) before the next turn
6949
+ * starts; fromQueue bypasses the re-enqueue guard. */
6950
+ if (state.queue && state.queue.length) {
6951
+ const next = state.queue.shift();
6952
+ renderQueue();
6953
+ setTimeout(function () { send(next, { fromQueue: true }); }, 60);
6954
+ }
6146
6955
  }
6147
6956
  }
6148
6957
 
6149
6958
  function attachBar(form) {
6150
- const input = form.querySelector('input');
6959
+ const input = form.querySelector('textarea');
6960
+ /* Interrupt (task #27) -- the Detener button shares the composer
6961
+ * row; wire it to the stop endpoint. */
6962
+ const stopBtn = form.querySelector('[data-stop]');
6963
+ if (stopBtn) stopBtn.addEventListener('click', function () { interruptTurn(); });
6964
+ /* Auto-grow composer: grows with the content (bubble-like) up to
6965
+ ~40vh (CSS cap), then scrolls. Reset to one row after sending. */
6966
+ function autoGrow() {
6967
+ input.style.height = 'auto';
6968
+ const cap = Math.round(window.innerHeight * 0.4);
6969
+ input.style.height = Math.min(input.scrollHeight, cap) + 'px';
6970
+ input.classList.toggle('expanded', input.scrollHeight > 48);
6971
+ }
6972
+ function resetGrow() {
6973
+ input.style.height = 'auto';
6974
+ input.classList.remove('expanded');
6975
+ }
6976
+ input.addEventListener('input', autoGrow);
6977
+ /* Enter sends; Shift/Alt/Ctrl/Cmd+Enter inserts a newline. Skip
6978
+ while an IME is composing (e.g. CJK) so Enter commits, not sends. */
6979
+ input.addEventListener('keydown', function (e) {
6980
+ if (e.key === 'Enter' && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && !e.isComposing) {
6981
+ e.preventDefault();
6982
+ if (typeof form.requestSubmit === 'function') form.requestSubmit();
6983
+ else form.dispatchEvent(new Event('submit', { cancelable: true }));
6984
+ }
6985
+ });
6151
6986
  form.addEventListener('submit', (e) => {
6152
6987
  e.preventDefault();
6153
6988
  const v = input.value;
6154
6989
  input.value = '';
6990
+ resetGrow();
6155
6991
  /* SQ 0.8 slash command: /modo didactico|tecnico (alias EN
6156
6992
  /mode). Handled client-side -- no LLM round-trip.
6157
6993
  isSlashCommand is SYNCHRONOUS; handleSlashCommand runs
@@ -6604,15 +7440,7 @@ async function pizarronPlanHintCheck() {
6604
7440
  try {
6605
7441
  const r = await fetch('/api/forge/brain-config');
6606
7442
  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
- }
7443
+ if (hint) hint.classList.add('hidden');
6616
7444
  } catch { /* dejar como esta */ }
6617
7445
  }
6618
7446
 
@@ -6655,9 +7483,26 @@ function materializePanelDispatch(d) {
6655
7483
  /* proyecto nuevo: re-mostrar las 4 pestanas canonicas (respeta
6656
7484
  * el switch en el endpoint). Se limpia el set de cerradas. */
6657
7485
  try { localStorage.removeItem(YF_CLOSED_STARTUP_KEY); } catch (e) { /* */ }
7486
+ /* Project selector (task #33) -- update the header label to the new
7487
+ * project name the server reported. */
7488
+ try {
7489
+ if (d.args && d.args.name) { CONFIG.projectName = String(d.args.name); setProj(); }
7490
+ } catch (e) { /* */ }
6658
7491
  loadStartupTabs(false).catch(function () {});
6659
7492
  return;
6660
7493
  }
7494
+ if (d.verb === 'system.remote-turn') {
7495
+ /* UI sync (task #32) -- a turn issued from the phone in the unified
7496
+ * 'notebook' thread ran on the desktop; show it here so the laptop
7497
+ * reflects what was asked + answered from the mobile. */
7498
+ const ra = d.args || {};
7499
+ if (ra.user) state.messages.push({ role: 'user', content: String(ra.user), remote: true });
7500
+ if (typeof ra.reply === 'string' && ra.reply) {
7501
+ state.messages.push({ role: 'assistant', content: ra.reply, remote: true });
7502
+ }
7503
+ renderStream();
7504
+ return;
7505
+ }
6661
7506
  if (!d.args) return;
6662
7507
  const a = d.args;
6663
7508
  if (d.verb === 'pizarron.create-tab') {
@@ -6697,10 +7542,14 @@ function materializePanelDispatch(d) {
6697
7542
  /* ---- PND-069: seccion Brain del panel de settings ----------- */
6698
7543
  const yfBrain = { cfg: null, loading: false };
6699
7544
 
7545
+ /* Strict BYOK: the everyday brain is always the user own key
7546
+ * (Console / Plan / other providers). The yujin_managed virtual
7547
+ * provider is intentionally NOT offered here -- managed access is
7548
+ * reserved for the bounded onboarding concierge, never the daily brain. */
6700
7549
  const BRAIN_PROVIDER_LABELS = {
6701
7550
  anthropic: 'Anthropic', openai: 'OpenAI', google_ai: 'Google AI',
6702
7551
  deepseek: 'DeepSeek', xai: 'xAI', mistral: 'Mistral',
6703
- cohere: 'Cohere', yujin_managed: 'Yujin (managed)',
7552
+ cohere: 'Cohere',
6704
7553
  };
6705
7554
 
6706
7555
  function brainStatus(msg) {
@@ -6805,22 +7654,34 @@ async function brainConfigSave(patch, statusMsg) {
6805
7654
  body: JSON.stringify(patch),
6806
7655
  });
6807
7656
  const data = await r.json();
6808
- if (!r.ok || !data.ok) { brainStatus('error guardando'); return; }
7657
+ if (!r.ok || !data.ok) { brainStatus('error guardando'); return null; }
6809
7658
  yfBrain.cfg = data.config;
6810
7659
  brainStatus(statusMsg || 'guardado');
6811
7660
  setTimeout(() => brainStatus(''), 2500);
7661
+ return data;
6812
7662
  } catch (err) {
6813
7663
  brainStatus('error: ' + (err && err.message ? err.message : String(err)));
7664
+ return null;
6814
7665
  }
6815
7666
  }
6816
7667
 
6817
7668
  function brainConfigBind() {
6818
7669
  document.querySelectorAll('input[name="brain-anthropic-mode"]').forEach((radio) => {
6819
- radio.addEventListener('change', () => {
7670
+ radio.addEventListener('change', async () => {
6820
7671
  if (!radio.checked) return;
6821
7672
  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');
7673
+ const modeLabel = radio.value === 'plan' ? 'Plan (suscripcion)' : 'Console (API key)';
7674
+ const data = await brainConfigSave({ anthropic_mode: radio.value }, 'aplicando ' + modeLabel + '...');
7675
+ /* Hot-swap (server applies live now). Only show the restart hint if
7676
+ * the swap did NOT apply live (e.g. test client). */
7677
+ const live = data && data.applied_live;
7678
+ if (note) {
7679
+ note.hidden = false;
7680
+ note.textContent = live
7681
+ ? modeLabel + ' aplicado. El proximo turno ya usa este modo.'
7682
+ : 'Se aplica al reabrir el panel.';
7683
+ }
7684
+ if (live) brainStatus(modeLabel + ' activo');
6824
7685
  });
6825
7686
  });
6826
7687
  const ecoToggle = document.querySelector('[data-brain-eco-enabled]');
@@ -7511,7 +8372,7 @@ async function micToggle(button) {
7511
8372
  return;
7512
8373
  }
7513
8374
  /* Inject the transcript into the active input + submit. */
7514
- const inputSel = state.mode === 'full' ? '#bar-full input' : '#bar-mini input';
8375
+ const inputSel = state.mode === 'full' ? '#bar-full textarea' : '#bar-mini textarea';
7515
8376
  const input = $(inputSel);
7516
8377
  if (input) {
7517
8378
  input.value = text;
@@ -8099,7 +8960,7 @@ async function _micToggleWebSpeech(button) {
8099
8960
  _lastInputWasVoice = true;
8100
8961
  _lastVoiceButton = recordingPanelButton;
8101
8962
  /* Inject + submit, same as the Google-STT path. */
8102
- const inputSel = state.mode === 'full' ? '#bar-full input' : '#bar-mini input';
8963
+ const inputSel = state.mode === 'full' ? '#bar-full textarea' : '#bar-mini textarea';
8103
8964
  const input = $(inputSel);
8104
8965
  if (input) {
8105
8966
  input.value = text;
@@ -8356,6 +9217,11 @@ function installSupportCollector() {
8356
9217
  document.addEventListener('DOMContentLoaded', () => {
8357
9218
  installSupportCollector();
8358
9219
  setProj();
9220
+ /* Project selector (task #33) -- the header project label opens the
9221
+ * switcher. */
9222
+ $$('[data-proj-switch]').forEach(function (el) {
9223
+ el.addEventListener('click', function (e) { e.stopPropagation(); openProjectMenu(el); });
9224
+ });
8359
9225
  /* alpha.43 F1 -- prime the NAC3 manifest cache on boot so the
8360
9226
  first chat-response version stamp has something to compare
8361
9227
  against. Silent: if the panel never sent a request before,
@@ -8574,6 +9440,13 @@ document.addEventListener('DOMContentLoaded', () => {
8574
9440
  brainConfigBind();
8575
9441
  panelZoomMaximizeBind();
8576
9442
  pizarronPlanHintCheck();
9443
+ /* Panel-queue polling runs in ALL modes (Pablo 2026-06-15): the queue
9444
+ * carries cross-cutting events -- system.remote-turn (mobile chats in
9445
+ * the unified session) + system.reanchor (project switch) + chat-log --
9446
+ * that must reflect in Console/API mode too, not only Plan/CLI. The bug
9447
+ * was gating the poller on claude-code-cli, so in Console mode the
9448
+ * desktop never saw mobile turns nor a project switch. */
9449
+ startPanelQueuePolling();
8577
9450
  /* Click outside closes. */
8578
9451
  document.addEventListener('click', (e) => {
8579
9452
  if (!settingsPanel || settingsPanel.classList.contains('hidden')) return;