reviewflow 3.3.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cli/formatters/initSummary.d.ts +1 -1
  3. package/dist/cli/formatters/initSummary.d.ts.map +1 -1
  4. package/dist/cli/formatters/initSummary.js +2 -0
  5. package/dist/cli/formatters/initSummary.js.map +1 -1
  6. package/dist/cli/parseCliArgs.d.ts +6 -1
  7. package/dist/cli/parseCliArgs.d.ts.map +1 -1
  8. package/dist/cli/parseCliArgs.js +11 -1
  9. package/dist/cli/parseCliArgs.js.map +1 -1
  10. package/dist/config/projectConfig.d.ts +6 -0
  11. package/dist/config/projectConfig.d.ts.map +1 -1
  12. package/dist/config/projectConfig.js +13 -0
  13. package/dist/config/projectConfig.js.map +1 -1
  14. package/dist/entities/language/language.schema.d.ts +7 -0
  15. package/dist/entities/language/language.schema.d.ts.map +1 -0
  16. package/dist/entities/language/language.schema.js +3 -0
  17. package/dist/entities/language/language.schema.js.map +1 -0
  18. package/dist/entities/mcpSettings/mcpSettings.guard.d.ts +12 -0
  19. package/dist/entities/mcpSettings/mcpSettings.guard.d.ts.map +1 -0
  20. package/dist/entities/mcpSettings/mcpSettings.guard.js +15 -0
  21. package/dist/entities/mcpSettings/mcpSettings.guard.js.map +1 -0
  22. package/dist/entities/mcpSettings/mcpSettings.schema.d.ts +13 -0
  23. package/dist/entities/mcpSettings/mcpSettings.schema.d.ts.map +1 -0
  24. package/dist/entities/mcpSettings/mcpSettings.schema.js +9 -0
  25. package/dist/entities/mcpSettings/mcpSettings.schema.js.map +1 -0
  26. package/dist/frameworks/claude/claudeInvoker.d.ts +1 -1
  27. package/dist/frameworks/claude/claudeInvoker.d.ts.map +1 -1
  28. package/dist/frameworks/claude/claudeInvoker.js +6 -3
  29. package/dist/frameworks/claude/claudeInvoker.js.map +1 -1
  30. package/dist/frameworks/claude/languageDirective.d.ts +3 -0
  31. package/dist/frameworks/claude/languageDirective.d.ts.map +1 -0
  32. package/dist/frameworks/claude/languageDirective.js +9 -0
  33. package/dist/frameworks/claude/languageDirective.js.map +1 -0
  34. package/dist/frameworks/queue/pQueueAdapter.d.ts +2 -0
  35. package/dist/frameworks/queue/pQueueAdapter.d.ts.map +1 -1
  36. package/dist/frameworks/queue/pQueueAdapter.js +1 -1
  37. package/dist/frameworks/queue/pQueueAdapter.js.map +1 -1
  38. package/dist/frameworks/settings/runtimeSettings.d.ts +4 -0
  39. package/dist/frameworks/settings/runtimeSettings.d.ts.map +1 -1
  40. package/dist/frameworks/settings/runtimeSettings.js +8 -1
  41. package/dist/frameworks/settings/runtimeSettings.js.map +1 -1
  42. package/dist/interface-adapters/controllers/http/settings.routes.d.ts.map +1 -1
  43. package/dist/interface-adapters/controllers/http/settings.routes.js +12 -1
  44. package/dist/interface-adapters/controllers/http/settings.routes.js.map +1 -1
  45. package/dist/interface-adapters/controllers/webhook/github.controller.d.ts.map +1 -1
  46. package/dist/interface-adapters/controllers/webhook/github.controller.js +2 -1
  47. package/dist/interface-adapters/controllers/webhook/github.controller.js.map +1 -1
  48. package/dist/interface-adapters/controllers/webhook/gitlab.controller.d.ts.map +1 -1
  49. package/dist/interface-adapters/controllers/webhook/gitlab.controller.js +2 -1
  50. package/dist/interface-adapters/controllers/webhook/gitlab.controller.js.map +1 -1
  51. package/dist/interface-adapters/views/dashboard/index.html +974 -393
  52. package/dist/interface-adapters/views/dashboard/modules/assignee.d.ts +7 -0
  53. package/dist/interface-adapters/views/dashboard/modules/assignee.d.ts.map +1 -0
  54. package/dist/interface-adapters/views/dashboard/modules/assignee.js +47 -0
  55. package/dist/interface-adapters/views/dashboard/modules/assignee.js.map +1 -0
  56. package/dist/interface-adapters/views/dashboard/modules/constants.d.ts +7 -0
  57. package/dist/interface-adapters/views/dashboard/modules/constants.d.ts.map +1 -0
  58. package/dist/interface-adapters/views/dashboard/modules/constants.js +6 -0
  59. package/dist/interface-adapters/views/dashboard/modules/constants.js.map +1 -0
  60. package/dist/interface-adapters/views/dashboard/modules/desktopNotifications.d.ts +23 -0
  61. package/dist/interface-adapters/views/dashboard/modules/desktopNotifications.d.ts.map +1 -0
  62. package/dist/interface-adapters/views/dashboard/modules/desktopNotifications.js +37 -0
  63. package/dist/interface-adapters/views/dashboard/modules/desktopNotifications.js.map +1 -0
  64. package/dist/interface-adapters/views/dashboard/modules/formatting.d.ts +23 -0
  65. package/dist/interface-adapters/views/dashboard/modules/formatting.d.ts.map +1 -0
  66. package/dist/interface-adapters/views/dashboard/modules/formatting.js +57 -0
  67. package/dist/interface-adapters/views/dashboard/modules/formatting.js.map +1 -0
  68. package/dist/interface-adapters/views/dashboard/modules/html.d.ts +16 -0
  69. package/dist/interface-adapters/views/dashboard/modules/html.d.ts.map +1 -0
  70. package/dist/interface-adapters/views/dashboard/modules/html.js +68 -0
  71. package/dist/interface-adapters/views/dashboard/modules/html.js.map +1 -0
  72. package/dist/interface-adapters/views/dashboard/modules/i18n.d.ts +11 -0
  73. package/dist/interface-adapters/views/dashboard/modules/i18n.d.ts.map +1 -0
  74. package/dist/interface-adapters/views/dashboard/modules/i18n.js +500 -0
  75. package/dist/interface-adapters/views/dashboard/modules/i18n.js.map +1 -0
  76. package/dist/interface-adapters/views/dashboard/modules/icons.d.ts +13 -0
  77. package/dist/interface-adapters/views/dashboard/modules/icons.d.ts.map +1 -0
  78. package/dist/interface-adapters/views/dashboard/modules/icons.js +29 -0
  79. package/dist/interface-adapters/views/dashboard/modules/icons.js.map +1 -0
  80. package/dist/interface-adapters/views/dashboard/modules/loading.d.ts +30 -0
  81. package/dist/interface-adapters/views/dashboard/modules/loading.d.ts.map +1 -0
  82. package/dist/interface-adapters/views/dashboard/modules/loading.js +48 -0
  83. package/dist/interface-adapters/views/dashboard/modules/loading.js.map +1 -0
  84. package/dist/interface-adapters/views/dashboard/modules/notifications.d.ts +39 -0
  85. package/dist/interface-adapters/views/dashboard/modules/notifications.d.ts.map +1 -0
  86. package/dist/interface-adapters/views/dashboard/modules/notifications.js +131 -0
  87. package/dist/interface-adapters/views/dashboard/modules/notifications.js.map +1 -0
  88. package/dist/interface-adapters/views/dashboard/modules/priority.d.ts +10 -0
  89. package/dist/interface-adapters/views/dashboard/modules/priority.d.ts.map +1 -0
  90. package/dist/interface-adapters/views/dashboard/modules/priority.js +86 -0
  91. package/dist/interface-adapters/views/dashboard/modules/priority.js.map +1 -0
  92. package/dist/interface-adapters/views/dashboard/modules/quality.d.ts +30 -0
  93. package/dist/interface-adapters/views/dashboard/modules/quality.d.ts.map +1 -0
  94. package/dist/interface-adapters/views/dashboard/modules/quality.js +83 -0
  95. package/dist/interface-adapters/views/dashboard/modules/quality.js.map +1 -0
  96. package/dist/interface-adapters/views/dashboard/modules/queueLanes.d.ts +22 -0
  97. package/dist/interface-adapters/views/dashboard/modules/queueLanes.d.ts.map +1 -0
  98. package/dist/interface-adapters/views/dashboard/modules/queueLanes.js +27 -0
  99. package/dist/interface-adapters/views/dashboard/modules/queueLanes.js.map +1 -0
  100. package/dist/interface-adapters/views/dashboard/modules/sessionMetrics.d.ts +54 -0
  101. package/dist/interface-adapters/views/dashboard/modules/sessionMetrics.d.ts.map +1 -0
  102. package/dist/interface-adapters/views/dashboard/modules/sessionMetrics.js +120 -0
  103. package/dist/interface-adapters/views/dashboard/modules/sessionMetrics.js.map +1 -0
  104. package/dist/interface-adapters/views/dashboard/styles.css +1031 -93
  105. package/dist/main/cli.d.ts +41 -1
  106. package/dist/main/cli.d.ts.map +1 -1
  107. package/dist/main/cli.js +228 -88
  108. package/dist/main/cli.js.map +1 -1
  109. package/dist/tests/factories/reviewJob.factory.d.ts.map +1 -1
  110. package/dist/tests/factories/reviewJob.factory.js +1 -0
  111. package/dist/tests/factories/reviewJob.factory.js.map +1 -1
  112. package/dist/tests/units/cli/parseCliArgs.test.js +14 -0
  113. package/dist/tests/units/cli/parseCliArgs.test.js.map +1 -1
  114. package/dist/tests/units/config/projectConfig.test.d.ts +2 -0
  115. package/dist/tests/units/config/projectConfig.test.d.ts.map +1 -0
  116. package/dist/tests/units/config/projectConfig.test.js +69 -0
  117. package/dist/tests/units/config/projectConfig.test.js.map +1 -0
  118. package/dist/tests/units/entities/language/language.schema.test.d.ts +2 -0
  119. package/dist/tests/units/entities/language/language.schema.test.d.ts.map +1 -0
  120. package/dist/tests/units/entities/language/language.schema.test.js +17 -0
  121. package/dist/tests/units/entities/language/language.schema.test.js.map +1 -0
  122. package/dist/tests/units/entities/mcpSettings/mcpSettings.guard.test.d.ts +2 -0
  123. package/dist/tests/units/entities/mcpSettings/mcpSettings.guard.test.d.ts.map +1 -0
  124. package/dist/tests/units/entities/mcpSettings/mcpSettings.guard.test.js +52 -0
  125. package/dist/tests/units/entities/mcpSettings/mcpSettings.guard.test.js.map +1 -0
  126. package/dist/tests/units/frameworks/claude/languageDirective.test.d.ts +2 -0
  127. package/dist/tests/units/frameworks/claude/languageDirective.test.d.ts.map +1 -0
  128. package/dist/tests/units/frameworks/claude/languageDirective.test.js +13 -0
  129. package/dist/tests/units/frameworks/claude/languageDirective.test.js.map +1 -0
  130. package/dist/tests/units/frameworks/settings/runtimeSettings.test.d.ts +2 -0
  131. package/dist/tests/units/frameworks/settings/runtimeSettings.test.d.ts.map +1 -0
  132. package/dist/tests/units/frameworks/settings/runtimeSettings.test.js +20 -0
  133. package/dist/tests/units/frameworks/settings/runtimeSettings.test.js.map +1 -0
  134. package/dist/tests/units/interface-adapters/controllers/webhook/gitlab.controller.test.js +1 -0
  135. package/dist/tests/units/interface-adapters/controllers/webhook/gitlab.controller.test.js.map +1 -1
  136. package/dist/tests/units/interface-adapters/views/dashboard/modules/assignee.test.d.ts +2 -0
  137. package/dist/tests/units/interface-adapters/views/dashboard/modules/assignee.test.d.ts.map +1 -0
  138. package/dist/tests/units/interface-adapters/views/dashboard/modules/assignee.test.js +35 -0
  139. package/dist/tests/units/interface-adapters/views/dashboard/modules/assignee.test.js.map +1 -0
  140. package/dist/tests/units/interface-adapters/views/dashboard/modules/constants.test.d.ts +2 -0
  141. package/dist/tests/units/interface-adapters/views/dashboard/modules/constants.test.d.ts.map +1 -0
  142. package/dist/tests/units/interface-adapters/views/dashboard/modules/constants.test.js +17 -0
  143. package/dist/tests/units/interface-adapters/views/dashboard/modules/constants.test.js.map +1 -0
  144. package/dist/tests/units/interface-adapters/views/dashboard/modules/desktopNotifications.test.d.ts +2 -0
  145. package/dist/tests/units/interface-adapters/views/dashboard/modules/desktopNotifications.test.d.ts.map +1 -0
  146. package/dist/tests/units/interface-adapters/views/dashboard/modules/desktopNotifications.test.js +54 -0
  147. package/dist/tests/units/interface-adapters/views/dashboard/modules/desktopNotifications.test.js.map +1 -0
  148. package/dist/tests/units/interface-adapters/views/dashboard/modules/formatting.test.d.ts +2 -0
  149. package/dist/tests/units/interface-adapters/views/dashboard/modules/formatting.test.d.ts.map +1 -0
  150. package/dist/tests/units/interface-adapters/views/dashboard/modules/formatting.test.js +95 -0
  151. package/dist/tests/units/interface-adapters/views/dashboard/modules/formatting.test.js.map +1 -0
  152. package/dist/tests/units/interface-adapters/views/dashboard/modules/html.test.d.ts +2 -0
  153. package/dist/tests/units/interface-adapters/views/dashboard/modules/html.test.d.ts.map +1 -0
  154. package/dist/tests/units/interface-adapters/views/dashboard/modules/html.test.js +55 -0
  155. package/dist/tests/units/interface-adapters/views/dashboard/modules/html.test.js.map +1 -0
  156. package/dist/tests/units/interface-adapters/views/dashboard/modules/i18n.test.d.ts +2 -0
  157. package/dist/tests/units/interface-adapters/views/dashboard/modules/i18n.test.d.ts.map +1 -0
  158. package/dist/tests/units/interface-adapters/views/dashboard/modules/i18n.test.js +98 -0
  159. package/dist/tests/units/interface-adapters/views/dashboard/modules/i18n.test.js.map +1 -0
  160. package/dist/tests/units/interface-adapters/views/dashboard/modules/icons.test.d.ts +2 -0
  161. package/dist/tests/units/interface-adapters/views/dashboard/modules/icons.test.d.ts.map +1 -0
  162. package/dist/tests/units/interface-adapters/views/dashboard/modules/icons.test.js +28 -0
  163. package/dist/tests/units/interface-adapters/views/dashboard/modules/icons.test.js.map +1 -0
  164. package/dist/tests/units/interface-adapters/views/dashboard/modules/loading.test.d.ts +2 -0
  165. package/dist/tests/units/interface-adapters/views/dashboard/modules/loading.test.d.ts.map +1 -0
  166. package/dist/tests/units/interface-adapters/views/dashboard/modules/loading.test.js +51 -0
  167. package/dist/tests/units/interface-adapters/views/dashboard/modules/loading.test.js.map +1 -0
  168. package/dist/tests/units/interface-adapters/views/dashboard/modules/notifications.test.d.ts +2 -0
  169. package/dist/tests/units/interface-adapters/views/dashboard/modules/notifications.test.d.ts.map +1 -0
  170. package/dist/tests/units/interface-adapters/views/dashboard/modules/notifications.test.js +43 -0
  171. package/dist/tests/units/interface-adapters/views/dashboard/modules/notifications.test.js.map +1 -0
  172. package/dist/tests/units/interface-adapters/views/dashboard/modules/priority.test.d.ts +2 -0
  173. package/dist/tests/units/interface-adapters/views/dashboard/modules/priority.test.d.ts.map +1 -0
  174. package/dist/tests/units/interface-adapters/views/dashboard/modules/priority.test.js +78 -0
  175. package/dist/tests/units/interface-adapters/views/dashboard/modules/priority.test.js.map +1 -0
  176. package/dist/tests/units/interface-adapters/views/dashboard/modules/quality.test.d.ts +2 -0
  177. package/dist/tests/units/interface-adapters/views/dashboard/modules/quality.test.d.ts.map +1 -0
  178. package/dist/tests/units/interface-adapters/views/dashboard/modules/quality.test.js +87 -0
  179. package/dist/tests/units/interface-adapters/views/dashboard/modules/quality.test.js.map +1 -0
  180. package/dist/tests/units/interface-adapters/views/dashboard/modules/queueLanes.test.d.ts +2 -0
  181. package/dist/tests/units/interface-adapters/views/dashboard/modules/queueLanes.test.d.ts.map +1 -0
  182. package/dist/tests/units/interface-adapters/views/dashboard/modules/queueLanes.test.js +29 -0
  183. package/dist/tests/units/interface-adapters/views/dashboard/modules/queueLanes.test.js.map +1 -0
  184. package/dist/tests/units/interface-adapters/views/dashboard/modules/sessionMetrics.test.d.ts +2 -0
  185. package/dist/tests/units/interface-adapters/views/dashboard/modules/sessionMetrics.test.d.ts.map +1 -0
  186. package/dist/tests/units/interface-adapters/views/dashboard/modules/sessionMetrics.test.js +60 -0
  187. package/dist/tests/units/interface-adapters/views/dashboard/modules/sessionMetrics.test.js.map +1 -0
  188. package/dist/tests/units/main/executeDiscover.test.d.ts +2 -0
  189. package/dist/tests/units/main/executeDiscover.test.d.ts.map +1 -0
  190. package/dist/tests/units/main/executeDiscover.test.js +106 -0
  191. package/dist/tests/units/main/executeDiscover.test.js.map +1 -0
  192. package/dist/tests/units/main/executeInit.test.d.ts +2 -0
  193. package/dist/tests/units/main/executeInit.test.d.ts.map +1 -0
  194. package/dist/tests/units/main/executeInit.test.js +290 -0
  195. package/dist/tests/units/main/executeInit.test.js.map +1 -0
  196. package/dist/tests/units/usecases/cli/addRepositoriesToConfig.usecase.test.d.ts +2 -0
  197. package/dist/tests/units/usecases/cli/addRepositoriesToConfig.usecase.test.d.ts.map +1 -0
  198. package/dist/tests/units/usecases/cli/addRepositoriesToConfig.usecase.test.js +127 -0
  199. package/dist/tests/units/usecases/cli/addRepositoriesToConfig.usecase.test.js.map +1 -0
  200. package/dist/tests/units/usecases/cli/checkInitPrerequisites.test.d.ts +2 -0
  201. package/dist/tests/units/usecases/cli/checkInitPrerequisites.test.d.ts.map +1 -0
  202. package/dist/tests/units/usecases/cli/checkInitPrerequisites.test.js +57 -0
  203. package/dist/tests/units/usecases/cli/checkInitPrerequisites.test.js.map +1 -0
  204. package/dist/tests/units/usecases/cli/configureMcp.usecase.test.js +67 -9
  205. package/dist/tests/units/usecases/cli/configureMcp.usecase.test.js.map +1 -1
  206. package/dist/usecases/cli/addRepositoriesToConfig.usecase.d.ts +27 -0
  207. package/dist/usecases/cli/addRepositoriesToConfig.usecase.d.ts.map +1 -0
  208. package/dist/usecases/cli/addRepositoriesToConfig.usecase.js +34 -0
  209. package/dist/usecases/cli/addRepositoriesToConfig.usecase.js.map +1 -0
  210. package/dist/usecases/cli/checkInitPrerequisites.d.ts +16 -0
  211. package/dist/usecases/cli/checkInitPrerequisites.d.ts.map +1 -0
  212. package/dist/usecases/cli/checkInitPrerequisites.js +23 -0
  213. package/dist/usecases/cli/checkInitPrerequisites.js.map +1 -0
  214. package/dist/usecases/cli/configureMcp.usecase.d.ts +2 -2
  215. package/dist/usecases/cli/configureMcp.usecase.d.ts.map +1 -1
  216. package/dist/usecases/cli/configureMcp.usecase.js +16 -3
  217. package/dist/usecases/cli/configureMcp.usecase.js.map +1 -1
  218. package/dist/usecases/cli/validateConfig.usecase.d.ts +1 -1
  219. package/dist/usecases/cli/validateConfig.usecase.d.ts.map +1 -1
  220. package/dist/usecases/triggerReview.usecase.d.ts +2 -0
  221. package/dist/usecases/triggerReview.usecase.d.ts.map +1 -1
  222. package/dist/usecases/triggerReview.usecase.js +1 -0
  223. package/dist/usecases/triggerReview.usecase.js.map +1 -1
  224. package/package.json +1 -1
  225. package/templates/SETUP.md +23 -8
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="fr">
2
+ <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -14,67 +14,105 @@
14
14
  <h1>Reviewflow</h1>
