create-walle 0.9.21 → 0.9.23

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 (500) hide show
  1. package/README.md +27 -5
  2. package/package.json +2 -2
  3. package/template/CLAUDE.md +2 -2
  4. package/template/LICENSE +1 -1
  5. package/template/bin/ctm-dev-cleanup.js +24 -3
  6. package/template/bin/ctm-launch.sh +13 -0
  7. package/template/bin/dev.sh +156 -18
  8. package/template/bin/node-bin.sh +84 -0
  9. package/template/bin/pin-node.sh +51 -0
  10. package/template/claude-task-manager/api-prompts.js +1203 -182
  11. package/template/claude-task-manager/api-reviews.js +109 -15
  12. package/template/claude-task-manager/approval-agent.js +1360 -280
  13. package/template/claude-task-manager/bin/restart-ctm.sh +64 -23
  14. package/template/claude-task-manager/bin/storage-migration-supervisor.js +338 -0
  15. package/template/claude-task-manager/db.js +4417 -295
  16. package/template/claude-task-manager/docs/app-update-refresh-protocol.md +69 -0
  17. package/template/claude-task-manager/docs/approval-ai-refinement.md +138 -0
  18. package/template/claude-task-manager/docs/approval-rescue-loop.md +74 -0
  19. package/template/claude-task-manager/docs/codex-operational-warning-health.md +107 -0
  20. package/template/claude-task-manager/docs/codex-resume-state-guard-design.md +17 -12
  21. package/template/claude-task-manager/docs/codex-terminal-render-controller-handoff.md +311 -0
  22. package/template/claude-task-manager/docs/coding-agent-hooks-architecture.md +418 -0
  23. package/template/claude-task-manager/docs/conversation-import-freshness.md +20 -0
  24. package/template/claude-task-manager/docs/google-workspace-auth-health.md +77 -0
  25. package/template/claude-task-manager/docs/image-paste-ux.md +13 -0
  26. package/template/claude-task-manager/docs/ipad-web-preview.md +88 -0
  27. package/template/claude-task-manager/docs/main-loop-offload-architecture.md +66 -0
  28. package/template/claude-task-manager/docs/microsoft-dev-tunnel-phone-access-design.md +274 -519
  29. package/template/claude-task-manager/docs/mobile-live-streaming.md +27 -5
  30. package/template/claude-task-manager/docs/mobile-remote-submission-lifecycle.md +69 -0
  31. package/template/claude-task-manager/docs/phone-access-design.md +53 -15
  32. package/template/claude-task-manager/docs/phone-passkey-identity.md +122 -0
  33. package/template/claude-task-manager/docs/phone-setup.md +3 -0
  34. package/template/claude-task-manager/docs/prompt-editing-tree-design.md +25 -1
  35. package/template/claude-task-manager/docs/remote-desktop-access-design.md +268 -0
  36. package/template/claude-task-manager/docs/restart-lifecycle-architecture.md +95 -0
  37. package/template/claude-task-manager/docs/runtime-work-control-plane.md +53 -0
  38. package/template/claude-task-manager/docs/session-interactive-wait-surfaces.md +38 -0
  39. package/template/claude-task-manager/docs/session-needs-you-dismissal.md +84 -0
  40. package/template/claude-task-manager/docs/session-render-state-management-design.md +91 -3
  41. package/template/claude-task-manager/docs/session-standup-command-center-design.md +25 -1
  42. package/template/claude-task-manager/docs/session-title-authority.md +32 -0
  43. package/template/claude-task-manager/docs/session-workspace-binding.md +33 -0
  44. package/template/claude-task-manager/docs/skill-intent-resolution-design.md +72 -0
  45. package/template/claude-task-manager/docs/walle-mcp-supervisor-health.md +86 -0
  46. package/template/claude-task-manager/docs/walle-relay-phone-access-design.md +24 -15
  47. package/template/claude-task-manager/docs/walle-session-history-hydration.md +114 -0
  48. package/template/claude-task-manager/docs/walle-session-input-queue.md +104 -0
  49. package/template/claude-task-manager/docs/walle-session-model-catalog.md +90 -0
  50. package/template/claude-task-manager/docs/walle-session-model-preferences.md +15 -6
  51. package/template/claude-task-manager/git-utils.js +897 -27
  52. package/template/claude-task-manager/lib/agent-capabilities.js +33 -0
  53. package/template/claude-task-manager/lib/agent-cli-cache.js +37 -7
  54. package/template/claude-task-manager/lib/agent-hooks-installer.js +26 -2
  55. package/template/claude-task-manager/lib/agent-presets.js +17 -1
  56. package/template/claude-task-manager/lib/all-sessions-query.js +108 -0
  57. package/template/claude-task-manager/lib/approval-ai-refinement.js +488 -0
  58. package/template/claude-task-manager/lib/approval-self-adapt.js +168 -0
  59. package/template/claude-task-manager/lib/async-semaphore.js +44 -0
  60. package/template/claude-task-manager/lib/auth-context.js +5 -0
  61. package/template/claude-task-manager/lib/auth-rate-limit.js +47 -4
  62. package/template/claude-task-manager/lib/auth-rules.js +29 -2
  63. package/template/claude-task-manager/lib/auto-approval-verifier.js +129 -16
  64. package/template/claude-task-manager/lib/background-llm.js +144 -17
  65. package/template/claude-task-manager/lib/branch-inventory.js +212 -0
  66. package/template/claude-task-manager/lib/claude-desktop-sessions.js +15 -3
  67. package/template/claude-task-manager/lib/coalesce-sync-frames.js +151 -0
  68. package/template/claude-task-manager/lib/codex-launch-health.js +762 -0
  69. package/template/claude-task-manager/lib/codex-transcript-pager.js +51 -0
  70. package/template/claude-task-manager/lib/codex-zst.js +124 -0
  71. package/template/claude-task-manager/lib/coding-agent-models.js +233 -30
  72. package/template/claude-task-manager/lib/connection-health.js +232 -0
  73. package/template/claude-task-manager/lib/conversation-blob-parser.js +42 -0
  74. package/template/claude-task-manager/lib/conversation-tail-merge.js +89 -26
  75. package/template/claude-task-manager/lib/ctm-session-context-api.js +39 -10
  76. package/template/claude-task-manager/lib/cursor-conversation-store.js +354 -0
  77. package/template/claude-task-manager/lib/db-owner-worker-client.js +315 -0
  78. package/template/claude-task-manager/lib/document-review.js +141 -6
  79. package/template/claude-task-manager/lib/escalation-review.js +152 -0
  80. package/template/claude-task-manager/lib/graceful-shutdown.js +159 -0
  81. package/template/claude-task-manager/lib/headless-term-service.js +678 -0
  82. package/template/claude-task-manager/lib/heavy-worker-fallback.js +38 -0
  83. package/template/claude-task-manager/lib/jsonl-conversation-parser.js +542 -0
  84. package/template/claude-task-manager/lib/jsonl-range-reader.js +112 -0
  85. package/template/claude-task-manager/lib/main-db-census.js +216 -0
  86. package/template/claude-task-manager/lib/message-pagination.js +106 -4
  87. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +750 -26
  88. package/template/claude-task-manager/lib/mobile-auth-api.js +274 -7
  89. package/template/claude-task-manager/lib/mobile-auth-store.js +592 -10
  90. package/template/claude-task-manager/lib/mobile-notification-dispatcher.js +15 -0
  91. package/template/claude-task-manager/lib/model-overview-brain-fallback.js +311 -0
  92. package/template/claude-task-manager/lib/model-overview-cache.js +141 -0
  93. package/template/claude-task-manager/lib/models-health-routing-notice.js +126 -0
  94. package/template/claude-task-manager/lib/node-pin-guard.js +93 -0
  95. package/template/claude-task-manager/lib/perf-tracker.js +242 -6
  96. package/template/claude-task-manager/lib/permission-match.js +76 -0
  97. package/template/claude-task-manager/lib/permission-sync.js +133 -20
  98. package/template/claude-task-manager/lib/process-title.js +35 -0
  99. package/template/claude-task-manager/lib/prompt-executions-query.js +25 -0
  100. package/template/claude-task-manager/lib/prompt-index-disk-cache.js +44 -0
  101. package/template/claude-task-manager/lib/prompt-intent.js +132 -0
  102. package/template/claude-task-manager/lib/provider-user-context.js +34 -0
  103. package/template/claude-task-manager/lib/read-pool-client.js +313 -0
  104. package/template/claude-task-manager/lib/readpool-breaker.js +31 -0
  105. package/template/claude-task-manager/lib/recent-sessions-breaker.js +12 -0
  106. package/template/claude-task-manager/lib/remote-feedback-client.js +72 -0
  107. package/template/claude-task-manager/lib/remote-relay-protocol.js +37 -4
  108. package/template/claude-task-manager/lib/remote-relay-store.js +159 -0
  109. package/template/claude-task-manager/lib/remote-submission-observer.js +278 -0
  110. package/template/claude-task-manager/lib/restart-guard.js +109 -0
  111. package/template/claude-task-manager/lib/restore-interruption-detector.js +439 -0
  112. package/template/claude-task-manager/lib/restore-policy.js +13 -0
  113. package/template/claude-task-manager/lib/restore-resume-batch.js +74 -0
  114. package/template/claude-task-manager/lib/restore-runtime.js +68 -0
  115. package/template/claude-task-manager/lib/restore-storm.js +34 -0
  116. package/template/claude-task-manager/lib/resume-cwd.js +36 -0
  117. package/template/claude-task-manager/lib/resume-preflight.js +313 -0
  118. package/template/claude-task-manager/lib/runtime-work-registry.js +444 -0
  119. package/template/claude-task-manager/lib/sanitize-openai-auth.js +31 -0
  120. package/template/claude-task-manager/lib/scheduler.js +21 -1
  121. package/template/claude-task-manager/lib/scrollback-snapshot-store.js +159 -0
  122. package/template/claude-task-manager/lib/serial-task-queue.js +64 -0
  123. package/template/claude-task-manager/lib/server-listeners.js +239 -0
  124. package/template/claude-task-manager/lib/session-capture.js +42 -7
  125. package/template/claude-task-manager/lib/session-content-backfill.js +131 -0
  126. package/template/claude-task-manager/lib/session-history.js +388 -43
  127. package/template/claude-task-manager/lib/session-host-manager.js +287 -0
  128. package/template/claude-task-manager/lib/session-image-refs.js +209 -0
  129. package/template/claude-task-manager/lib/session-jobs.js +399 -59
  130. package/template/claude-task-manager/lib/session-prompt-index.js +137 -0
  131. package/template/claude-task-manager/lib/session-restore.js +53 -0
  132. package/template/claude-task-manager/lib/session-standup.js +123 -23
  133. package/template/claude-task-manager/lib/session-state-bus.js +14 -0
  134. package/template/claude-task-manager/lib/session-stream.js +64 -16
  135. package/template/claude-task-manager/lib/session-timeline-summary.js +260 -0
  136. package/template/claude-task-manager/lib/session-token-usage.js +494 -0
  137. package/template/claude-task-manager/lib/session-workspace-binding.js +356 -0
  138. package/template/claude-task-manager/lib/setup-network-config.js +9 -0
  139. package/template/claude-task-manager/lib/size-cap.js +45 -0
  140. package/template/claude-task-manager/lib/size-cap.test.js +62 -0
  141. package/template/claude-task-manager/lib/skill-autocomplete.js +180 -1
  142. package/template/claude-task-manager/lib/skill-intent-resolver.js +304 -0
  143. package/template/claude-task-manager/lib/sqlite-driver.js +19 -3
  144. package/template/claude-task-manager/lib/standup-attention.js +7 -3
  145. package/template/claude-task-manager/lib/status-authority.js +39 -0
  146. package/template/claude-task-manager/lib/status-hooks.js +4 -0
  147. package/template/claude-task-manager/lib/storage-migration.js +235 -0
  148. package/template/claude-task-manager/lib/structured-capture.js +298 -0
  149. package/template/claude-task-manager/lib/sync-io-census.js +163 -0
  150. package/template/claude-task-manager/lib/tailscale-setup.js +6 -0
  151. package/template/claude-task-manager/lib/terminal-activity-evidence.js +33 -0
  152. package/template/claude-task-manager/lib/terminal-choice.js +364 -0
  153. package/template/claude-task-manager/lib/terminal-control-sanitize.js +17 -0
  154. package/template/claude-task-manager/lib/terminal-fingerprint.js +48 -0
  155. package/template/claude-task-manager/lib/terminal-output-flush.js +84 -0
  156. package/template/claude-task-manager/lib/timeline-order.js +122 -0
  157. package/template/claude-task-manager/lib/transcript-store.js +348 -43
  158. package/template/claude-task-manager/lib/transport-security.js +84 -1
  159. package/template/claude-task-manager/lib/wait-state.js +184 -0
  160. package/template/claude-task-manager/lib/walle-client.js +47 -5
  161. package/template/claude-task-manager/lib/walle-ctm-history.js +564 -4
  162. package/template/claude-task-manager/lib/walle-external-actions.js +135 -16
  163. package/template/claude-task-manager/lib/walle-history-hydration.js +46 -0
  164. package/template/claude-task-manager/lib/walle-native-health.js +403 -0
  165. package/template/claude-task-manager/lib/walle-repair.js +701 -0
  166. package/template/claude-task-manager/lib/walle-session-cache.js +109 -0
  167. package/template/claude-task-manager/lib/walle-session-context.js +57 -21
  168. package/template/claude-task-manager/lib/walle-session-model-catalog.js +34 -0
  169. package/template/claude-task-manager/lib/walle-supervisor.js +539 -63
  170. package/template/claude-task-manager/lib/walle-transcript.js +52 -0
  171. package/template/claude-task-manager/lib/worktree-active-sync.js +11 -7
  172. package/template/claude-task-manager/lib/worktree-cwd.js +32 -1
  173. package/template/claude-task-manager/package.json +1 -1
  174. package/template/claude-task-manager/prompt-harvest.js +89 -66
  175. package/template/claude-task-manager/providers/claude-code.js +51 -3
  176. package/template/claude-task-manager/providers/cursor.js +140 -45
  177. package/template/claude-task-manager/public/css/reviews.css +551 -61
  178. package/template/claude-task-manager/public/css/setup.css +191 -0
  179. package/template/claude-task-manager/public/css/walle-session.css +865 -10
  180. package/template/claude-task-manager/public/css/walle.css +154 -0
  181. package/template/claude-task-manager/public/designs/ai-providers-consolidation-v2.html +830 -0
  182. package/template/claude-task-manager/public/index.html +18516 -2058
  183. package/template/claude-task-manager/public/ipad.html +363 -0
  184. package/template/claude-task-manager/public/js/document-review-links.js +301 -0
  185. package/template/claude-task-manager/public/js/image-normalize.js +69 -36
  186. package/template/claude-task-manager/public/js/message-renderer.js +1265 -77
  187. package/template/claude-task-manager/public/js/prompts.js +66 -29
  188. package/template/claude-task-manager/public/js/reviews.js +901 -133
  189. package/template/claude-task-manager/public/js/session-activity-utils.js +11 -1
  190. package/template/claude-task-manager/public/js/session-search-utils.js +94 -10
  191. package/template/claude-task-manager/public/js/session-status-precedence.js +23 -5
  192. package/template/claude-task-manager/public/js/setup.js +1273 -176
  193. package/template/claude-task-manager/public/js/stream-view.js +691 -73
  194. package/template/claude-task-manager/public/js/terminal-reconciler.js +210 -0
  195. package/template/claude-task-manager/public/js/walle-session.js +2455 -158
  196. package/template/claude-task-manager/public/js/walle.js +455 -28
  197. package/template/claude-task-manager/public/m/app.css +2909 -262
  198. package/template/claude-task-manager/public/m/app.js +6601 -398
  199. package/template/claude-task-manager/public/m/claim.html +224 -17
  200. package/template/claude-task-manager/public/m/index.html +117 -21
  201. package/template/claude-task-manager/public/m/sw.js +3 -1
  202. package/template/claude-task-manager/public/manifest.json +2 -2
  203. package/template/claude-task-manager/public/prompts.html +30 -14
  204. package/template/claude-task-manager/queue-engine.js +507 -28
  205. package/template/claude-task-manager/scripts/repair-claude-session-images.js +27 -8
  206. package/template/claude-task-manager/server.js +14341 -2197
  207. package/template/claude-task-manager/session-integrity.js +160 -18
  208. package/template/claude-task-manager/session-search-ranking.js +1 -0
  209. package/template/claude-task-manager/session-utils.js +25 -5
  210. package/template/claude-task-manager/workers/approval-blocklist.js +96 -6
  211. package/template/claude-task-manager/workers/approval-widget-validator.js +14 -8
  212. package/template/claude-task-manager/workers/conversation-import-worker.js +11 -50
  213. package/template/claude-task-manager/workers/db-owner-worker.js +386 -0
  214. package/template/claude-task-manager/workers/harvest-worker.js +9 -55
  215. package/template/claude-task-manager/workers/headless-term-worker.js +9 -530
  216. package/template/claude-task-manager/workers/read-pool-worker.js +387 -0
  217. package/template/claude-task-manager/workers/scrollback-worker.js +11 -72
  218. package/template/claude-task-manager/workers/session-host-process.js +146 -0
  219. package/template/claude-task-manager/workers/session-integrity-worker.js +10 -54
  220. package/template/claude-task-manager/workers/state-detectors/base.js +18 -1
  221. package/template/claude-task-manager/workers/state-detectors/claude-code.js +182 -9
  222. package/template/claude-task-manager/workers/state-detectors/codex.js +150 -2
  223. package/template/claude-task-manager/workers/state-detectors/cursor.js +127 -0
  224. package/template/claude-task-manager/workers/state-detectors/gemini.js +21 -0
  225. package/template/claude-task-manager/workers/state-detectors/index.js +29 -0
  226. package/template/claude-task-manager/workers/state-detectors/opencode.js +103 -0
  227. package/template/docs/design/markdown-review-pane.md +206 -0
  228. package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +129 -38
  229. package/template/docs/designs/2026-05-20-mobile-worktree-finish-command.md +27 -0
  230. package/template/docs/designs/2026-05-22-ai-configuration-consolidation.md +248 -0
  231. package/template/docs/designs/ai-configuration-consolidation-mock.html +812 -0
  232. package/template/docs/private-memory-and-pii-policy.md +69 -0
  233. package/template/package.json +2 -1
  234. package/template/scripts/check-private-data.js +201 -0
  235. package/template/shared/sqlite-owner-guard.js +30 -0
  236. package/template/shared/sqlite-owner-write-queue.js +225 -0
  237. package/template/shared/sqlite-storage-policy.js +111 -0
  238. package/template/shared/sqlite-write-lock.js +428 -0
  239. package/template/wall-e/agent-runners/claude-code.js +5 -0
  240. package/template/wall-e/agent.js +166 -22
  241. package/template/wall-e/api-walle.js +524 -70
  242. package/template/wall-e/auth/provider-flows.js +11 -1
  243. package/template/wall-e/bin/walle-mcp-stdio.js +341 -17
  244. package/template/wall-e/brain.js +1614 -141
  245. package/template/wall-e/chat/attachment-blocks.js +96 -0
  246. package/template/wall-e/chat/attachments.js +2 -1
  247. package/template/wall-e/chat/capability-resolver.js +7 -7
  248. package/template/wall-e/chat/context-messages.js +28 -0
  249. package/template/wall-e/chat/conversation-frame.js +630 -0
  250. package/template/wall-e/chat/provider-messages.js +125 -0
  251. package/template/wall-e/chat.js +1002 -233
  252. package/template/wall-e/coding/acceptance-contract.js +170 -0
  253. package/template/wall-e/coding/acp-adapter.js +1 -1
  254. package/template/wall-e/coding/agent-catalog.js +3 -0
  255. package/template/wall-e/coding/artifact-store.js +93 -0
  256. package/template/wall-e/coding/capability-router.js +120 -0
  257. package/template/wall-e/coding/coding-run-controller.js +423 -0
  258. package/template/wall-e/coding/compaction-service.js +157 -12
  259. package/template/wall-e/coding/frontend-verification.js +258 -0
  260. package/template/wall-e/coding/lifecycle-hooks.js +75 -0
  261. package/template/wall-e/coding/local-preview-contract.js +157 -0
  262. package/template/wall-e/coding/permission-service.js +57 -13
  263. package/template/wall-e/coding/prompt-bundle.js +19 -1
  264. package/template/wall-e/coding/prompt-section-registry.js +227 -0
  265. package/template/wall-e/coding/provider-compat.js +15 -0
  266. package/template/wall-e/coding/runtime-events.js +224 -0
  267. package/template/wall-e/coding/runtime-mode.js +3 -0
  268. package/template/wall-e/coding/side-git-snapshot.js +160 -4
  269. package/template/wall-e/coding/snapshot-service.js +143 -1
  270. package/template/wall-e/coding/stream-processor.js +388 -34
  271. package/template/wall-e/coding/task-tool.js +141 -4
  272. package/template/wall-e/coding/tool-execution-controller.js +365 -0
  273. package/template/wall-e/coding/tool-registry.js +43 -5
  274. package/template/wall-e/coding/user-hooks.js +217 -0
  275. package/template/wall-e/coding-orchestrator.js +1330 -221
  276. package/template/wall-e/coding-prompts.js +20 -4
  277. package/template/wall-e/context/context-builder.js +15 -2
  278. package/template/wall-e/decision/confidence.js +1 -1
  279. package/template/wall-e/docs/coding-acceptance-contract.md +41 -0
  280. package/template/wall-e/docs/external-action-controller.md +26 -6
  281. package/template/wall-e/docs/telemetry-lifecycle.md +8 -2
  282. package/template/wall-e/embeddings.js +591 -53
  283. package/template/wall-e/external-action-controller.js +12 -0
  284. package/template/wall-e/http/auth.js +1 -0
  285. package/template/wall-e/http/chat-api.js +46 -11
  286. package/template/wall-e/http/model-admin.js +836 -34
  287. package/template/wall-e/lib/boot-profile.js +88 -0
  288. package/template/wall-e/lib/event-loop-monitor.js +93 -0
  289. package/template/wall-e/lib/service-health.js +194 -0
  290. package/template/wall-e/llm/anthropic.js +130 -5
  291. package/template/wall-e/llm/client.js +266 -63
  292. package/template/wall-e/llm/default-fallback.js +382 -0
  293. package/template/wall-e/llm/health.js +19 -0
  294. package/template/wall-e/llm/message-guard.js +78 -0
  295. package/template/wall-e/llm/model-catalog.js +252 -1
  296. package/template/wall-e/llm/openai.js +26 -4
  297. package/template/wall-e/llm/portkey-sync.js +654 -0
  298. package/template/wall-e/llm/provider-error.js +30 -2
  299. package/template/wall-e/llm/registry.js +5 -1
  300. package/template/wall-e/llm/request-compat.js +67 -0
  301. package/template/wall-e/loops/backfill.js +79 -23
  302. package/template/wall-e/loops/brain-optimize.js +67 -0
  303. package/template/wall-e/loops/ingest.js +25 -10
  304. package/template/wall-e/loops/question-digest.js +160 -0
  305. package/template/wall-e/loops/reflect.js +6 -4
  306. package/template/wall-e/loops/think.js +39 -12
  307. package/template/wall-e/mcp-server.js +318 -36
  308. package/template/wall-e/memory/ctm-context-client.js +52 -14
  309. package/template/wall-e/memory/ctm-operational-context.js +237 -0
  310. package/template/wall-e/memory/ctm-prompt-executions-client.js +128 -0
  311. package/template/wall-e/memory/ctm-session-context.js +111 -63
  312. package/template/wall-e/prompts/coding/deepseek.txt +3 -0
  313. package/template/wall-e/prompts/coding/gemini.txt +6 -0
  314. package/template/wall-e/prompts/coding/gpt.txt +6 -0
  315. package/template/wall-e/prompts/coding/local.txt +7 -0
  316. package/template/wall-e/runtime/decision-hooks.js +115 -0
  317. package/template/wall-e/runtime/devbox-gateway.js +82 -8
  318. package/template/wall-e/runtime/prompt-manifest.js +86 -0
  319. package/template/wall-e/runtime/tool-executor.js +269 -0
  320. package/template/wall-e/runtime/tool-result-envelope.js +138 -0
  321. package/template/wall-e/runtime/transcript-projection.js +60 -0
  322. package/template/wall-e/runtime/walle-runtime.js +224 -0
  323. package/template/wall-e/scripts/db-optimize/migrate.js +162 -0
  324. package/template/wall-e/scripts/db-optimize/recall-eval.js +117 -0
  325. package/template/wall-e/server.js +15 -0
  326. package/template/wall-e/session-files.js +9 -0
  327. package/template/wall-e/skills/_bundled/google-calendar/run.js +1 -1
  328. package/template/wall-e/skills/_bundled/gws-workspace/run.js +1 -1
  329. package/template/wall-e/skills/_bundled/slack-mentions/run.js +76 -6
  330. package/template/wall-e/skills/claude-code-reader.js +7 -3
  331. package/template/wall-e/skills/script-skill-runner.js +10 -0
  332. package/template/wall-e/skills/skill-planner.js +38 -0
  333. package/template/wall-e/tools/builtin-middleware.js +19 -9
  334. package/template/wall-e/tools/local-tools.js +1428 -16
  335. package/template/wall-e/tools/permission-checker.js +73 -5
  336. package/template/wall-e/tools/question-manager.js +117 -7
  337. package/template/wall-e/training/harvester.js +12 -28
  338. package/template/wall-e/training/replay.js +25 -80
  339. package/template/website/index.html +10 -10
  340. package/template/wall-e/eval/ab-test.js +0 -203
  341. package/template/wall-e/eval/agent-runner.js +0 -772
  342. package/template/wall-e/eval/agent-scorer.js +0 -461
  343. package/template/wall-e/eval/aggregator.js +0 -414
  344. package/template/wall-e/eval/allowed-test-commands.js +0 -34
  345. package/template/wall-e/eval/benchmark-generator.js +0 -113
  346. package/template/wall-e/eval/benchmarks/chat-eval.json +0 -1662
  347. package/template/wall-e/eval/benchmarks/chat.json +0 -82
  348. package/template/wall-e/eval/benchmarks/coding-agent-real.json +0 -1
  349. package/template/wall-e/eval/benchmarks/coding-agent.json +0 -1581
  350. package/template/wall-e/eval/benchmarks/coding.json +0 -122
  351. package/template/wall-e/eval/benchmarks/memory-retrieval.json +0 -234
  352. package/template/wall-e/eval/benchmarks/reasoning.json +0 -82
  353. package/template/wall-e/eval/benchmarks/swebench-lite-30.json +0 -212
  354. package/template/wall-e/eval/benchmarks.js +0 -669
  355. package/template/wall-e/eval/cc-replay.js +0 -719
  356. package/template/wall-e/eval/chat-eval.js +0 -525
  357. package/template/wall-e/eval/check-keys.js +0 -15
  358. package/template/wall-e/eval/check-providers.js +0 -42
  359. package/template/wall-e/eval/codex-cli-baseline.js +0 -669
  360. package/template/wall-e/eval/coding-agent-real.js +0 -570
  361. package/template/wall-e/eval/context-compactor.js +0 -251
  362. package/template/wall-e/eval/debug-agent003.js +0 -68
  363. package/template/wall-e/eval/diagnostics.js +0 -216
  364. package/template/wall-e/eval/eval-orchestrator.js +0 -642
  365. package/template/wall-e/eval/evaluate.js +0 -202
  366. package/template/wall-e/eval/evaluator.js +0 -373
  367. package/template/wall-e/eval/exporter.js +0 -212
  368. package/template/wall-e/eval/fixtures/express-basic/package.json +0 -9
  369. package/template/wall-e/eval/fixtures/express-basic/server.js +0 -115
  370. package/template/wall-e/eval/fixtures/express-basic/test.js +0 -83
  371. package/template/wall-e/eval/fixtures/express-buggy/package.json +0 -9
  372. package/template/wall-e/eval/fixtures/express-buggy/server.js +0 -113
  373. package/template/wall-e/eval/fixtures/express-buggy/test.js +0 -83
  374. package/template/wall-e/eval/fixtures/express-buggy-items/package.json +0 -9
  375. package/template/wall-e/eval/fixtures/express-buggy-items/server.js +0 -112
  376. package/template/wall-e/eval/fixtures/express-buggy-items/test.js +0 -83
  377. package/template/wall-e/eval/fixtures/express-buggy-search/package.json +0 -9
  378. package/template/wall-e/eval/fixtures/express-buggy-search/server.js +0 -121
  379. package/template/wall-e/eval/fixtures/express-buggy-search/test.js +0 -83
  380. package/template/wall-e/eval/fixtures/express-rename-data/data.js +0 -34
  381. package/template/wall-e/eval/fixtures/express-rename-data/package.json +0 -9
  382. package/template/wall-e/eval/fixtures/express-rename-data/server.js +0 -97
  383. package/template/wall-e/eval/fixtures/express-rename-data/test.js +0 -88
  384. package/template/wall-e/eval/fixtures/express-xss/package.json +0 -12
  385. package/template/wall-e/eval/fixtures/express-xss/server.js +0 -90
  386. package/template/wall-e/eval/fixtures/express-xss/test.js +0 -67
  387. package/template/wall-e/eval/fixtures/express-xss/views/profile.ejs +0 -9
  388. package/template/wall-e/eval/fixtures/fullstack-app/config/default.js +0 -9
  389. package/template/wall-e/eval/fixtures/fullstack-app/config/test.js +0 -13
  390. package/template/wall-e/eval/fixtures/fullstack-app/package.json +0 -11
  391. package/template/wall-e/eval/fixtures/fullstack-app/public/css/style.css +0 -137
  392. package/template/wall-e/eval/fixtures/fullstack-app/public/index.html +0 -46
  393. package/template/wall-e/eval/fixtures/fullstack-app/public/js/app.js +0 -121
  394. package/template/wall-e/eval/fixtures/fullstack-app/public/js/auth.js +0 -71
  395. package/template/wall-e/eval/fixtures/fullstack-app/public/js/items.js +0 -80
  396. package/template/wall-e/eval/fixtures/fullstack-app/public/js/users.js +0 -46
  397. package/template/wall-e/eval/fixtures/fullstack-app/public/login.html +0 -45
  398. package/template/wall-e/eval/fixtures/fullstack-app/public/register.html +0 -38
  399. package/template/wall-e/eval/fixtures/fullstack-app/scripts/migrate.js +0 -23
  400. package/template/wall-e/eval/fixtures/fullstack-app/scripts/seed.js +0 -46
  401. package/template/wall-e/eval/fixtures/fullstack-app/server/db.js +0 -99
  402. package/template/wall-e/eval/fixtures/fullstack-app/server/index.js +0 -94
  403. package/template/wall-e/eval/fixtures/fullstack-app/server/middleware/auth.js +0 -19
  404. package/template/wall-e/eval/fixtures/fullstack-app/server/middleware/logger.js +0 -19
  405. package/template/wall-e/eval/fixtures/fullstack-app/server/router.js +0 -50
  406. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/auth.js +0 -69
  407. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/health.js +0 -23
  408. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/items.js +0 -88
  409. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/users.js +0 -75
  410. package/template/wall-e/eval/fixtures/fullstack-app/server/test.js +0 -198
  411. package/template/wall-e/eval/fixtures/fullstack-app/server/utils/response.js +0 -34
  412. package/template/wall-e/eval/fixtures/fullstack-app/server/utils/validate.js +0 -26
  413. package/template/wall-e/eval/fixtures/fullstack-app/server.js +0 -8
  414. package/template/wall-e/eval/fixtures/fullstack-app/test.js +0 -12
  415. package/template/wall-e/eval/fixtures/monorepo-basic/package.json +0 -8
  416. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/data.js +0 -58
  417. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/middleware.js +0 -46
  418. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/package.json +0 -8
  419. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/routes.js +0 -64
  420. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/server.js +0 -56
  421. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/test.js +0 -116
  422. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/commands.js +0 -61
  423. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/index.js +0 -62
  424. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/output.js +0 -43
  425. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/package.json +0 -11
  426. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/test.js +0 -44
  427. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/formatters.js +0 -43
  428. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/index.js +0 -12
  429. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/package.json +0 -5
  430. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/test.js +0 -55
  431. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/validators.js +0 -29
  432. package/template/wall-e/eval/fixtures/monorepo-basic/test.js +0 -46
  433. package/template/wall-e/eval/fixtures/node-cli/index.js +0 -78
  434. package/template/wall-e/eval/fixtures/node-cli/package.json +0 -10
  435. package/template/wall-e/eval/fixtures/node-cli/test.js +0 -57
  436. package/template/wall-e/eval/fixtures/node-typed/package.json +0 -8
  437. package/template/wall-e/eval/fixtures/node-typed/src/handlers.js +0 -31
  438. package/template/wall-e/eval/fixtures/node-typed/src/utils.js +0 -33
  439. package/template/wall-e/eval/fixtures/node-typed/test.js +0 -36
  440. package/template/wall-e/eval/fixtures/python-flask/app.py +0 -14
  441. package/template/wall-e/eval/fixtures/python-flask/requirements.txt +0 -2
  442. package/template/wall-e/eval/fixtures/python-flask/test_app.py +0 -25
  443. package/template/wall-e/eval/fixtures/wall-e-subset/brain.js +0 -105
  444. package/template/wall-e/eval/fixtures/wall-e-subset/eval/aggregator.js +0 -101
  445. package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks/chat.json +0 -20
  446. package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks/coding.json +0 -32
  447. package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks.js +0 -64
  448. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/package.json +0 -6
  449. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/server.js +0 -31
  450. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/test.js +0 -18
  451. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/utils.js +0 -34
  452. package/template/wall-e/eval/fixtures/wall-e-subset/eval/runner.js +0 -104
  453. package/template/wall-e/eval/fixtures/wall-e-subset/eval/scorer.js +0 -73
  454. package/template/wall-e/eval/fixtures/wall-e-subset/eval/test.js +0 -134
  455. package/template/wall-e/eval/fixtures/wall-e-subset/llm/client.js +0 -99
  456. package/template/wall-e/eval/fixtures/wall-e-subset/llm/providers.js +0 -63
  457. package/template/wall-e/eval/fixtures/wall-e-subset/llm/test.js +0 -70
  458. package/template/wall-e/eval/fixtures/wall-e-subset/package.json +0 -10
  459. package/template/wall-e/eval/fixtures/wall-e-subset/test.js +0 -86
  460. package/template/wall-e/eval/harvester.js +0 -685
  461. package/template/wall-e/eval/head-to-head.js +0 -388
  462. package/template/wall-e/eval/humaneval-adapter.js +0 -321
  463. package/template/wall-e/eval/list-models.js +0 -31
  464. package/template/wall-e/eval/livecodebench-adapter.js +0 -291
  465. package/template/wall-e/eval/mail-integration.js +0 -443
  466. package/template/wall-e/eval/manifest.js +0 -186
  467. package/template/wall-e/eval/meta-harness/adapters/coding-agent.js +0 -57
  468. package/template/wall-e/eval/meta-harness/bootstrap-snapshot.js +0 -149
  469. package/template/wall-e/eval/meta-harness/candidate-store.js +0 -117
  470. package/template/wall-e/eval/meta-harness/cli.js +0 -86
  471. package/template/wall-e/eval/meta-harness/domain-spec.js +0 -154
  472. package/template/wall-e/eval/meta-harness/domains/coding-agent.domain.json +0 -84
  473. package/template/wall-e/eval/meta-harness/examples/env-bootstrap-candidate.js +0 -29
  474. package/template/wall-e/eval/meta-harness/experience-store.js +0 -174
  475. package/template/wall-e/eval/meta-harness/frontier.js +0 -96
  476. package/template/wall-e/eval/meta-harness/harness-interface.js +0 -90
  477. package/template/wall-e/eval/meta-harness/leakage-guard.js +0 -80
  478. package/template/wall-e/eval/meta-harness/optimizer.js +0 -207
  479. package/template/wall-e/eval/meta-harness/proposer-runner.js +0 -110
  480. package/template/wall-e/eval/meta-harness/reporting.js +0 -58
  481. package/template/wall-e/eval/meta-harness/telemetry.js +0 -27
  482. package/template/wall-e/eval/meta-harness/validation.js +0 -81
  483. package/template/wall-e/eval/promoter.js +0 -228
  484. package/template/wall-e/eval/provider-normalizer.js +0 -33
  485. package/template/wall-e/eval/replay.js +0 -395
  486. package/template/wall-e/eval/run-agent-benchmarks.js +0 -386
  487. package/template/wall-e/eval/run-codex-cli-baseline.js +0 -177
  488. package/template/wall-e/eval/run-coding-agent-real.js +0 -187
  489. package/template/wall-e/eval/run-eval.js +0 -435
  490. package/template/wall-e/eval/run-model-comparison.js +0 -142
  491. package/template/wall-e/eval/session-evaluator.js +0 -187
  492. package/template/wall-e/eval/session-miner.js +0 -207
  493. package/template/wall-e/eval/session-retrieval-benchmark.js +0 -150
  494. package/template/wall-e/eval/session-transcripts.js +0 -509
  495. package/template/wall-e/eval/shadow.js +0 -161
  496. package/template/wall-e/eval/swebench-adapter.js +0 -345
  497. package/template/wall-e/eval/swebench-docker.js +0 -192
  498. package/template/wall-e/eval/train.py +0 -320
  499. package/template/wall-e/eval/trainer.js +0 -232
  500. package/template/wall-e/eval/weekly-eval-loop.js +0 -241
