@dsdenes/devboard 0.1.560 → 0.1.562

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 (323) hide show
  1. package/AGENTS.md +59 -0
  2. package/README.md +2497 -26
  3. package/bin/devboard.mjs +352 -118
  4. package/client/App.svelte +523 -0
  5. package/client/FeedbackOverlay.svelte +1125 -0
  6. package/client/FlowMode.svelte +1015 -0
  7. package/client/app.css +3922 -0
  8. package/client/env.d.ts +23 -0
  9. package/client/lib/commands.ts +836 -0
  10. package/client/lib/components/ActivityBar.svelte +295 -0
  11. package/client/lib/components/AdrTable.svelte +241 -0
  12. package/client/lib/components/CommandPalette.svelte +198 -0
  13. package/client/lib/components/ConceptKind.svelte +66 -0
  14. package/client/lib/components/ConfirmDialog.svelte +62 -0
  15. package/client/lib/components/DataTable.svelte +243 -0
  16. package/client/lib/components/DetailSplit.svelte +68 -0
  17. package/client/lib/components/DiscoverOverview.svelte +898 -0
  18. package/client/lib/components/DiscoverWorkspace.svelte +437 -0
  19. package/client/lib/components/DraftsPage.svelte +186 -0
  20. package/client/lib/components/GoalWorkspace.svelte +1042 -0
  21. package/client/lib/components/IssuesAgentFocus.svelte +117 -0
  22. package/client/lib/components/IssuesAgentLog.svelte +50 -0
  23. package/client/lib/components/IssuesQuestion.svelte +95 -0
  24. package/client/lib/components/IssuesWorkspace.svelte +577 -0
  25. package/client/lib/components/MermaidDiagram.svelte +89 -0
  26. package/client/lib/components/MonacoEditor.svelte +190 -0
  27. package/client/lib/components/NotificationRegion.svelte +195 -0
  28. package/client/lib/components/PaneHandle.svelte +25 -0
  29. package/client/lib/components/ScoutIssues.svelte +215 -0
  30. package/client/lib/components/ScoutWorkspace.svelte +180 -0
  31. package/client/lib/components/Sidebar.svelte +622 -0
  32. package/client/lib/components/StatusBar.svelte +118 -0
  33. package/client/lib/components/Terminal.svelte +193 -0
  34. package/client/lib/components/WatchdogWorkspace.svelte +537 -0
  35. package/client/lib/components/ui/button.svelte +56 -0
  36. package/client/lib/components/ui/icon-button.svelte +56 -0
  37. package/client/lib/components/ui/input.svelte +19 -0
  38. package/client/lib/components/ui/page-header.svelte +120 -0
  39. package/client/lib/components/ui/stage-track.svelte +141 -0
  40. package/client/lib/components/watchdog-indicator.svelte +189 -0
  41. package/client/lib/connection.svelte.ts +210 -0
  42. package/client/lib/dependency-diagram.test.ts +90 -0
  43. package/client/lib/dependency-diagram.ts +75 -0
  44. package/client/lib/discover-format.test.ts +265 -0
  45. package/client/lib/discover-format.ts +394 -0
  46. package/client/lib/discover.svelte.ts +412 -0
  47. package/client/lib/draft-format.test.ts +48 -0
  48. package/client/lib/draft-format.ts +13 -0
  49. package/client/lib/flow-save.test.ts +148 -0
  50. package/client/lib/flow.svelte.ts +445 -0
  51. package/client/lib/goal-reconnect.test.ts +282 -0
  52. package/client/lib/goal.svelte.ts +652 -0
  53. package/client/lib/issues.svelte.ts +439 -0
  54. package/client/lib/layout.svelte.ts +234 -0
  55. package/client/lib/notification-issue.test.ts +53 -0
  56. package/client/lib/notification-issue.ts +27 -0
  57. package/client/lib/notifications.svelte.ts +62 -0
  58. package/client/lib/poll-status.test.ts +141 -0
  59. package/client/lib/poll-status.ts +88 -0
  60. package/client/lib/record-status.test.ts +19 -0
  61. package/client/lib/record-status.ts +25 -0
  62. package/client/lib/scout.svelte.ts +164 -0
  63. package/client/lib/skill-document.test.ts +77 -0
  64. package/client/lib/skill-document.ts +57 -0
  65. package/client/lib/status-tone.test.ts +40 -0
  66. package/client/lib/status-tone.ts +33 -0
  67. package/client/lib/types.ts +578 -0
  68. package/client/lib/utils.ts +6 -0
  69. package/client/lib/viewport.svelte.ts +20 -0
  70. package/client/lib/watchdog.svelte.ts +69 -0
  71. package/client/main.ts +7 -0
  72. package/dist/apple-touch-icon.png +0 -0
  73. package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
  74. package/dist/assets/arc-BZgCSX2H.js +1 -0
  75. package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
  76. package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
  77. package/dist/assets/array-BifhSqXX.js +1 -0
  78. package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
  79. package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
  80. package/dist/assets/channel-CgM4lcUW.js +1 -0
  81. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  82. package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
  83. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  84. package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
  85. package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
  86. package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
  87. package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
  88. package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
  89. package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
  90. package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
  91. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  92. package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
  93. package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
  94. package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
  95. package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
  96. package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
  97. package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
  98. package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
  99. package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
  100. package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
  101. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  102. package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
  103. package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
  104. package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
  105. package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
  106. package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
  107. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  108. package/dist/assets/dagre-D7JM2Yhv.js +1 -0
  109. package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
  110. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  111. package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
  112. package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
  113. package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
  114. package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
  115. package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
  116. package/dist/assets/dist-Dce-2YbA.js +1 -0
  117. package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
  118. package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
  119. package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
  120. package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
  121. package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
  122. package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
  123. package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
  124. package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
  125. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  126. package/dist/assets/index-ByQ-XX38.css +1 -0
  127. package/dist/assets/index-CIf5TpfY.js +214 -0
  128. package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
  129. package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
  130. package/dist/assets/init-C-OQMol4.js +1 -0
  131. package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
  132. package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
  133. package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
  134. package/dist/assets/katex-DJhrJSbw.js +257 -0
  135. package/dist/assets/line-N9XiCREw.js +1 -0
  136. package/dist/assets/linear-C9hnqmJv.js +1 -0
  137. package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
  138. package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
  139. package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
  140. package/dist/assets/monaco-DjpsE8to.css +1 -0
  141. package/dist/assets/monaco-DqzJaNIe.js +908 -0
  142. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  143. package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
  144. package/dist/assets/path-fybaL0A-.js +1 -0
  145. package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
  146. package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
  147. package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
  148. package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
  149. package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
  150. package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
  151. package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
  152. package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
  153. package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
  154. package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
  155. package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
  156. package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  157. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  158. package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
  159. package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
  160. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  161. package/dist/assets/src-CGp3bfio.js +1 -0
  162. package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
  163. package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
  164. package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
  165. package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
  166. package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
  167. package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
  168. package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
  169. package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
  170. package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
  171. package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
  172. package/dist/assets/xterm-BrP-ENHg.css +1 -0
  173. package/dist/assets/xterm-C8zLTNGk.js +16 -0
  174. package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
  175. package/dist/favicon.ico +0 -0
  176. package/dist/favicon.svg +6 -0
  177. package/dist/index.html +34 -0
  178. package/extensions/implement-goal-issue.ts +368 -0
  179. package/extensions/implement-next-epic.ts +835 -0
  180. package/index.html +28 -0
  181. package/install.sh +111 -0
  182. package/issues/control.ts +91 -0
  183. package/issues/guards.ts +12 -0
  184. package/issues/ledger.ts +122 -0
  185. package/issues/legacy.ts +68 -0
  186. package/issues/lock.ts +305 -0
  187. package/issues/paths.ts +76 -0
  188. package/issues/preflight.ts +189 -0
  189. package/issues/repository.ts +203 -0
  190. package/issues/scout/actions.ts +255 -0
  191. package/issues/scout/control.ts +80 -0
  192. package/issues/scout/github.ts +358 -0
  193. package/issues/scout/paths.ts +129 -0
  194. package/issues/scout/preflight.ts +277 -0
  195. package/issues/scout/recommendation.ts +400 -0
  196. package/issues/scout/scan.ts +143 -0
  197. package/issues/scout/state.ts +448 -0
  198. package/issues/scout/worker.ts +1282 -0
  199. package/issues/state.ts +584 -0
  200. package/issues/tracker.ts +271 -0
  201. package/issues/worker.ts +1065 -0
  202. package/lib/dependencies.ts +61 -0
  203. package/lib/forge-auth.ts +953 -0
  204. package/lib/forge.ts +141 -0
  205. package/lib/gitea.ts +709 -0
  206. package/lib/github-release.ts +318 -0
  207. package/lib/inventory.test.ts +79 -0
  208. package/lib/inventory.ts +53 -0
  209. package/lib/release.test.ts +269 -0
  210. package/lib/release.ts +801 -0
  211. package/omp/commands/adr-resolve.md +8 -0
  212. package/omp/commands/discover.md +6 -0
  213. package/omp/commands/epic-next.md +8 -0
  214. package/omp/commands/flow-change.md +8 -0
  215. package/omp/commands/flow-goal.md +8 -0
  216. package/omp/commands/flow-implement.md +8 -0
  217. package/omp/commands/flow-widget.md +8 -0
  218. package/omp/commands/goal-draft.md +8 -0
  219. package/omp/commands/goal-outcome.md +8 -0
  220. package/omp/commands/implement-issue.md +8 -0
  221. package/omp/commands/prepare-skills.md +6 -0
  222. package/omp/commands/scout-recommendations.md +8 -0
  223. package/omp/commands/setup-devboard.md +24 -0
  224. package/omp/config.yml +12 -0
  225. package/omp/extensions/implement-goal-issue.ts +2 -0
  226. package/omp/extensions/implement-next-epic.ts +2 -0
  227. package/omp/mcp.json +9 -0
  228. package/package.json +86 -7
  229. package/prompts/adr-resolve.md +130 -0
  230. package/prompts/discover.md +541 -0
  231. package/prompts/epic-next.md +533 -0
  232. package/prompts/flow-change.md +76 -0
  233. package/prompts/flow-goal.md +126 -0
  234. package/prompts/flow-implement.md +103 -0
  235. package/prompts/flow-widget.md +54 -0
  236. package/prompts/goal-draft.md +147 -0
  237. package/prompts/goal-outcome.md +87 -0
  238. package/prompts/implement-issue.md +66 -0
  239. package/prompts/prepare-skills.md +142 -0
  240. package/prompts/scout-recommendations.md +143 -0
  241. package/public/apple-touch-icon.png +0 -0
  242. package/public/favicon.ico +0 -0
  243. package/public/favicon.svg +6 -0
  244. package/server/acp.ts +540 -0
  245. package/server/activity.test.ts +96 -0
  246. package/server/activity.ts +48 -0
  247. package/server/app.test.ts +882 -0
  248. package/server/app.ts +6179 -0
  249. package/server/ask.test.ts +543 -0
  250. package/server/ask.ts +589 -0
  251. package/server/dependencies.test.ts +118 -0
  252. package/server/discover-remote.test.ts +1602 -0
  253. package/server/discover-sheet.test.ts +176 -0
  254. package/server/discover.test.ts +1510 -0
  255. package/server/discover.ts +3198 -0
  256. package/server/feedback.test.ts +458 -0
  257. package/server/feedback.ts +369 -0
  258. package/server/fixtures/adr-source.ts +71 -0
  259. package/server/fixtures/hang-download.ts +14 -0
  260. package/server/fixtures/stub-agent.ts +1870 -0
  261. package/server/fixtures/stub-forge.ts +141 -0
  262. package/server/fixtures/stub-gh.ts +57 -0
  263. package/server/flow-pr.test.ts +976 -0
  264. package/server/flow.test.ts +1632 -0
  265. package/server/flow.ts +2655 -0
  266. package/server/forge-pr.ts +387 -0
  267. package/server/goal-creation.test.ts +957 -0
  268. package/server/goal-draft.test.ts +348 -0
  269. package/server/goal-drafts.ts +449 -0
  270. package/server/goal-proposal.test.ts +479 -0
  271. package/server/goal-proposal.ts +232 -0
  272. package/server/goal-revision.test.ts +613 -0
  273. package/server/goal-slug.test.ts +56 -0
  274. package/server/goal-slug.ts +96 -0
  275. package/server/goal.ts +20 -0
  276. package/server/index.ts +147 -0
  277. package/server/issue-close.test.ts +100 -0
  278. package/server/issue-close.ts +178 -0
  279. package/server/issues.test.ts +167 -0
  280. package/server/issues.ts +479 -0
  281. package/server/journal.test.ts +350 -0
  282. package/server/journal.ts +329 -0
  283. package/server/lifecycle-pause.test.ts +1795 -0
  284. package/server/lifecycle.test.ts +1879 -0
  285. package/server/parallel-lifecycle.test.ts +1029 -0
  286. package/server/persistence-restart.test.ts +723 -0
  287. package/server/persistence.test.ts +1935 -0
  288. package/server/records.test.ts +180 -0
  289. package/server/records.ts +99 -0
  290. package/server/reset-implementation.test.ts +997 -0
  291. package/server/run.test.ts +611 -0
  292. package/server/scout.test.ts +877 -0
  293. package/server/scout.ts +459 -0
  294. package/server/skills-preparation.test.ts +1073 -0
  295. package/server/state.ts +249 -0
  296. package/server/tracker.ts +369 -0
  297. package/server/updates.test.ts +444 -0
  298. package/server/updates.ts +198 -0
  299. package/server/version.test.ts +140 -0
  300. package/server/version.ts +151 -0
  301. package/server/watchdog.ts +389 -0
  302. package/svelte.config.js +5 -0
  303. package/templates/host-AGENTS.md +235 -0
  304. package/tsconfig.json +21 -0
  305. package/vite.config.ts +21 -0
  306. package/watchdog/cleanup-main.ts +98 -0
  307. package/watchdog/cleanup.ts +301 -0
  308. package/watchdog/collect.ts +311 -0
  309. package/watchdog/coordinator.ts +397 -0
  310. package/watchdog/delivery.ts +107 -0
  311. package/watchdog/discovery.ts +209 -0
  312. package/watchdog/findings.ts +211 -0
  313. package/watchdog/github.ts +189 -0
  314. package/watchdog/history.ts +138 -0
  315. package/watchdog/install.ts +103 -0
  316. package/watchdog/ledger.ts +188 -0
  317. package/watchdog/lock.ts +63 -0
  318. package/watchdog/main.ts +106 -0
  319. package/watchdog/status.ts +71 -0
  320. package/watchdog/systemd.ts +96 -0
  321. package/watchdog/uninstall.ts +30 -0
  322. package/watchdog/units/devboard-watchdog.service +9 -0
  323. package/watchdog/units/devboard-watchdog.timer +10 -0