15
15
  <div class="header-actions">
16
16
  <button id="check-claude-btn" class="btn btn-primary" onclick="checkClaudeStatus()">
17
- <i data-lucide="search"></i> Vérifier Claude
17
+ <i data-lucide="search"></i> <span id="i18n-check-claude"></span>
18
18
  </button>
19
19
  <button id="toggle-logs-btn" class="btn btn-secondary" onclick="toggleLogs()">
20
- <i data-lucide="scroll-text"></i> Logs
20
+ <i data-lucide="scroll-text"></i> <span id="i18n-logs-btn"></span>
21
21
  </button>
22
22
  <div id="server-status" class="status-indicator connecting">
23
23
  <span class="status-dot"></span>
24
- <span>Connexion...</span>
24
+ <span id="i18n-server-status"></span>
25
25
  </div>
26
26
  </div>
27
27
  </header>
28
28
 
29
29
  <div class="cards">
30
- <div class="card">
31
- <div class="card-label">En cours</div>
30
+ <div class="card card-priority">
31
+ <div class="card-label" id="i18n-card-running"></div>
32
32
  <div id="running-count" class="card-value running">-</div>
33
33
  </div>
34
- <div class="card">
35
- <div class="card-label">En attente</div>
34
+ <div class="card card-priority">
35
+ <div class="card-label" id="i18n-card-queued"></div>
36
36
  <div id="queued-count" class="card-value queued">-</div>
37
37
  </div>
38
38
  <div class="card">
39
- <div class="card-label">Terminées</div>
39
+ <div class="card-label" id="i18n-card-completed"></div>
40
40
  <div id="completed-count" class="card-value">-</div>
41
41
  </div>
42
42
  <div class="card">
43
- <div class="card-label">Claude CLI</div>
43
+ <div class="card-label" id="i18n-card-claude-cli"></div>
44
44
  <div id="claude-status" class="card-claude checking">
45
- <span class="status">Vérification...</span>
45
+ <span class="status" id="i18n-claude-checking"></span>
46
46
  <span class="version"></span>
47
47
  </div>
48
48
  </div>
49
49
  <div class="card" id="git-cli-card">
50
- <div class="card-label" id="git-cli-label">Git CLI</div>
50
+ <div class="card-label" id="git-cli-label"></div>
51
51
  <div id="git-cli-status" class="card-claude checking">
52
- <span class="status">Charger un projet...</span>
52
+ <span class="status" id="i18n-git-load-project"></span>
53
53
  <span class="version"></span>
54
54
  </div>
55
55
  </div>
56
56
  <div class="card">
57
- <div class="card-label">Modèle</div>
57
+ <div class="card-label" id="i18n-card-model"></div>
58
58
  <div class="card-model">
59
59
  <select id="model-select" class="model-select" onchange="changeModel(this.value)">
60
- <option value="opus">Opus (puissant)</option>
61
- <option value="sonnet">Sonnet (rapide)</option>
60
+ <option value="opus" id="i18n-model-opus"></option>
61
+ <option value="sonnet" id="i18n-model-sonnet"></option>
62
+ </select>
63
+ </div>
64
+ </div>
65
+ <div class="card">
66
+ <div class="card-label" id="i18n-card-language"></div>
67
+ <div class="card-model">
68
+ <select id="language-select" class="model-select" onchange="changeLanguage(this.value)">
69
+ <option value="en">English</option>
70
+ <option value="fr">Français</option>
62
71
  </select>
63
72
  </div>
64
73
  </div>
65
74
  </div>
66
75
 
76
+ <div class="focus-strip">
77
+ <div class="focus-chip focus-now">
78
+ <div class="focus-copy">
79
+ <span class="focus-label" id="i18n-strip-now"></span>
80
+ <span class="focus-meta" id="i18n-strip-now-meta"></span>
81
+ </div>
82
+ <span class="focus-value" id="focus-now-count">-</span>
83
+ </div>
84
+ <div class="focus-chip focus-next">
85
+ <div class="focus-copy">
86
+ <span class="focus-label" id="i18n-strip-next"></span>
87
+ <span class="focus-meta" id="i18n-strip-next-meta"></span>
88
+ </div>
89
+ <span class="focus-value" id="focus-next-count">-</span>
90
+ </div>
91
+ <div class="focus-chip focus-blocked">
92
+ <div class="focus-copy">
93
+ <span class="focus-label" id="i18n-strip-blocked"></span>
94
+ <span class="focus-meta" id="i18n-strip-blocked-meta"></span>
95
+ </div>
96
+ <span class="focus-value" id="focus-blocked-count">-</span>
97
+ </div>
98
+ <button id="focus-strip-toggle" class="focus-toggle-btn" onclick="toggleFocusStripMode()"></button>
99
+ </div>
100
+
101
+ <div id="data-loading-state" class="data-loading hidden" role="status" aria-live="polite">
102
+ <i data-lucide="loader-circle"></i>
103
+ <span id="i18n-loading-data"></span>
104
+ </div>
105
+
67
106
  <div class="project-loader">
68
107
  <select id="project-select" class="project-input" style="min-width: 350px;" onchange="onProjectSelect(this.value)">
69
- <option value="">-- Sélectionner un projet --</option>
108
+ <option value="" id="i18n-project-placeholder"></option>
70
109
  </select>
71
110
  <input type="text" id="project-path-input" class="project-input" style="min-width: 250px;"
72
- placeholder="Ou entrer un nouveau chemin..."
73
111
  value="">
74
112
  <button class="btn btn-primary" onclick="loadProjectConfig()">
75
- <i data-lucide="folder-open"></i> Charger
113
+ <i data-lucide="folder-open"></i> <span id="i18n-project-load"></span>
76
114
  </button>
77
- <button class="btn btn-secondary" onclick="removeCurrentProject()" title="Retirer de la liste">
115
+ <button id="remove-project-btn" class="btn btn-secondary" onclick="removeCurrentProject()">
78
116
  <i data-lucide="trash-2"></i>
79
117
  </button>
80
118
  <span id="config-status" class="config-status hidden"></span>
@@ -82,108 +120,127 @@
82
120
  <div id="config-info" class="config-info hidden"></div>
83
121
 
84
122
  <div id="claude-login-section" class="login-instructions hidden">
85
- <strong><i data-lucide="alert-triangle"></i> Claude n'est pas authentifié</strong>
86
- <p style="margin-top: 0.5rem;">Exécutez cette commande dans un terminal :</p>
123
+ <strong><i data-lucide="alert-triangle"></i> <span id="i18n-claude-login-title"></span></strong>
124
+ <p style="margin-top: 0.5rem;" id="i18n-claude-login-instruction"></p>
87
125
  <p style="margin-top: 0.5rem;"><code>claude login</code></p>
88
- <p style="margin-top: 0.5rem; font-size: 0.875rem; color: #a1a1aa;">Puis rechargez cette page.</p>
126
+ <p style="margin-top: 0.5rem; font-size: 0.875rem; color: #a1a1aa;" id="i18n-claude-login-reload"></p>
89
127
  </div>
90
128
 
91
129
  <div id="git-login-section" class="login-instructions hidden">
92
- <strong id="git-login-title"><i data-lucide="alert-triangle"></i> CLI non authentifié</strong>
130
+ <strong id="git-login-title"><i data-lucide="alert-triangle"></i> <span id="i18n-git-login-title"></span></strong>
93
131
  <div style="margin-top: 0.75rem;" id="git-login-instructions"></div>
94
132
  </div>
95
133
 
96
134
  <div id="logs-section" class="section hidden">
97
135
  <div class="section-header">
98
- <i data-lucide="scroll-text"></i> Logs récents
99
- <span id="error-count" class="badge-count hidden">0 erreurs</span>
136
+ <i data-lucide="scroll-text"></i> <span id="i18n-section-logs"></span>
137
+ <span id="error-count" class="badge-count hidden"></span>
100
138
  </div>
101
139
  <div id="logs-content" class="section-content logs">
102
- <div class="empty-state">Aucun log</div>
140
+ <div class="empty-state" id="i18n-empty-logs"></div>
103
141
  </div>
104
142
  </div>
105
143
 
106
144
  <div id="stats-section" class="section hidden">
107
- <div class="section-header clickable" onclick="toggleStats()">
108
- <i data-lucide="bar-chart-3"></i> Statistiques du projet
145
+ <div class="section-header clickable" onclick="toggleStats()" role="button" tabindex="0" onkeydown="activateOnKeydown(event)">
146
+ <i data-lucide="bar-chart-3"></i> <span id="i18n-section-stats"></span>
109
147
  <span id="stats-toggle" class="toggle-icon collapsed"><i data-lucide="chevron-down"></i></span>
110
148
  </div>
111
149
  <div id="project-stats" class="section-content stats-grid hidden">
112
- <div class="empty-state">Charger un projet pour voir les stats</div>
150
+ <div class="empty-state" id="i18n-empty-stats"></div>
113
151
  </div>
114
152
  </div>
115
153
 
116
154
  <div class="section" id="active-reviews-section">
117
155
  <div class="section-header">
118
- <i data-lucide="file-search"></i> Reviews actives
156
+ <i data-lucide="file-search"></i> <span id="i18n-section-active-reviews"></span>
119
157
  <span id="active-reviews-count" class="badge-count hidden">0</span>
120
158
  </div>
121
159
  <div id="active-reviews" class="section-content">
122
- <div class="empty-state">Aucune review en cours</div>
160
+ <div class="empty-state" id="i18n-empty-active-reviews"></div>
123
161
  </div>
124
162
  </div>
125
163
 
126
164
  <div class="section hidden" id="active-followups-section">
127
- <div class="section-header">
128
- <i data-lucide="refresh-cw"></i> Followups actifs
165
+ <div class="section-header clickable" onclick="toggleSection('active-followups-section')" role="button" tabindex="0" onkeydown="activateOnKeydown(event)">
166
+ <i data-lucide="refresh-cw"></i> <span id="i18n-section-active-followups"></span>
129
167
  <span id="active-followups-count" class="badge-count hidden">0</span>
168
+ <span class="section-toggle collapsed"><i data-lucide="chevron-down"></i></span>
130
169
  </div>
131
170
  <div id="active-followups" class="section-content">
132
- <div class="empty-state">Aucun followup en cours</div>
171
+ <div class="empty-state" id="i18n-empty-active-followups"></div>
133
172
  </div>
134
173
  </div>
135
174
 
136
175
  <div class="section hidden" id="pending-fix-section">
137
176
  <div class="section-header">
138
- <i data-lucide="wrench"></i> En attente de correctif
177
+ <i data-lucide="wrench"></i> <span id="i18n-section-pending-fix"></span>
139
178
  <span id="pending-fix-count" class="badge-count hidden">0</span>
140
- <button id="sync-threads-btn" class="btn-icon btn-sync" title="Synchroniser les threads GitLab" onclick="syncGitLabThreads()">
179
+ <button id="sync-threads-btn" class="btn-icon btn-sync" onclick="syncGitLabThreads()">
141
180
  <i data-lucide="refresh-cw"></i>
142
181
  </button>
143
182
  </div>
144
183
  <div id="pending-fix-reviews" class="section-content">
145
- <div class="empty-state">Aucune MR en attente de correctif</div>
184
+ <div class="empty-state" id="i18n-empty-pending-fix"></div>
146
185
  </div>
147
186
  </div>
148
187
 
149
188
  <div class="section hidden" id="pending-approval-section">
150
- <div class="section-header">
151
- <i data-lucide="circle-check"></i> En attente d'approbation
189
+ <div class="section-header clickable" onclick="toggleSection('pending-approval-section')" role="button" tabindex="0" onkeydown="activateOnKeydown(event)">
190
+ <i data-lucide="circle-check"></i> <span id="i18n-section-pending-approval"></span>
152
191
  <span id="pending-approval-count" class="badge-count hidden">0</span>
192
+ <span class="section-toggle collapsed"><i data-lucide="chevron-down"></i></span>
153
193
  </div>
154
194
  <div id="pending-approval-reviews" class="section-content">
155
- <div class="empty-state">Aucune MR en attente d'approbation</div>
195
+ <div class="empty-state" id="i18n-empty-pending-approval"></div>
156
196
  </div>
157
197
  </div>
158
198
 
159
- <div class="section">
160
- <div class="section-header">
161
- <i data-lucide="file-check"></i> Reviews terminées
199
+ <div class="section" id="completed-reviews-section">
200
+ <div class="section-header clickable" onclick="toggleSection('completed-reviews-section')" role="button" tabindex="0" onkeydown="activateOnKeydown(event)">
201
+ <i data-lucide="file-check"></i> <span id="i18n-section-completed-reviews"></span>
202
+ <span class="section-toggle collapsed"><i data-lucide="chevron-down"></i></span>
162
203
  </div>
163
204
  <div id="recent-reviews" class="section-content">
164
- <div class="empty-state">Chargement...</div>
205
+ <div class="empty-state" id="i18n-empty-loading"></div>
165
206
  </div>
166
207
  </div>
167
208
 
168
209
  <div class="refresh-info">
169
- <span id="connection-mode">WebSocket temps réel</span> • Fallback polling 5s
210
+ <span id="connection-mode"></span> • <span id="i18n-connection-fallback"></span>
211
+ <span class="refresh-separator"> • </span>
212
+ <span id="session-metrics"></span>
170
213
  </div>
171
214
  </div>
172
215
 
173
216
  <div id="cancel-modal" class="modal-overlay hidden" onclick="closeCancelModal(event)">
174
217
  <div class="modal-content" onclick="event.stopPropagation()">
175
218
  <div class="modal-title" id="cancel-modal-title"></div>
176
- <div class="modal-message">Cette action est irréversible. La review sera arrêtée immédiatement.</div>
219
+ <div class="modal-message" id="i18n-modal-message"></div>
177
220
  <div class="modal-actions">
178
- <button class="btn-modal-back" onclick="closeCancelModal()">Revenir</button>
179
- <button class="btn-modal-confirm" id="cancel-modal-confirm" onclick="confirmCancelReview()">Confirmer</button>
221
+ <button class="btn-modal-back" onclick="closeCancelModal()" id="i18n-modal-back"></button>
222
+ <button class="btn-modal-confirm" id="cancel-modal-confirm" onclick="confirmCancelReview()"></button>
180
223
  </div>
181
224
  </div>
182
225
  </div>
183
226
 
184
227
  <div id="toast-container" class="toast-container"></div>
185
228
 
186
- <script>
229
+ <script type="module">
230
+ import { t, setLanguage, getLanguage } from './modules/i18n.js';
231
+ import { formatTime, formatDuration, formatPhase, formatLogTime } from './modules/formatting.js';
232
+ import { escapeHtml, markdownToHtml, sanitizeHttpUrl } from './modules/html.js';
233
+ import { getAgentIcon, icon, refreshIcons } from './modules/icons.js';
234
+ import { MAX_RECONNECT_ATTEMPTS, RECONNECT_DELAY, STORAGE_KEY_PROJECTS, STORAGE_KEY_CURRENT, STORAGE_KEY_FOCUS_STRIP_MODE, QUALITY_TARGET_SCORE } from './modules/constants.js';
235
+ import { getDesktopNotificationPayload, shouldNotifyDesktop } from './modules/desktopNotifications.js';
236
+ import { getLoadingPresentation, getQuietRefreshSectionIdentifiers } from './modules/loading.js';
237
+ import { collectReviewNotifications, createReviewNotificationState } from './modules/notifications.js';
238
+ import { resolveReviewAssigneeDisplay } from './modules/assignee.js';
239
+ import { buildQueueLanesModel } from './modules/queueLanes.js';
240
+ import { rankPendingFixForNowLane } from './modules/priority.js';
241
+ import { getQualityProgress, getQualityTrend } from './modules/quality.js';
242
+ import { createSessionMetricsState, trackSessionAction, updatePriorityItemTracking, getSessionMetricsSnapshot } from './modules/sessionMetrics.js';
243
+
187
244
  const API_URL = window.location.origin;