@@ -5,6 +5,13 @@
5
5
 
6
6
  const CR = {};
7
7
  let pinnedProjects = []; // Array of pinned project paths, persisted via savePref
8
+ let projectChooserState = {
9
+ projects: [],
10
+ query: '',
11
+ filter: 'all',
12
+ scan: null,
13
+ };
14
+ let projectChooserRefreshTimer = null;
8
15
 
9
16
  let crState = {
10
17
  reviewType: 'code', // code | doc
@@ -16,8 +23,10 @@ let crState = {
16
23
  files: [], // Parsed diff files
17
24
  activeFile: null, // Currently viewed file path
18
25
  comments: [], // All comments for current review
26
+ viewedFiles: new Set(),// Paths marked "Viewed" (review progress), persisted per review+base
19
27
  diffMode: 'unified', // unified | split
20
28
  document: null, // Current document artifact in doc review mode
29
+ documentCandidates: null,
21
30
  docViewMode: 'rendered',// rendered | source | split
22
31
  activeBlock: null, // Current document block id
23
32
  docLine: 1,
@@ -40,7 +49,10 @@ async function api(path, method, body) {
40
49
  let data = null;
41
50
  try { data = await res.json(); } catch (_) {}
42
51
  if (!res.ok || (data && data.error)) {
43
- throw new Error((data && data.error) || ('HTTP ' + res.status));
52
+ const err = new Error((data && data.error) || ('HTTP ' + res.status));
53
+ err.status = res.status;
54
+ err.data = data;
55
+ throw err;
44
56
  }
45
57
  return data || {};
46
58
  }
@@ -54,6 +66,63 @@ function basename(p) {
54
66
  return String(p || '').split('/').filter(Boolean).pop() || 'document';
55
67
  }
56
68
 
69
+ function replaceHash(hash) {
70
+ history.replaceState(null, '', location.pathname + location.search + (hash || ''));
71
+ }
72
+
73
+ function paramsFromHash(hash) {
74
+ const raw = String(hash || '').replace(/^#/, '');
75
+ const amp = raw.indexOf('&');
76
+ if (amp >= 0) return new URLSearchParams(raw.slice(amp + 1));
77
+ return new URLSearchParams(raw);
78
+ }
79
+
80
+ function sessionIdFromHash(hash) {
81
+ const raw = String(hash || '').replace(/^#/, '');
82
+ if (raw.startsWith('session=')) return decodeURIComponent(raw.slice('session='.length));
83
+ const params = paramsFromHash(hash);
84
+ return params.get('session') || params.get('sessionId') || '';
85
+ }
86
+
87
+ function activateSessionRoute(sessionId) {
88
+ if (!sessionId) return false;
89
+ const state = window._ctmState || window.state;
90
+ if (state) {
91
+ state.pendingHashSession = sessionId;
92
+ state.pendingHashType = 'active';
93
+ }
94
+ if (state?.sessions && typeof state.sessions.has === 'function' && state.sessions.has(sessionId) &&
95
+ typeof window.activateTab === 'function') {
96
+ window.activateTab(sessionId);
97
+ } else {
98
+ replaceHash('#session=' + encodeURIComponent(sessionId));
99
+ }
100
+ return true;
101
+ }
102
+
103
+ function isRecoverableDocumentOpenError(err) {
104
+ const status = Number(err && (err.status || err.statusCode)) || 0;
105
+ if (status === 400 || status === 404) return true;
106
+ return /document not found|relative document path requires|document path must be a file|unsupported document type/i
107
+ .test(String(err && err.message || ''));
108
+ }
109
+
110
+ function recoverFailedDocumentReviewNavigation(err, opts, previousHash) {
111
+ if (!isRecoverableDocumentOpenError(err)) return false;
112
+ const currentHash = String(location.hash || '');
113
+ const sessionId = opts?.sessionId || sessionIdFromHash(currentHash) || sessionIdFromHash(previousHash);
114
+ if (activateSessionRoute(sessionId)) return true;
115
+ if (previousHash && previousHash !== currentHash && !String(previousHash).startsWith('#review&type=doc')) {
116
+ replaceHash(previousHash);
117
+ return true;
118
+ }
119
+ if (currentHash.startsWith('#review') || currentHash === '#codereview') {
120
+ replaceHash('#review');
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+
57
126
  function markdownToSafeHtml(markdown) {
58
127
  const text = String(markdown || '');
59
128
  if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
@@ -125,30 +194,347 @@ CR.loadPinnedProjects = function(prefs) {
125
194
  if (prefs && Array.isArray(prefs)) pinnedProjects = prefs;
126
195
  };
127
196
 
197
+ function homeShortPath(value) {
198
+ return String(value || '').replace(/^\/Users\/[^/]+/, '~');
199
+ }
200
+
201
+ function reviewTargetInfo(project) {
202
+ const projectPath = String(project?.path || project?.cwd || '');
203
+ const name = String(project?.name || basename(projectPath) || 'folder');
204
+ const branch = String(project?.branch || '');
205
+ const claudeMatch = projectPath.match(/^(.*)\/\.claude\/worktrees\/([^/]+)(?:\/(.*))?$/);
206
+ const walleMatch = projectPath.match(/^(.*)\/\.walle\/worktrees\/([^/]+)(?:\/(.*))?$/);
207
+ const genericMatch = projectPath.match(/^(.*)\/\.worktrees\/([^/]+)(?:\/(.*))?$/);
208
+ const match = claudeMatch || walleMatch || genericMatch;
209
+
210
+ if (match) {
211
+ const provider = match === claudeMatch ? 'Claude' : match === walleMatch ? 'Wall-E' : '';
212
+ const repoRoot = match[1] || '';
213
+ const worktreeName = match[2] || name;
214
+ const innerFolder = match[3] || '';
215
+ return {
216
+ kind: match === claudeMatch ? 'claude-worktree' : match === walleMatch ? 'walle-worktree' : 'worktree',
217
+ label: provider ? `${provider} worktree` : 'Worktree',
218
+ isWorktree: true,
219
+ displayName: name,
220
+ worktreeName,
221
+ repoName: basename(repoRoot),
222
+ repoRoot,
223
+ innerFolder,
224
+ path: projectPath,
225
+ shortPath: homeShortPath(projectPath),
226
+ branch,
227
+ };
228
+ }
229
+
230
+ const looksLikeGit = !!branch;
231
+ return {
232
+ kind: looksLikeGit && branch === 'main' ? 'main-checkout' : looksLikeGit ? 'git-folder' : 'folder',
233
+ label: looksLikeGit && branch === 'main' ? 'Main checkout' : looksLikeGit ? 'Git folder' : 'Folder',
234
+ isWorktree: false,
235
+ displayName: name,
236
+ worktreeName: '',
237
+ repoName: name,
238
+ repoRoot: projectPath,
239
+ innerFolder: '',
240
+ path: projectPath,
241
+ shortPath: homeShortPath(projectPath),
242
+ branch,
243
+ };
244
+ }
245
+
246
+ function projectMatchesChooserFilter(project, info) {
247
+ const filter = projectChooserState.filter || 'all';
248
+ if (filter === 'changed') return Number(project.fileCount || 0) > 0;
249
+ if (filter === 'worktrees') return !!info.isWorktree;
250
+ if (filter === 'main') return !info.isWorktree;
251
+ return true;
252
+ }
253
+
254
+ function projectMatchesChooserQuery(project, info) {
255
+ const query = String(projectChooserState.query || '').trim().toLowerCase();
256
+ if (!query) return true;
257
+ const sessionsText = (project.sessions || []).map(s => s.label || s.id || '').join(' ');
258
+ const haystack = [
259
+ project.name,
260
+ project.path,
261
+ project.branch,
262
+ info.label,
263
+ info.displayName,
264
+ info.worktreeName,
265
+ info.repoName,
266
+ sessionsText,
267
+ ].join(' ').toLowerCase();
268
+ return haystack.includes(query);
269
+ }
270
+
271
+ function filteredReviewProjects() {
272
+ return (projectChooserState.projects || []).filter((project) => {
273
+ const info = reviewTargetInfo(project);
274
+ return projectMatchesChooserFilter(project, info) && projectMatchesChooserQuery(project, info);
275
+ });
276
+ }
277
+
278
+ function reviewProjectChooserCounts(projects) {
279
+ const changed = projects.filter(p => Number(p.fileCount || 0) > 0).length;
280
+ const worktrees = projects.filter(p => reviewTargetInfo(p).isWorktree).length;
281
+ return {
282
+ total: projects.length,
283
+ changed,
284
+ worktrees,
285
+ folders: Math.max(0, projects.length - worktrees),
286
+ };
287
+ }
288
+
289
+ function scheduleProjectChooserRefresh(delayMs) {
290
+ if (projectChooserRefreshTimer) return;
291
+ projectChooserRefreshTimer = setTimeout(() => {
292
+ projectChooserRefreshTimer = null;
293
+ if (crState._view === 'projects' && document.getElementById('codereview-panel')?.classList.contains('active')) {
294
+ CR.showProjectList({ quiet: true });
295
+ }
296
+ }, Math.max(100, Number(delayMs || 1000)));
297
+ }
298
+
299
+ function updateProjectChooserPolling(scan) {
300
+ if (scan && scan.running) {
301
+ scheduleProjectChooserRefresh(1400);
302
+ } else if (projectChooserRefreshTimer) {
303
+ clearTimeout(projectChooserRefreshTimer);
304
+ projectChooserRefreshTimer = null;
305
+ }
306
+ }
307
+
308
+ function renderProjectScanStatus() {
309
+ const scan = projectChooserState.scan || {};
310
+ if (scan.running) {
311
+ const existing = (projectChooserState.projects || []).length;
312
+ return `<span class="cr-project-scan-status active"><span class="spinner"></span>${existing ? 'Refreshing counts' : 'Finding projects'}</span>`;
313
+ }
314
+ if (scan.error) {
315
+ return `<span class="cr-project-scan-status error">Refresh failed</span>`;
316
+ }
317
+ if (scan.builtAt) {
318
+ return `<span class="cr-project-scan-status">Updated ${escHtml(formatTimeAgo(scan.builtAt))}</span>`;
319
+ }
320
+ return '';
321
+ }
322
+
323
+ function renderReviewProjectFilterButton(filter, label, count) {
324
+ const active = projectChooserState.filter === filter ? ' active' : '';
325
+ const suffix = typeof count === 'number' ? ` <span>${count}</span>` : '';
326
+ return `<button class="cr-project-filter${active}" type="button" data-filter="${escAttr(filter)}" onclick="CR.setProjectFilter('${escAttr(filter)}')">${escHtml(label)}${suffix}</button>`;
327
+ }
328
+
329
+ function renderReviewProjectResults() {
330
+ const projects = filteredReviewProjects();
331
+ if (!projects.length) {
332
+ const query = String(projectChooserState.query || '').trim();
333
+ if (!query && projectChooserState.scan && projectChooserState.scan.running) {
334
+ return `<div class="cr-project-empty-state">
335
+ <div class="cr-project-empty-title">Refreshing review targets</div>
336
+ <div class="cr-project-empty-copy">Project discovery is running in the background. The page stays responsive while git counts load.</div>
337
+ </div>`;
338
+ }
339
+ return `<div class="cr-project-empty-state">
340
+ <div class="cr-project-empty-title">No matching review targets</div>
341
+ <div class="cr-project-empty-copy">${query ? 'Try a folder, worktree, branch, or session name.' : 'Change the filter to see more folders and worktrees.'}</div>
342
+ </div>`;
343
+ }
344
+
345
+ let html = '<div class="cr-project-list">';
346
+ for (const p of projects) {
347
+ const info = reviewTargetInfo(p);
348
+ const sessions = p.sessions || [];
349
+ const hasChanges = Number(p.fileCount || 0) > 0;
350
+ const isPinned = pinnedProjects.includes(p.path);
351
+ const firstSessionId = sessions.length ? sessions[0].id : '';
352
+ const targetLabel = info.isWorktree
353
+ ? `${info.label}: ${info.worktreeName}`
354
+ : info.label;
355
+
356
+ const sessionRows = renderReviewProjectSessionRows(p);
357
+ const filesHtml = hasChanges ? `<div class="cr-project-files" aria-label="Changed files">${(p.files || []).map(f =>
358
+ `<span class="cr-project-file">${escHtml(f.path)} <span class="cr-file-add">+${f.additions}</span> <span class="cr-file-del">-${f.deletions}</span></span>`
359
+ ).join('')}</div>` : '';
360
+
361
+ html += `<div class="cr-project-card ${hasChanges ? 'has-changes' : ''}${isPinned ? ' pinned' : ''}"
362
+ data-project-path="${escAttr(p.path)}"
363
+ role="button"
364
+ tabindex="0"
365
+ aria-label="Review ${escAttr(targetLabel)}"
366
+ onclick="CR.openReviewForProject('${escAttr(p.path)}', '${escAttr(firstSessionId)}')"
367
+ onkeydown="CR.onProjectCardKey(event, '${escAttr(p.path)}', '${escAttr(firstSessionId)}')"
368
+ ${isPinned ? `draggable="true" ondragstart="CR.onPinnedDragStart(event)" ondragover="CR.onPinnedDragOver(event)" ondragleave="CR.onPinnedDragLeave(event)" ondrop="CR.onPinnedDrop(event)"` : ''}>
369
+ <div class="cr-project-card-header">
370
+ <div class="cr-project-card-left">
371
+ ${isPinned ? '<span class="cr-drag-handle" title="Drag to reorder">&#8942;</span>' : ''}
372
+ <button class="cr-pin-btn ${isPinned ? 'pinned' : ''}" onclick="event.stopPropagation(); CR.togglePinProject('${escAttr(p.path)}')" title="${isPinned ? 'Unpin target' : 'Pin target'}">&#x1F4CC;</button>
373
+ <div class="cr-project-title-group">
374
+ <span class="cr-project-name">${escHtml(info.displayName)}</span>
375
+ ${info.isWorktree ? `<span class="cr-worktree-name" title="${escAttr(info.repoRoot)}">${escHtml(info.worktreeName)}</span>` : ''}
376
+ </div>
377
+ </div>
378
+ <div class="cr-project-status-row">
379
+ <span class="cr-target-type ${escAttr(info.kind)}">${escHtml(info.label)}</span>
380
+ ${p.branch ? `<span class="cr-header-branch">${escHtml(p.branch)}</span>` : ''}
381
+ <span class="cr-project-session-count">${sessions.length} session${sessions.length !== 1 ? 's' : ''}</span>
382
+ ${hasChanges ? `<span class="cr-project-badge">${p.fileCount} file${p.fileCount !== 1 ? 's' : ''} changed</span>` : '<span class="cr-project-clean">Clean</span>'}
383
+ </div>
384
+ </div>
385
+ <div class="cr-project-path-grid">
386
+ <span class="cr-path-label">Folder</span>
387
+ <span class="cr-project-path" title="${escAttr(p.path)}">${escHtml(info.shortPath)}</span>
388
+ ${info.isWorktree ? `<span class="cr-path-label">Source</span><span class="cr-project-path" title="${escAttr(info.repoRoot)}">${escHtml(info.repoName || homeShortPath(info.repoRoot))}</span>` : ''}
389
+ </div>
390
+ <div class="cr-project-card-actions">
391
+ <button class="cr-btn primary cr-review-target-btn" type="button" onclick="event.stopPropagation(); CR.openReviewForProject('${escAttr(p.path)}', '${escAttr(firstSessionId)}')">Review this ${info.isWorktree ? 'worktree' : 'folder'}</button>
392
+ <span class="cr-project-action-hint">${info.isWorktree ? 'Exact worktree checkout' : 'Exact folder checkout'}</span>
393
+ </div>
394
+ ${sessionRows}
395
+ ${filesHtml}
396
+ </div>`;
397
+ }
398
+ html += '</div>';
399
+ return html;
400
+ }
401
+
402
+ // Session labels are derived from a session's first message, which often leaks CTM/agent
403
+ // command wrappers (e.g. <command-message>pua:pua-en</command-message>) and bare URLs.
404
+ // Strip those so the chooser shows a clean, human-readable hint.
405
+ function _cleanReviewLabel(text) {
406
+ let t = String(text || '');
407
+ t = t.replace(/<command-(?:message|name|args|stdout|stderr)>[\s\S]*?<\/command-(?:message|name|args|stdout|stderr)>/gi, ' ');
408
+ t = t.replace(/<\/?[a-z][^>]*>/gi, ' '); // any other stray tags (e.g. unmatched command-* )
409
+ t = t.replace(/\s+/g, ' ').trim();
410
+ return t;
411
+ }
412
+
413
+ function renderReviewProjectSessionRows(project) {
414
+ const sessions = project.sessions || [];
415
+ if (!sessions.length) return '';
416
+ const maxVisible = 3;
417
+ let html = '<div class="cr-project-session-section"><div class="cr-session-section-label">Optional session context</div><div class="cr-project-session-list">';
418
+ for (let i = 0; i < sessions.length; i++) {
419
+ const s = sessions[i];
420
+ const hidden = i >= maxVisible ? ' hidden' : '';
421
+ const activeTag = s.active ? '<span class="cr-session-active">Active</span>' : '';
422
+ const timeAgo = formatTimeAgo(s.modifiedAt || s.fileModifiedAt);
423
+ html += `<div class="cr-project-session-item${hidden}" data-overflow="${i >= maxVisible ? '1' : '0'}"
424
+ onclick="event.stopPropagation(); CR.openReviewForProject('${escAttr(project.path)}', '${escAttr(s.id)}')"
425
+ title="${escAttr(s.id)}">
426
+ ${activeTag}
427
+ <span class="cr-session-label">${escHtml(_cleanReviewLabel(s.label) || s.id.slice(0, 8))}</span>
428
+ <span class="cr-session-time">${escHtml(timeAgo)}</span>
429
+ </div>`;
430
+ }
431
+ if (sessions.length > maxVisible) {
432
+ html += `<button class="cr-session-toggle" onclick="event.stopPropagation(); CR.toggleSessionList(this)">
433
+ +${sessions.length - maxVisible} more session${sessions.length - maxVisible > 1 ? 's' : ''}
434
+ </button>`;
435
+ }
436
+ html += '</div></div>';
437
+ return html;
438
+ }
439
+
440
+ function renderReviewProjectChooser(projects) {
441
+ const counts = reviewProjectChooserCounts(projects);
442
+ return `<div class="cr-project-picker">
443
+ <div class="cr-project-chooser-header">
444
+ <div class="cr-project-chooser-copy">
445
+ <div class="cr-picker-eyebrow">Review</div>
446
+ <h2>Choose folder or worktree</h2>
447
+ <p>Select the exact checkout to review. Session rows are optional context; the folder or worktree is the review target.</p>
448
+ </div>
449
+ ${renderProjectScanStatus()}
450
+ </div>
451
+ <div class="cr-project-toolbar">
452
+ <label class="cr-project-search">
453
+ <span class="cr-project-search-icon">&#x2315;</span>
454
+ <input type="search" value="${escAttr(projectChooserState.query)}" placeholder="Search folders, worktrees, branches, sessions..." aria-label="Search review targets" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" oninput="CR.setProjectSearch(this.value)">
455
+ </label>
456
+ <div class="cr-project-filters" aria-label="Filter review targets">
457
+ ${renderReviewProjectFilterButton('all', 'All', counts.total)}
458
+ ${renderReviewProjectFilterButton('changed', 'Changed', counts.changed)}
459
+ ${renderReviewProjectFilterButton('worktrees', 'Worktrees', counts.worktrees)}
460
+ ${renderReviewProjectFilterButton('main', 'Folders', counts.folders)}
461
+ </div>
462
+ <span class="cr-project-result-count" id="cr-project-result-count"></span>
463
+ </div>
464
+ <div class="cr-project-results" id="cr-project-results">${renderReviewProjectResults()}</div>
465
+ </div>`;
466
+ }
467
+
468
+ function updateReviewProjectResults() {
469
+ const results = document.getElementById('cr-project-results');
470
+ if (results) results.innerHTML = renderReviewProjectResults();
471
+ const count = document.getElementById('cr-project-result-count');
472
+ if (count) {
473
+ const filtered = filteredReviewProjects().length;
474
+ const total = (projectChooserState.projects || []).length;
475
+ count.textContent = filtered === total ? `${total} targets` : `${filtered} of ${total}`;
476
+ }
477
+ document.querySelectorAll('.cr-project-filter').forEach((button) => {
478
+ button.classList.toggle('active', button.dataset.filter === projectChooserState.filter);
479
+ });
480
+ }
481
+
482
+ CR.setProjectSearch = function(value) {
483
+ projectChooserState.query = String(value || '');
484
+ updateReviewProjectResults();
485
+ };
486
+
487
+ CR.setProjectFilter = function(filter) {
488
+ projectChooserState.filter = ['all', 'changed', 'worktrees', 'main'].includes(filter) ? filter : 'all';
489
+ updateReviewProjectResults();
490
+ };
491
+
492
+ CR.onProjectCardKey = function(event, projectPath, sessionId) {
493
+ if (!event || (event.key !== 'Enter' && event.key !== ' ')) return;
494
+ event.preventDefault();
495
+ CR.openReviewForProject(projectPath, sessionId || '');
496
+ };
497
+
128
498
  // --- Show project list (default view when panel opens) ---
129
- CR.showProjectList = async function() {
499
+ CR.showProjectList = async function(options = {}) {
130
500
  const seq = ++crState._projectSeq;
501
+ const quiet = !!options.quiet;
131
502
  crState._view = 'projects';
132
503
  crState.reviewType = 'code';
133
504
  crState.reviewId = null;
134
505
  crState.document = null;
506
+ crState.documentCandidates = null;
135
507
  crState.activeBlock = null;
136
508
  const header = document.getElementById('cr-header');
137
509
  const tree = document.getElementById('cr-file-tree');
138
510
  const area = document.getElementById('cr-diff-area');
139
511
  const footer = document.getElementById('cr-footer');
140
512
 
141
- if (header) header.innerHTML = '<span class="cr-header-title">Code Review</span>';
513
+ // Chooser view has no file tree — hide the empty left rail (restored on open).
514
+ document.getElementById('codereview-panel')?.classList.add('cr-choosing');
515
+ if (header) header.innerHTML = '<span class="cr-header-title">Review</span>';
142
516
  if (tree) tree.innerHTML = '';
143
517
  if (footer) footer.innerHTML = '';
144
- if (area) area.innerHTML = '<div class="cr-loading"><span class="spinner"></span> Scanning projects...</div>';
518
+ if (area && (!quiet || !(projectChooserState.projects || []).length)) {
519
+ area.innerHTML = projectChooserState.projects.length
520
+ ? renderReviewProjectChooser(projectChooserState.projects)
521
+ : '<div class="cr-loading"><span class="spinner"></span> Loading cached review targets...</div>';
522
+ }
145
523
 
146
524
  try {
147
525
  const data = await api('/reviews/tracked-projects');
148
526
  if (seq !== crState._projectSeq || crState._view !== 'projects') return;
149
527
  const projects = data.projects || [];
528
+ projectChooserState.scan = data.scan || null;
150
529
 
151
530
  if (projects.length === 0) {
531
+ if (projectChooserState.scan && projectChooserState.scan.running) {
532
+ projectChooserState.projects = [];
533
+ if (area) area.innerHTML = renderReviewProjectChooser([]);
534
+ updateReviewProjectResults();
535
+ updateProjectChooserPolling(projectChooserState.scan);
536
+ return;
537
+ }
152
538
  if (area) area.innerHTML = `<div class="cr-diff-empty">
153
539
  <div style="text-align:center">
154
540
  <div style="font-size:32px;opacity:0.3;margin-bottom:12px">&#x1F4C1;</div>
@@ -170,63 +556,14 @@ CR.showProjectList = async function() {
170
556
  return 0; // Keep original (most recent) order for unpinned
171
557
  });
172
558
 
173
- // Render project cards
174
- let html = '<div class="cr-project-list">';
175
- for (const p of projects) {
176
- const hasChanges = p.fileCount > 0;
177
- const isPinned = pinnedProjects.includes(p.path);
178
- const firstSessionId = p.sessions.length ? p.sessions[0].id : '';
179
-
180
- // Sessions list (collapsed if > 3, show all with toggle)
181
- const maxVisible = 3;
182
- let sessionsHtml = '';
183
- if (p.sessions.length > 0) {
184
- sessionsHtml = '<div class="cr-project-session-list">';
185
- for (let i = 0; i < p.sessions.length; i++) {
186
- const s = p.sessions[i];
187
- const hidden = i >= maxVisible ? ' hidden' : '';
188
- const activeTag = s.active ? '<span class="cr-session-active">Active</span>' : '';
189
- const timeAgo = formatTimeAgo(s.modifiedAt);
190
- sessionsHtml += `<div class="cr-project-session-item${hidden}" data-overflow="${i >= maxVisible ? '1' : '0'}"
191
- onclick="event.stopPropagation(); CR.openReviewForProject('${escAttr(p.path)}', '${escAttr(s.id)}')"
192
- title="${escAttr(s.id)}">
193
- ${activeTag}
194
- <span class="cr-session-label">${escHtml(s.label)}</span>
195
- <span class="cr-session-time">${escHtml(timeAgo)}</span>
196
- </div>`;
197
- }
198
- if (p.sessions.length > maxVisible) {
199
- sessionsHtml += `<button class="cr-session-toggle" onclick="event.stopPropagation(); CR.toggleSessionList(this)">
200
- +${p.sessions.length - maxVisible} more session${p.sessions.length - maxVisible > 1 ? 's' : ''}
201
- </button>`;
202
- }
203
- sessionsHtml += '</div>';
204
- }
205
-
206
- html += `<div class="cr-project-card ${hasChanges ? 'has-changes' : ''}${isPinned ? ' pinned' : ''}"
207
- data-project-path="${escAttr(p.path)}"
208
- onclick="CR.openReviewForProject('${escAttr(p.path)}', '${escAttr(firstSessionId)}')"
209
- ${isPinned ? `draggable="true" ondragstart="CR.onPinnedDragStart(event)" ondragover="CR.onPinnedDragOver(event)" ondragleave="CR.onPinnedDragLeave(event)" ondrop="CR.onPinnedDrop(event)"` : ''}>
210
- <div class="cr-project-card-header">
211
- ${isPinned ? '<span class="cr-drag-handle" title="Drag to reorder">&#8942;</span>' : ''}
212
- <button class="cr-pin-btn ${isPinned ? 'pinned' : ''}" onclick="event.stopPropagation(); CR.togglePinProject('${escAttr(p.path)}')" title="${isPinned ? 'Unpin project' : 'Pin to top'}">&#x1F4CC;</button>
213
- <span class="cr-project-name">${escHtml(p.name)}</span>
214
- ${p.branch ? `<span class="cr-header-branch">${escHtml(p.branch)}</span>` : ''}
215
- <span class="cr-project-session-count">${p.sessions.length} session${p.sessions.length !== 1 ? 's' : ''}</span>
216
- ${hasChanges ? `<span class="cr-project-badge">${p.fileCount} file${p.fileCount !== 1 ? 's' : ''} changed</span>` : '<span class="cr-project-clean">Clean</span>'}
217
- </div>
218
- <div class="cr-project-path">${escHtml(p.path)}</div>
219
- ${sessionsHtml}
220
- ${hasChanges ? `<div class="cr-project-files">${p.files.map(f =>
221
- `<span class="cr-project-file">${escHtml(f.path)} <span class="cr-file-add">+${f.additions}</span> <span class="cr-file-del">-${f.deletions}</span></span>`
222
- ).join('')}</div>` : ''}
223
- </div>`;
224
- }
225
- html += '</div>';
226
- if (area) area.innerHTML = html;
559
+ projectChooserState.projects = projects;
560
+ if (area) area.innerHTML = renderReviewProjectChooser(projects);
561
+ updateReviewProjectResults();
562
+ updateProjectChooserPolling(projectChooserState.scan);
227
563
  } catch (e) {
228
564
  if (seq !== crState._projectSeq || crState._view !== 'projects') return;
229
565
  if (area) area.innerHTML = `<div class="cr-diff-empty" style="color:var(--red)">Failed to load projects: ${escHtml(e.message)}</div>`;
566
+ updateProjectChooserPolling(null);
230
567
  }
231
568
  };
232
569
 
@@ -281,10 +618,14 @@ CR.openReview = async function(sessionId, projectPath) {
281
618
  crState.baseRef = '';
282
619
  crState.files = [];
283
620
  crState.comments = [];
621
+ crState.viewedFiles = new Set();
622
+ try { crState.diffMode = localStorage.getItem('ctm.review.diffMode') === 'split' ? 'split' : 'unified'; } catch {}
284
623
  crState.activeFile = null;
285
624
  crState.reviewId = null;
286
625
  crState.document = null;
626
+ crState.documentCandidates = null;
287
627
  crState.activeBlock = null;
628
+ document.getElementById('codereview-panel')?.classList.remove('cr-choosing');
288
629
 
289
630
  // Show panel
290
631
  if (typeof window.activateTab === 'function') {
@@ -296,7 +637,7 @@ CR.openReview = async function(sessionId, projectPath) {
296
637
  }
297
638
 
298
639
  // Update URL hash with folder info
299
- const hashParts = ['#codereview'];
640
+ const hashParts = ['#review'];
300
641
  if (projectPath) hashParts.push('project=' + encodeURIComponent(projectPath));
301
642
  if (sessionId) hashParts.push('session=' + encodeURIComponent(sessionId));
302
643
  history.replaceState(null, '', location.pathname + location.search + hashParts.join('&'));
@@ -305,22 +646,28 @@ CR.openReview = async function(sessionId, projectPath) {
305
646
  renderLoading();
306
647
 
307
648
  try {
308
- // Load branch, commits, and rich commit log in parallel
309
- const [branchData, commitsData, logData] = await Promise.all([
649
+ // Load branch, commits, rich commit log, and the default review base in parallel
650
+ const [branchData, commitsData, logData, baseInfo] = await Promise.all([
310
651
  api(`/reviews/branch?project=${encodeURIComponent(projectPath)}`),
311
652
  api(`/reviews/commits?project=${encodeURIComponent(projectPath)}`),
312
653
  api(`/reviews/commit-log?project=${encodeURIComponent(projectPath)}&count=20`),
654
+ api(`/reviews/base-info?project=${encodeURIComponent(projectPath)}`).catch(() => null),
313
655
  ]);
314
656
  if (seq !== crState._openSeq || crState._view !== 'review') return;
315
657
  crState.branch = branchData.branch || '';
316
658
  crState._commitLog = logData.commits || [];
659
+ crState._baseInfo = baseInfo;
660
+ // Default to "all branch work vs main" (PR model); fall back through uncommitted →
661
+ // working tree → staged to the first base that actually has changes, so a freshly
662
+ // opened project never lands on a misleading empty "No changes detected" screen.
663
+ crState.baseRef = pickInitialBaseRef(baseInfo);
317
664
  renderHeader(commitsData.commits || []);
318
665
 
319
666
  // Create review record in DB
320
667
  const { id } = await api('/reviews', 'POST', {
321
668
  session_id: sessionId,
322
669
  project_path: projectPath,
323
- base_ref: '',
670
+ base_ref: crState.baseRef,
324
671
  });
325
672
  if (seq !== crState._openSeq || crState._view !== 'review') return;
326
673
  crState.reviewId = id;
@@ -336,11 +683,13 @@ CR.openReview = async function(sessionId, projectPath) {
336
683
  };
337
684
 
338
685
  CR.openDocumentReview = async function(filePath, line, opts) {
686
+ opts = opts || {};
339
687
  const seq = ++crState._openSeq;
340
688
  const targetLine = Math.max(1, Number(line) || 1);
689
+ const previousHash = location.hash || '';
341
690
  crState._view = 'review';
342
691
  crState.reviewType = 'doc';
343
- crState.sessionId = opts?.sessionId || null;
692
+ crState.sessionId = opts.sessionId || null;
344
693
  crState.projectPath = null;
345
694
  crState.baseRef = '';
346
695
  crState.branch = '';
@@ -349,8 +698,10 @@ CR.openDocumentReview = async function(filePath, line, opts) {
349
698
  crState.activeFile = filePath || null;
350
699
  crState.reviewId = null;
351
700
  crState.document = null;
701
+ crState.documentCandidates = null;
352
702
  crState.activeBlock = null;
353
703
  crState.docLine = targetLine;
704
+ document.getElementById('codereview-panel')?.classList.remove('cr-choosing');
354
705
 
355
706
  if (typeof window.activateTab === 'function') {
356
707
  if (!window._ctmState.tabOrder.includes('codereview')) {
@@ -360,9 +711,13 @@ CR.openDocumentReview = async function(filePath, line, opts) {
360
711
  if (typeof window.renderTabs === 'function') window.renderTabs();
361
712
  }
362
713
 
363
- history.replaceState(null, '', location.pathname + location.search
364
- + '#review&type=doc&path=' + encodeURIComponent(filePath || '')
365
- + '&line=' + encodeURIComponent(String(targetLine)));
714
+ const routeParams = new URLSearchParams();
715
+ routeParams.set('type', 'doc');
716
+ routeParams.set('path', filePath || '');
717
+ routeParams.set('line', String(targetLine));
718
+ if (opts.cwd) routeParams.set('cwd', opts.cwd);
719
+ if (crState.sessionId) routeParams.set('session', crState.sessionId);
720
+ history.replaceState(null, '', location.pathname + location.search + '#review&' + routeParams.toString());
366
721
 
367
722
  renderDocHeader();
368
723
  renderLoading();
@@ -371,27 +726,103 @@ CR.openDocumentReview = async function(filePath, line, opts) {
371
726
  const data = await api('/reviews/document-review', 'POST', {
372
727
  path: filePath,
373
728
  line: targetLine,
729
+ cwd: opts.cwd || undefined,
374
730
  session_id: crState.sessionId || undefined,
375
731
  });
376
732
  if (seq !== crState._openSeq || crState.reviewType !== 'doc') return;
377
733
  crState.reviewId = data.id;
378
734
  crState.document = data.document || null;
735
+ crState.documentCandidates = null;
379
736
  crState.projectPath = crState.document?.projectRoot || data.review?.project_path || null;
380
737
  crState.activeFile = crState.document?.path || filePath;
381
738
  crState.comments = data.review?.comments || [];
382
739
  const targetBlock = findDocBlockForLine(crState.document?.line || targetLine);
383
740
  crState.activeBlock = targetBlock?.id || null;
741
+ if (crState.document?.path) {
742
+ const canonicalParams = new URLSearchParams();
743
+ canonicalParams.set('type', 'doc');
744
+ canonicalParams.set('path', crState.document.path);
745
+ canonicalParams.set('line', String(crState.document.line || targetLine));
746
+ if (crState.sessionId) canonicalParams.set('session', crState.sessionId);
747
+ history.replaceState(null, '', location.pathname + location.search + '#review&' + canonicalParams.toString());
748
+ }
384
749
  renderDocHeader();
385
750
  renderDocTree();
386
751
  renderDocReview();
387
752
  } catch (e) {
388
753
  if (seq !== crState._openSeq || crState.reviewType !== 'doc') return;
754
+ if (e?.data?.code === 'EAMBIGUOUS' && Array.isArray(e.data.candidates) && e.data.candidates.length) {
755
+ renderDocumentCandidateChooser(filePath, targetLine, opts, e.data.candidates);
756
+ if (typeof window.toast === 'function') window.toast('Choose which document to review', { type: 'info' });
757
+ return;
758
+ }
389
759
  const area = document.getElementById('cr-diff-area');
390
760
  if (area) area.innerHTML = `<div class="cr-diff-empty" style="color:var(--red)">Failed to open document review: ${escHtml(e.message)}</div>`;
391
761
  if (typeof window.toast === 'function') window.toast('Failed to open document review: ' + e.message, { type: 'error' });
762
+ recoverFailedDocumentReviewNavigation(e, opts, previousHash);
392
763
  }
393
764
  };
394
765
 
766
+ CR.openDocumentReference = async function(rawReference, opts) {
767
+ const parsed = window.CTMDocLinks && typeof window.CTMDocLinks.splitReference === 'function'
768
+ ? window.CTMDocLinks.splitReference(rawReference, opts && opts.line)
769
+ : { path: String(rawReference || ''), line: opts && opts.line || 1 };
770
+ return CR.openDocumentReview(parsed.path, parsed.line, opts || {});
771
+ };
772
+
773
+ CR.openDocumentCandidate = function(index) {
774
+ const state = crState.documentCandidates;
775
+ const candidate = state && Array.isArray(state.candidates) ? state.candidates[index] : null;
776
+ if (!candidate || !candidate.path) return;
777
+ const opts = Object.assign({}, state.opts || {});
778
+ delete opts.cwd;
779
+ return CR.openDocumentReview(candidate.path, state.line || 1, opts);
780
+ };
781
+
782
+ function renderDocumentCandidateChooser(rawPath, line, opts, candidates) {
783
+ crState.documentCandidates = {
784
+ rawPath: rawPath || '',
785
+ line: Math.max(1, Number(line) || 1),
786
+ opts: Object.assign({}, opts || {}),
787
+ candidates: candidates.slice(0, 50),
788
+ };
789
+ crState.document = null;
790
+ crState.projectPath = candidates[0]?.projectRoot || null;
791
+ crState.comments = [];
792
+ crState.activeBlock = null;
793
+ renderDocHeader();
794
+ const tree = document.getElementById('cr-file-tree');
795
+ const footer = document.getElementById('cr-footer');
796
+ const area = document.getElementById('cr-diff-area');
797
+ if (tree) {
798
+ tree.innerHTML = candidates.slice(0, 50).map((candidate, index) => `
799
+ <div class="cr-file-item ${index === 0 ? 'active' : ''}" onclick="CR.openDocumentCandidate(${index})">
800
+ <span class="cr-file-icon">MD</span>
801
+ <span class="cr-file-name">${escHtml(candidate.relativePath || candidate.path || '')}</span>
802
+ </div>
803
+ `).join('');
804
+ }
805
+ if (footer) footer.innerHTML = '';
806
+ if (!area) return;
807
+ const title = escHtml(rawPath || 'document');
808
+ const list = candidates.slice(0, 50).map((candidate, index) => `
809
+ <button class="cr-doc-candidate" type="button" onclick="CR.openDocumentCandidate(${index})">
810
+ <span class="cr-doc-candidate-name">${escHtml(candidate.relativePath || basename(candidate.path))}</span>
811
+ <span class="cr-doc-candidate-path">${escHtml(candidate.projectRoot || '')}</span>
812
+ </button>
813
+ `).join('');
814
+ area.innerHTML = `
815
+ <div class="cr-doc-candidate-shell">
816
+ <div class="cr-doc-candidate-panel">
817
+ <div class="cr-doc-candidate-eyebrow">Document Review</div>
818
+ <h2>Choose the document for ${title}</h2>
819
+ <p>The reference matches multiple Markdown/text files in this project. Pick the artifact to review; CTM will then canonicalize the route to the exact file.</p>
820
+ <div class="cr-doc-candidate-list">${list}</div>
821
+ </div>
822
+ </div>
823
+ `;
824
+ }
825
+
395
826
  async function loadDiff() {
396
827
  const seq = ++crState._diffSeq;
397
828
  renderLoading();
@@ -407,6 +838,7 @@ async function loadDiff() {
407
838
  api(`/reviews/${crState.reviewId}`, 'PUT', { file_count: crState.files.length }).catch(() => {});
408
839
  }
409
840
 
841
+ _loadViewedFiles();
410
842
  if (crState.files.length === 0) {
411
843
  renderNoChanges();
412
844
  } else {
@@ -431,15 +863,108 @@ CR.changeBase = async function(value) {
431
863
 
432
864
  CR.refreshDiff = function() { loadDiff(); };
433
865
 
866
+ // Diff layout: 'unified' (default) or 'split' (side-by-side). Persisted globally.
867
+ CR.setDiffMode = function(mode) {
868
+ mode = mode === 'split' ? 'split' : 'unified';
869
+ if (crState.diffMode === mode) return;
870
+ crState.diffMode = mode;
871
+ try { localStorage.setItem('ctm.review.diffMode', mode); } catch {}
872
+ renderHeader();
873
+ if (crState.reviewType === 'code' && crState.files.length) renderDiff();
874
+ };
875
+
876
+ // --- "Viewed" file state + review progress (persisted per review+base in localStorage) ---
877
+ function _viewedStorageKey() {
878
+ // Key by project + base (stable across reopens) rather than the ephemeral review row id,
879
+ // which is recreated on each open and would lose viewed-state between sessions.
880
+ return `ctm.review.viewed.${crState.projectPath || 'none'}.${crState.baseRef || 'wt'}`;
881
+ }
882
+ function _loadViewedFiles() {
883
+ crState.viewedFiles = new Set();
884
+ try {
885
+ const raw = localStorage.getItem(_viewedStorageKey());
886
+ if (raw) {
887
+ const arr = JSON.parse(raw);
888
+ const present = new Set(crState.files.map(f => f.path));
889
+ // Drop stale entries for files no longer in the diff (e.g. after a base change).
890
+ for (const p of arr) if (present.has(p)) crState.viewedFiles.add(p);
891
+ }
892
+ } catch {}
893
+ }
894
+ function _saveViewedFiles() {
895
+ try { localStorage.setItem(_viewedStorageKey(), JSON.stringify([...crState.viewedFiles])); } catch {}
896
+ }
897
+ CR.toggleViewed = function(filePath, ev) {
898
+ if (ev) { ev.stopPropagation(); ev.preventDefault(); }
899
+ if (crState.viewedFiles.has(filePath)) crState.viewedFiles.delete(filePath);
900
+ else crState.viewedFiles.add(filePath);
901
+ _saveViewedFiles();
902
+ // Update just the affected DOM: the tree item + the diff section collapse + progress.
903
+ renderFileTree();
904
+ const section = document.querySelector(`.cr-diff-file-section[data-file-path="${CSS.escape(filePath)}"]`);
905
+ if (section) section.classList.toggle('viewed', crState.viewedFiles.has(filePath));
906
+ _renderReviewProgress();
907
+ };
908
+ CR.markAllViewed = function(viewed) {
909
+ crState.viewedFiles = viewed ? new Set(crState.files.map(f => f.path)) : new Set();
910
+ _saveViewedFiles();
911
+ renderFileTree();
912
+ renderDiff();
913
+ };
914
+ // Progress indicator ("N / M reviewed" + bar) lives in the footer; updated in place.
915
+ function _renderReviewProgress() {
916
+ const el = document.getElementById('cr-progress');
917
+ if (!el) return;
918
+ const total = crState.files.length;
919
+ const done = crState.files.filter(f => crState.viewedFiles.has(f.path)).length;
920
+ const pct = total ? Math.round((done / total) * 100) : 0;
921
+ el.innerHTML = total
922
+ ? `<span class="cr-progress-label">${done} / ${total} reviewed</span>
923
+ <span class="cr-progress-bar"><span class="cr-progress-fill" style="width:${pct}%"></span></span>`
924
+ : '';
925
+ }
926
+
927
+ // Order the "working-set" bases by preference for the initial view, given base-info counts.
928
+ // On a feature branch: all-branch-work → all-uncommitted → working tree → staged.
929
+ // On main/detached: all-uncommitted → working tree → staged.
930
+ function _workingBaseOrder(baseInfo) {
931
+ const c = (baseInfo && baseInfo.counts) || {};
932
+ const vsMain = ['--vs-main', 'All changes vs ' + ((baseInfo && baseInfo.mainBranch) || 'main'), c['vs-main'] || 0];
933
+ const rest = [
934
+ ['--uncommitted', 'All uncommitted', c['uncommitted'] || 0],
935
+ ['', 'Working tree (unstaged)', c['working'] || 0],
936
+ ['--staged', 'Staged changes', c['staged'] || 0],
937
+ ];
938
+ return (baseInfo && baseInfo.kind === 'vs-main') ? [vsMain, ...rest] : rest;
939
+ }
940
+
941
+ // Choose the initial base ref: the first working-set base with a non-zero count, else the
942
+ // server's default sentinel (so a truly-clean repo still shows an honest empty state).
943
+ function pickInitialBaseRef(baseInfo) {
944
+ if (!baseInfo) return '';
945
+ const firstNonEmpty = _workingBaseOrder(baseInfo).find(([, , n]) => n > 0);
946
+ return firstNonEmpty ? firstNonEmpty[0] : (baseInfo.defaultBase || '');
947
+ }
948
+
434
949
  // --- Render functions ---
435
950
 
436
951
  function renderHeader(commits) {
437
952
  const h = document.getElementById('cr-header');
438
953
  if (!h) return;
439
- const backLink = `<a class="cr-back-link" onclick="CR.backToSession()">&#x2190; Back to Projects</a>`;
440
- const branchBadge = crState.branch
441
- ? `<span class="cr-header-branch">${escHtml(crState.branch)}</span>`
442
- : '';
954
+ const backLink = `<a class="cr-back-link" onclick="CR.backToSession()">&#x2190; Change target</a>`;
955
+ const targetInfo = reviewTargetInfo({
956
+ path: crState.projectPath,
957
+ name: basename(crState.projectPath),
958
+ branch: crState.branch,
959
+ });
960
+ const targetMeta = targetInfo.isWorktree
961
+ ? `${targetInfo.label} · ${targetInfo.worktreeName}`
962
+ : targetInfo.label;
963
+ const targetHtml = `<div class="cr-review-target" title="${escAttr(crState.projectPath || '')}">
964
+ <span class="cr-review-target-kicker">Target</span>
965
+ <span class="cr-review-target-name">${escHtml(targetInfo.displayName)}</span>
966
+ <span class="cr-review-target-meta">${escHtml(targetMeta)}${crState.branch ? ` · ${escHtml(crState.branch)}` : ''}</span>
967
+ </div>`;
443
968
  const modeTabs = `<div class="cr-review-mode-tabs" aria-label="Review type">
444
969
  <button class="cr-review-mode-btn active" type="button">Code</button>
445
970
  <button class="cr-review-mode-btn" type="button" onclick="CR.showDocumentOpenState()" title="Open a Markdown document URL to review docs">Docs</button>
@@ -449,35 +974,40 @@ function renderHeader(commits) {
449
974
  // logMap keyed by both full SHA and short SHA for flexible lookup
450
975
  const logMap = {};
451
976
  for (const lc of (crState._commitLog || [])) { logMap[lc.sha] = lc; logMap[lc.shortSha] = lc; }
452
- let baseOptions = `<option value=""${crState.baseRef === '' ? ' selected' : ''}>Working tree (unstaged)</option>`;
453
- baseOptions += `<option value="--staged"${crState.baseRef === '--staged' ? ' selected' : ''}>Staged changes</option>`;
454
- if (commits) {
455
- for (const c of commits) {
977
+ // Working-set bases first (all branch work / uncommitted / working / staged), each
978
+ // labelled with its file count so the choice is obvious; then a commits group.
979
+ const _countSuffix = (n) => (typeof n === 'number' ? ` (${n})` : '');
980
+ let baseOptions = '';
981
+ for (const [val, label, count] of _workingBaseOrder(crState._baseInfo)) {
982
+ const sel = crState.baseRef === val ? ' selected' : '';
983
+ baseOptions += `<option value="${escAttr(val)}"${sel}>${escHtml(label)}${_countSuffix(count)}</option>`;
984
+ }
985
+ let commitOptions = '';
986
+ const commitList = commits || crState._commits;
987
+ if (commits) crState._commits = commits; // store for later re-render
988
+ if (commitList) {
989
+ for (const c of commitList) {
456
990
  const sel = crState.baseRef === c.sha ? ' selected' : '';
457
991
  const rich = logMap[c.sha];
458
992
  const meta = rich ? ` \u00B7 ${rich.author.split(' ')[0]} \u00B7 ${formatCommitDate(rich.authorDate)}` : '';
459
- baseOptions += `<option value="${escHtml(c.sha)}"${sel}>${escHtml(c.sha.slice(0,7))} ${escHtml(c.message.slice(0,40))}${meta}</option>`;
460
- }
461
- // Store for later re-render
462
- crState._commits = commits;
463
- } else if (crState._commits) {
464
- for (const c of crState._commits) {
465
- const sel = crState.baseRef === c.sha ? ' selected' : '';
466
- const rich = logMap[c.sha];
467
- const meta = rich ? ` \u00B7 ${rich.author.split(' ')[0]} \u00B7 ${formatCommitDate(rich.authorDate)}` : '';
468
- baseOptions += `<option value="${escHtml(c.sha)}"${sel}>${escHtml(c.sha.slice(0,7))} ${escHtml(c.message.slice(0,40))}${meta}</option>`;
993
+ commitOptions += `<option value="${escHtml(c.sha)}"${sel}>${escHtml(c.sha.slice(0,7))} ${escHtml(c.message.slice(0,40))}${meta}</option>`;
469
994
  }
470
995
  }
996
+ const commitGroup = commitOptions ? `<optgroup label="Since a commit">${commitOptions}</optgroup>` : '';
471
997
 
472
998
  h.innerHTML = `
473
999
  ${backLink}
474
1000
  <span class="cr-header-title">Review</span>
475
1001
  ${modeTabs}
476
- ${branchBadge}
1002
+ ${targetHtml}
477
1003
  <div class="cr-header-actions">
478
1004
  <button class="cr-chat-toggle" onclick="CR.toggleChat()" title="Toggle AI Chat (Cmd+Shift+C)" id="cr-chat-toggle-btn">&#x1F4AC; Chat</button>
479
- <label style="font-size:11px;color:var(--fg-dim)">Base:</label>
480
- <select class="cr-base-select" onchange="CR.changeBase(this.value)">${baseOptions}</select>
1005
+ <div class="cr-diff-mode-toggle" role="group" aria-label="Diff layout">
1006
+ <button class="cr-diff-mode-btn ${crState.diffMode !== 'split' ? 'active' : ''}" type="button" onclick="CR.setDiffMode('unified')" title="Unified diff">Unified</button>
1007
+ <button class="cr-diff-mode-btn ${crState.diffMode === 'split' ? 'active' : ''}" type="button" onclick="CR.setDiffMode('split')" title="Side-by-side diff">Split</button>
1008
+ </div>
1009
+ <label class="cr-compare-label">Compare:</label>
1010
+ <select class="cr-base-select" onchange="CR.changeBase(this.value)"><optgroup label="Changes">${baseOptions}</optgroup>${commitGroup}</select>
481
1011
  <button class="cr-btn" onclick="CR.refreshDiff()" title="Refresh diff">&#x21BB; Refresh</button>
482
1012
  <div class="send-dropdown-wrap" id="cr-send-dropdown-wrap">
483
1013
  <button class="cr-btn primary btn-main" onclick="CR.submitReview()" id="cr-submit-btn" disabled>Send</button>
@@ -530,6 +1060,7 @@ CR.showDocumentOpenState = function() {
530
1060
  crState._view = 'review';
531
1061
  crState.reviewId = null;
532
1062
  crState.document = null;
1063
+ crState.documentCandidates = null;
533
1064
  crState.comments = [];
534
1065
  renderDocHeader();
535
1066
  const tree = document.getElementById('cr-file-tree');
@@ -577,8 +1108,11 @@ function renderDocTree() {
577
1108
  for (const block of headings) {
578
1109
  const count = docCommentsForBlock(block).filter(c => c.status === 'open').length;
579
1110
  const active = crState.activeBlock === block.id;
1111
+ // TOC entry: no leading `#` glyph; hierarchy is shown via padding-left
1112
+ // (cr-doc-level-N classes from CSS). The empty cr-file-icon span is
1113
+ // retained so the existing icon+name+badge layout stays predictable.
580
1114
  html += `<div class="cr-file-item cr-doc-heading cr-doc-level-${Math.min(block.level || 1, 6)} ${active ? 'active' : ''}" onclick="CR.selectDocBlock('${escAttr(block.id)}')">
581
- <span class="cr-file-icon">#</span>
1115
+ <span class="cr-file-icon" aria-hidden="true"></span>
582
1116
  <span class="cr-file-name" title="${escAttr(block.text)}">${escHtml(block.text)}</span>
583
1117
  ${count ? `<span class="cr-file-badge">${count}</span>` : ''}
584
1118
  </div>`;
@@ -622,9 +1156,70 @@ function renderDocReview() {
622
1156
  } else {
623
1157
  area.scrollTop = 0;
624
1158
  }
1159
+ setupDocScrollSpy(area);
625
1160
  renderFooter();
626
1161
  }
627
1162
 
1163
+ // Scroll-spy: highlight the TOC entry for the heading-block currently in
1164
+ // view. Uses IntersectionObserver scoped to the rendered pane so the active
1165
+ // block tracks the user's reading position. Re-runs after every renderDoc
1166
+ // because the DOM is rebuilt each time.
1167
+ let _docScrollSpyObserver = null;
1168
+ function setupDocScrollSpy(area) {
1169
+ if (_docScrollSpyObserver) { try { _docScrollSpyObserver.disconnect(); } catch {} _docScrollSpyObserver = null; }
1170
+ if (!area || typeof IntersectionObserver !== 'function') return;
1171
+ const pane = area.querySelector('.cr-doc-rendered-pane');
1172
+ if (!pane) return;
1173
+ const blocks = Array.from(pane.querySelectorAll('.cr-doc-block'));
1174
+ if (!blocks.length) return;
1175
+
1176
+ // Map block id → its heading block id (for non-heading blocks, attribute
1177
+ // them to the nearest preceding heading so the TOC stays anchored).
1178
+ const headingFor = new Map();
1179
+ let lastHeadingId = null;
1180
+ for (const block of blocks) {
1181
+ const blockId = block.getAttribute('data-block-id');
1182
+ const isHeading = !!block.querySelector('.cr-doc-markdown > h1, .cr-doc-markdown > h2, .cr-doc-markdown > h3, .cr-doc-markdown > h4, .cr-doc-markdown > h5, .cr-doc-markdown > h6');
1183
+ if (isHeading) lastHeadingId = blockId;
1184
+ headingFor.set(blockId, lastHeadingId);
1185
+ }
1186
+
1187
+ const tree = document.getElementById('cr-file-tree');
1188
+ const setActive = (headingId) => {
1189
+ if (!tree) return;
1190
+ tree.querySelectorAll('.cr-doc-heading.active').forEach(el => el.classList.remove('active'));
1191
+ if (!headingId) return;
1192
+ const escaped = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(headingId) : headingId.replace(/"/g, '');
1193
+ const sel = `.cr-doc-heading[onclick*="${escaped}"]`;
1194
+ const target = tree.querySelector(sel);
1195
+ if (target) target.classList.add('active');
1196
+ };
1197
+
1198
+ // Track the topmost intersecting block. Threshold 0 + rootMargin
1199
+ // forces the "active" block to be the one just below the top of the
1200
+ // viewport — same pattern as Stripe Docs and Notion.
1201
+ const visible = new Map();
1202
+ _docScrollSpyObserver = new IntersectionObserver((entries) => {
1203
+ for (const entry of entries) {
1204
+ const id = entry.target.getAttribute('data-block-id');
1205
+ if (entry.isIntersecting) visible.set(id, entry.boundingClientRect.top);
1206
+ else visible.delete(id);
1207
+ }
1208
+ if (!visible.size) return;
1209
+ // Choose the entry with the smallest top — i.e. closest to the top of viewport.
1210
+ let topId = null, topY = Infinity;
1211
+ for (const [id, y] of visible) {
1212
+ if (y < topY) { topY = y; topId = id; }
1213
+ }
1214
+ setActive(headingFor.get(topId) || null);
1215
+ }, {
1216
+ root: area,
1217
+ rootMargin: '-12px 0px -70% 0px', // active = block crossing the top 30% band
1218
+ threshold: 0,
1219
+ });
1220
+ blocks.forEach(b => _docScrollSpyObserver.observe(b));
1221
+ }
1222
+
628
1223
  function renderDocRenderedPane(doc) {
629
1224
  let html = `<article class="cr-doc-article" data-doc-path="${escAttr(doc.path)}">`;
630
1225
  for (const block of doc.blocks) {
@@ -812,9 +1407,13 @@ function renderLoading() {
812
1407
  function renderNoChanges() {
813
1408
  const area = document.getElementById('cr-diff-area');
814
1409
  const tree = document.getElementById('cr-file-tree');
815
- const message = crState.baseRef === '--staged'
816
- ? 'No staged changes'
817
- : (crState.baseRef ? `Commit ${crState.baseRef.slice(0,7)} has no file changes` : 'Working tree is clean against HEAD');
1410
+ const mainBranch = (crState._baseInfo && crState._baseInfo.mainBranch) || 'main';
1411
+ let message;
1412
+ if (crState.baseRef === '--vs-main') message = `This branch has no changes vs ${mainBranch}`;
1413
+ else if (crState.baseRef === '--uncommitted') message = 'Nothing uncommitted — working tree and index are clean';
1414
+ else if (crState.baseRef === '--staged') message = 'No staged changes';
1415
+ else if (crState.baseRef) message = `Commit ${crState.baseRef.slice(0,7)} has no file changes`;
1416
+ else message = 'Working tree is clean against HEAD';
818
1417
  if (area) area.innerHTML = `<div class="cr-no-changes">
819
1418
  <div class="cr-no-changes-icon">&#x2714;</div>
820
1419
  <div class="cr-no-changes-text">No changes detected</div>
@@ -846,8 +1445,12 @@ function renderFileTree() {
846
1445
  const fExt = (f.path.match(/\.[^.]+$/) || [''])[0].toLowerCase();
847
1446
  const isImage = f.isBinary && imgExts.includes(fExt);
848
1447
  const icon = f.isNew ? '&#x2795;' : f.isDeleted ? '&#x2796;' : isImage ? '&#x1F5BC;' : f.isBinary ? '&#x1F4E6;' : '&#x1F4C4;';
849
- return `<div class="cr-file-item ${isActive ? 'active' : ''} ${commentCount ? 'has-comments' : ''}"
1448
+ const viewed = crState.viewedFiles.has(f.path);
1449
+ return `<div class="cr-file-item ${isActive ? 'active' : ''} ${commentCount ? 'has-comments' : ''} ${viewed ? 'viewed' : ''}"
850
1450
  onclick="CR.selectFile('${escAttr(f.path)}')">
1451
+ <span class="cr-file-viewed ${viewed ? 'on' : ''}" role="checkbox" aria-checked="${viewed}"
1452
+ title="${viewed ? 'Mark as not viewed' : 'Mark as viewed'} (v)"
1453
+ onclick="CR.toggleViewed('${escAttr(f.path)}', event)">${viewed ? '&#x2714;' : ''}</span>
851
1454
  <span class="cr-file-icon">${icon}</span>
852
1455
  <span class="cr-file-name" title="${escHtml(f.path)}">${escHtml(f.path)}</span>
853
1456
  <span class="cr-file-stats">
@@ -857,6 +1460,7 @@ function renderFileTree() {
857
1460
  ${commentCount ? `<span class="cr-file-badge">${commentCount}</span>` : ''}
858
1461
  </div>`;
859
1462
  }).join('');
1463
+ _renderReviewProgress();
860
1464
  }
861
1465
 
862
1466
  CR.selectFile = function(filePath) {
@@ -905,6 +1509,151 @@ function _renderCommitSummaryCard(c) {
905
1509
  return html;
906
1510
  }
907
1511
 
1512
+ // Comment threads anchored exactly at (file, lineNum, side). Shared by unified + split.
1513
+ function _commentThreadRows(file, lineNum, side) {
1514
+ if (!lineNum) return '';
1515
+ const threadComments = crState.comments.filter(c =>
1516
+ c.file_path === file.path && c.side === side && (c.line_end || c.line_start) === lineNum
1517
+ );
1518
+ return threadComments.length ? renderCommentThread(threadComments, file.path, lineNum, side) : '';
1519
+ }
1520
+
1521
+ // An "expand hidden context" row. from/to are new-side line numbers; oldDelta maps a new
1522
+ // line number to its old number (oldNum = newNum + oldDelta) in the unchanged gap.
1523
+ function _expandRow(file, from, to, oldDelta) {
1524
+ const count = to > 0 ? (to - from + 1) : 0;
1525
+ if (to > 0 && count <= 0) return '';
1526
+ const label = count > 0 ? `Expand ${count} line${count > 1 ? 's' : ''}` : 'Expand remaining lines';
1527
+ return `<tr class="cr-diff-expand-row"><td colspan="4">
1528
+ <button class="cr-expand-btn" onclick="CR.expandContext(this, '${escAttr(file.path)}', ${from}, ${to}, ${oldDelta})">&#x22EF; ${label}</button>
1529
+ </td></tr>`;
1530
+ }
1531
+
1532
+ // Unified diff table for one file (old + new gutters, single content column).
1533
+ function _renderFileDiffUnified(file) {
1534
+ let html = '<table class="cr-diff-table" data-file-path="' + escAttr(file.path) + '"><tbody>';
1535
+ for (let hi = 0; hi < file.hunks.length; hi++) {
1536
+ const hunk = file.hunks[hi];
1537
+ // Expand row for the gap before this hunk (leading context, or between hunks).
1538
+ if (!file.isNew && !file.isDeleted) {
1539
+ const oldDelta = hunk.oldStart - hunk.newStart;
1540
+ if (hi === 0) {
1541
+ if (hunk.newStart > 1) html += _expandRow(file, 1, hunk.newStart - 1, oldDelta);
1542
+ } else {
1543
+ const prev = file.hunks[hi - 1];
1544
+ const gapStart = prev.newStart + prev.newLines;
1545
+ const gapEnd = hunk.newStart - 1;
1546
+ if (gapEnd >= gapStart) html += _expandRow(file, gapStart, gapEnd, oldDelta);
1547
+ }
1548
+ }
1549
+ html += `<tr class="cr-diff-hunk-header"><td colspan="4">@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@ ${escHtml(hunk.header)}</td></tr>`;
1550
+ for (const line of hunk.lines) {
1551
+ const cls = line.type === 'add' ? 'add' : line.type === 'del' ? 'del' : '';
1552
+ const prefix = line.type === 'add' ? '+' : line.type === 'del' ? '-' : ' ';
1553
+ const oldNum = line.oldNum != null ? line.oldNum : '';
1554
+ const newNum = line.newNum != null ? line.newNum : '';
1555
+ const side = line.type === 'del' ? 'old' : 'new';
1556
+ const lineNum = newNum || oldNum;
1557
+ const lineId = `${file.path}:${side}:${lineNum}`;
1558
+ html += `<tr class="cr-diff-line ${cls}" data-line-id="${escAttr(lineId)}" data-file="${escAttr(file.path)}" data-line="${lineNum}" data-side="${side}">
1559
+ <td class="cr-line-num" onmousedown="CR.onLineClick(event, '${escAttr(file.path)}', ${oldNum || 'null'}, '${side}')">${oldNum}</td>
1560
+ <td class="cr-line-num" onmousedown="CR.onLineClick(event, '${escAttr(file.path)}', ${newNum || 'null'}, 'new')">${newNum}</td>
1561
+ <td class="cr-line-prefix">${prefix}</td>
1562
+ <td class="cr-line-content">${escHtml(line.content)}</td>
1563
+ </tr>`;
1564
+ html += _commentThreadRows(file, lineNum, side);
1565
+ }
1566
+ }
1567
+ // Trailing expand row (context after the last hunk, to end of file).
1568
+ if (!file.isNew && !file.isDeleted && file.hunks.length) {
1569
+ const last = file.hunks[file.hunks.length - 1];
1570
+ const lastNewEnd = last.newStart + last.newLines - 1;
1571
+ const oldDelta = (last.oldStart + last.oldLines - 1) - lastNewEnd;
1572
+ html += _expandRow(file, lastNewEnd + 1, 0, oldDelta);
1573
+ }
1574
+ return html + '</tbody></table>';
1575
+ }
1576
+
1577
+ // Fetch hidden context lines and splice them in where the "Expand" row was clicked.
1578
+ CR.expandContext = async function(btn, filePath, from, to, oldDelta) {
1579
+ if (!btn || btn._loading) return;
1580
+ btn._loading = true;
1581
+ const row = btn.closest('tr');
1582
+ const original = btn.innerHTML;
1583
+ btn.innerHTML = '&#x22EF; Loading…';
1584
+ try {
1585
+ const q = new URLSearchParams({ project: crState.projectPath, path: filePath, from: String(from) });
1586
+ if (to > 0) q.set('to', String(to));
1587
+ if (crState.baseRef) q.set('ref', crState.baseRef);
1588
+ const data = await api('/reviews/file-lines?' + q.toString());
1589
+ const lines = data.lines || [];
1590
+ if (!lines.length) { row.remove(); return; }
1591
+ const rowsHtml = lines.map(l => {
1592
+ const newNum = l.num;
1593
+ const oldNum = newNum + oldDelta;
1594
+ const lineId = `${filePath}:new:${newNum}`;
1595
+ return `<tr class="cr-diff-line" data-line-id="${escAttr(lineId)}" data-file="${escAttr(filePath)}" data-line="${newNum}" data-side="new">
1596
+ <td class="cr-line-num" onmousedown="CR.onLineClick(event, '${escAttr(filePath)}', ${oldNum}, 'old')">${oldNum}</td>
1597
+ <td class="cr-line-num" onmousedown="CR.onLineClick(event, '${escAttr(filePath)}', ${newNum}, 'new')">${newNum}</td>
1598
+ <td class="cr-line-prefix"> </td>
1599
+ <td class="cr-line-content">${escHtml(l.text)}</td>
1600
+ </tr>`;
1601
+ }).join('');
1602
+ row.insertAdjacentHTML('beforebegin', rowsHtml);
1603
+ row.remove();
1604
+ } catch (e) {
1605
+ btn.innerHTML = original;
1606
+ btn._loading = false;
1607
+ if (typeof window.toast === 'function') window.toast('Could not expand context: ' + e.message, { type: 'error' });
1608
+ }
1609
+ };
1610
+
1611
+ // Align hunk lines into side-by-side rows: context lines pair 1:1; runs of deletions and
1612
+ // additions are paired index-wise (extra rows on one side get a blank cell opposite).
1613
+ function _alignHunkRows(lines) {
1614
+ const rows = [];
1615
+ let dels = [], adds = [];
1616
+ const flush = () => {
1617
+ const n = Math.max(dels.length, adds.length);
1618
+ for (let i = 0; i < n; i++) rows.push({ left: dels[i] || null, right: adds[i] || null });
1619
+ dels = []; adds = [];
1620
+ };
1621
+ for (const line of lines) {
1622
+ if (line.type === 'del') dels.push(line);
1623
+ else if (line.type === 'add') adds.push(line);
1624
+ else { flush(); rows.push({ left: line, right: line, ctx: true }); }
1625
+ }
1626
+ flush();
1627
+ return rows;
1628
+ }
1629
+
1630
+ // Side-by-side diff table for one file (old | new columns).
1631
+ function _renderFileDiffSplit(file) {
1632
+ const fp = escAttr(file.path);
1633
+ const cell = (line, sideNum, sideName, sideCls) => {
1634
+ if (!line) return `<td class="cr-line-num"></td><td class="cr-line-content cr-split-empty"></td>`;
1635
+ const num = line[sideNum] != null ? line[sideNum] : '';
1636
+ return `<td class="cr-line-num" onmousedown="CR.onLineClick(event, '${fp}', ${num || 'null'}, '${sideName}')">${num}</td>
1637
+ <td class="cr-line-content ${sideCls}">${escHtml(line.content)}</td>`;
1638
+ };
1639
+ let html = '<table class="cr-diff-table split"><tbody>';
1640
+ for (const hunk of file.hunks) {
1641
+ html += `<tr class="cr-diff-hunk-header"><td colspan="4">@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@ ${escHtml(hunk.header)}</td></tr>`;
1642
+ for (const row of _alignHunkRows(hunk.lines)) {
1643
+ const leftCls = row.ctx ? '' : (row.left ? 'del' : '');
1644
+ const rightCls = row.ctx ? '' : (row.right ? 'add' : '');
1645
+ html += `<tr class="cr-diff-line-split">
1646
+ ${cell(row.left, 'oldNum', 'old', leftCls)}
1647
+ ${cell(row.right, 'newNum', 'new', rightCls)}
1648
+ </tr>`;
1649
+ // Comment threads: anchor under the new side when present, else the old side.
1650
+ if (row.right && row.right.newNum != null) html += _commentThreadRows(file, row.right.newNum, 'new');
1651
+ else if (row.left && row.left.oldNum != null) html += _commentThreadRows(file, row.left.oldNum, 'old');
1652
+ }
1653
+ }
1654
+ return html + '</tbody></table>';
1655
+ }
1656
+
908
1657
  function renderDiff() {
909
1658
  const area = document.getElementById('cr-diff-area');
910
1659
  if (!area) return;
@@ -954,9 +1703,13 @@ function renderDiff() {
954
1703
  for (const file of crState.files) {
955
1704
  const fileId = 'cr-file-' + escAttr(file.path);
956
1705
 
957
- // Sticky file header
958
- html += `<div class="cr-diff-file-section" id="${fileId}" data-file-path="${escAttr(file.path)}">`;
1706
+ // Sticky file header — viewed files collapse to just this header (click to expand).
1707
+ const viewed = crState.viewedFiles.has(file.path);
1708
+ html += `<div class="cr-diff-file-section ${viewed ? 'viewed' : ''}" id="${fileId}" data-file-path="${escAttr(file.path)}">`;
959
1709
  html += `<div class="cr-diff-file-header sticky">
1710
+ <span class="cr-diff-file-viewed ${viewed ? 'on' : ''}" role="checkbox" aria-checked="${viewed}"
1711
+ title="${viewed ? 'Mark as not viewed' : 'Mark as viewed'} (v)"
1712
+ onclick="CR.toggleViewed('${escAttr(file.path)}', event)">${viewed ? '&#x2714; Viewed' : 'Viewed'}</span>
960
1713
  <span class="cr-diff-file-path">${escHtml(file.path)}</span>
961
1714
  <span class="cr-file-stats" style="font-size:12px">
962
1715
  <span class="cr-file-add">+${file.additions}</span>
@@ -984,41 +1737,8 @@ function renderDiff() {
984
1737
  continue;
985
1738
  }
986
1739
 
987
- // Diff table
988
- html += '<table class="cr-diff-table"><tbody>';
989
-
990
- for (const hunk of file.hunks) {
991
- html += `<tr class="cr-diff-hunk-header"><td colspan="4">@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@ ${escHtml(hunk.header)}</td></tr>`;
992
-
993
- for (let i = 0; i < hunk.lines.length; i++) {
994
- const line = hunk.lines[i];
995
- const cls = line.type === 'add' ? 'add' : line.type === 'del' ? 'del' : '';
996
- const prefix = line.type === 'add' ? '+' : line.type === 'del' ? '-' : ' ';
997
- const oldNum = line.oldNum != null ? line.oldNum : '';
998
- const newNum = line.newNum != null ? line.newNum : '';
999
- const lineId = `${file.path}:${line.type === 'del' ? 'old' : 'new'}:${newNum || oldNum}`;
1000
-
1001
- html += `<tr class="cr-diff-line ${cls}" data-line-id="${escAttr(lineId)}" data-file="${escAttr(file.path)}" data-line="${newNum || oldNum}" data-side="${line.type === 'del' ? 'old' : 'new'}">
1002
- <td class="cr-line-num" onmousedown="CR.onLineClick(event, '${escAttr(file.path)}', ${oldNum || 'null'}, '${line.type === 'del' ? 'old' : 'new'}')">${oldNum}</td>
1003
- <td class="cr-line-num" onmousedown="CR.onLineClick(event, '${escAttr(file.path)}', ${newNum || 'null'}, 'new')">${newNum}</td>
1004
- <td class="cr-line-prefix">${prefix}</td>
1005
- <td class="cr-line-content">${escHtml(line.content)}</td>
1006
- </tr>`;
1007
-
1008
- // Insert comment threads after this line
1009
- const lineNum = newNum || oldNum;
1010
- const side = line.type === 'del' ? 'old' : 'new';
1011
- const lineComments = crState.comments.filter(c =>
1012
- c.file_path === file.path && c.line_start <= lineNum && (c.line_end || c.line_start) >= lineNum && c.side === side
1013
- );
1014
- const threadComments = lineComments.filter(c => (c.line_end || c.line_start) === lineNum);
1015
- if (threadComments.length > 0) {
1016
- html += renderCommentThread(threadComments, file.path, lineNum, side);
1017
- }
1018
- }
1019
- }
1020
-
1021
- html += '</tbody></table>';
1740
+ // Diff table — unified or side-by-side depending on crState.diffMode
1741
+ html += crState.diffMode === 'split' ? _renderFileDiffSplit(file) : _renderFileDiffUnified(file);
1022
1742
  html += '</div>'; // close file section
1023
1743
  }
1024
1744
 
@@ -1118,8 +1838,14 @@ function renderFooter() {
1118
1838
  ? `${openComments.length} ${commentWord} (${parts.join(', ')}) across ${fileCount} files`
1119
1839
  : `${fileCount} files changed, no comments yet`);
1120
1840
 
1841
+ const resolveAllBtn = openComments.length > 1
1842
+ ? `<button class="cr-btn cr-resolve-all" onclick="CR.resolveAllComments()" title="Resolve all open comments">Resolve all (${openComments.length})</button>`
1843
+ : '';
1844
+
1121
1845
  footer.innerHTML = `
1122
1846
  <div class="cr-footer-summary">${summary}</div>
1847
+ <div class="cr-progress" id="cr-progress"></div>
1848
+ ${resolveAllBtn}
1123
1849
  <div class="send-dropdown-wrap" id="cr-footer-send-wrap">
1124
1850
  <button class="cr-btn primary btn-main cr-footer-submit" onclick="CR.submitReview()" ${openComments.length === 0 ? 'disabled' : ''}>Send</button>
1125
1851
  <button class="cr-btn primary btn-caret" onclick="CR.toggleSendDropdown(event)" ${openComments.length === 0 ? 'disabled' : ''}>&#9660;</button>
@@ -1130,6 +1856,8 @@ function renderFooter() {
1130
1856
  // Also update submit button in header
1131
1857
  const submitBtn = document.getElementById('cr-submit-btn');
1132
1858
  if (submitBtn) submitBtn.disabled = openComments.length === 0;
1859
+
1860
+ _renderReviewProgress();
1133
1861
  }
1134
1862
 
1135
1863
  // --- Line selection (click-drag or shift-click) → add comment ---
@@ -1297,6 +2025,18 @@ CR.resolveComment = async function(commentId) {
1297
2025
  }
1298
2026
  };
1299
2027
 
2028
+ CR.resolveAllComments = async function() {
2029
+ const open = crState.comments.filter(c => c.status === 'open');
2030
+ if (!open.length) return;
2031
+ if (typeof window.confirm === 'function' && !window.confirm(`Resolve all ${open.length} open comment${open.length > 1 ? 's' : ''}?`)) return;
2032
+ // Optimistic local update + render once; persist each in parallel.
2033
+ open.forEach(c => { c.status = 'resolved'; });
2034
+ renderCurrentReviewSurface();
2035
+ const results = await Promise.allSettled(open.map(c => api(`/review-comments/${c.id}`, 'PUT', { status: 'resolved' })));
2036
+ const failed = results.filter(r => r.status === 'rejected').length;
2037
+ if (failed && typeof window.toast === 'function') window.toast(`${failed} comment(s) failed to resolve — reload to retry`, { type: 'error' });
2038
+ };
2039
+
1300
2040
  CR.reopenComment = async function(commentId) {
1301
2041
  try {
1302
2042
  await api(`/review-comments/${commentId}`, 'PUT', { status: 'open' });
@@ -1669,11 +2409,23 @@ CR.handleFilesChanged = function(msg) {
1669
2409
  for (const count of projectChanges.values()) total += count;
1670
2410
  updateBadge(total);
1671
2411
 
1672
- // Refresh project list if it's currently visible (not in a diff review)
1673
- if (!crState.reviewId) {
2412
+ // Keep the chooser responsive: update the visible cached row immediately and
2413
+ // let the server's bounded background scan refresh exact branch/file metadata.
2414
+ if (!crState.reviewId && crState._view === 'projects') {
1674
2415
  const panel = document.getElementById('codereview-panel');
1675
2416
  if (panel && panel.classList.contains('active')) {
1676
- CR.showProjectList();
2417
+ const projects = projectChooserState.projects || [];
2418
+ const project = projects.find(p => p && p.path === msg.project);
2419
+ if (project) {
2420
+ project.fileCount = Number(msg.fileCount || 0);
2421
+ if (Array.isArray(msg.files)) {
2422
+ project.files = msg.files.map(f => typeof f === 'string'
2423
+ ? { path: f, additions: 0, deletions: 0 }
2424
+ : f).filter(Boolean);
2425
+ }
2426
+ updateReviewProjectResults();
2427
+ }
2428
+ scheduleProjectChooserRefresh(1800);
1677
2429
  }
1678
2430
  }
1679
2431
 
@@ -1688,6 +2440,15 @@ CR.handleFilesChanged = function(msg) {
1688
2440
  }
1689
2441
  };
1690
2442
 
2443
+ CR.handleReviewProjectsUpdated = function(msg) {
2444
+ if (crState._view !== 'projects') return;
2445
+ const panel = document.getElementById('codereview-panel');
2446
+ if (!panel || !panel.classList.contains('active')) return;
2447
+ projectChooserState.scan = msg && msg.scan ? msg.scan : projectChooserState.scan;
2448
+ updateReviewProjectResults();
2449
+ scheduleProjectChooserRefresh(msg && msg.scan && msg.scan.running ? 1200 : 150);
2450
+ };
2451
+
1691
2452
  function updateBadge(count) {
1692
2453
  let badge = document.getElementById('cr-nav-badge');
1693
2454
  const navBtn = document.querySelector('[data-nav="codereview"]');
@@ -1878,6 +2639,9 @@ document.addEventListener('keydown', function(e) {
1878
2639
  } else if ((e.key === 'k' || e.key === '[') && curIdx > 0) {
1879
2640
  e.preventDefault();
1880
2641
  CR.selectFile(crState.files[curIdx - 1].path);
2642
+ } else if (e.key === 'v' && crState.reviewType === 'code') {
2643
+ e.preventDefault();
2644
+ CR.toggleViewed(crState.activeFile);
1881
2645
  }
1882
2646
  });
1883
2647
 
@@ -1934,7 +2698,11 @@ window.crState = crState;
1934
2698
  if (window._ctmState?.pendingDocumentReview) {
1935
2699
  const pending = window._ctmState.pendingDocumentReview;
1936
2700
  delete window._ctmState.pendingDocumentReview;
1937
- setTimeout(() => CR.openDocumentReview(pending.path, pending.line || 1), 0);
2701
+ setTimeout(() => CR.openDocumentReview(pending.path, pending.line || 1, {
2702
+ cwd: pending.cwd || '',
2703
+ sessionId: pending.sessionId || '',
2704
+ fromHash: !!pending.fromHash,
2705
+ }), 0);
1938
2706
  }
1939
2707
 
1940
2708
  })();