@@ -0,0 +1,1125 @@
1
+ <script lang="ts">
2
+ import { onMount, tick } from "svelte";
3
+ import Check from "lucide-svelte/icons/check";
4
+ import TriangleAlert from "lucide-svelte/icons/alert-triangle";
5
+ import X from "lucide-svelte/icons/x";
6
+ import { connection } from "./lib/connection.svelte";
7
+ import { issues } from "./lib/issues.svelte";
8
+ import type { AskContext, AskTurn, FeedbackAction, FeedbackPhase } from "./lib/types";
9
+
10
+ /**
11
+ * Operator issue reporter. The activity bar's three issue actions start a
12
+ * selection mode that highlights whatever is under the pointer. The report
13
+ * action opens a free-text panel anchored beside the picked element; the
14
+ * removal and redesign shortcuts file a fixed-instruction issue for the
15
+ * picked element at once and stay armed. Every filing posts the captured DOM
16
+ * context to the server, which files a GitHub issue for an implementation
17
+ * agent. The component owns the overlay; the shell owns the controls and the
18
+ * armed action.
19
+ *
20
+ * The same panel answers questions: `Ask`, or Enter in the composer, sends
21
+ * the question and the captured context over the WebSocket to a background
22
+ * ACP agent and streams its plain-text answer into the transcript. One
23
+ * conversation belongs to the picked element and lives only while its panel
24
+ * is open.
25
+ */
26
+
27
+ type Action = FeedbackAction;
28
+ type Phase = FeedbackPhase;
29
+
30
+ type Rect = { top: number; left: number; width: number; height: number };
31
+ type Toast = { ok: boolean; text: string; url?: string };
32
+
33
+ const PANEL_WIDTH = 380;
34
+ const PANEL_ESTIMATED_HEIGHT = 390;
35
+ const GAP = 12;
36
+ const MARGIN = 8;
37
+ const MAX_DOM_EXCERPT = 4000;
38
+ const MAX_VISIBLE_TEXT = 1500;
39
+ const MAX_SELECTOR = 500;
40
+ const MAX_ROUTE = 2000;
41
+ const MAX_URL = 2000;
42
+ const MAX_VIEWPORT = 500;
43
+ const MAX_CAPTURED_AT = 100;
44
+ /** Bounds shared with the server's Ask conversation capture. */
45
+ const MAX_CONVERSATION_TURNS = 40;
46
+ const MAX_CONVERSATION_TURN = 4000;
47
+ const MAX_CONVERSATION_TEXT = 12_000;
48
+ /** How long a successful filing toast stays before it dismisses itself. */
49
+ const TOAST_TIMEOUT = 8000;
50
+
51
+ let {
52
+ phase = $bindable("idle"),
53
+ action = $bindable<Action | null>(null),
54
+ }: { phase?: Phase; action?: Action | null } = $props();
55
+
56
+ const HINTS: Record<Action, string> = {
57
+ report: "Click an element · Esc cancels",
58
+ remove: "Click an element to remove · Esc cancels",
59
+ redesign: "Click an element to redesign · Esc cancels",
60
+ };
61
+
62
+ let target = $state<Element | null>(null);
63
+ let selector = $state("");
64
+ let marker = $state<Rect | null>(null);
65
+ let panel = $state<{ top: number; left: number } | null>(null);
66
+ let panelElement = $state<HTMLDivElement | null>(null);
67
+ /** True once the operator has dragged the panel away from its anchor. */
68
+ let moved = $state(false);
69
+ let dragging = $state(false);
70
+ let report = $state("");
71
+ let errorMessage = $state<string | null>(null);
72
+ /** The last filing outcome; outlives `reset()` so cancelling never hides it. */
73
+ let toast = $state<Toast | null>(null);
74
+ let toastTimer: ReturnType<typeof setTimeout> | null = null;
75
+ let textarea = $state<HTMLTextAreaElement | null>(null);
76
+ /** The one conversation this open panel owns; null before the first ask. */
77
+ let conversationId = $state<string | null>(null);
78
+ let transcript = $state<AskTurn[]>([]);
79
+ let askPending = $state(false);
80
+ let askError = $state<string | null>(null);
81
+ /** True while the proposed-goal draft is being written. */
82
+ let goalPending = $state(false);
83
+
84
+ const drag = { pointerId: -1, offsetX: 0, offsetY: 0 };
85
+
86
+ let sending = $derived(phase === "sending");
87
+ // Every selection mode intercepts clicks; a filing in flight keeps doing so
88
+ // so the click that started it never activates the element it selected.
89
+ let selecting = $derived(
90
+ action !== null && panel === null && (phase === "picking" || phase === "sending"),
91
+ );
92
+ let picking = $derived(selecting && !sending);
93
+ let hint = $derived(action !== null && selecting ? HINTS[action] : null);
94
+
95
+ function viewportRect(element: Element): Rect {
96
+ const box = element.getBoundingClientRect();
97
+ return { top: box.top, left: box.left, width: box.width, height: box.height };
98
+ }
99
+
100
+ /** Places the panel beside the element, flipping and clamping to stay visible. */
101
+ function anchorFor(rect: Rect): { top: number; left: number } {
102
+ const roomRight = window.innerWidth - rect.left - rect.width - GAP;
103
+ const left =
104
+ roomRight >= PANEL_WIDTH ? rect.left + rect.width + GAP : rect.left - PANEL_WIDTH - GAP;
105
+ return {
106
+ left: Math.min(Math.max(left, MARGIN), window.innerWidth - PANEL_WIDTH - MARGIN),
107
+ top: Math.min(
108
+ Math.max(rect.top, MARGIN),
109
+ Math.max(MARGIN, window.innerHeight - PANEL_ESTIMATED_HEIGHT - MARGIN),
110
+ ),
111
+ };
112
+ }
113
+
114
+ /** Keeps a dragged panel on screen, never under the viewport edge. */
115
+ function clampPlacement(top: number, left: number): { top: number; left: number } {
116
+ const height = panelElement?.getBoundingClientRect().height ?? PANEL_ESTIMATED_HEIGHT;
117
+ return {
118
+ left: Math.min(Math.max(left, MARGIN), Math.max(MARGIN, window.innerWidth - PANEL_WIDTH - MARGIN)),
119
+ top: Math.min(Math.max(top, MARGIN), Math.max(MARGIN, window.innerHeight - height - MARGIN)),
120
+ };
121
+ }
122
+
123
+ function startDrag(event: PointerEvent): void {
124
+ if (event.button !== 0 || panel === null) return;
125
+ const origin = event.target instanceof Element ? event.target : null;
126
+ if (origin !== null && origin.closest(".feedback-close") !== null) return;
127
+ const handle = event.currentTarget as HTMLElement;
128
+ handle.setPointerCapture(event.pointerId);
129
+ drag.pointerId = event.pointerId;
130
+ drag.offsetX = event.clientX - panel.left;
131
+ drag.offsetY = event.clientY - panel.top;
132
+ dragging = true;
133
+ }
134
+
135
+ function moveDrag(event: PointerEvent): void {
136
+ if (!dragging || event.pointerId !== drag.pointerId) return;
137
+ panel = clampPlacement(event.clientY - drag.offsetY, event.clientX - drag.offsetX);
138
+ moved = true;
139
+ }
140
+
141
+ function endDrag(event: PointerEvent): void {
142
+ if (event.pointerId !== drag.pointerId) return;
143
+ dragging = false;
144
+ drag.pointerId = -1;
145
+ }
146
+
147
+ function moveWithKeys(event: KeyboardEvent): void {
148
+ if (panel === null) return;
149
+ const step = event.shiftKey ? 24 : 8;
150
+ const moves: Record<string, [number, number]> = {
151
+ ArrowUp: [-step, 0],
152
+ ArrowDown: [step, 0],
153
+ ArrowLeft: [0, -step],
154
+ ArrowRight: [0, step],
155
+ };
156
+ const move = moves[event.key];
157
+ if (move === undefined) return;
158
+ event.preventDefault();
159
+ panel = clampPlacement(panel.top + move[0], panel.left + move[1]);
160
+ moved = true;
161
+ }
162
+
163
+ /**
164
+ * The surfaces that keep working while an action is armed: the overlay's own
165
+ * parts, the activity-bar controls that toggle it, and any dialog. Picking an
166
+ * element never selects them, and a click inside them reaches them.
167
+ */
168
+ function ownOverlay(node: Element | null): boolean {
169
+ return (
170
+ node !== null &&
171
+ (node.closest("[data-feedback-overlay]") !== null ||
172
+ node.closest('[data-command="app.report"]') !== null ||
173
+ node.closest('[data-command="app.remove"]') !== null ||
174
+ node.closest('[data-command="app.redesign"]') !== null ||
175
+ node.closest('[role="dialog"], [role="alertdialog"], dialog') !== null)
176
+ );
177
+ }
178
+
179
+ function elementAt(event: MouseEvent): Element | null {
180
+ // Both the event target and the element under the pointer count: a
181
+ // keyboard or scripted activation has no meaningful point.
182
+ if (event.target instanceof Element && ownOverlay(event.target)) return null;
183
+ const node = document.elementFromPoint(event.clientX, event.clientY);
184
+ if (node === null || ownOverlay(node)) return null;
185
+ return node;
186
+ }
187
+
188
+ /** A stable-looking selector path for the selected element. */
189
+ function describeTarget(element: Element): string {
190
+ const parts: string[] = [];
191
+ let node: Element | null = element;
192
+ while (node !== null && node !== document.documentElement && parts.length < 12) {
193
+ const name = node.tagName.toLowerCase();
194
+ const id = node.getAttribute("id");
195
+ if (id !== null && id.length > 0 && !id.startsWith("svelte-")) {
196
+ parts.unshift(`#${id}`);
197
+ break;
198
+ }
199
+ const classes = Array.from(node.classList)
200
+ .filter((value) => !value.startsWith("svelte-"))
201
+ .slice(0, 3);
202
+ let part = name + classes.map((value) => `.${value}`).join("");
203
+ const parent = node.parentElement;
204
+ if (parent !== null) {
205
+ const siblings = Array.from(parent.children).filter(
206
+ (child) => child.tagName === node?.tagName,
207
+ );
208
+ if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
209
+ }
210
+ parts.unshift(part);
211
+ node = node.parentElement;
212
+ }
213
+ return parts.join(" > ");
214
+ }
215
+
216
+ function truncate(value: string, limit: number): string {
217
+ const collapsed = value.replace(/[ \t]+/g, " ").trim();
218
+ if (collapsed.length <= limit) return collapsed;
219
+ return `${collapsed.slice(0, limit - 1)}…`;
220
+ }
221
+
222
+ /** The captured context every filing carries. */
223
+ function captured(element: Element): Record<string, string> {
224
+ return {
225
+ route: window.location.pathname,
226
+ url: window.location.href,
227
+ selector,
228
+ dom_excerpt: truncate(element.outerHTML ?? "", MAX_DOM_EXCERPT),
229
+ visible_text: truncate(
230
+ (element as HTMLElement).innerText ?? element.textContent ?? "",
231
+ MAX_VISIBLE_TEXT,
232
+ ),
233
+ viewport: `${window.innerWidth}x${window.innerHeight} @${window.devicePixelRatio}x`,
234
+ captured_at: new Date().toISOString(),
235
+ };
236
+ }
237
+
238
+ /** What the ask agent receives; each value is bounded to the server's limit. */
239
+ function captureContext(element: Element): AskContext {
240
+ return {
241
+ route: truncate(window.location.pathname, MAX_ROUTE),
242
+ url: truncate(window.location.href, MAX_URL),
243
+ selector: truncate(selector, MAX_SELECTOR),
244
+ viewport: truncate(
245
+ `${window.innerWidth}x${window.innerHeight} @${window.devicePixelRatio}x`,
246
+ MAX_VIEWPORT,
247
+ ),
248
+ captured_at: truncate(new Date().toISOString(), MAX_CAPTURED_AT),
249
+ visible_text: truncate(
250
+ (element as HTMLElement).innerText ?? element.textContent ?? "",
251
+ MAX_VISIBLE_TEXT,
252
+ ),
253
+ dom_excerpt: truncate(element.outerHTML ?? "", MAX_DOM_EXCERPT),
254
+ };
255
+ }
256
+
257
+ /** The Ask conversation bounded to the server's limits; the newest turns win. */
258
+ function conversationPayload(): Array<{ role: AskTurn["role"]; text: string }> {
259
+ const turns = transcript
260
+ .slice(-MAX_CONVERSATION_TURNS)
261
+ .map((turn) => ({ role: turn.role, text: truncate(turn.text, MAX_CONVERSATION_TURN) }));
262
+ let total = turns.reduce((sum, turn) => sum + turn.text.length, 0);
263
+ let start = 0;
264
+ while (total > MAX_CONVERSATION_TEXT && start < turns.length - 1) {
265
+ total -= turns[start]?.text.length ?? 0;
266
+ start += 1;
267
+ }
268
+ return turns.slice(start);
269
+ }
270
+
271
+ /** Keeps the panel fully inside the viewport after the transcript grows. */
272
+ async function fitPanel(): Promise<void> {
273
+ await tick();
274
+ if (panel === null || panelElement === null) return;
275
+ const height = panelElement.getBoundingClientRect().height;
276
+ const maxTop = Math.max(MARGIN, window.innerHeight - height - MARGIN);
277
+ const top = Math.min(Math.max(panel.top, MARGIN), maxTop);
278
+ if (top !== panel.top) panel = { top, left: panel.left };
279
+ }
280
+
281
+ function focusComposer(): void {
282
+ void tick().then(() => textarea?.focus());
283
+ }
284
+
285
+ function ask(): void {
286
+ const question = report.trim();
287
+ if (target === null || question.length === 0 || askPending || sending) return;
288
+ const id = conversationId ?? crypto.randomUUID();
289
+ conversationId = id;
290
+ askPending = true;
291
+ askError = null;
292
+ transcript = [...transcript, { role: "developer", text: question }];
293
+ const sent = connection.send({
294
+ type: "ask",
295
+ conversationId: id,
296
+ question,
297
+ context: captureContext(target),
298
+ });
299
+ if (!sent) {
300
+ askPending = false;
301
+ askError = "Not connected to the server.";
302
+ focusComposer();
303
+ return;
304
+ }
305
+ // The question now lives in the transcript: clear the composer at once so
306
+ // the next message never starts from the previous one.
307
+ report = "";
308
+ focusComposer();
309
+ }
310
+
311
+ /** Enter sends; Shift+Enter and an IME composition keep their newline. */
312
+ function onComposerKeyDown(event: KeyboardEvent): void {
313
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing || event.keyCode === 229) {
314
+ return;
315
+ }
316
+ event.preventDefault();
317
+ ask();
318
+ }
319
+
320
+ /** Events for a closed or replaced conversation never reach the transcript. */
321
+ function onConnectionMessage(message: Record<string, unknown>): void {
322
+ if (typeof message.conversationId !== "string") return;
323
+ if (message.conversationId !== conversationId) return;
324
+
325
+ if (message.type === "askChunk") {
326
+ const text = typeof message.text === "string" ? message.text : "";
327
+ if (text.length === 0 || !askPending) return;
328
+ const last = transcript[transcript.length - 1];
329
+ if (last !== undefined && last.role === "agent") {
330
+ last.text += text;
331
+ } else {
332
+ transcript = [...transcript, { role: "agent", text }];
333
+ }
334
+ return;
335
+ }
336
+
337
+ if (message.type === "askDone") {
338
+ askPending = false;
339
+ askError = null;
340
+ focusComposer();
341
+ return;
342
+ }
343
+
344
+ if (message.type === "askError") {
345
+ askPending = false;
346
+ askError =
347
+ typeof message.message === "string" && message.message.length > 0
348
+ ? message.message
349
+ : "The agent could not answer.";
350
+ focusComposer();
351
+ }
352
+ }
353
+
354
+ function openPanel(element: Element): void {
355
+ closeConversation();
356
+ target = element;
357
+ selector = describeTarget(element);
358
+ const rect = viewportRect(element);
359
+ marker = rect;
360
+ panel = anchorFor(rect);
361
+ moved = false;
362
+ dragging = false;
363
+ report = "";
364
+ errorMessage = null;
365
+ action = "report";
366
+ phase = "composing";
367
+ document.body.style.cursor = "";
368
+ queueMicrotask(() => textarea?.focus());
369
+ }
370
+
371
+ /** Drops the panel's conversation: no transcript, and the peer is killed. */
372
+ function closeConversation(): void {
373
+ const id = conversationId;
374
+ conversationId = null;
375
+ transcript = [];
376
+ askPending = false;
377
+ askError = null;
378
+ if (id !== null && connection.open) {
379
+ connection.send({ type: "closeAsk", conversationId: id });
380
+ }
381
+ }
382
+
383
+ /** Shows one outcome toast; a success dismisses itself, an error waits. */
384
+ function showToast(next: Toast): void {
385
+ if (toastTimer !== null) {
386
+ clearTimeout(toastTimer);
387
+ toastTimer = null;
388
+ }
389
+ toast = next;
390
+ if (next.ok) {
391
+ toastTimer = setTimeout(() => {
392
+ toast = null;
393
+ toastTimer = null;
394
+ }, TOAST_TIMEOUT);
395
+ }
396
+ }
397
+
398
+ function dismissToast(): void {
399
+ if (toastTimer !== null) {
400
+ clearTimeout(toastTimer);
401
+ toastTimer = null;
402
+ }
403
+ toast = null;
404
+ }
405
+
406
+ function reset(): void {
407
+ closeConversation();
408
+ goalPending = false;
409
+ action = null;
410
+ phase = "idle";
411
+ target = null;
412
+ selector = "";
413
+ marker = null;
414
+ panel = null;
415
+ moved = false;
416
+ dragging = false;
417
+ report = "";
418
+ errorMessage = null;
419
+ document.body.style.cursor = "";
420
+ }
421
+
422
+ /** Arms one action: the next click on an element uses it. */
423
+ function arm(next: Action): void {
424
+ reset();
425
+ action = next;
426
+ phase = "picking";
427
+ document.body.style.cursor = "crosshair";
428
+ }
429
+
430
+ export function startPicking(): void {
431
+ arm("report");
432
+ }
433
+
434
+ export function startRemoving(): void {
435
+ arm("remove");
436
+ }
437
+
438
+ export function startRedesigning(): void {
439
+ arm("redesign");
440
+ }
441
+
442
+ /** The activity-bar controls: the active action cancels, any other arms. */
443
+ export function toggleAction(next: Action): void {
444
+ if (sending) return;
445
+ if (action === next) {
446
+ reset();
447
+ return;
448
+ }
449
+ arm(next);
450
+ }
451
+
452
+ export function toggleReport(): void {
453
+ toggleAction("report");
454
+ }
455
+
456
+ function onPointerMove(event: MouseEvent): void {
457
+ if (!picking) return;
458
+ const element = elementAt(event);
459
+ marker = element === null ? null : viewportRect(element);
460
+ }
461
+
462
+ function onPointerDown(event: MouseEvent): void {
463
+ if (!selecting) return;
464
+ if (elementAt(event) !== null) event.preventDefault();
465
+ }
466
+
467
+ function onClick(event: MouseEvent): void {
468
+ if (!selecting) return;
469
+ const element = elementAt(event);
470
+ if (element === null) return;
471
+ // Swallow the click so picking an element never activates it.
472
+ event.preventDefault();
473
+ event.stopPropagation();
474
+ if (sending) return;
475
+ if (action === "report") {
476
+ openPanel(element);
477
+ return;
478
+ }
479
+ if (action === "remove" || action === "redesign") {
480
+ void fileShortcut(action, element);
481
+ }
482
+ }
483
+
484
+ export function cancelReport(): void {
485
+ reset();
486
+ }
487
+
488
+ function onKeyDown(event: KeyboardEvent): void {
489
+ if (event.key !== "Escape" || sending || goalPending) return;
490
+ reset();
491
+ }
492
+
493
+ function onViewportChange(): void {
494
+ if (phase === "picking") {
495
+ marker = null;
496
+ return;
497
+ }
498
+ if (target === null) return;
499
+ const rect = viewportRect(target);
500
+ marker = rect;
501
+ panel = moved && panel !== null
502
+ ? clampPlacement(panel.top, panel.left)
503
+ : anchorFor(rect);
504
+ void fitPanel();
505
+ }
506
+
507
+ /** Posts one submission and returns the filed issue URL, or the error text. */
508
+ async function post(payload: Record<string, unknown>): Promise<
509
+ { ok: true; url: string } | { ok: false; error: string }
510
+ > {
511
+ try {
512
+ const response = await fetch("/api/feedback", {
513
+ method: "POST",
514
+ headers: { "content-type": "application/json" },
515
+ body: JSON.stringify(payload),
516
+ });
517
+ const body = (await response.json().catch(() => null)) as {
518
+ url?: unknown;
519
+ error?: unknown;
520
+ } | null;
521
+ if (!response.ok || typeof body?.url !== "string") {
522
+ return {
523
+ ok: false,
524
+ error:
525
+ typeof body?.error === "string"
526
+ ? body.error
527
+ : `The server refused the report (${response.status}).`,
528
+ };
529
+ }
530
+ return { ok: true, url: body.url };
531
+ } catch (error) {
532
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
533
+ }
534
+ }
535
+
536
+ /** Files a removal or redesign issue for one element and stays armed. */
537
+ async function fileShortcut(kind: "remove" | "redesign", element: Element): Promise<void> {
538
+ selector = describeTarget(element);
539
+ marker = null;
540
+ dismissToast();
541
+ phase = "sending";
542
+ const response = await post({ action: kind, report: "", ...captured(element) });
543
+ const noun = kind === "remove" ? "Removal" : "Redesign";
544
+ if (response.ok) {
545
+ showToast({ ok: true, text: `${noun} issue filed.`, url: response.url });
546
+ } else {
547
+ showToast({ ok: false, text: `${noun} issue failed: ${response.error}` });
548
+ }
549
+ // The shortcut stays armed so the next element is one click away.
550
+ phase = "picking";
551
+ selector = "";
552
+ document.body.style.cursor = "crosshair";
553
+ }
554
+
555
+ /** Writes one proposed-goal draft and returns the draft path, or the error. */
556
+ async function postGoal(
557
+ payload: Record<string, unknown>,
558
+ ): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
559
+ try {
560
+ const response = await fetch("/api/goal-proposals", {
561
+ method: "POST",
562
+ headers: { "content-type": "application/json" },
563
+ body: JSON.stringify(payload),
564
+ });
565
+ const body = (await response.json().catch(() => null)) as {
566
+ path?: unknown;
567
+ error?: unknown;
568
+ } | null;
569
+ if (!response.ok || typeof body?.path !== "string") {
570
+ return {
571
+ ok: false,
572
+ error:
573
+ typeof body?.error === "string"
574
+ ? body.error
575
+ : `The server refused the proposal (${response.status}).`,
576
+ };
577
+ }
578
+ return { ok: true, path: body.path };
579
+ } catch (error) {
580
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
581
+ }
582
+ }
583
+
584
+ /** Saves the composer text as a raw goal draft and returns to picking. */
585
+ async function proposeGoal(): Promise<void> {
586
+ if (
587
+ target === null ||
588
+ report.trim().length === 0 ||
589
+ goalPending ||
590
+ sending ||
591
+ askPending
592
+ ) {
593
+ return;
594
+ }
595
+ const element = target;
596
+ goalPending = true;
597
+ errorMessage = null;
598
+ dismissToast();
599
+ const response = await postGoal({
600
+ report,
601
+ conversation: conversationPayload(),
602
+ ...captured(element),
603
+ });
604
+ goalPending = false;
605
+ if (!response.ok) {
606
+ errorMessage = response.error;
607
+ phase = "failed";
608
+ return;
609
+ }
610
+ const text = `Goal proposed: ${response.path}`;
611
+ issues.refreshDrafts();
612
+ // Stay in selection mode so the next draft starts with one click.
613
+ arm("report");
614
+ showToast({ ok: true, text });
615
+ }
616
+
617
+ async function send(): Promise<void> {
618
+ if (target === null || report.trim().length === 0 || sending) return;
619
+ const element = target;
620
+ dismissToast();
621
+ phase = "sending";
622
+ errorMessage = null;
623
+ const response = await post({
624
+ action: "report",
625
+ report,
626
+ conversation: conversationPayload(),
627
+ ...captured(element),
628
+ });
629
+ if (!response.ok) {
630
+ errorMessage = response.error;
631
+ phase = "failed";
632
+ return;
633
+ }
634
+ // Stay in selection mode so the next report starts with one click.
635
+ arm("report");
636
+ showToast({ ok: true, text: "Report filed.", url: response.url });
637
+ }
638
+
639
+ // A dropped socket owns no conversation; the server kills its peer.
640
+ $effect(() => {
641
+ if (connection.state === "closed" && conversationId !== null) closeConversation();
642
+ });
643
+
644
+ // Measure after every transcript change so the panel never grows past the viewport.
645
+ $effect(() => {
646
+ void transcript.length;
647
+ void transcript[transcript.length - 1]?.text.length;
648
+ void askPending;
649
+ void askError;
650
+ void fitPanel();
651
+ });
652
+
653
+ onMount(() => {
654
+ connection.onMessage(onConnectionMessage);
655
+ window.addEventListener("mousemove", onPointerMove);
656
+ window.addEventListener("mousedown", onPointerDown, true);
657
+ window.addEventListener("click", onClick, true);
658
+ window.addEventListener("keydown", onKeyDown, true);
659
+ window.addEventListener("scroll", onViewportChange, true);
660
+ window.addEventListener("resize", onViewportChange);
661
+ return () => {
662
+ document.body.style.cursor = "";
663
+ if (toastTimer !== null) clearTimeout(toastTimer);
664
+ window.removeEventListener("mousemove", onPointerMove);
665
+ window.removeEventListener("mousedown", onPointerDown, true);
666
+ window.removeEventListener("click", onClick, true);
667
+ window.removeEventListener("keydown", onKeyDown, true);
668
+ window.removeEventListener("scroll", onViewportChange, true);
669
+ window.removeEventListener("resize", onViewportChange);
670
+ };
671
+ });
672
+ </script>
673
+
674
+ {#if marker !== null && phase !== "idle"}
675
+ <div
676
+ class="feedback-marker"
677
+ data-feedback-overlay
678
+ style={`top:${marker.top}px;left:${marker.left}px;width:${marker.width}px;` +
679
+ `height:${marker.height}px`}
680
+ ></div>
681
+ {/if}
682
+
683
+ {#if hint !== null}
684
+ <div class="feedback-hint" data-feedback-overlay>{hint}</div>
685
+ {/if}
686
+
687
+ {#if panel !== null && (phase === "composing" || phase === "sending" || phase === "failed")}
688
+ <div
689
+ class="feedback-panel"
690
+ class:feedback-panel--dragging={dragging}
691
+ data-feedback-overlay
692
+ role="dialog"
693
+ aria-label="Report a UI issue"
694
+ style={`top:${panel.top}px;left:${panel.left}px;width:${PANEL_WIDTH}px`}
695
+ bind:this={panelElement}
696
+ >
697
+ <header>
698
+ <button
699
+ type="button"
700
+ class="feedback-drag"
701
+ aria-label="Move the report panel"
702
+ title="Drag to move, or use the arrow keys"
703
+ onpointerdown={startDrag}
704
+ onpointermove={moveDrag}
705
+ onpointerup={endDrag}
706
+ onpointercancel={endDrag}
707
+ onkeydown={moveWithKeys}
708
+ >
709
+ <strong>Report a UI issue</strong>
710
+ </button>
711
+ <button
712
+ type="button"
713
+ class="feedback-close"
714
+ aria-label="Close"
715
+ data-command="app.report.cancel"
716
+ onclick={reset}>×</button>
717
+ </header>
718
+ <code class="feedback-selector" title={selector}>{selector}</code>
719
+ {#if transcript.length > 0}
720
+ <ul class="feedback-transcript" role="log" aria-label="Ask conversation" aria-live="polite">
721
+ {#each transcript as turn, index (index)}
722
+ <li class="feedback-message" data-role={turn.role}>
723
+ <span class="feedback-message__who" aria-hidden="true">
724
+ {turn.role === "developer" ? "You" : "Agent"}
725
+ </span>
726
+ <p
727
+ class="feedback-turn"
728
+ class:feedback-turn--developer={turn.role === "developer"}
729
+ class:feedback-turn--agent={turn.role === "agent"}
730
+ >{turn.text}</p>
731
+ </li>
732
+ {/each}
733
+ </ul>
734
+ {/if}
735
+ <textarea
736
+ bind:this={textarea}
737
+ bind:value={report}
738
+ placeholder="What is wrong with this element?"
739
+ aria-label="Issue description"
740
+ rows="5"
741
+ disabled={sending || askPending}
742
+ onkeydown={onComposerKeyDown}
743
+ ></textarea>
744
+ {#if askPending}
745
+ <p class="feedback-status" role="status">Asking…</p>
746
+ {/if}
747
+ {#if askError !== null}
748
+ <p class="feedback-error" role="alert">{askError}</p>
749
+ {/if}
750
+ {#if phase === "failed" && errorMessage !== null}
751
+ <p class="feedback-error">{errorMessage}</p>
752
+ {/if}
753
+ <div class="feedback-actions">
754
+ <button
755
+ type="button"
756
+ onclick={send}
757
+ disabled={sending || askPending || goalPending || report.trim().length === 0}
758
+ >
759
+ {sending ? "Filing…" : "File issue"}
760
+ </button>
761
+ <button
762
+ type="button"
763
+ data-testid="feedback-ask"
764
+ aria-label="Ask the agent about this element"
765
+ onclick={ask}
766
+ disabled={askPending || sending || goalPending || report.trim().length === 0}
767
+ >
768
+ {askPending ? "Asking…" : "Ask"}
769
+ </button>
770
+ <button
771
+ type="button"
772
+ data-testid="feedback-goal"
773
+ aria-label="Goal: propose a draft from this element"
774
+ onclick={proposeGoal}
775
+ disabled={goalPending || sending || askPending || report.trim().length === 0}
776
+ >
777
+ {goalPending ? "Saving…" : "Goal"}
778
+ </button>
779
+ <button
780
+ type="button"
781
+ class="feedback-secondary"
782
+ data-command="app.report.cancel"
783
+ onclick={reset}
784
+ disabled={sending || goalPending}
785
+ >
786
+ Cancel
787
+ </button>
788
+ </div>
789
+ </div>
790
+ {/if}
791
+
792
+ {#if toast !== null}
793
+ <div
794
+ class="feedback-result"
795
+ class:feedback-result--error={!toast.ok}
796
+ data-feedback-overlay
797
+ data-testid="feedback-result"
798
+ role={toast.ok ? "status" : "alert"}
799
+ >
800
+ <span class="feedback-result__icon" aria-hidden="true">
801
+ {#if toast.ok}
802
+ <Check size={14} />
803
+ {:else}
804
+ <TriangleAlert size={14} />
805
+ {/if}
806
+ </span>
807
+ <div class="feedback-result__body">
808
+ <p class="feedback-result__text">{toast.text}</p>
809
+ {#if toast.ok && toast.url !== undefined}
810
+ <a class="feedback-result__link" href={toast.url} target="_blank" rel="noreferrer">
811
+ Open issue
812
+ </a>
813
+ {/if}
814
+ </div>
815
+ <button
816
+ type="button"
817
+ class="feedback-result__close"
818
+ aria-label="Dismiss"
819
+ title="Dismiss"
820
+ onclick={dismissToast}
821
+ >
822
+ <X size={12} aria-hidden="true" />
823
+ </button>
824
+ </div>
825
+ {/if}
826
+
827
+ <style>
828
+ .feedback-result {
829
+ align-items: flex-start;
830
+ animation: feedback-toast-in 160ms ease-out;
831
+ background: var(--surface-raised);
832
+ border: 1px solid var(--border-strong);
833
+ border-left: 3px solid var(--success);
834
+ border-radius: 6px;
835
+ bottom: 40px;
836
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.32);
837
+ color: var(--text);
838
+ display: flex;
839
+ gap: 8px;
840
+ left: 50%;
841
+ max-width: min(460px, calc(100vw - 32px));
842
+ padding: 10px 12px;
843
+ position: fixed;
844
+ transform: translateX(-50%);
845
+ z-index: 2147483647;
846
+ }
847
+
848
+ .feedback-result--error {
849
+ border-color: var(--error);
850
+ border-left-color: var(--error);
851
+ }
852
+
853
+ .feedback-result__icon {
854
+ color: var(--success);
855
+ display: flex;
856
+ margin-top: 1px;
857
+ }
858
+
859
+ .feedback-result--error .feedback-result__icon {
860
+ color: var(--error);
861
+ }
862
+
863
+ .feedback-result__body {
864
+ display: flex;
865
+ flex-direction: column;
866
+ gap: 3px;
867
+ min-width: 0;
868
+ }
869
+
870
+ .feedback-result__text {
871
+ font-size: 13px;
872
+ font-weight: 600;
873
+ margin: 0;
874
+ overflow-wrap: anywhere;
875
+ }
876
+
877
+ .feedback-result__link {
878
+ color: var(--accent);
879
+ font-size: 12px;
880
+ overflow-wrap: anywhere;
881
+ }
882
+
883
+ .feedback-result__close {
884
+ align-items: center;
885
+ background: none;
886
+ border: 0;
887
+ color: var(--muted);
888
+ cursor: pointer;
889
+ display: flex;
890
+ padding: 2px;
891
+ }
892
+
893
+ .feedback-result__close:hover {
894
+ color: var(--text);
895
+ }
896
+
897
+ @keyframes feedback-toast-in {
898
+ from {
899
+ opacity: 0;
900
+ transform: translate(-50%, 6px);
901
+ }
902
+ to {
903
+ opacity: 1;
904
+ transform: translate(-50%, 0);
905
+ }
906
+ }
907
+
908
+ @media (prefers-reduced-motion: reduce) {
909
+ .feedback-result {
910
+ animation: none;
911
+ }
912
+ }
913
+
914
+ .feedback-marker {
915
+ border: 2px solid var(--accent);
916
+ border-radius: 3px;
917
+ box-shadow: 0 0 0 2px rgb(0 0 0 / 0.12);
918
+ pointer-events: none;
919
+ position: fixed;
920
+ z-index: 2147483646;
921
+ }
922
+
923
+ .feedback-hint {
924
+ background: var(--surface);
925
+ border: 1px solid var(--accent);
926
+ border-radius: 999px;
927
+ color: var(--accent);
928
+ font-size: 0.85rem;
929
+ font-weight: 600;
930
+ left: 50%;
931
+ padding: 0.45rem 1rem;
932
+ pointer-events: none;
933
+ position: fixed;
934
+ top: 1rem;
935
+ transform: translateX(-50%);
936
+ z-index: 2147483647;
937
+ }
938
+
939
+ .feedback-panel {
940
+ background: var(--surface);
941
+ border: 1px solid var(--border);
942
+ border-radius: 10px;
943
+ box-shadow: 0 8px 30px rgb(0 0 0 / 0.25);
944
+ color: var(--text);
945
+ display: flex;
946
+ flex-direction: column;
947
+ gap: 0.6rem;
948
+ padding: 0.9rem;
949
+ position: fixed;
950
+ z-index: 2147483647;
951
+ }
952
+
953
+ .feedback-panel header {
954
+ align-items: center;
955
+ display: flex;
956
+ justify-content: space-between;
957
+ }
958
+
959
+ .feedback-drag {
960
+ align-items: center;
961
+ background: none;
962
+ border: 0;
963
+ border-radius: 4px;
964
+ color: inherit;
965
+ cursor: grab;
966
+ display: flex;
967
+ flex: 1;
968
+ font: inherit;
969
+ gap: 0.4rem;
970
+ min-width: 0;
971
+ padding: 0;
972
+ text-align: left;
973
+ touch-action: none;
974
+ user-select: none;
975
+ }
976
+
977
+ .feedback-drag:focus-visible {
978
+ outline: 2px solid var(--accent);
979
+ outline-offset: 2px;
980
+ }
981
+
982
+ .feedback-panel--dragging .feedback-drag {
983
+ cursor: grabbing;
984
+ }
985
+
986
+ .feedback-close {
987
+ background: none;
988
+ border: none;
989
+ color: var(--muted);
990
+ cursor: pointer;
991
+ font-size: 1.1rem;
992
+ line-height: 1;
993
+ padding: 0 0.2rem;
994
+ }
995
+
996
+ .feedback-close:hover {
997
+ color: var(--text);
998
+ }
999
+
1000
+ .feedback-selector {
1001
+ color: var(--muted);
1002
+ font-size: 0.75rem;
1003
+ overflow: hidden;
1004
+ text-overflow: ellipsis;
1005
+ white-space: nowrap;
1006
+ }
1007
+
1008
+ .feedback-transcript {
1009
+ background: var(--background);
1010
+ border: 1px solid var(--border);
1011
+ border-radius: 6px;
1012
+ display: flex;
1013
+ flex-direction: column;
1014
+ gap: 0.5rem;
1015
+ list-style: none;
1016
+ margin: 0;
1017
+ max-height: 180px;
1018
+ overflow-y: auto;
1019
+ padding: 0.5rem;
1020
+ }
1021
+
1022
+ .feedback-message {
1023
+ display: flex;
1024
+ flex-direction: column;
1025
+ gap: 2px;
1026
+ max-width: 92%;
1027
+ }
1028
+
1029
+ .feedback-message[data-role="developer"] {
1030
+ align-items: flex-end;
1031
+ align-self: flex-end;
1032
+ }
1033
+
1034
+ .feedback-message[data-role="agent"] {
1035
+ align-items: flex-start;
1036
+ align-self: flex-start;
1037
+ }
1038
+
1039
+ .feedback-message__who {
1040
+ color: var(--muted);
1041
+ font-size: 10px;
1042
+ font-weight: 700;
1043
+ letter-spacing: 0.08em;
1044
+ line-height: 1.2;
1045
+ text-transform: uppercase;
1046
+ }
1047
+
1048
+ .feedback-turn {
1049
+ border: 1px solid var(--border);
1050
+ border-radius: 6px;
1051
+ color: var(--text);
1052
+ font-size: 0.8rem;
1053
+ margin: 0;
1054
+ overflow-wrap: anywhere;
1055
+ padding: 0.4rem 0.55rem;
1056
+ white-space: pre-wrap;
1057
+ }
1058
+
1059
+ .feedback-turn--developer {
1060
+ background: var(--accent-surface);
1061
+ border-color: var(--accent);
1062
+ }
1063
+
1064
+ .feedback-turn--agent {
1065
+ background: var(--surface-raised);
1066
+ border-color: var(--border-subtle);
1067
+ }
1068
+
1069
+ .feedback-panel textarea {
1070
+ background: var(--background);
1071
+ border: 1px solid var(--border);
1072
+ border-radius: 6px;
1073
+ color: var(--text);
1074
+ font: inherit;
1075
+ font-size: 0.9rem;
1076
+ padding: 0.5rem;
1077
+ resize: vertical;
1078
+ width: 100%;
1079
+ }
1080
+
1081
+ .feedback-panel textarea:focus {
1082
+ border-color: var(--accent);
1083
+ outline: none;
1084
+ }
1085
+
1086
+ .feedback-actions {
1087
+ display: flex;
1088
+ gap: 0.5rem;
1089
+ }
1090
+
1091
+ .feedback-actions button {
1092
+ background: var(--accent);
1093
+ border: 1px solid var(--accent);
1094
+ border-radius: 6px;
1095
+ color: var(--surface);
1096
+ cursor: pointer;
1097
+ font: inherit;
1098
+ font-size: 0.85rem;
1099
+ font-weight: 600;
1100
+ padding: 0.4rem 0.8rem;
1101
+ }
1102
+
1103
+ .feedback-actions button:disabled {
1104
+ cursor: not-allowed;
1105
+ opacity: 0.5;
1106
+ }
1107
+
1108
+ .feedback-actions .feedback-secondary {
1109
+ background: var(--surface);
1110
+ border-color: var(--border);
1111
+ color: var(--text);
1112
+ }
1113
+
1114
+ .feedback-status {
1115
+ color: var(--muted);
1116
+ font-size: 0.8rem;
1117
+ margin: 0;
1118
+ }
1119
+
1120
+ .feedback-error {
1121
+ color: var(--error);
1122
+ font-size: 0.8rem;
1123
+ margin: 0;
1124
+ }
1125
+ </style>