188
245
  const WS_URL = `ws://${window.location.host}/ws`;
189
246
 
@@ -191,66 +248,280 @@
191
248
  let wsConnected = false;
192
249
  let reconnectAttempts = 0;
193
250
  let logsVisible = false;
194
- const MAX_RECONNECT_ATTEMPTS = 10;
195
- const RECONNECT_DELAY = 3000;
196
251
 
197
252
  let currentData = { activeReviews: [], recentReviews: [], logs: [], reviewFiles: [], pendingFix: [], pendingApproval: [] };
198
- let loadedReviews = {}; // Cache for loaded review content
199
- let statsCollapsed = true; // Track stats section state (collapsed by default)
200
-
201
- // Agent status icons (will be replaced with Lucide icons after render)
202
- const agentIconNames = { pending: 'clock', running: 'loader', completed: 'check', failed: 'x' };
203
- const getAgentIcon = (status) => `<i data-lucide="${agentIconNames[status] || 'help-circle'}"></i>`;
204
-
205
- function formatTime(dateStr) {
206
- if (!dateStr) return '-';
207
- const date = new Date(dateStr);
208
- const now = new Date();
209
- const diff = now - date;
210
- if (diff < 60000) return "À l'instant";
211
- if (diff < 3600000) return `Il y a ${Math.floor(diff / 60000)} min`;
212
- if (diff < 86400000) return `Il y a ${Math.floor(diff / 3600000)}h`;
213
- return date.toLocaleDateString('fr-FR');
214
- }
215
-
216
- function formatDuration(startStr, endStr, totalMs) {
217
- let diff;
218
- if (totalMs !== undefined && totalMs !== null) {
219
- diff = Math.floor(totalMs / 1000);
220
- } else if (startStr) {
221
- const start = new Date(startStr);
222
- const end = endStr ? new Date(endStr) : new Date();
223
- diff = Math.floor((end - start) / 1000);
253
+ let loadedReviews = {};
254
+ let statsCollapsed = true;
255
+ let focusStripCompact = false;
256
+ const loadingState = { status: 0, reviewFiles: 0, stats: 0, mrTracking: 0 };
257
+ let hasLoadedStatusOnce = false;
258
+ const secondarySections = ['active-followups-section', 'pending-approval-section', 'completed-reviews-section'];
259
+ const sectionExpandedState = Object.fromEntries(secondarySections.map((sectionIdentifier) => [sectionIdentifier, false]));
260
+ const quietRefreshSections = [
261
+ 'active-reviews-section',
262
+ 'active-followups-section',
263
+ 'completed-reviews-section',
264
+ 'pending-fix-section',
265
+ ];
266
+ const loadingLabelBySource = {
267
+ status: 'loading.status',
268
+ reviewFiles: 'loading.reviewFiles',
269
+ stats: 'loading.stats',
270
+ mrTracking: 'loading.mrTracking',
271
+ };
272
+ let loadingShowTimeout = null;
273
+ let loadingHideTimeout = null;
274
+ let loadingVisibleSince = null;
275
+ const loadingDelayMs = 260;
276
+ const loadingMinimumVisibleMs = 420;
277
+
278
+ let sessionMetrics = createSessionMetricsState(Date.now());
279
+ let reviewNotificationState = createReviewNotificationState();
280
+
281
+ function applyFocusStripMode() {
282
+ const strip = document.querySelector('.focus-strip');
283
+ if (strip) {
284
+ strip.classList.toggle('compact', focusStripCompact);
285
+ }
286
+ document.body.classList.toggle('compact-density', focusStripCompact);
287
+ const toggleBtn = document.getElementById('focus-strip-toggle');
288
+ if (toggleBtn) {
289
+ toggleBtn.textContent = focusStripCompact ? t('strip.modeDetailed') : t('strip.modeCompact');
290
+ toggleBtn.setAttribute('aria-pressed', focusStripCompact ? 'true' : 'false');
291
+ }
292
+ }
293
+
294
+ function loadFocusStripMode() {
295
+ const mode = localStorage.getItem(STORAGE_KEY_FOCUS_STRIP_MODE);
296
+ focusStripCompact = mode === 'compact';
297
+ applyFocusStripMode();
298
+ }
299
+
300
+ function toggleFocusStripMode() {
301
+ focusStripCompact = !focusStripCompact;
302
+ localStorage.setItem(STORAGE_KEY_FOCUS_STRIP_MODE, focusStripCompact ? 'compact' : 'detailed');
303
+ applyFocusStripMode();
304
+ }
305
+
306
+ function applySectionExpansion(sectionIdentifier) {
307
+ const section = document.getElementById(sectionIdentifier);
308
+ if (!section) return;
309
+ const content = section.querySelector('.section-content');
310
+ const toggle = section.querySelector('.section-toggle');
311
+ if (!content) return;
312
+
313
+ const expanded = sectionExpandedState[sectionIdentifier] === true;
314
+ content.classList.toggle('hidden', !expanded);
315
+ section.classList.toggle('section-collapsed', !expanded);
316
+ if (toggle) {
317
+ toggle.classList.toggle('collapsed', !expanded);
318
+ }
319
+ }
320
+
321
+ function toggleSection(sectionIdentifier) {
322
+ if (!(sectionIdentifier in sectionExpandedState)) return;
323
+ sectionExpandedState[sectionIdentifier] = !sectionExpandedState[sectionIdentifier];
324
+ applySectionExpansion(sectionIdentifier);
325
+ refreshIcons();
326
+ }
327
+
328
+ function setLoadingFlag(source, isLoading) {
329
+ if (!(source in loadingState)) return;
330
+ const nextValue = isLoading
331
+ ? loadingState[source] + 1
332
+ : Math.max(0, loadingState[source] - 1);
333
+ loadingState[source] = nextValue;
334
+ updateLoadingStateUI();
335
+ }
336
+
337
+ function applyQuietRefreshSectionIndicators(sectionIdentifiers) {
338
+ quietRefreshSections.forEach((sectionIdentifier) => {
339
+ const section = document.getElementById(sectionIdentifier);
340
+ if (!section) return;
341
+ section.classList.toggle('is-refreshing', sectionIdentifiers.includes(sectionIdentifier));
342
+ });
343
+ }
344
+
345
+ function getLoadingMessageKey() {
346
+ const activeSource = Object.entries(loadingState).find(([, value]) => value > 0)?.[0];
347
+ return activeSource ? loadingLabelBySource[activeSource] : 'loading.data';
348
+ }
349
+
350
+ function updateLoadingStateUI() {
351
+ const isLoading = Object.values(loadingState).some((value) => value > 0);
352
+ const loadingPresentation = getLoadingPresentation(loadingState, { hasLoadedStatusOnce });
353
+ const loadingElement = document.getElementById('data-loading-state');
354
+ if (!loadingElement) return;
355
+
356
+ const loadingLabel = document.getElementById('i18n-loading-data');
357
+ if (loadingLabel) {
358
+ loadingLabel.textContent = t(getLoadingMessageKey());
359
+ }
360
+
361
+ if (isLoading) {
362
+ document.body.classList.toggle('is-quiet-refresh', loadingPresentation.isQuietRefresh);
363
+ const quietSectionIdentifiers = getQuietRefreshSectionIdentifiers(loadingState, { hasLoadedStatusOnce });
364
+ applyQuietRefreshSectionIndicators(quietSectionIdentifiers);
365
+ if (!loadingPresentation.showGlobalLoading) {
366
+ if (loadingShowTimeout) {
367
+ clearTimeout(loadingShowTimeout);
368
+ loadingShowTimeout = null;
369
+ }
370
+ if (loadingHideTimeout) {
371
+ clearTimeout(loadingHideTimeout);
372
+ loadingHideTimeout = null;
373
+ }
374
+ loadingVisibleSince = null;
375
+ loadingElement.classList.add('hidden');
376
+ document.body.classList.remove('is-loading-data');
377
+ return;
378
+ }
379
+
380
+ if (loadingHideTimeout) {
381
+ clearTimeout(loadingHideTimeout);
382
+ loadingHideTimeout = null;
383
+ }
384
+ if (!loadingVisibleSince && !loadingShowTimeout) {
385
+ loadingShowTimeout = setTimeout(() => {
386
+ loadingElement.classList.remove('hidden');
387
+ document.body.classList.add('is-loading-data');
388
+ loadingVisibleSince = Date.now();
389
+ loadingShowTimeout = null;
390
+ refreshIcons();
391
+ }, loadingDelayMs);
392
+ }
224
393
  } else {
225
- return '';
394
+ document.body.classList.remove('is-quiet-refresh');
395
+ applyQuietRefreshSectionIndicators([]);
396
+ if (loadingShowTimeout) {
397
+ clearTimeout(loadingShowTimeout);
398
+ loadingShowTimeout = null;
399
+ }
400
+ if (!loadingVisibleSince) {
401
+ loadingElement.classList.add('hidden');
402
+ document.body.classList.remove('is-loading-data');
403
+ return;
404
+ }
405
+ const elapsed = Date.now() - loadingVisibleSince;
406
+ const remaining = Math.max(0, loadingMinimumVisibleMs - elapsed);
407
+ loadingHideTimeout = setTimeout(() => {
408
+ loadingElement.classList.add('hidden');
409
+ document.body.classList.remove('is-loading-data');
410
+ loadingVisibleSince = null;
411
+ loadingHideTimeout = null;
412
+ updateUI();
413
+ }, remaining);
226
414
  }
227
- if (diff < 60) return `${diff}s`;
228
- if (diff < 3600) return `${Math.floor(diff / 60)}m ${diff % 60}s`;
229
- return `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m`;
230
- }
231
-
232
- function formatPhase(phase) {
233
- const labels = {
234
- 'initializing': 'Initialisation',
235
- 'agents-running': 'Agents en cours',
236
- 'synthesizing': 'Synthèse',
237
- 'publishing': 'Publication',
238
- 'completed': 'Terminé'
239
- };
240
- return labels[phase] || phase;
241
415
  }
242
416
 
243
- function formatLogTime(timestamp) {
244
- const date = new Date(timestamp);
245
- return date.toLocaleTimeString('fr-FR');
417
+ function formatSessionDuration(milliseconds) {
418
+ const seconds = Math.max(0, Math.floor(milliseconds / 1000));
419
+ if (seconds < 60) return `${seconds}s`;
420
+ const minutes = Math.floor(seconds / 60);
421
+ const remainingSeconds = seconds % 60;
422
+ return `${minutes}m ${remainingSeconds}s`;
423
+ }
424
+
425
+ function formatActionBreakdown(actionBreakdown) {
426
+ return `${t('metrics.action.followup')} ${actionBreakdown.followup}, ${t('metrics.action.open')} ${actionBreakdown.open}, ${t('metrics.action.approve')} ${actionBreakdown.approve}, ${t('metrics.action.cancelReview')} ${actionBreakdown.cancelReview}, ${t('metrics.action.syncThreads')} ${actionBreakdown.syncThreads}`;
427
+ }
428
+
429
+ function updateSessionMetricsUI() {
430
+ const metricsElement = document.getElementById('session-metrics');
431
+ if (!metricsElement) return;
432
+
433
+ const metricsSnapshot = getSessionMetricsSnapshot(sessionMetrics);
434
+ const firstActionLabel = metricsSnapshot.firstUsefulActionDelayMs === null
435
+ ? t('metrics.pending')
436
+ : formatSessionDuration(metricsSnapshot.firstUsefulActionDelayMs);
437
+ const priorityResolutionLabel = metricsSnapshot.averagePriorityResolutionMs === null
438
+ ? t('metrics.pending')
439
+ : formatSessionDuration(metricsSnapshot.averagePriorityResolutionMs);
440
+ const actionBreakdownLabel = formatActionBreakdown(metricsSnapshot.actionBreakdown);
441
+ metricsElement.textContent = `${t('metrics.session')}: ${t('metrics.firstAction')} ${firstActionLabel} • ${metricsSnapshot.actionCount} ${t('metrics.actions')} • ${t('metrics.priorityResolution')} ${priorityResolutionLabel} • ${t('metrics.breakdown')} ${actionBreakdownLabel}`;
442
+ }
443
+
444
+ function trackUsefulAction(actionType = 'other') {
445
+ sessionMetrics = trackSessionAction(sessionMetrics, actionType, Date.now());
446
+ updateSessionMetricsUI();
447
+ }
448
+
449
+ function onUsefulLinkAction() {
450
+ trackUsefulAction('open');
451
+ return true;
452
+ }
453
+
454
+ function activateOnKeydown(event) {
455
+ if (event.key !== 'Enter' && event.key !== ' ') return;
456
+ event.preventDefault();
457
+ event.currentTarget.click();
458
+ }
459
+
460
+ function safeDecodeURIComponent(value) {
461
+ try {
462
+ return decodeURIComponent(String(value ?? ''));
463
+ } catch {
464
+ return String(value ?? '');
465
+ }
466
+ }
467
+
468
+ function getNotificationMrNumber(review) {
469
+ return typeof review.mrNumber === 'number' ? String(review.mrNumber) : '?';
470
+ }
471
+
472
+ function mapNotificationToToast(notification) {
473
+ const mrNumber = getNotificationMrNumber(notification.review);
474
+ switch (notification.kind) {
475
+ case 'reviewStarted':
476
+ return { message: t('notify.reviewStarted', { mrNumber }), type: 'info' };
477
+ case 'followupStarted':
478
+ return { message: t('notify.followupStarted', { mrNumber }), type: 'info' };
479
+ case 'reviewCompleted':
480
+ return { message: t('notify.reviewCompleted', { mrNumber }), type: 'success' };
481
+ case 'followupCompleted':
482
+ return { message: t('notify.followupCompleted', { mrNumber }), type: 'success' };
483
+ case 'reviewFailed':
484
+ return { message: t('notify.reviewFailed', { mrNumber }), type: 'error' };
485
+ default:
486
+ return null;
487
+ }
488
+ }
489
+
490
+ function maybeShowDesktopNotification(notification) {
491
+ if (typeof Notification === 'undefined') return;
492
+
493
+ const shouldNotify = shouldNotifyDesktop({
494
+ permission: Notification.permission,
495
+ isDocumentHidden: document.visibilityState !== 'visible',
496
+ });
497
+ if (!shouldNotify) return;
498
+
499
+ const desktopPayload = getDesktopNotificationPayload(notification, t);
500
+ if (!desktopPayload) return;
501
+ new Notification(desktopPayload.title, {
502
+ body: desktopPayload.body,
503
+ tag: desktopPayload.tag,
504
+ });
505
+ }
506
+
507
+ function dispatchReviewNotifications(activeReviews, recentReviews) {
508
+ const result = collectReviewNotifications(reviewNotificationState, activeReviews, recentReviews);
509
+ reviewNotificationState = result.nextState;
510
+ result.notifications.forEach((notification) => {
511
+ const toastPayload = mapNotificationToToast(notification);
512
+ if (!toastPayload) return;
513
+ showToast(toastPayload.message, toastPayload.type);
514
+ maybeShowDesktopNotification(notification);
515
+ });
246
516
  }
247
517
 
248
518
  function renderAgentTimeline(progress) {
249
519
  if (!progress?.agents?.length) return '';
520
+ const allowedAgentStatuses = new Set(['running', 'completed', 'failed', 'pending', 'queued']);
250
521
  const agentsHtml = progress.agents.map(agent => `
251
- <div class="agent-box ${agent.status}" title="${agent.displayName}: ${agent.status}">
522
+ <div class="agent-box ${allowedAgentStatuses.has(agent.status) ? agent.status : 'queued'}" title="${escapeHtml(agent.displayName)}: ${escapeHtml(agent.status)}">
252
523
  <span class="agent-icon">${getAgentIcon(agent.status)}</span>
253
- <span class="agent-name">${agent.displayName}</span>
524
+ <span class="agent-name">${escapeHtml(agent.displayName)}</span>
254
525
  </div>
255
526
  `).join('');
256
527
  return `<div class="agent-timeline">${agentsHtml}</div>`;
@@ -258,72 +529,89 @@
258
529
 
259
530
  function renderProgressBar(progress) {
260
531
  if (!progress) return '';
532
+ const overallProgress = typeof progress.overallProgress === 'number'
533
+ ? Math.max(0, Math.min(100, progress.overallProgress))
534
+ : 0;
261
535
  return `
262
536
  <div class="progress-container">
263
537
  <div class="progress-bar-wrapper">
264
538
  <div class="progress-bar">
265
- <div class="progress-bar-fill" style="width: ${progress.overallProgress}%"></div>
539
+ <div class="progress-bar-fill" style="width: ${overallProgress}%"></div>
266
540
  </div>
267
- <span class="progress-percent">${progress.overallProgress}%</span>
541
+ <span class="progress-percent">${overallProgress}%</span>
268
542
  </div>
269
543
  <div class="progress-phase">${formatPhase(progress.currentPhase)}</div>
270
544
  </div>
271
545
  `;
272
546
  }
273
547
 
548
+ function getReviewStatusPresentation(status) {
549
+ const statusMap = {
550
+ running: { icon: 'activity', label: t('review.status.running') },
551
+ queued: { icon: 'clock-3', label: t('review.status.queued') },
552
+ completed: { icon: 'check-circle-2', label: t('review.status.completed') },
553
+ failed: { icon: 'triangle-alert', label: t('review.status.failed') },
554
+ };
555
+ return statusMap[status] || { icon: 'circle-help', label: status };
556
+ }
557
+
274
558
  function renderReview(review, isActive) {
275
- const statusClass = review.status;
559
+ const statusClass = typeof review.status === 'string' ? review.status : 'queued';
560
+ const safeStatusClass = ['running', 'queued', 'completed', 'failed'].includes(statusClass) ? statusClass : 'queued';
561
+ const statusPresentation = getReviewStatusPresentation(statusClass);
276
562
  const mrNumber = review.mrNumber;
277
- const project = review.project.split('/').pop();
278
- const mrPrefix = review.id.startsWith('github') ? '#' : '!';
279
- const mrLabel = getMrLabel(review.id.startsWith('github') ? 'github' : 'gitlab');
563
+ const project = typeof review.project === 'string' ? review.project.split('/').pop() : '';
564
+ const reviewId = typeof review.id === 'string' ? review.id : '';
565
+ const reviewIsGitHub = reviewId.startsWith('github');
566
+ const mrPrefix = reviewIsGitHub ? '#' : '!';
567
+ const mrLabel = getMrLabel(reviewIsGitHub ? 'github' : 'gitlab');
568
+ const safeMrUrl = sanitizeHttpUrl(typeof review.mrUrl === 'string' ? review.mrUrl : null);
280
569
 
281
570
  const progressHtml = isActive && review.progress ? `
282
571
  ${renderAgentTimeline(review.progress)}
283
572
  ${renderProgressBar(review.progress)}
284
573
  ` : '';
285
574
 
286
- // Assignee info with avatar
287
- const assignerUsername = review.assignedBy?.username || 'unknown';
288
- const assignerDisplay = review.assignedBy?.displayName || assignerUsername;
575
+ const trackedMergeRequests = [...currentData.pendingFix, ...currentData.pendingApproval];
576
+ const assignerDisplay = resolveReviewAssigneeDisplay(review, trackedMergeRequests);
289
577
  const assignerInitial = assignerDisplay.charAt(0).toUpperCase();
578
+ const safeReviewAccordionId = reviewId.replace(/[^a-zA-Z0-9-]/g, '-');
579
+ const safeJobType = review.jobType === 'followup' ? 'followup' : 'review';
290
580
 
291
- // Description accordion (for active reviews)
292
581
  const descriptionHtml = isActive && review.description ? `
293
582
  <div class="review-description-accordion">
294
- <div class="review-description-toggle" onclick="toggleReviewDescription('${review.id}')">
295
- <i data-lucide="chevron-right"></i> Description
583
+ <div class="review-description-toggle" onclick="toggleReviewDescription('${safeReviewAccordionId}')" role="button" tabindex="0" onkeydown="activateOnKeydown(event)">
584
+ <i data-lucide="chevron-right"></i> ${t('review.description')}
296
585
  </div>
297
- <div class="review-description-content" id="review-desc-${review.id.replace(/[^a-zA-Z0-9-]/g, '-')}">
586
+ <div class="review-description-content" id="review-desc-${safeReviewAccordionId}">
298
587
  <div class="review-description-text">${escapeHtml(review.description)}</div>
299
588
  </div>
300
589
  </div>
301
590
  ` : '';
302
591
 
303
- // Title: use MR title if available, else fallback to project name
304
592
  const displayTitle = review.title || project;
305
593
 
306
594
  return `
307
- <div class="review-item" data-job-id="${review.id}">
595
+ <div class="review-item" data-job-id="${escapeHtml(reviewId)}">
308
596
  <div class="review-header">
309
- <div class="review-status ${statusClass}"></div>
597
+ <div class="review-status ${safeStatusClass}" title="${escapeHtml(statusPresentation.label)}"></div>
310
598
  <div class="review-info">
311
599
  <div class="review-title">
312
- <a href="${review.mrUrl}" target="_blank">${mrPrefix}${mrNumber}</a> - ${displayTitle}
600
+ <a href="${safeMrUrl}" target="_blank" rel="noopener noreferrer">${mrPrefix}${mrNumber}</a> - ${escapeHtml(displayTitle)}
313
601
  </div>
314
602
  <div class="review-meta">
315
- <span class="badge ${statusClass}">${review.status}</span>
603
+ <span class="badge ${safeStatusClass}"><i data-lucide="${statusPresentation.icon}"></i> ${escapeHtml(statusPresentation.label)}</span>
316
604
  ${isActive ? `<i data-lucide="clock"></i> ${formatDuration(review.startedAt)}` : ''}
317
605
  </div>
318
- ${review.error ? `<div class="error-message">${review.error}</div>` : ''}
606
+ ${review.error ? `<div class="error-message">${escapeHtml(review.error)}</div>` : ''}
319
607
  </div>
320
- ${isActive ? `<button class="btn-cancel-review" onclick="event.stopPropagation(); showCancelModal('${review.id}', ${mrNumber}, '${review.jobType || 'review'}')" title="Annuler"><i data-lucide="x"></i> Annuler</button>` : ''}
608
+ ${isActive ? `<button class="btn-cancel-review" onclick="event.stopPropagation(); showCancelModal('${encodeURIComponent(reviewId)}', ${mrNumber}, '${safeJobType}')" title="${t('button.cancel')}"><i data-lucide="x"></i> ${t('button.cancel')}</button>` : ''}
321
609
  <div class="review-assigner">
322
610
  <div class="review-assigner-info">
323
- <span class="review-assigner-name">${assignerDisplay}</span>
611
+ <span class="review-assigner-name">${escapeHtml(assignerDisplay)}</span>
324
612
  <span class="review-assigner-time">${isActive ? formatTime(review.startedAt) : formatTime(review.completedAt)}</span>
325
613
  </div>
326
- <div class="review-avatar" title="${assignerDisplay}">${assignerInitial}</div>
614
+ <div class="review-avatar" title="${escapeHtml(assignerDisplay)}">${escapeHtml(assignerInitial)}</div>
327
615
  </div>
328
616
  </div>
329
617
  ${descriptionHtml}
@@ -349,21 +637,16 @@
349
637
  }
350
638
  }
351
639
 
352
- function escapeHtml(text) {
353
- if (!text) return '';
354
- const div = document.createElement('div');
355
- div.textContent = text;
356
- return div.innerHTML;
357
- }
358
-
359
640
  function renderLog(log) {
360
- const dataStr = log.data ? JSON.stringify(log.data, null, 2) : '';
641
+ const dataStr = log.data ? escapeHtml(JSON.stringify(log.data, null, 2)) : '';
642
+ const logLevel = typeof log.level === 'string' ? log.level : 'info';
643
+ const safeLogLevelClass = logLevel.replace(/[^a-zA-Z0-9-]/g, '-');
361
644
  return `
362
645
  <div class="log-entry">
363
646
  <span class="log-time">${formatLogTime(log.timestamp)}</span>
364
- <span class="log-level ${log.level}">${log.level.toUpperCase()}</span>
647
+ <span class="log-level ${safeLogLevelClass}">${escapeHtml(logLevel.toUpperCase())}</span>
365
648
  <div class="log-message">
366
- ${log.message}
649
+ ${escapeHtml(String(log.message ?? ''))}
367
650
  ${dataStr ? `<div class="log-data">${dataStr}</div>` : ''}
368
651
  </div>
369
652
  </div>
@@ -371,31 +654,36 @@
371
654
  }
372
655
 
373
656
  function updateUI() {
374
- // Separate reviews and followups
375
657
  const reviews = currentData.activeReviews.filter(r => r.jobType !== 'followup');
376
658
  const followups = currentData.activeReviews.filter(r => r.jobType === 'followup');
377
659
 
378
660
  const running = currentData.activeReviews.filter(r => r.status === 'running').length;
379
661
  const queued = currentData.activeReviews.filter(r => r.status === 'queued').length;
662
+ const blocked = currentData.pendingFix.length;
663
+ const nowCount = running + blocked;
664
+ const nextCount = queued + currentData.pendingApproval.length;
380
665
  document.getElementById('running-count').textContent = running;
381
666
  document.getElementById('queued-count').textContent = queued;
382
667
  document.getElementById('completed-count').textContent = currentData.reviewFiles.length;
668
+ document.getElementById('focus-now-count').textContent = String(nowCount);
669
+ document.getElementById('focus-next-count').textContent = String(nextCount);
670
+ document.getElementById('focus-blocked-count').textContent = String(blocked);
383
671
 
384
- // Update Reviews section
385
672
  const activeReviewsSection = document.getElementById('active-reviews-section');
386
673
  const activeReviewsEl = document.getElementById('active-reviews');
387
674
  const activeReviewsCount = document.getElementById('active-reviews-count');
388
675
 
676
+ activeReviewsSection.classList.remove('hidden');
389
677
  if (reviews.length === 0) {
390
- activeReviewsSection.classList.add('hidden');
678
+ const showInitialLoadingState = !hasLoadedStatusOnce && loadingState.status > 0;
679
+ activeReviewsEl.innerHTML = `<div class="empty-state">${showInitialLoadingState ? t('loading.section') : t('empty.activeReviews')}</div>`;
680
+ activeReviewsCount.classList.add('hidden');
391
681
  } else {
392
- activeReviewsSection.classList.remove('hidden');
393
682
  activeReviewsEl.innerHTML = reviews.map(r => renderReview(r, true)).join('');
394
683
  activeReviewsCount.textContent = reviews.length;
395
684
  activeReviewsCount.classList.remove('hidden');
396
685
  }
397
686
 
398
- // Update Followups section
399
687
  const activeFollowupsSection = document.getElementById('active-followups-section');
400
688
  const activeFollowupsEl = document.getElementById('active-followups');
401
689
  const activeFollowupsCount = document.getElementById('active-followups-count');
@@ -407,10 +695,10 @@
407
695
  activeFollowupsEl.innerHTML = followups.map(r => renderReview(r, true)).join('');
408
696
  activeFollowupsCount.textContent = followups.length;
409
697
  activeFollowupsCount.classList.remove('hidden');
698
+ applySectionExpansion('active-followups-section');
410
699
  }
411
700
 
412
701
  refreshIcons();
413
- // Note: recent-reviews is managed by updateReviewFilesUI() - don't overwrite here
414
702
  }
415
703
 
416
704
  function updateLogs() {
@@ -419,112 +707,68 @@
419
707
  const errorBadge = document.getElementById('error-count');
420
708
 
421
709
  if (errorCount > 0) {
422
- errorBadge.textContent = `${errorCount} erreurs`;
710
+ errorBadge.textContent = t('logs.errorCount', { count: errorCount });
423
711
  errorBadge.classList.remove('hidden');
424
712
  } else {
425
713
  errorBadge.classList.add('hidden');
426
714
  }
427
715
 
428
716
  if (currentData.logs.length === 0) {
429
- logsEl.innerHTML = '<div class="empty-state">Aucun log</div>';
717
+ logsEl.innerHTML = `<div class="empty-state">${t('empty.logs')}</div>`;
430
718
  } else {
431
719
  logsEl.innerHTML = currentData.logs.slice().reverse().map(renderLog).join('');
432
720
  }
433
721
  }
434
722
 
435
- function updateConnectionStatus(status, text) {
723
+ function updateConnectionStatus(status, textKey, textFallback) {
436
724
  const statusEl = document.getElementById('server-status');
437
725
  statusEl.className = `status-indicator ${status}`;
438
- statusEl.innerHTML = `<span class="status-dot"></span><span>${text}</span>`;
726
+ const displayText = textKey ? t(textKey) : textFallback;
727
+ statusEl.innerHTML = `<span class="status-dot"></span><span>${escapeHtml(String(displayText ?? ''))}</span>`;
439
728
 
440
729
  const modeEl = document.getElementById('connection-mode');
441
730
  modeEl.textContent = status === 'online' && wsConnected
442
- ? 'WebSocket temps réel'
443
- : status === 'online' ? 'Mode polling' : 'Déconnecté';
444
- }
445
-
446
- // Simple markdown to HTML converter
447
- function markdownToHtml(md) {
448
- let html = md
449
- // Escape HTML
450
- .replace(/&/g, '&amp;')
451
- .replace(/</g, '&lt;')
452
- .replace(/>/g, '&gt;')
453
- // Code blocks
454
- .replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
455
- // Inline code
456
- .replace(/`([^`]+)`/g, '<code>$1</code>')
457
- // Headers
458
- .replace(/^### (.+)$/gm, '<h3>$1</h3>')
459
- .replace(/^## (.+)$/gm, '<h2>$1</h2>')
460
- .replace(/^# (.+)$/gm, '<h1>$1</h1>')
461
- // Bold
462
- .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
463
- // Italic
464
- .replace(/\*([^*]+)\*/g, '<em>$1</em>')
465
- // Links
466
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
467
- // Blockquotes
468
- .replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
469
- // Unordered lists
470
- .replace(/^- (.+)$/gm, '<li>$1</li>')
471
- // Ordered lists
472
- .replace(/^\d+\. (.+)$/gm, '<li>$1</li>')
473
- // Horizontal rules
474
- .replace(/^---$/gm, '<hr>')
475
- // Tables (simple)
476
- .replace(/^\|(.+)\|$/gm, (match, content) => {
477
- const cells = content.split('|').map(c => c.trim());
478
- if (cells.every(c => c.match(/^-+$/))) return '';
479
- const cellTag = match.includes('---') ? 'th' : 'td';
480
- return '<tr>' + cells.map(c => `<${cellTag}>${c}</${cellTag}>`).join('') + '</tr>';
481
- })
482
- // Wrap consecutive table rows
483
- .replace(/(<tr>[\s\S]*?<\/tr>)+/g, '<table>$&</table>')
484
- // Wrap consecutive list items
485
- .replace(/(<li>[\s\S]*?<\/li>)+/g, '<ul>$&</ul>')
486
- // Paragraphs
487
- .replace(/\n\n/g, '</p><p>')
488
- .replace(/\n/g, '<br>');
489
-
490
- return '<p>' + html + '</p>';
731
+ ? t('connection.websocket')
732
+ : status === 'online' ? t('connection.polling') : t('connection.disconnected');
491
733
  }
492
734
 
493
735
  function renderReviewFile(review) {
494
736
  const typeIcon = review.type === 'review' ? 'file-text' : review.type === 'followup' ? 'refresh-cw' : 'file';
495
- const typeLabel = review.type === 'review' ? 'Review' : review.type === 'followup' ? 'Followup' : review.type;
737
+ const typeLabel = review.type === 'review' ? t('review.type.review') : review.type === 'followup' ? t('review.type.followup') : review.type;
496
738
  const sizeKb = (review.size / 1024).toFixed(1);
497
739
  const mrLabel = getMrLabel();
740
+ const encodedFilename = encodeURIComponent(String(review.filename ?? ''));
741
+ const filenameDomId = encodedFilename.replace(/[^a-zA-Z0-9-]/g, '-');
498
742
 
499
743
  return `
500
- <div class="review-accordion" data-filename="${review.filename}">
744
+ <div class="review-accordion" data-filename="${encodedFilename}">
501
745
  <div class="review-accordion-header">
502
- <div class="review-accordion-toggle" onclick="toggleReviewAccordion('${review.filename}')"><i data-lucide="chevron-right"></i></div>
503
- <div class="review-status completed" onclick="toggleReviewAccordion('${review.filename}')"></div>
504
- <div class="review-info" onclick="toggleReviewAccordion('${review.filename}')" style="cursor: pointer;">
505
- <div class="review-title">${mrLabel} ${review.mrNumber}${review.title ? ` - ${review.title}` : ''}</div>
746
+ <div class="review-accordion-toggle" onclick="toggleReviewAccordion('${encodedFilename}')" role="button" tabindex="0" onkeydown="activateOnKeydown(event)"><i data-lucide="chevron-right"></i></div>
747
+ <div class="review-status completed" onclick="toggleReviewAccordion('${encodedFilename}')" role="button" tabindex="0" onkeydown="activateOnKeydown(event)"></div>
748
+ <div class="review-info" onclick="toggleReviewAccordion('${encodedFilename}')" role="button" tabindex="0" onkeydown="activateOnKeydown(event)" style="cursor: pointer;">
749
+ <div class="review-title">${mrLabel} ${review.mrNumber}${review.title ? ` - ${escapeHtml(String(review.title))}` : ''}</div>
506
750
  <div class="review-meta">
507
751
  <span class="badge completed"><i data-lucide="${typeIcon}"></i> ${typeLabel}</span>
508
752
  <span style="margin-left: 0.5rem; color: #71717a;">${sizeKb} KB</span>
509
753
  </div>
510
754
  </div>
511
- <div class="review-time" onclick="toggleReviewAccordion('${review.filename}')" style="cursor: pointer;">${review.date}</div>
512
- <button class="btn-delete" onclick="deleteReviewFile('${review.filename}')" title="Supprimer"><i data-lucide="trash-2"></i></button>
755
+ <div class="review-time" onclick="toggleReviewAccordion('${encodedFilename}')" role="button" tabindex="0" onkeydown="activateOnKeydown(event)" style="cursor: pointer;">${escapeHtml(String(review.date ?? ''))}</div>
756
+ <button class="btn-delete" onclick="deleteReviewFile('${encodedFilename}')" title="${t('button.delete')}"><i data-lucide="trash-2"></i></button>
513
757
  </div>
514
758
  <div class="review-accordion-content">
515
- <div class="markdown-content" id="review-content-${review.filename.replace(/\./g, '-')}">
516
- <div class="empty-state">Chargement...</div>
759
+ <div class="markdown-content" id="review-content-${filenameDomId}">
760
+ <div class="empty-state">${t('status.loading')}</div>
517
761
  </div>
518
762
  </div>
519
763
  </div>
520
764
  `;
521
765
  }
522
766
 
523
- async function toggleReviewAccordion(filename) {
524
- const accordion = document.querySelector(`.review-accordion[data-filename="${filename}"]`);
767
+ async function toggleReviewAccordion(encodedFilename) {
768
+ const filename = safeDecodeURIComponent(encodedFilename);
769
+ const accordion = document.querySelector(`.review-accordion[data-filename="${encodedFilename}"]`);
525
770
  const isOpen = accordion.classList.contains('open');
526
771
 
527
- // Close all other accordions
528
772
  document.querySelectorAll('.review-accordion.open').forEach(el => {
529
773
  if (el !== accordion) el.classList.remove('open');
530
774
  });
@@ -535,17 +779,16 @@
535
779
  }
536
780
 
537
781
  accordion.classList.add('open');
538
- const contentId = `review-content-${filename.replace(/\./g, '-')}`;
782
+ const contentId = `review-content-${encodedFilename.replace(/[^a-zA-Z0-9-]/g, '-')}`;
539
783
  const contentEl = document.getElementById(contentId);
540
784
 
541
- // Load content if not cached
542
785
  if (!loadedReviews[filename]) {
543
786
  try {
544
787
  const response = await fetch(`${API_URL}/api/reviews/${filename}`);
545
788
  const data = await response.json();
546
789
  loadedReviews[filename] = data.content;
547
790
  } catch (error) {
548
- contentEl.innerHTML = '<div class="empty-state">Erreur de chargement</div>';
791
+ contentEl.innerHTML = `<div class="empty-state">${t('error.loading')}</div>`;
549
792
  return;
550
793
  }
551
794
  }
@@ -554,8 +797,8 @@
554
797
  }
555
798
 
556
799
  async function fetchReviewFiles() {
800
+ setLoadingFlag('reviewFiles', true);
557
801
  try {
558
- // Fetch reviews for current project if loaded, otherwise all
559
802
  const url = currentProjectPath
560
803
  ? `${API_URL}/api/reviews?path=${encodeURIComponent(currentProjectPath)}`
561
804
  : `${API_URL}/api/reviews`;
@@ -565,14 +808,18 @@
565
808
  updateReviewFilesUI();
566
809
  } catch (error) {
567
810
  console.error('Error fetching review files:', error);
811
+ } finally {
812
+ setLoadingFlag('reviewFiles', false);
568
813
  }
569
814
  }
570
815
 
571
816
  async function fetchProjectStats() {
572
817
  const statsEl = document.getElementById('project-stats');
818
+ setLoadingFlag('stats', true);
573
819
 
574
820
  if (!currentProjectPath) {
575
- statsEl.innerHTML = '<div class="empty-state">Charger un projet pour voir les stats</div>';
821
+ statsEl.innerHTML = `<div class="empty-state">${t('empty.statsNoProject')}</div>`;
822
+ setLoadingFlag('stats', false);
576
823
  return;
577
824
  }
578
825
 
@@ -582,58 +829,70 @@
582
829
 
583
830
  if (data.summary) {
584
831
  const s = data.summary;
585
- const trendIcon = (t) => t === 'up' ? '<i data-lucide="trending-up"></i>' : t === 'down' ? '<i data-lucide="trending-down"></i>' : '<i data-lucide="minus"></i>';
832
+ const trendIcon = (trend) => trend === 'up'
833
+ ? '<span class="stat-trend up"><i data-lucide="trending-up"></i></span>'
834
+ : trend === 'down'
835
+ ? '<span class="stat-trend down"><i data-lucide="trending-down"></i></span>'
836
+ : '<span class="stat-trend flat"><i data-lucide="minus"></i></span>';
586
837
 
587
838
  statsEl.innerHTML = `
588
- <div class="stat-card">
589
- <div class="stat-value">${s.totalReviews}</div>
590
- <div class="stat-label"><i data-lucide="file-search"></i> Reviews</div>
839
+ <div class="stat-card metric-reviews">
840
+ <div class="stat-value"><span class="stat-main">${s.totalReviews}</span></div>
841
+ <div class="stat-label"><i data-lucide="file-search"></i> ${t('stats.reviews')}</div>
591
842
  </div>
592
- <div class="stat-card">
593
- <div class="stat-value">${s.averageScore}/10 ${trendIcon(s.trend.score)}</div>
594
- <div class="stat-label"><i data-lucide="star"></i> Score moyen</div>
843
+ <div class="stat-card metric-score">
844
+ <div class="stat-value"><span class="stat-main">${s.averageScore}</span><span class="stat-denominator">/10</span>${trendIcon(s.trend.score)}</div>
845
+ <div class="stat-label"><i data-lucide="star"></i> ${t('stats.averageScore')}</div>
595
846
  </div>
596
- <div class="stat-card">
597
- <div class="stat-value">${s.totalTime}</div>
598
- <div class="stat-label"><i data-lucide="timer"></i> Temps total</div>
847
+ <div class="stat-card metric-time">
848
+ <div class="stat-value"><span class="stat-main">${s.totalTime}</span></div>
849
+ <div class="stat-label"><i data-lucide="timer"></i> ${t('stats.totalTime')}</div>
599
850
  </div>
600
- <div class="stat-card">
601
- <div class="stat-value">${s.averageTime}</div>
602
- <div class="stat-label"><i data-lucide="clock"></i> Durée moyenne</div>
851
+ <div class="stat-card metric-average-time">
852
+ <div class="stat-value"><span class="stat-main">${s.averageTime}</span></div>
853
+ <div class="stat-label"><i data-lucide="clock"></i> ${t('stats.averageTime')}</div>
603
854
  </div>
604
- <div class="stat-card warning">
605
- <div class="stat-value">${s.totalBlocking} ${trendIcon(s.trend.blocking)}</div>
606
- <div class="stat-label"><i data-lucide="octagon-alert"></i> Bloquants</div>
855
+ <div class="stat-card warning metric-blocking">
856
+ <div class="stat-value"><span class="stat-main">${s.totalBlocking}</span>${trendIcon(s.trend.blocking)}</div>
857
+ <div class="stat-label"><i data-lucide="octagon-alert"></i> ${t('stats.blocking')}</div>
607
858
  </div>
608
- <div class="stat-card">
609
- <div class="stat-value">${s.totalWarnings}</div>
610
- <div class="stat-label"><i data-lucide="alert-triangle"></i> Importants</div>
859
+ <div class="stat-card metric-warnings">
860
+ <div class="stat-value"><span class="stat-main">${s.totalWarnings}</span></div>
861
+ <div class="stat-label"><i data-lucide="alert-triangle"></i> ${t('stats.warnings')}</div>
611
862
  </div>
612
863
  `;
613
864
  refreshIcons();
614
865
  } else {
615
- statsEl.innerHTML = '<div class="empty-state">Aucune statistique disponible</div>';
866
+ statsEl.innerHTML = `<div class="empty-state">${t('empty.statsNoData')}</div>`;
616
867
  }
617
868
  } catch (error) {
618
869
  console.error('Error fetching stats:', error);
619
- statsEl.innerHTML = '<div class="empty-state">Erreur de chargement des stats</div>';
870
+ statsEl.innerHTML = `<div class="empty-state">${t('error.loadingStats')}</div>`;
871
+ } finally {
872
+ setLoadingFlag('stats', false);
620
873
  }
621
874
  }
622
875
 
623
876
  function updateReviewFilesUI() {
624
877
  const recentEl = document.getElementById('recent-reviews');
878
+ const completedSection = document.getElementById('completed-reviews-section');
625
879
 
626
880
  if (currentData.reviewFiles.length === 0) {
627
- recentEl.innerHTML = '<div class="empty-state">Aucun fichier de review</div>';
881
+ completedSection?.classList.remove('hidden');
882
+ recentEl.innerHTML = `<div class="empty-state">${loadingState.reviewFiles ? t('loading.section') : t('empty.reviewFiles')}</div>`;
883
+ applySectionExpansion('completed-reviews-section');
628
884
  return;
629
885
  }
630
886
 
887
+ completedSection?.classList.remove('hidden');
631
888
  recentEl.innerHTML = currentData.reviewFiles.map(renderReviewFile).join('');
889
+ applySectionExpansion('completed-reviews-section');
632
890
  refreshIcons();
633
891
  }
634
892
 
635
- async function deleteReviewFile(filename) {
636
- if (!confirm(`Supprimer ${filename} ?`)) return;
893
+ async function deleteReviewFile(encodedFilename) {
894
+ const filename = safeDecodeURIComponent(encodedFilename);
895
+ if (!confirm(t('confirm.deleteReview', { filename }))) return;
637
896
 
638
897
  try {
639
898
  const response = await fetch(`${API_URL}/api/reviews/${filename}`, {
@@ -642,18 +901,16 @@
642
901
  const data = await response.json();
643
902
 
644
903
  if (data.success) {
645
- // Remove from cache
646
904
  delete loadedReviews[filename];
647
- // Remove from list and update UI
648
905
  currentData.reviewFiles = currentData.reviewFiles.filter(r => r.filename !== filename);
649
906
  updateReviewFilesUI();
650
- updateUI(); // Update count
907
+ updateUI();
651
908
  } else {
652
- alert('Erreur: ' + data.error);
909
+ alert(t('error.deleteReview') + ': ' + data.error);
653
910
  }
654
911
  } catch (error) {
655
912
  console.error('Error deleting review:', error);
656
- alert('Erreur lors de la suppression');
913
+ alert(t('error.deleteReview'));
657
914
  }
658
915
  }
659
916
 
@@ -662,7 +919,7 @@
662
919
  const loginSection = document.getElementById('claude-login-section');
663
920
 
664
921
  claudeEl.className = 'card-claude checking';
665
- claudeEl.innerHTML = '<span class="status">Vérification...</span>';
922
+ claudeEl.innerHTML = `<span class="status">${t('status.checking')}</span>`;
666
923
 
667
924
  try {
668
925
  const response = await fetch(`${API_URL}/api/claude/status`);
@@ -671,29 +928,28 @@
671
928
  if (data.available) {
672
929
  claudeEl.className = 'card-claude available';
673
930
  claudeEl.innerHTML = `
674
- <span class="status"><i data-lucide="check-circle"></i> Opérationnel</span>
675
- <span class="version">${data.version}</span>
931
+ <span class="status"><i data-lucide="check-circle"></i> ${t('status.operational')}</span>
932
+ <span class="version">${escapeHtml(String(data.version ?? ''))}</span>
676
933
  `;
677
934
  loginSection.classList.add('hidden');
678
935
  refreshIcons();
679
936
  } else {
680
937
  claudeEl.className = 'card-claude unavailable';
681
938
  claudeEl.innerHTML = `
682
- <span class="status"><i data-lucide="x-circle"></i> ${data.message}</span>
939
+ <span class="status"><i data-lucide="x-circle"></i> ${escapeHtml(String(data.message ?? ''))}</span>
683
940
  `;
684
941
  refreshIcons();
685
- if (data.message.includes('authentifié') || data.message.includes('login')) {
942
+ if (data.message.includes('authentifié') || data.message.includes('login') || data.message.includes('authenticated')) {
686
943
  loginSection.classList.remove('hidden');
687
944
  }
688
945
  }
689
946
  } catch (error) {
690
947
  claudeEl.className = 'card-claude unavailable';
691
- claudeEl.innerHTML = '<span class="status"><i data-lucide="x-circle"></i> Erreur de vérification</span>';
948
+ claudeEl.innerHTML = `<span class="status"><i data-lucide="x-circle"></i> ${t('error.checkStatus')}</span>`;
692
949
  refreshIcons();
693
950
  }
694
951
  }
695
952
 
696
- // Active platform from config (gitlab or github)
697
953
  let activePlatform = null;
698
954
 
699
955
  function updateGitCliUI() {
@@ -702,44 +958,43 @@
702
958
  const loginSection = document.getElementById('git-login-section');
703
959
 
704
960
  if (!activePlatform) {
705
- labelEl.textContent = 'Git CLI';
961
+ labelEl.textContent = t('card.gitCli');
706
962
  statusEl.className = 'card-claude checking';
707
- statusEl.innerHTML = '<span class="status">Charger un projet...</span>';
963
+ statusEl.innerHTML = `<span class="status">${t('status.loadProject')}</span>`;
708
964
  loginSection.classList.add('hidden');
709
965
  return;
710
966
  }
711
967
 
712
968
  const isGitlab = activePlatform === 'gitlab';
713
- labelEl.textContent = isGitlab ? 'GitLab CLI' : 'GitHub CLI';
969
+ labelEl.textContent = isGitlab ? t('card.gitlabCli') : t('card.githubCli');
714
970
 
715
- // Update login section with CLI + Webhook instructions
716
971
  const titleEl = document.getElementById('git-login-title');
717
972
  const instructionsEl = document.getElementById('git-login-instructions');
718
973
 
719
974
  if (isGitlab) {
720
- titleEl.innerHTML = '<i data-lucide="alert-triangle"></i> GitLab CLI non authentifié';
975
+ titleEl.innerHTML = `<i data-lucide="alert-triangle"></i> ${t('login.gitlab.title')}`;
721
976
  instructionsEl.innerHTML = `
722
- <p><strong>1. Installer et authentifier glab :</strong></p>
977
+ <p><strong>${t('setup.installAndAuth', { cli: 'glab' })}</strong></p>
723
978
  <p style="margin: 0.5rem 0;"><code>sudo apt install glab</code></p>
724
979
  <p style="margin: 0.5rem 0;"><code>glab auth login</code></p>
725
- <p style="margin-top: 1rem;"><strong>2. Configurer le webhook GitLab :</strong></p>
726
- <p style="margin: 0.5rem 0; font-size: 0.8rem;">Settings → Webhooks → Add webhook</p>
980
+ <p style="margin-top: 1rem;"><strong>${t('setup.configureWebhook', { platform: 'GitLab' })}</strong></p>
981
+ <p style="margin: 0.5rem 0; font-size: 0.8rem;">${t('setup.webhookPath')}</p>
727
982
  <p style="margin: 0.25rem 0; font-size: 0.8rem;">URL: <code>http://&lt;your-server&gt;:3847/webhooks/gitlab</code></p>
728
- <p style="margin: 0.25rem 0; font-size: 0.8rem;">Trigger: Merge request events</p>
729
- <p style="margin-top: 0.75rem; font-size: 0.75rem; color: #a1a1aa;">Puis rechargez cette page.</p>
983
+ <p style="margin: 0.25rem 0; font-size: 0.8rem;">${t('setup.gitlab.trigger')}</p>
984
+ <p style="margin-top: 0.75rem; font-size: 0.75rem; color: #a1a1aa;">${t('setup.reload')}</p>
730
985
  `;
731
986
  } else {
732
- titleEl.innerHTML = '<i data-lucide="alert-triangle"></i> GitHub CLI non authentifié';
987
+ titleEl.innerHTML = `<i data-lucide="alert-triangle"></i> ${t('login.github.title')}`;
733
988
  instructionsEl.innerHTML = `
734
- <p><strong>1. Installer et authentifier gh :</strong></p>
989
+ <p><strong>${t('setup.installAndAuth', { cli: 'gh' })}</strong></p>
735
990
  <p style="margin: 0.5rem 0;"><code>sudo apt install gh</code></p>
736
991
  <p style="margin: 0.5rem 0;"><code>gh auth login</code></p>
737
- <p style="margin-top: 1rem;"><strong>2. Configurer le webhook GitHub :</strong></p>
738
- <p style="margin: 0.5rem 0; font-size: 0.8rem;">Settings → Webhooks → Add webhook</p>
992
+ <p style="margin-top: 1rem;"><strong>${t('setup.configureWebhook', { platform: 'GitHub' })}</strong></p>
993
+ <p style="margin: 0.5rem 0; font-size: 0.8rem;">${t('setup.webhookPath')}</p>
739
994
  <p style="margin: 0.25rem 0; font-size: 0.8rem;">Payload URL: <code>http://&lt;your-server&gt;:3847/webhooks/github</code></p>
740
- <p style="margin: 0.25rem 0; font-size: 0.8rem;">Content type: application/json</p>
741
- <p style="margin: 0.25rem 0; font-size: 0.8rem;">Events: Pull requests</p>
742
- <p style="margin-top: 0.75rem; font-size: 0.75rem; color: #a1a1aa;">Puis rechargez cette page.</p>
995
+ <p style="margin: 0.25rem 0; font-size: 0.8rem;">${t('setup.github.contentType')}</p>
996
+ <p style="margin: 0.25rem 0; font-size: 0.8rem;">${t('setup.github.events')}</p>
997
+ <p style="margin-top: 0.75rem; font-size: 0.75rem; color: #a1a1aa;">${t('setup.reload')}</p>
743
998
  `;
744
999
  }
745
1000
 
@@ -756,7 +1011,7 @@
756
1011
  const endpoint = isGitlab ? '/api/gitlab/status' : '/api/github/status';
757
1012
 
758
1013
  statusEl.className = 'card-claude checking';
759
- statusEl.innerHTML = '<span class="status">Vérification...</span>';
1014
+ statusEl.innerHTML = `<span class="status">${t('status.checking')}</span>`;
760
1015
 
761
1016
  try {
762
1017
  const response = await fetch(`${API_URL}${endpoint}`);
@@ -765,25 +1020,25 @@
765
1020
  if (data.available && data.authenticated) {
766
1021
  statusEl.className = 'card-claude available';
767
1022
  statusEl.innerHTML = `
768
- <span class="status"><i data-lucide="check-circle"></i> Opérationnel</span>
769
- <span class="version">@${data.username}</span>
1023
+ <span class="status"><i data-lucide="check-circle"></i> ${t('status.operational')}</span>
1024
+ <span class="version">@${escapeHtml(String(data.username ?? ''))}</span>
770
1025
  `;
771
1026
  loginSection.classList.add('hidden');
772
1027
  refreshIcons();
773
1028
  } else if (data.available && !data.authenticated) {
774
1029
  statusEl.className = 'card-claude unavailable';
775
- statusEl.innerHTML = `<span class="status"><i data-lucide="x-circle"></i> ${data.message}</span>`;
1030
+ statusEl.innerHTML = `<span class="status"><i data-lucide="x-circle"></i> ${escapeHtml(String(data.message ?? ''))}</span>`;
776
1031
  loginSection.classList.remove('hidden');
777
1032
  refreshIcons();
778
1033
  } else {
779
1034
  statusEl.className = 'card-claude unavailable';
780
- statusEl.innerHTML = `<span class="status"><i data-lucide="x-circle"></i> ${data.message}</span>`;
1035
+ statusEl.innerHTML = `<span class="status"><i data-lucide="x-circle"></i> ${escapeHtml(String(data.message ?? ''))}</span>`;
781
1036
  loginSection.classList.remove('hidden');
782
1037
  refreshIcons();
783
1038
  }
784
1039
  } catch (error) {
785
1040
  statusEl.className = 'card-claude unavailable';
786
- statusEl.innerHTML = '<span class="status"><i data-lucide="x-circle"></i> Erreur de vérification</span>';
1041
+ statusEl.innerHTML = `<span class="status"><i data-lucide="x-circle"></i> ${t('error.checkStatus')}</span>`;
787
1042
  refreshIcons();
788
1043
  }
789
1044
  }
@@ -795,11 +1050,11 @@
795
1050
 
796
1051
  if (logsVisible) {
797
1052
  logsSection.classList.remove('hidden');
798
- btn.innerHTML = '<i data-lucide="scroll-text"></i> Masquer Logs';
1053
+ btn.innerHTML = `<i data-lucide="scroll-text"></i> ${t('header.hideLogs')}`;
799
1054
  fetchLogs();
800
1055
  } else {
801
1056
  logsSection.classList.add('hidden');
802
- btn.innerHTML = '<i data-lucide="scroll-text"></i> Logs';
1057
+ btn.innerHTML = `<i data-lucide="scroll-text"></i> ${t('header.logs')}`;
803
1058
  }
804
1059
  refreshIcons();
805
1060
  }
@@ -820,26 +1075,26 @@
820
1075
 
821
1076
  function renderMrItem(mr, type) {
822
1077
  const mrPrefix = mr.platform === 'github' ? '#' : '!';
1078
+ const encodedMrId = encodeURIComponent(String(mr.id ?? ''));
823
1079
  const threadInfo = type === 'pending-fix'
824
- ? `<span class="mr-threads has-open"><i data-lucide="message-circle"></i> ${mr.openThreads} ouvert${mr.openThreads > 1 ? 's' : ''}</span>`
1080
+ ? `<span class="mr-threads has-open"><i data-lucide="message-circle"></i> ${t('mr.threads.openAction', { count: mr.openThreads })}</span>`
825
1081
  : (type === 'pending-approval' && mr.totalWarnings > 0)
826
- ? `<span class="mr-threads has-warnings"><i data-lucide="alert-triangle"></i> ${mr.totalWarnings} important${mr.totalWarnings > 1 ? 's' : ''}</span>`
827
- : `<span class="mr-threads all-resolved"><i data-lucide="check-circle"></i> Résolus</span>`;
1082
+ ? `<span class="mr-threads has-warnings"><i data-lucide="alert-triangle"></i> ${t('mr.threads.warningAction', { count: mr.totalWarnings })}</span>`
1083
+ : `<span class="mr-threads all-resolved"><i data-lucide="check-circle"></i> ${t('mr.threads.resolvedAction')}</span>`;
828
1084
 
829
- const openBtn = `<a href="${mr.url}" target="_blank" class="btn-action open"><i data-lucide="external-link"></i> Ouvrir</a>`;
1085
+ const openBtn = `<a href="${sanitizeHttpUrl(mr.url)}" target="_blank" rel="noopener noreferrer" class="btn-action open" onclick="return onUsefulLinkAction()"><i data-lucide="external-link"></i> ${t('button.open')}</a>`;
830
1086
  const autoFollowupChecked = mr.autoFollowup !== false ? 'checked' : '';
831
1087
  const showFollowupActions = type === 'pending-fix' ||
832
1088
  (type === 'pending-approval' && mr.totalWarnings > 0);
833
1089
 
834
1090
  const actions = showFollowupActions
835
- ? `<label class="auto-followup-toggle" onclick="event.stopPropagation()" title="Active/désactive le followup automatique après un push">
836
- <input type="checkbox" ${autoFollowupChecked} onchange="toggleAutoFollowup('${mr.id}', this.checked)">
837
- Auto follow-up
1091
+ ? `<label class="auto-followup-toggle" onclick="event.stopPropagation()" title="${t('button.autoFollowup')}">
1092
+ <input type="checkbox" ${autoFollowupChecked} onchange="toggleAutoFollowup('${encodedMrId}', this.checked)">
1093
+ ${t('button.autoFollowup')}
838
1094
  </label>
839
- <button class="btn-action" onclick="triggerFollowup('${mr.id}')"><i data-lucide="refresh-cw"></i> Followup</button>${openBtn}`
1095
+ <button class="btn-action" onclick="triggerFollowup('${encodedMrId}')"><i data-lucide="refresh-cw"></i> ${t('button.followup')}</button>${openBtn}`
840
1096
  : openBtn;
841
1097
 
842
- // Stats badges
843
1098
  const statsBadges = [];
844
1099
  if (mr.totalBlocking > 0) statsBadges.push(`<span class="stat-badge blocking"><i data-lucide="octagon-alert"></i> ${mr.totalBlocking}</span>`);
845
1100
  if (mr.totalWarnings > 0) statsBadges.push(`<span class="stat-badge warning"><i data-lucide="alert-triangle"></i> ${mr.totalWarnings}</span>`);
@@ -848,22 +1103,20 @@
848
1103
 
849
1104
  const durationFormatted = mr.totalDurationMs ? formatDuration(null, null, mr.totalDurationMs) : '';
850
1105
 
851
- // Assigner info with avatar
852
1106
  const assignerUsername = mr.assignment?.username || 'unknown';
853
1107
  const assignerDisplay = mr.assignment?.displayName || assignerUsername;
854
1108
  const assignerInitial = assignerDisplay.charAt(0).toUpperCase();
855
1109
  const assignedAt = mr.assignment?.assignedAt ? formatTime(mr.assignment.assignedAt) : '';
856
1110
 
857
- // Sanitize ID for accordion toggle
858
1111
  const accordionId = mr.id.replace(/[^a-zA-Z0-9-]/g, '-');
859
1112
 
860
1113
  return `
861
1114
  <div class="mr-item-accordion" data-mr-id="${mr.id}">
862
- <div class="mr-item-header" onclick="toggleMrAccordion('${accordionId}')">
1115
+ <div class="mr-item-header" onclick="toggleMrAccordion('${accordionId}')" role="button" tabindex="0" onkeydown="activateOnKeydown(event)">
863
1116
  <div class="review-status ${type === 'pending-fix' ? 'running' : (type === 'pending-approval' && mr.totalWarnings > 0) ? 'warnings' : 'completed'}"></div>
864
1117
  <div class="mr-info">
865
1118
  <div class="mr-title">
866
- <a href="${mr.url}" target="_blank" onclick="event.stopPropagation()">${mrPrefix}${mr.mrNumber}</a> - ${mr.title}
1119
+ <a href="${sanitizeHttpUrl(mr.url)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${mrPrefix}${mr.mrNumber}</a> - ${escapeHtml(String(mr.title ?? ''))}
867
1120
  </div>
868
1121
  <div class="mr-meta">
869
1122
  ${threadInfo}
@@ -873,43 +1126,43 @@
873
1126
  </div>
874
1127
  <div class="mr-assigner">
875
1128
  <div class="mr-assigner-info">
876
- <span class="mr-assigner-name">${assignerDisplay}</span>
1129
+ <span class="mr-assigner-name">${escapeHtml(assignerDisplay)}</span>
877
1130
  <span class="mr-assigner-time">${assignedAt}</span>
878
1131
  </div>
879
- <div class="mr-avatar" title="${assignerDisplay}">${assignerInitial}</div>
1132
+ <div class="mr-avatar" title="${escapeHtml(assignerDisplay)}">${escapeHtml(assignerInitial)}</div>
880
1133
  </div>
881
1134
  <div class="mr-toggle"><i data-lucide="chevron-down"></i></div>
882
1135
  </div>
883
1136
  <div class="mr-item-content" id="mr-content-${accordionId}">
884
1137
  <div class="mr-details">
885
1138
  <div class="mr-detail-row">
886
- <span class="mr-detail-label"><i data-lucide="git-branch"></i> Source:</span>
887
- <span class="mr-detail-value">${mr.sourceBranch}</span>
1139
+ <span class="mr-detail-label"><i data-lucide="git-branch"></i> ${t('mr.detail.source')}</span>
1140
+ <span class="mr-detail-value">${escapeHtml(String(mr.sourceBranch ?? ''))}</span>
888
1141
  </div>
889
1142
  <div class="mr-detail-row">
890
- <span class="mr-detail-label"><i data-lucide="git-merge"></i> Target:</span>
891
- <span class="mr-detail-value">${mr.targetBranch}</span>
1143
+ <span class="mr-detail-label"><i data-lucide="git-merge"></i> ${t('mr.detail.target')}</span>
1144
+ <span class="mr-detail-value">${escapeHtml(String(mr.targetBranch ?? ''))}</span>
892
1145
  </div>
893
1146
  <div class="mr-detail-row">
894
- <span class="mr-detail-label"><i data-lucide="calendar"></i> Créée:</span>
1147
+ <span class="mr-detail-label"><i data-lucide="calendar"></i> ${t('mr.detail.created')}</span>
895
1148
  <span class="mr-detail-value">${formatTime(mr.createdAt)}</span>
896
1149
  </div>
897
1150
  ${mr.lastReviewAt ? `
898
1151
  <div class="mr-detail-row">
899
- <span class="mr-detail-label"><i data-lucide="file-search"></i> Dernière review:</span>
1152
+ <span class="mr-detail-label"><i data-lucide="file-search"></i> ${t('mr.detail.lastReview')}</span>
900
1153
  <span class="mr-detail-value">${formatTime(mr.lastReviewAt)}</span>
901
1154
  </div>
902
1155
  ` : ''}
903
1156
  ${mr.reviews?.length ? `
904
1157
  <div class="mr-reviews-history">
905
- <div class="mr-detail-label"><i data-lucide="history"></i> Historique (${mr.reviews.length}):</div>
1158
+ <div class="mr-detail-label"><i data-lucide="history"></i> ${t('mr.detail.history', { count: mr.reviews.length })}</div>
906
1159
  <div class="mr-reviews-list">
907
1160
  ${mr.reviews.slice(-5).reverse().map(r => `
908
1161
  <div class="mr-review-event ${r.type}">
909
- <span class="review-event-type">${r.type === 'review' ? 'Review' : 'Followup'}</span>
1162
+ <span class="review-event-type">${r.type === 'review' ? t('review.type.review') : t('review.type.followup')}</span>
910
1163
  <span class="review-event-time">${formatTime(r.timestamp)}</span>
911
1164
  ${r.score !== null ? `<span class="review-event-score">${r.score}/10</span>` : ''}
912
- ${r.blocking > 0 ? `<span class="review-event-blocking">${r.blocking} bloquant${r.blocking > 1 ? 's' : ''}</span>` : ''}
1165
+ ${r.blocking > 0 ? `<span class="review-event-blocking">${t('mr.review.blocking', { count: r.blocking })}</span>` : ''}
913
1166
  </div>
914
1167
  `).join('')}
915
1168
  </div>
@@ -924,6 +1177,121 @@
924
1177
  `;
925
1178
  }
926
1179
 
1180
+ function renderNowLane(mr) {
1181
+ const mrPrefix = mr.platform === 'github' ? '#' : '!';
1182
+ const assigneeUsername = mr.assignment?.username || 'unknown';
1183
+ const assigneeDisplay = mr.assignment?.displayName || assigneeUsername;
1184
+ const ownerLabel = t('lane.owner', { owner: assigneeDisplay });
1185
+ const qualityScore = typeof mr.latestScore === 'number' ? mr.latestScore : null;
1186
+ const qualityProgress = getQualityProgress(qualityScore, QUALITY_TARGET_SCORE);
1187
+ const qualityTarget = qualityProgress.qualityTarget;
1188
+ const qualityDelta = qualityProgress.targetDelta;
1189
+ const qualityState = qualityScore === null
1190
+ ? 'unknown'
1191
+ : qualityScore >= QUALITY_TARGET_SCORE
1192
+ ? 'excellent'
1193
+ : qualityScore >= 6
1194
+ ? 'warning'
1195
+ : 'critical';
1196
+ const qualityLabel = qualityScore === null
1197
+ ? t('quality.notAvailable')
1198
+ : qualityScore >= 10
1199
+ ? t('quality.perfect')
1200
+ : qualityScore >= QUALITY_TARGET_SCORE
1201
+ ? t('quality.onTarget')
1202
+ : qualityScore >= 6
1203
+ ? t('quality.belowTarget')
1204
+ : t('quality.lovableQuality');
1205
+ const deltaLabel = qualityProgress.targetDeltaLabel;
1206
+ const qualityTrend = getQualityTrend(mr);
1207
+ const qualityTrendLabel = qualityTrend.direction === 'up'
1208
+ ? t('quality.trendUp', { delta: qualityTrend.label })
1209
+ : qualityTrend.direction === 'down'
1210
+ ? t('quality.trendDown', { delta: qualityTrend.label })
1211
+ : qualityTrend.direction === 'flat'
1212
+ ? t('quality.trendFlat')
1213
+ : t('quality.trendUnknown');
1214
+ const qualityTrendIcon = qualityTrend.direction === 'up'
1215
+ ? 'trending-up'
1216
+ : qualityTrend.direction === 'down'
1217
+ ? 'trending-down'
1218
+ : qualityTrend.direction === 'flat'
1219
+ ? 'minus'
1220
+ : 'circle-help';
1221
+
1222
+ return `
1223
+ <div class="now-lane">
1224
+ <div class="now-lane-copy">
1225
+ <span class="now-lane-kicker">${t('lane.nowKicker')}</span>
1226
+ <div class="now-lane-title">${mrPrefix}${mr.mrNumber} - ${escapeHtml(String(mr.title ?? ''))}</div>
1227
+ <div class="now-lane-meta">${t('lane.nowMeta', { count: mr.openThreads })}</div>
1228
+ <div class="now-lane-owner">${escapeHtml(ownerLabel)}</div>
1229
+ </div>
1230
+ <div class="now-lane-quality ${qualityState}">
1231
+ <span class="quality-kicker">${t('quality.kicker')}</span>
1232
+ <div class="quality-main">
1233
+ <span class="quality-score">${qualityScore !== null ? qualityScore.toFixed(1) : '-'}</span>
1234
+ <span class="quality-over">/10</span>
1235
+ </div>
1236
+ <div class="quality-target">${t('quality.target', { target: qualityTarget })} ${deltaLabel ? `(${deltaLabel})` : ''}</div>
1237
+ <div class="quality-progress">
1238
+ <div class="quality-progress-track">
1239
+ <div class="quality-progress-fill ${qualityState}" style="width: ${qualityProgress.clampedProgressPercent}%"></div>
1240
+ </div>
1241
+ <span class="quality-progress-value">${qualityProgress.progressPercent !== null ? `${qualityProgress.clampedProgressPercent.toFixed(0)}%` : '-'}</span>
1242
+ </div>
1243
+ <div class="quality-label">${qualityLabel}</div>
1244
+ <div class="quality-trend ${qualityTrend.direction}">
1245
+ <i data-lucide="${qualityTrendIcon}"></i>
1246
+ <span>${qualityTrendLabel}</span>
1247
+ </div>
1248
+ <div class="now-lane-actions">
1249
+ <button class="btn-action" onclick="triggerFollowup('${encodeURIComponent(String(mr.id ?? ''))}')"><i data-lucide="refresh-cw"></i> ${t('button.followup')}</button>
1250
+ <a href="${sanitizeHttpUrl(mr.url)}" target="_blank" rel="noopener noreferrer" class="btn-action open" onclick="return onUsefulLinkAction()"><i data-lucide="external-link"></i> ${t('button.open')}</a>
1251
+ </div>
1252
+ </div>
1253
+ </div>
1254
+ `;
1255
+ }
1256
+
1257
+ function renderQueueLanes(queueLanesModel) {
1258
+ const nowLaneContent = queueLanesModel.nowLaneItem
1259
+ ? renderNowLane(queueLanesModel.nowLaneItem)
1260
+ : `<div class="empty-state">${t('queueLane.emptyNow')}</div>`;
1261
+ const needsFixContent = queueLanesModel.needsFixItems.length > 0
1262
+ ? queueLanesModel.needsFixItems.map((mergeRequest) => renderMrItem(mergeRequest, 'pending-fix')).join('')
1263
+ : `<div class="empty-state">${t('queueLane.emptyNeedsFix')}</div>`;
1264
+ const readyToApproveContent = queueLanesModel.readyToApproveItems.length > 0
1265
+ ? queueLanesModel.readyToApproveItems.map((mergeRequest) => renderMrItem(mergeRequest, 'pending-approval')).join('')
1266
+ : `<div class="empty-state">${t('queueLane.emptyReadyToApprove')}</div>`;
1267
+
1268
+ return `
1269
+ <div class="queue-lanes-grid">
1270
+ <div class="queue-lane">
1271
+ <div class="queue-lane-header">
1272
+ <span class="queue-lane-title">${t('queueLane.now')}</span>
1273
+ <span class="queue-lane-count">${queueLanesModel.nowLaneCount}</span>
1274
+ </div>
1275
+ <div class="queue-lane-body">${nowLaneContent}</div>
1276
+ </div>
1277
+ <div class="queue-lane">
1278
+ <div class="queue-lane-header">
1279
+ <span class="queue-lane-title">${t('queueLane.needsFix')}</span>
1280
+ <span class="queue-lane-count">${queueLanesModel.needsFixCount}</span>
1281
+ </div>
1282
+ <div class="queue-lane-body">${needsFixContent}</div>
1283
+ </div>
1284
+ <div class="queue-lane">
1285
+ <div class="queue-lane-header">
1286
+ <span class="queue-lane-title">${t('queueLane.readyToApprove')}</span>
1287
+ <span class="queue-lane-count">${queueLanesModel.readyToApproveCount}</span>
1288
+ </div>
1289
+ <div class="queue-lane-body">${readyToApproveContent}</div>
1290
+ </div>
1291
+ </div>
1292
+ `;
1293
+ }
1294
+
927
1295
  function toggleMrAccordion(accordionId) {
928
1296
  const content = document.getElementById(`mr-content-${accordionId}`);
929
1297
  const accordion = content?.closest('.mr-item-accordion');
@@ -931,7 +1299,6 @@
931
1299
 
932
1300
  const isOpen = accordion.classList.contains('open');
933
1301
 
934
- // Close all other MR accordions
935
1302
  document.querySelectorAll('.mr-item-accordion.open').forEach(el => {
936
1303
  if (el !== accordion) {
937
1304
  el.classList.remove('open');
@@ -950,43 +1317,40 @@
950
1317
  const pendingFixSection = document.getElementById('pending-fix-section');
951
1318
  const pendingFixEl = document.getElementById('pending-fix-reviews');
952
1319
  const pendingApprovalSection = document.getElementById('pending-approval-section');
953
- const pendingApprovalEl = document.getElementById('pending-approval-reviews');
954
1320
  const pendingFixCount = document.getElementById('pending-fix-count');
955
1321
  const pendingApprovalCount = document.getElementById('pending-approval-count');
956
- const mrLabel = getMrLabel();
957
1322
 
958
- // Pending fix - show/hide section dynamically
959
- if (currentData.pendingFix.length === 0) {
960
- pendingFixSection.classList.add('hidden');
961
- pendingFixCount.classList.add('hidden');
1323
+ pendingFixSection.classList.remove('hidden');
1324
+ const rankedPendingFix = rankPendingFixForNowLane(currentData.pendingFix);
1325
+ const queueLanesModel = buildQueueLanesModel(rankedPendingFix, currentData.pendingApproval);
1326
+ sessionMetrics = updatePriorityItemTracking(sessionMetrics, {
1327
+ nowLaneItemId: queueLanesModel.nowLaneItem?.id ?? null,
1328
+ pendingFixIds: rankedPendingFix.map((mergeRequest) => String(mergeRequest.id)),
1329
+ nowMs: Date.now(),
1330
+ });
1331
+ updateSessionMetricsUI();
1332
+ if (queueLanesModel.nowLaneCount + queueLanesModel.needsFixCount + queueLanesModel.readyToApproveCount === 0) {
1333
+ pendingFixEl.innerHTML = `<div class="empty-state">${loadingState.mrTracking ? t('loading.section') : t('empty.pendingFix')}</div>`;
962
1334
  } else {
963
- pendingFixSection.classList.remove('hidden');
964
- pendingFixEl.innerHTML = currentData.pendingFix.map(mr => renderMrItem(mr, 'pending-fix')).join('');
965
- pendingFixCount.textContent = currentData.pendingFix.length;
966
- pendingFixCount.classList.remove('hidden');
1335
+ pendingFixEl.innerHTML = renderQueueLanes(queueLanesModel);
967
1336
  }
1337
+ pendingFixCount.textContent = String(queueLanesModel.nowLaneCount + queueLanesModel.needsFixCount + queueLanesModel.readyToApproveCount);
1338
+ pendingFixCount.classList.remove('hidden');
968
1339
 
969
- // Pending approval - show/hide section dynamically
970
- if (currentData.pendingApproval.length === 0) {
971
- pendingApprovalSection.classList.add('hidden');
972
- pendingApprovalCount.classList.add('hidden');
973
- } else {
974
- pendingApprovalSection.classList.remove('hidden');
975
- pendingApprovalEl.innerHTML = currentData.pendingApproval.map(mr => renderMrItem(mr, 'pending-approval')).join('');
976
- pendingApprovalCount.textContent = currentData.pendingApproval.length;
977
- pendingApprovalCount.classList.remove('hidden');
978
- }
1340
+ pendingApprovalSection.classList.add('hidden');
1341
+ pendingApprovalCount.classList.add('hidden');
979
1342
 
980
1343
  refreshIcons();
981
1344
  }
982
1345
 
983
1346
  async function fetchMrTracking() {
1347
+ setLoadingFlag('mrTracking', true);
984
1348
  if (!currentProjectPath) {
985
1349
  currentData.pendingFix = [];
986
1350
  currentData.pendingApproval = [];
987
- // Hide sections when no project loaded
988
1351
  document.getElementById('pending-fix-section').classList.add('hidden');
989
1352
  document.getElementById('pending-approval-section').classList.add('hidden');
1353
+ setLoadingFlag('mrTracking', false);
990
1354
  return;
991
1355
  }
992
1356
 
@@ -1000,10 +1364,14 @@
1000
1364
  updateMrTrackingUI();
1001
1365
  } catch (error) {
1002
1366
  console.error('Error fetching MR tracking:', error);
1367
+ } finally {
1368
+ setLoadingFlag('mrTracking', false);
1003
1369
  }
1004
1370
  }
1005
1371
 
1006
- async function triggerFollowup(mrId) {
1372
+ async function triggerFollowup(encodedMrId) {
1373
+ const mrId = safeDecodeURIComponent(encodedMrId);
1374
+ trackUsefulAction('followup');
1007
1375
  try {
1008
1376
  const response = await fetch(`${API_URL}/api/mr-tracking/followup`, {
1009
1377
  method: 'POST',
@@ -1013,16 +1381,18 @@
1013
1381
  const data = await response.json();
1014
1382
  if (data.success) {
1015
1383
  console.log('Followup triggered for MR:', mrId);
1384
+ showToast(t('notify.followupRequested', { mrNumber: String(data.mrNumber ?? '?') }), 'info');
1016
1385
  } else {
1017
- alert('Erreur: ' + data.error);
1386
+ alert(t('error.triggerFollowup') + ': ' + data.error);
1018
1387
  }
1019
1388
  } catch (error) {
1020
1389
  console.error('Error triggering followup:', error);
1021
- alert('Erreur lors du déclenchement du followup');
1390
+ alert(t('error.triggerFollowup'));
1022
1391
  }
1023
1392
  }
1024
1393
 
1025
- async function toggleAutoFollowup(mrId, enabled) {
1394
+ async function toggleAutoFollowup(encodedMrId, enabled) {
1395
+ const mrId = safeDecodeURIComponent(encodedMrId);
1026
1396
  try {
1027
1397
  const response = await fetch(`${API_URL}/api/mr-tracking/auto-followup`, {
1028
1398
  method: 'POST',
@@ -1031,16 +1401,18 @@
1031
1401
  });
1032
1402
  const data = await response.json();
1033
1403
  if (!data.success) {
1034
- alert('Erreur: ' + data.error);
1404
+ alert(t('error.toggleAutoFollowup') + ': ' + data.error);
1035
1405
  }
1036
1406
  } catch (error) {
1037
1407
  console.error('Error toggling auto-followup:', error);
1038
- alert('Erreur lors du changement de auto follow-up');
1408
+ alert(t('error.toggleAutoFollowup'));
1039
1409
  }
1040
1410
  }
1041
1411
 
1042
- async function approveMr(mrId) {
1043
- if (!confirm(`Marquer cette ${getMrLabel()} comme approuvée ?`)) return;
1412
+ async function approveMr(encodedMrId) {
1413
+ const mrId = safeDecodeURIComponent(encodedMrId);
1414
+ if (!confirm(t('confirm.approveMr', { label: getMrLabel() }))) return;
1415
+ trackUsefulAction('approve');
1044
1416
 
1045
1417
  try {
1046
1418
  const response = await fetch(`${API_URL}/api/mr-tracking/approve`, {
@@ -1050,30 +1422,29 @@
1050
1422
  });
1051
1423
  const data = await response.json();
1052
1424
  if (data.success) {
1053
- // Remove from pending approval list
1054
1425
  currentData.pendingApproval = currentData.pendingApproval.filter(mr => mr.id !== mrId);
1055
1426
  updateMrTrackingUI();
1056
1427
  } else {
1057
- alert('Erreur: ' + data.error);
1428
+ alert(t('error.approveMr') + ': ' + data.error);
1058
1429
  }
1059
1430
  } catch (error) {
1060
1431
  console.error('Error approving MR:', error);
1061
- alert('Erreur lors de l\'approbation');
1432
+ alert(t('error.approveMr'));
1062
1433
  }
1063
1434
  }
1064
1435
 
1065
1436
  async function syncGitLabThreads() {
1066
1437
  if (!currentProjectPath) {
1067
- alert('Charger un projet d\'abord');
1438
+ alert(t('error.projectNotLoaded'));
1068
1439
  return;
1069
1440
  }
1441
+ trackUsefulAction('syncThreads');
1070
1442
 
1071
1443
  const btn = document.getElementById('sync-threads-btn');
1072
- const icon = btn.querySelector('i');
1444
+ const iconEl = btn.querySelector('i');
1073
1445
 
1074
- // Add spinning animation
1075
1446
  btn.disabled = true;
1076
- icon.classList.add('spinning');
1447
+ iconEl.classList.add('spinning');
1077
1448
 
1078
1449
  try {
1079
1450
  const response = await fetch(`${API_URL}/api/mr-tracking/sync`, {
@@ -1083,18 +1454,17 @@
1083
1454
  });
1084
1455
  const data = await response.json();
1085
1456
  if (data.success) {
1086
- // Refresh MR tracking data
1087
1457
  await fetchMrTracking();
1088
1458
  console.log('Threads synchronized successfully');
1089
1459
  } else {
1090
- alert('Erreur: ' + data.error);
1460
+ alert(t('error.syncThreads') + ': ' + data.error);
1091
1461
  }
1092
1462
  } catch (error) {
1093
1463
  console.error('Error syncing threads:', error);
1094
- alert('Erreur lors de la synchronisation des threads');
1464
+ alert(t('error.syncThreads'));
1095
1465
  } finally {
1096
1466
  btn.disabled = false;
1097
- icon.classList.remove('spinning');
1467
+ iconEl.classList.remove('spinning');
1098
1468
  }
1099
1469
  }
1100
1470
 
@@ -1127,7 +1497,7 @@
1127
1497
  function connectWebSocket() {
1128
1498
  if (ws?.readyState === WebSocket.OPEN) return;
1129
1499
 
1130
- updateConnectionStatus('connecting', 'Connexion...');
1500
+ updateConnectionStatus('connecting', 'status.connecting');
1131
1501
 
1132
1502
  try {
1133
1503
  ws = new WebSocket(WS_URL);
@@ -1135,7 +1505,7 @@
1135
1505
  ws.onopen = () => {
1136
1506
  wsConnected = true;
1137
1507
  reconnectAttempts = 0;
1138
- updateConnectionStatus('online', 'En ligne');
1508
+ updateConnectionStatus('online', 'connection.online');
1139
1509
  };
1140
1510
 
1141
1511
  ws.onmessage = (event) => {
@@ -1147,8 +1517,8 @@
1147
1517
  console.log(`[WS] ${message.type}: ${message.activeReviews?.length || 0} active reviews`, message.activeReviews);
1148
1518
  currentData.activeReviews = message.activeReviews || [];
1149
1519
  currentData.recentReviews = message.recentReviews || [];
1520
+ dispatchReviewNotifications(currentData.activeReviews, currentData.recentReviews);
1150
1521
  updateUI();
1151
- // Also refresh MR tracking when state changes
1152
1522
  if (message.type === 'state') {
1153
1523
  fetchMrTracking();
1154
1524
  }
@@ -1170,7 +1540,7 @@
1170
1540
  ws.onclose = () => {
1171
1541
  wsConnected = false;
1172
1542
  ws = null;
1173
- updateConnectionStatus('offline', 'Hors ligne');
1543
+ updateConnectionStatus('offline', 'connection.offline');
1174
1544
  if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
1175
1545
  reconnectAttempts++;
1176
1546
  setTimeout(connectWebSocket, RECONNECT_DELAY);
@@ -1192,22 +1562,27 @@
1192
1562
  }, 30000);
1193
1563
 
1194
1564
  async function fetchStatus() {
1565
+ setLoadingFlag('status', true);
1195
1566
  try {
1196
1567
  const response = await fetch(`${API_URL}/api/status`);
1197
1568
  const data = await response.json();
1198
1569
  currentData.activeReviews = data.jobs?.active || [];
1199
1570
  currentData.recentReviews = data.jobs?.recent || [];
1200
- if (!wsConnected) updateConnectionStatus('online', 'En ligne (polling)');
1571
+ dispatchReviewNotifications(currentData.activeReviews, currentData.recentReviews);
1572
+ if (!wsConnected) updateConnectionStatus('online', 'connection.onlinePolling');
1201
1573
  updateUI();
1202
1574
  } catch (error) {
1203
1575
  if (!wsConnected) {
1204
- updateConnectionStatus('offline', 'Hors ligne');
1576
+ updateConnectionStatus('offline', 'connection.offline');
1205
1577
  document.getElementById('running-count').textContent = '-';
1206
1578
  document.getElementById('queued-count').textContent = '-';
1207
1579
  document.getElementById('completed-count').textContent = '-';
1208
- document.getElementById('active-reviews').innerHTML = '<div class="empty-state">Serveur non accessible</div>';
1209
- document.getElementById('recent-reviews').innerHTML = '<div class="empty-state">Serveur non accessible</div>';
1580
+ document.getElementById('active-reviews').innerHTML = `<div class="empty-state">${t('empty.serverNotAccessible')}</div>`;
1581
+ document.getElementById('recent-reviews').innerHTML = `<div class="empty-state">${t('empty.serverNotAccessible')}</div>`;
1210
1582
  }
1583
+ } finally {
1584
+ hasLoadedStatusOnce = true;
1585
+ setLoadingFlag('status', false);
1211
1586
  }
1212
1587
  }
1213
1588
 
@@ -1240,9 +1615,48 @@
1240
1615
  }
1241
1616
  }
1242
1617
 
1243
- // Project config loader with persistence
1244
- const STORAGE_KEY_PROJECTS = 'review-flow-projects';
1245
- const STORAGE_KEY_CURRENT = 'review-flow-current-project';
1618
+ async function loadLanguageSetting() {
1619
+ try {
1620
+ const response = await fetch(`${API_URL}/api/settings`);
1621
+ const data = await response.json();
1622
+ const select = document.getElementById('language-select');
1623
+ if (select && data.language) {
1624
+ select.value = data.language;
1625
+ setLanguage(data.language);
1626
+ document.documentElement.lang = data.language;
1627
+ renderStaticLabels();
1628
+ }
1629
+ } catch (error) {
1630
+ console.error('Error loading language setting:', error);
1631
+ }
1632
+ }
1633
+
1634
+ async function changeLanguage(language) {
1635
+ setLanguage(language);
1636
+ document.documentElement.lang = language;
1637
+ renderStaticLabels();
1638
+ // Re-render all dynamic content
1639
+ updateUI();
1640
+ updateLogs();
1641
+ updateReviewFilesUI();
1642
+ updateMrTrackingUI();
1643
+ updateGitCliUI();
1644
+ // Also persist on server
1645
+ try {
1646
+ const response = await fetch(`${API_URL}/api/settings/language`, {
1647
+ method: 'POST',
1648
+ headers: { 'Content-Type': 'application/json' },
1649
+ body: JSON.stringify({ language })
1650
+ });
1651
+ const data = await response.json();
1652
+ if (data.success) {
1653
+ console.log('Language changed to:', language);
1654
+ }
1655
+ } catch (error) {
1656
+ console.error('Error changing language:', error);
1657
+ }
1658
+ }
1659
+
1246
1660
  let currentProjectConfig = null;
1247
1661
  let currentProjectPath = null;
1248
1662
 
@@ -1260,11 +1674,8 @@
1260
1674
 
1261
1675
  function addProjectToHistory(path) {
1262
1676
  const projects = getStoredProjects();
1263
- // Remove if already exists (to move to top)
1264
1677
  const filtered = projects.filter(p => p !== path);
1265
- // Add to beginning
1266
1678
  filtered.unshift(path);
1267
- // Keep max 10 projects
1268
1679
  saveProjects(filtered.slice(0, 10));
1269
1680
  updateProjectSelect();
1270
1681
  }
@@ -1280,7 +1691,7 @@
1280
1691
  const projects = getStoredProjects();
1281
1692
  const current = localStorage.getItem(STORAGE_KEY_CURRENT) || '';
1282
1693
 
1283
- select.innerHTML = '<option value="">-- Sélectionner un projet --</option>';
1694
+ select.innerHTML = `<option value="">${t('project.selectPlaceholder')}</option>`;
1284
1695
  for (const path of projects) {
1285
1696
  const shortName = path.split('/').slice(-2).join('/');
1286
1697
  const option = document.createElement('option');
@@ -1302,11 +1713,10 @@
1302
1713
  async function loadProjectConfig() {
1303
1714
  const input = document.getElementById('project-path-input');
1304
1715
  const select = document.getElementById('project-select');
1305
- // Prioritize input field, fallback to select
1306
1716
  const projectPath = input.value.trim() || select.value;
1307
1717
 
1308
1718
  if (!projectPath) {
1309
- showConfigStatus('Sélectionnez ou entrez un chemin', 'error');
1719
+ showConfigStatus(t('error.selectOrEnterPath'), 'error');
1310
1720
  return;
1311
1721
  }
1312
1722
 
@@ -1317,7 +1727,7 @@
1317
1727
  const status = document.getElementById('config-status');
1318
1728
  const info = document.getElementById('config-info');
1319
1729
 
1320
- showConfigStatus('Chargement...', 'loading');
1730
+ showConfigStatus(t('status.loading'), 'loading');
1321
1731
  info.classList.add('hidden');
1322
1732
 
1323
1733
  try {
@@ -1328,19 +1738,16 @@
1328
1738
  currentProjectConfig = data.config;
1329
1739
  currentProjectPath = projectPath;
1330
1740
 
1331
- // Save to history and set as current
1332
1741
  addProjectToHistory(projectPath);
1333
1742
  localStorage.setItem(STORAGE_KEY_CURRENT, projectPath);
1334
1743
 
1335
- // Update select to show current project
1336
1744
  document.getElementById('project-select').value = projectPath;
1337
1745
  document.getElementById('project-path-input').value = '';
1338
1746
 
1339
1747
  const shortName = projectPath.split('/').slice(-2).join('/');
1340
- showConfigStatus(`<i data-lucide="check-circle"></i> ${shortName}`, 'success');
1748
+ showConfigStatus(`<i data-lucide="check-circle"></i> ${escapeHtml(shortName)}`, 'success');
1341
1749
  refreshIcons();
1342
1750
 
1343
- // Update model selector if defaultModel is set
1344
1751
  if (data.config.defaultModel) {
1345
1752
  const modelSelect = document.getElementById('model-select');
1346
1753
  if (modelSelect) {
@@ -1349,34 +1756,30 @@
1349
1756
  }
1350
1757
  }
1351
1758
 
1352
- // Update active platform and check CLI
1353
1759
  activePlatform = data.config.gitlab ? 'gitlab' : (data.config.github ? 'github' : null);
1354
1760
  updateGitCliUI();
1355
1761
 
1356
- // Reload reviews, stats, and MR tracking for this project
1357
1762
  fetchReviewFiles();
1358
1763
  fetchProjectStats();
1359
1764
  fetchMrTracking();
1360
1765
 
1361
- // Show stats section when project is loaded
1362
1766
  document.getElementById('stats-section').classList.remove('hidden');
1363
1767
 
1364
- // Display config info (only show active platform)
1365
1768
  const platformIcon = activePlatform === 'gitlab' ? '<i data-lucide="gitlab"></i> GitLab' : '<i data-lucide="github"></i> GitHub';
1366
1769
  info.innerHTML = `
1367
1770
  <span>${platformIcon}</span>
1368
- <span><i data-lucide="bot"></i> ${data.config.defaultModel || 'non défini'}</span>
1369
- <span><i data-lucide="file-text"></i> ${data.config.reviewSkill}</span>
1370
- <span><i data-lucide="refresh-cw"></i> ${data.config.reviewFollowupSkill}</span>
1771
+ <span><i data-lucide="bot"></i> ${escapeHtml(String(data.config.defaultModel || t('status.undefined')))}</span>
1772
+ <span><i data-lucide="file-text"></i> ${escapeHtml(String(data.config.reviewSkill ?? ''))}</span>
1773
+ <span><i data-lucide="refresh-cw"></i> ${escapeHtml(String(data.config.reviewFollowupSkill ?? ''))}</span>
1371
1774
  `;
1372
1775
  info.classList.remove('hidden');
1373
1776
  refreshIcons();
1374
1777
  } else {
1375
- showConfigStatus('<i data-lucide="x-circle"></i> ' + data.error, 'error');
1778
+ showConfigStatus('<i data-lucide="x-circle"></i> ' + escapeHtml(String(data.error ?? '')), 'error');
1376
1779
  refreshIcons();
1377
1780
  }
1378
1781
  } catch (error) {
1379
- showConfigStatus('<i data-lucide="x-circle"></i> Erreur de chargement', 'error');
1782
+ showConfigStatus(`<i data-lucide="x-circle"></i> ${t('error.loadingConfig')}`, 'error');
1380
1783
  refreshIcons();
1381
1784
  console.error('Error loading project config:', error);
1382
1785
  }
@@ -1394,56 +1797,43 @@
1394
1797
  const select = document.getElementById('project-select');
1395
1798
  const path = select.value;
1396
1799
  if (!path) {
1397
- showConfigStatus('Aucun projet sélectionné', 'error');
1800
+ showConfigStatus(t('project.noProjectSelected'), 'error');
1398
1801
  return;
1399
1802
  }
1400
- if (confirm(`Retirer "${path.split('/').slice(-2).join('/')}" de la liste ?`)) {
1803
+ const shortName = path.split('/').slice(-2).join('/');
1804
+ if (confirm(t('confirm.removeProject', { name: shortName }))) {
1401
1805
  removeProjectFromHistory(path);
1402
1806
  localStorage.removeItem(STORAGE_KEY_CURRENT);
1403
1807
  currentProjectPath = null;
1404
1808
  currentProjectConfig = null;
1405
1809
  document.getElementById('config-info').classList.add('hidden');
1406
- showConfigStatus('Projet retiré', 'success');
1810
+ showConfigStatus(t('project.removed'), 'success');
1407
1811
  }
1408
1812
  }
1409
1813
 
1410
1814
  function initProjectLoader() {
1411
1815
  updateProjectSelect();
1412
- // Auto-load last project
1413
1816
  const lastProject = localStorage.getItem(STORAGE_KEY_CURRENT);
1414
1817
  if (lastProject) {
1415
1818
  loadProjectConfigFromPath(lastProject);
1416
1819
  } else {
1417
- // No project loaded - show empty state
1418
1820
  document.getElementById('recent-reviews').innerHTML =
1419
- '<div class="empty-state">Charger un projet pour voir les reviews</div>';
1821
+ `<div class="empty-state">${t('empty.reviewsNoProject')}</div>`;
1420
1822
  document.getElementById('project-stats').innerHTML =
1421
- '<div class="empty-state">Charger un projet pour voir les stats</div>';
1823
+ `<div class="empty-state">${t('empty.statsNoProject')}</div>`;
1422
1824
  }
1423
1825
  }
1424
1826
 
1425
- // Lucide icon helper - call after dynamic content is added
1426
- function refreshIcons() {
1427
- lucide.createIcons();
1428
- }
1429
-
1430
- // Icon helper for dynamic HTML
1431
- function icon(name, className = '') {
1432
- return `<i data-lucide="${name}" class="${className}"></i>`;
1433
- }
1434
-
1435
- // Platform-aware MR/PR label
1436
1827
  function getMrLabel(platform = activePlatform) {
1437
1828
  return platform === 'github' ? 'PR' : 'MR';
1438
1829
  }
1439
1830
 
1440
- // Cancel review modal state
1441
1831
  let cancelModalJobId = null;
1442
1832
 
1443
- function showCancelModal(jobId, mrNumber, jobType) {
1444
- cancelModalJobId = jobId;
1445
- const typeLabel = jobType === 'followup' ? 'followup' : 'review';
1446
- document.getElementById('cancel-modal-title').textContent = `Annuler la ${typeLabel} de la ${getMrLabel()} !${mrNumber} (${typeLabel}) ?`;
1833
+ function showCancelModal(encodedJobId, mrNumber, jobType) {
1834
+ cancelModalJobId = safeDecodeURIComponent(encodedJobId);
1835
+ const typeLabel = jobType === 'followup' ? t('review.type.followup').toLowerCase() : t('review.type.review').toLowerCase();
1836
+ document.getElementById('cancel-modal-title').textContent = t('modal.cancel.title', { type: typeLabel, label: getMrLabel(), number: mrNumber });
1447
1837
  const modal = document.getElementById('cancel-modal');
1448
1838
  modal.classList.remove('hidden');
1449
1839
  requestAnimationFrame(() => modal.classList.add('visible'));
@@ -1459,6 +1849,7 @@
1459
1849
 
1460
1850
  async function confirmCancelReview() {
1461
1851
  if (!cancelModalJobId) return;
1852
+ trackUsefulAction('cancelReview');
1462
1853
 
1463
1854
  const jobId = cancelModalJobId;
1464
1855
  closeCancelModal();
@@ -1477,17 +1868,17 @@
1477
1868
  if (data.success) {
1478
1869
  const card = document.querySelector(`.review-item[data-job-id="${jobId}"]`);
1479
1870
  if (card) card.remove();
1480
- showToast('Review annulée', 'success');
1871
+ showToast(t('success.reviewCancelled'), 'success');
1481
1872
  fetchStatus();
1482
1873
  } else if (data.status === 'already-completed') {
1483
- showToast('Cette review est déjà terminée', 'info');
1874
+ showToast(t('success.reviewAlreadyCompleted'), 'info');
1484
1875
  fetchStatus();
1485
1876
  } else {
1486
- showToast(data.error || 'Erreur lors de l\'annulation', 'error');
1877
+ showToast(data.error || t('error.cancelReview'), 'error');
1487
1878
  }
1488
1879
  } catch (error) {
1489
1880
  console.error('Error cancelling review:', error);
1490
- showToast('Erreur lors de l\'annulation', 'error');
1881
+ showToast(t('error.cancelReview'), 'error');
1491
1882
  }
1492
1883
  }
1493
1884
 
@@ -1506,12 +1897,202 @@
1506
1897
  }, 3000);
1507
1898
  }
1508
1899
 
1900
+ function renderStaticLabels() {
1901
+ // Header buttons
1902
+ const checkClaudeSpan = document.getElementById('i18n-check-claude');
1903
+ if (checkClaudeSpan) checkClaudeSpan.textContent = t('header.checkClaude');
1904
+
1905
+ const logsBtnSpan = document.getElementById('i18n-logs-btn');
1906
+ if (logsBtnSpan) logsBtnSpan.textContent = logsVisible ? t('header.hideLogs') : t('header.logs');
1907
+
1908
+ const serverStatusSpan = document.getElementById('i18n-server-status');
1909
+ if (serverStatusSpan) serverStatusSpan.textContent = t('status.connecting');
1910
+
1911
+ // Cards
1912
+ const cardRunning = document.getElementById('i18n-card-running');
1913
+ if (cardRunning) cardRunning.textContent = t('card.running');
1914
+
1915
+ const cardQueued = document.getElementById('i18n-card-queued');
1916
+ if (cardQueued) cardQueued.textContent = t('card.queued');
1917
+
1918
+ const cardCompleted = document.getElementById('i18n-card-completed');
1919
+ if (cardCompleted) cardCompleted.textContent = t('card.completed');
1920
+
1921
+ const cardClaudeCli = document.getElementById('i18n-card-claude-cli');
1922
+ if (cardClaudeCli) cardClaudeCli.textContent = t('card.claudeCli');
1923
+
1924
+ const claudeChecking = document.getElementById('i18n-claude-checking');
1925
+ if (claudeChecking) claudeChecking.textContent = t('status.checking');
1926
+
1927
+ const gitLoadProject = document.getElementById('i18n-git-load-project');
1928
+ if (gitLoadProject) gitLoadProject.textContent = t('status.loadProject');
1929
+
1930
+ const gitCliLabel = document.getElementById('git-cli-label');
1931
+ if (gitCliLabel && !activePlatform) gitCliLabel.textContent = t('card.gitCli');
1932
+
1933
+ const cardModel = document.getElementById('i18n-card-model');
1934
+ if (cardModel) cardModel.textContent = t('card.model');
1935
+
1936
+ const cardLanguage = document.getElementById('i18n-card-language');
1937
+ if (cardLanguage) cardLanguage.textContent = t('card.language');
1938
+
1939
+ const stripNow = document.getElementById('i18n-strip-now');
1940
+ if (stripNow) stripNow.textContent = t('strip.now');
1941
+ const stripNowMeta = document.getElementById('i18n-strip-now-meta');
1942
+ if (stripNowMeta) stripNowMeta.textContent = t('strip.nowMeta');
1943
+
1944
+ const stripNext = document.getElementById('i18n-strip-next');
1945
+ if (stripNext) stripNext.textContent = t('strip.next');
1946
+ const stripNextMeta = document.getElementById('i18n-strip-next-meta');
1947
+ if (stripNextMeta) stripNextMeta.textContent = t('strip.nextMeta');
1948
+
1949
+ const stripBlocked = document.getElementById('i18n-strip-blocked');
1950
+ if (stripBlocked) stripBlocked.textContent = t('strip.blocked');
1951
+ const stripBlockedMeta = document.getElementById('i18n-strip-blocked-meta');
1952
+ if (stripBlockedMeta) stripBlockedMeta.textContent = t('strip.blockedMeta');
1953
+ applyFocusStripMode();
1954
+
1955
+ const loadingDataLabel = document.getElementById('i18n-loading-data');
1956
+ if (loadingDataLabel) loadingDataLabel.textContent = t(getLoadingMessageKey());
1957
+ updateSessionMetricsUI();
1958
+
1959
+ // Model options
1960
+ const modelOpus = document.getElementById('i18n-model-opus');
1961
+ if (modelOpus) modelOpus.textContent = t('model.opus');
1962
+
1963
+ const modelSonnet = document.getElementById('i18n-model-sonnet');
1964
+ if (modelSonnet) modelSonnet.textContent = t('model.sonnet');
1965
+
1966
+ // Project loader
1967
+ const projectPlaceholder = document.getElementById('i18n-project-placeholder');
1968
+ if (projectPlaceholder) projectPlaceholder.textContent = t('project.selectPlaceholder');
1969
+
1970
+ const projectPathInput = document.getElementById('project-path-input');
1971
+ if (projectPathInput) projectPathInput.placeholder = t('project.inputPlaceholder');
1972
+
1973
+ const projectLoad = document.getElementById('i18n-project-load');
1974
+ if (projectLoad) projectLoad.textContent = t('project.load');
1975
+
1976
+ const removeProjectBtn = document.getElementById('remove-project-btn');
1977
+ if (removeProjectBtn) removeProjectBtn.title = t('project.removeTooltip');
1978
+
1979
+ // Login sections
1980
+ const claudeLoginTitle = document.getElementById('i18n-claude-login-title');
1981
+ if (claudeLoginTitle) claudeLoginTitle.textContent = t('login.claude.title');
1982
+
1983
+ const claudeLoginInstruction = document.getElementById('i18n-claude-login-instruction');
1984
+ if (claudeLoginInstruction) claudeLoginInstruction.textContent = t('login.claude.instruction');
1985
+
1986
+ const claudeLoginReload = document.getElementById('i18n-claude-login-reload');
1987
+ if (claudeLoginReload) claudeLoginReload.textContent = t('login.claude.reload');
1988
+
1989
+ const gitLoginTitle = document.getElementById('i18n-git-login-title');
1990
+ if (gitLoginTitle) gitLoginTitle.textContent = t('login.git.title');
1991
+
1992
+ // Section headers
1993
+ const sectionLogs = document.getElementById('i18n-section-logs');
1994
+ if (sectionLogs) sectionLogs.textContent = t('section.logs');
1995
+
1996
+ const sectionStats = document.getElementById('i18n-section-stats');
1997
+ if (sectionStats) sectionStats.textContent = t('section.stats');
1998
+
1999
+ const sectionActiveReviews = document.getElementById('i18n-section-active-reviews');
2000
+ if (sectionActiveReviews) sectionActiveReviews.textContent = t('section.activeReviews');
2001
+
2002
+ const sectionActiveFollowups = document.getElementById('i18n-section-active-followups');
2003
+ if (sectionActiveFollowups) sectionActiveFollowups.textContent = t('section.activeFollowups');
2004
+
2005
+ const sectionPendingFix = document.getElementById('i18n-section-pending-fix');
2006
+ if (sectionPendingFix) sectionPendingFix.textContent = t('section.queueLanes');
2007
+
2008
+ const sectionPendingApproval = document.getElementById('i18n-section-pending-approval');
2009
+ if (sectionPendingApproval) sectionPendingApproval.textContent = t('section.pendingApproval');
2010
+
2011
+ const sectionCompletedReviews = document.getElementById('i18n-section-completed-reviews');
2012
+ if (sectionCompletedReviews) sectionCompletedReviews.textContent = t('section.completedReviews');
2013
+
2014
+ // Empty states
2015
+ const emptyLogs = document.getElementById('i18n-empty-logs');
2016
+ if (emptyLogs) emptyLogs.textContent = t('empty.logs');
2017
+
2018
+ const emptyStats = document.getElementById('i18n-empty-stats');
2019
+ if (emptyStats) emptyStats.textContent = t('empty.stats');
2020
+
2021
+ const emptyActiveReviews = document.getElementById('i18n-empty-active-reviews');
2022
+ if (emptyActiveReviews) emptyActiveReviews.textContent = t('empty.activeReviews');
2023
+
2024
+ const emptyActiveFollowups = document.getElementById('i18n-empty-active-followups');
2025
+ if (emptyActiveFollowups) emptyActiveFollowups.textContent = t('empty.activeFollowups');
2026
+
2027
+ const emptyPendingFix = document.getElementById('i18n-empty-pending-fix');
2028
+ if (emptyPendingFix) emptyPendingFix.textContent = t('empty.pendingFix');
2029
+
2030
+ const emptyPendingApproval = document.getElementById('i18n-empty-pending-approval');
2031
+ if (emptyPendingApproval) emptyPendingApproval.textContent = t('empty.pendingApproval');
2032
+
2033
+ const emptyLoading = document.getElementById('i18n-empty-loading');
2034
+ if (emptyLoading) emptyLoading.textContent = t('status.loading');
2035
+
2036
+ // Sync threads button
2037
+ const syncThreadsBtn = document.getElementById('sync-threads-btn');
2038
+ if (syncThreadsBtn) syncThreadsBtn.title = t('button.syncThreads');
2039
+
2040
+ // Connection footer
2041
+ const connectionFallback = document.getElementById('i18n-connection-fallback');
2042
+ if (connectionFallback) connectionFallback.textContent = t('connection.fallback');
2043
+
2044
+ // Modal
2045
+ const modalMessage = document.getElementById('i18n-modal-message');
2046
+ if (modalMessage) modalMessage.textContent = t('modal.cancel.message');
2047
+
2048
+ const modalBack = document.getElementById('i18n-modal-back');
2049
+ if (modalBack) modalBack.textContent = t('modal.back');
2050
+
2051
+ const modalConfirm = document.getElementById('cancel-modal-confirm');
2052
+ if (modalConfirm) modalConfirm.textContent = t('modal.confirm');
2053
+
2054
+ // Update project select placeholder (re-render the select)
2055
+ updateProjectSelect();
2056
+ }
2057
+
2058
+ // Expose functions to HTML onclick handlers
2059
+ window.checkClaudeStatus = checkClaudeStatus;
2060
+ window.toggleLogs = toggleLogs;
2061
+ window.toggleStats = toggleStats;
2062
+ window.changeModel = changeModel;
2063
+ window.changeLanguage = changeLanguage;
2064
+ window.onProjectSelect = onProjectSelect;
2065
+ window.loadProjectConfig = loadProjectConfig;
2066
+ window.removeCurrentProject = removeCurrentProject;
2067
+ window.toggleReviewAccordion = toggleReviewAccordion;
2068
+ window.toggleReviewDescription = toggleReviewDescription;
2069
+ window.deleteReviewFile = deleteReviewFile;
2070
+ window.toggleMrAccordion = toggleMrAccordion;
2071
+ window.triggerFollowup = triggerFollowup;
2072
+ window.toggleAutoFollowup = toggleAutoFollowup;
2073
+ window.approveMr = approveMr;
2074
+ window.syncGitLabThreads = syncGitLabThreads;
2075
+ window.showCancelModal = showCancelModal;
2076
+ window.closeCancelModal = closeCancelModal;
2077
+ window.confirmCancelReview = confirmCancelReview;
2078
+ window.toggleFocusStripMode = toggleFocusStripMode;
2079
+ window.toggleSection = toggleSection;
2080
+ window.onUsefulLinkAction = onUsefulLinkAction;
2081
+ window.activateOnKeydown = activateOnKeydown;
2082
+
1509
2083
  // Init
2084
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
2085
+ Notification.requestPermission().catch(() => {});
2086
+ }
2087
+ loadFocusStripMode();
2088
+ renderStaticLabels();
2089
+ updateSessionMetricsUI();
1510
2090
  connectWebSocket();
1511
2091
  fetchStatus();
1512
2092
  checkClaudeStatus();
1513
2093
  loadModelSetting();
1514
- initProjectLoader(); // Will check git CLI and load reviews/stats after loading project config
2094
+ loadLanguageSetting();
2095
+ initProjectLoader();
1515
2096
 
1516
2097
  // Initialize Lucide icons
1517
2098
  refreshIcons();
@@ -1521,7 +2102,7 @@
1521
2102
  fetchReviewFiles();
1522
2103
  fetchProjectStats();
1523
2104
  fetchMrTracking();
1524
- }, 30000); // Refresh review files, stats and MR tracking every 30s
2105
+ }, 30000);
1525
2106
 
1526
2107
  setInterval(() => {
1527
2108
  document.querySelectorAll('.review-item').forEach(el => {