toolroll 0.9.85 → 0.9.87

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 (220) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/agent-fence.d.ts +6 -2
  3. package/dist/agent-fence.js +3 -2
  4. package/dist/agents-board.d.ts +12 -0
  5. package/dist/agents-board.js +26 -0
  6. package/dist/approval-rules-ui.d.ts +4 -1
  7. package/dist/approval-rules-ui.js +31 -15
  8. package/dist/assignment-ui.js +1 -1
  9. package/dist/assignment.js +4 -3
  10. package/dist/backup-ui.d.ts +5 -4
  11. package/dist/backup-ui.js +65 -32
  12. package/dist/browser/terminal.css +1 -1
  13. package/dist/browser/terminal.js +57 -54
  14. package/dist/browser/workspace.css +2 -2
  15. package/dist/browser/workspace.js +35 -68
  16. package/dist/browser-crew.js +1 -1
  17. package/dist/browser-workspace.d.ts +363 -177
  18. package/dist/browser-workspace.js +10 -9
  19. package/dist/chat-actions.d.ts +11 -1
  20. package/dist/chat-actions.js +53 -1
  21. package/dist/chat-channel.js +1 -0
  22. package/dist/chat-controls.d.ts +1 -1
  23. package/dist/chat-controls.js +1 -1
  24. package/dist/chat-quiet.js +3 -2
  25. package/dist/chat-settings-page.d.ts +29 -9
  26. package/dist/chat-settings-page.js +59 -28
  27. package/dist/check-levels-ui.d.ts +2 -1
  28. package/dist/check-levels-ui.js +67 -58
  29. package/dist/claim.d.ts +3 -0
  30. package/dist/claim.js +4 -1
  31. package/dist/contracts/chat-actions.d.ts +9 -0
  32. package/dist/contracts/chat-actions.js +3 -0
  33. package/dist/contracts/cli.d.ts +43 -0
  34. package/dist/contracts/cli.js +4 -0
  35. package/dist/contracts/console-api.d.ts +4 -1
  36. package/dist/contracts/console-api.js +4 -1
  37. package/dist/contracts/flow-propose.d.ts +4 -1
  38. package/dist/contracts/flow-propose.js +1 -1
  39. package/dist/contracts/flow.d.ts +41 -0
  40. package/dist/contracts/flow.js +4 -2
  41. package/dist/contracts/lead-tools.d.ts +21 -0
  42. package/dist/contracts/lead-tools.js +2 -0
  43. package/dist/contracts/store-json.d.ts +2 -0
  44. package/dist/contracts/trigger-payloads.d.ts +20 -0
  45. package/dist/contracts/trigger-payloads.js +4 -0
  46. package/dist/coordinator.js +1 -0
  47. package/dist/credentials-ui.d.ts +5 -3
  48. package/dist/credentials-ui.js +58 -31
  49. package/dist/deploy-gate.js +4 -0
  50. package/dist/design/kit.css +685 -0
  51. package/dist/design/kit.d.ts +208 -0
  52. package/dist/design/kit.js +177 -0
  53. package/dist/design/tokens.css +19 -1
  54. package/dist/discord-settings.js +8 -4
  55. package/dist/dispatch.d.ts +3 -0
  56. package/dist/dispatch.js +4 -1
  57. package/dist/export-ui.d.ts +6 -4
  58. package/dist/export-ui.js +18 -8
  59. package/dist/flow-browser.d.ts +103 -0
  60. package/dist/flow-browser.js +204 -0
  61. package/dist/flow-changed.d.ts +14 -0
  62. package/dist/flow-changed.js +53 -0
  63. package/dist/flow-code.d.ts +5 -1
  64. package/dist/flow-code.js +4 -1
  65. package/dist/flow-engine.js +16 -0
  66. package/dist/flow-gallery-ui.d.ts +41 -4
  67. package/dist/flow-gallery-ui.js +95 -63
  68. package/dist/flow-live.js +9 -1
  69. package/dist/flow-pull-request.js +15 -7
  70. package/dist/flow-share.js +3 -1
  71. package/dist/flow-starters-ui.d.ts +3 -9
  72. package/dist/flow-starters-ui.js +12 -23
  73. package/dist/flow-steps.d.ts +9 -1
  74. package/dist/flow-steps.js +55 -5
  75. package/dist/flow-triggers.js +55 -0
  76. package/dist/flow-unattended-words.d.ts +20 -0
  77. package/dist/flow-unattended-words.js +24 -0
  78. package/dist/flow-unattended.d.ts +9 -5
  79. package/dist/flow-unattended.js +45 -23
  80. package/dist/flows-cli.d.ts +19 -0
  81. package/dist/flows-cli.js +54 -5
  82. package/dist/flows-ui.d.ts +12 -6
  83. package/dist/flows-ui.js +152 -32
  84. package/dist/flows.js +5 -2
  85. package/dist/integrations-ui.d.ts +6 -4
  86. package/dist/integrations-ui.js +34 -40
  87. package/dist/integrations.d.ts +2 -2
  88. package/dist/integrations.js +4 -4
  89. package/dist/kits-ui.d.ts +8 -10
  90. package/dist/kits-ui.js +29 -33
  91. package/dist/kits.js +1 -1
  92. package/dist/knowledge-ui.js +76 -19
  93. package/dist/lead-tools.js +35 -6
  94. package/dist/lead-voice.js +16 -5
  95. package/dist/ledger-view.d.ts +2 -0
  96. package/dist/ledger-view.js +41 -38
  97. package/dist/models-ui.d.ts +8 -3
  98. package/dist/models-ui.js +82 -59
  99. package/dist/monitoring-ui.d.ts +4 -2
  100. package/dist/monitoring-ui.js +44 -13
  101. package/dist/needs-you.d.ts +1 -1
  102. package/dist/needs-you.js +8 -0
  103. package/dist/operate-remote-arguments.js +1 -0
  104. package/dist/operate-remote.js +1 -1
  105. package/dist/operate.d.ts +1 -1
  106. package/dist/operate.js +249 -6
  107. package/dist/passwordless.d.ts +1 -1
  108. package/dist/passwordless.js +1 -1
  109. package/dist/people-audit-ui.d.ts +2 -1
  110. package/dist/people-audit-ui.js +25 -23
  111. package/dist/policy-ui.d.ts +3 -2
  112. package/dist/policy-ui.js +27 -21
  113. package/dist/policy.d.ts +3 -0
  114. package/dist/policy.js +14 -1
  115. package/dist/project-delete-ui.d.ts +4 -2
  116. package/dist/project-delete-ui.js +135 -126
  117. package/dist/project-delete.js +7 -3
  118. package/dist/project-knowledge.d.ts +10 -10
  119. package/dist/publish.d.ts +11 -0
  120. package/dist/publish.js +16 -3
  121. package/dist/pull-request-flow.js +35 -4
  122. package/dist/pull-request-ui.d.ts +2 -1
  123. package/dist/pull-request-ui.js +39 -28
  124. package/dist/recipe-ui.d.ts +4 -2
  125. package/dist/recipe-ui.js +14 -4
  126. package/dist/release-adapters.d.ts +3 -0
  127. package/dist/release-adapters.js +22 -2
  128. package/dist/release-cli.js +81 -9
  129. package/dist/release.d.ts +21 -0
  130. package/dist/release.js +40 -4
  131. package/dist/releases.js +1 -1
  132. package/dist/request-budget-ui.d.ts +4 -2
  133. package/dist/request-budget-ui.js +30 -19
  134. package/dist/result-follow-ups.js +4 -0
  135. package/dist/retention-ui.d.ts +4 -3
  136. package/dist/retention-ui.js +15 -12
  137. package/dist/scope.d.ts +8 -0
  138. package/dist/scope.js +13 -0
  139. package/dist/serve.d.ts +0 -1
  140. package/dist/serve.js +51 -7
  141. package/dist/server/agents-computers.d.ts +9 -0
  142. package/dist/server/agents-computers.js +67 -0
  143. package/dist/server/chat.js +20 -18
  144. package/dist/server/chrome.d.ts +1 -1
  145. package/dist/server/chrome.js +19 -9
  146. package/dist/server/console.css +31 -2
  147. package/dist/server/flows.js +79 -36
  148. package/dist/server/home.d.ts +35 -0
  149. package/dist/server/home.js +104 -0
  150. package/dist/server/http.d.ts +3 -0
  151. package/dist/server/http.js +5 -0
  152. package/dist/server/kit-standalone.d.ts +22 -0
  153. package/dist/server/kit-standalone.js +69 -0
  154. package/dist/server/kit.d.ts +405 -0
  155. package/dist/server/kit.js +361 -0
  156. package/dist/server/pages.d.ts +2 -1
  157. package/dist/server/pages.js +213 -174
  158. package/dist/server/people-tokens.js +26 -85
  159. package/dist/server/render-history.d.ts +51 -0
  160. package/dist/server/render-history.js +228 -0
  161. package/dist/server/render-new-task.d.ts +29 -0
  162. package/dist/server/render-new-task.js +96 -0
  163. package/dist/server/render-next.d.ts +63 -0
  164. package/dist/server/render-next.js +109 -0
  165. package/dist/server/render-pages.d.ts +35 -190
  166. package/dist/server/render-pages.js +154 -563
  167. package/dist/server/render-people.d.ts +24 -3
  168. package/dist/server/render-people.js +93 -72
  169. package/dist/server/render-projects.d.ts +103 -0
  170. package/dist/server/render-projects.js +247 -0
  171. package/dist/server/render-results.d.ts +5 -24
  172. package/dist/server/render-results.js +48 -185
  173. package/dist/server/render-settings.d.ts +5 -74
  174. package/dist/server/render-settings.js +102 -493
  175. package/dist/server/render-tasks.d.ts +43 -5
  176. package/dist/server/render-tasks.js +131 -91
  177. package/dist/server/route-table.js +31 -5
  178. package/dist/server/settings.js +276 -255
  179. package/dist/server/task-views.d.ts +8 -0
  180. package/dist/server/task-views.js +90 -2
  181. package/dist/server/tasks.d.ts +18 -0
  182. package/dist/server/tasks.js +148 -41
  183. package/dist/server/terminals.js +1 -1
  184. package/dist/server/welcome.d.ts +22 -0
  185. package/dist/server/welcome.js +69 -0
  186. package/dist/settings-groups-ui.d.ts +279 -0
  187. package/dist/settings-groups-ui.js +615 -0
  188. package/dist/settings-kit.d.ts +42 -6
  189. package/dist/settings-kit.js +69 -7
  190. package/dist/settings-proofs.js +1 -0
  191. package/dist/setup-guide.d.ts +2 -2
  192. package/dist/skills-ui.js +59 -7
  193. package/dist/slack-settings.js +12 -5
  194. package/dist/spend-ui.d.ts +45 -26
  195. package/dist/spend-ui.js +124 -56
  196. package/dist/sso-ui.d.ts +5 -2
  197. package/dist/sso-ui.js +63 -31
  198. package/dist/storage-ui.d.ts +3 -2
  199. package/dist/storage-ui.js +56 -50
  200. package/dist/store.d.ts +182 -5
  201. package/dist/store.js +512 -38
  202. package/dist/surface.js +23 -3
  203. package/dist/task-archive.d.ts +10 -0
  204. package/dist/task-archive.js +16 -0
  205. package/dist/task-control.d.ts +1 -1
  206. package/dist/teams-settings.d.ts +2 -1
  207. package/dist/teams-settings.js +24 -41
  208. package/dist/telegram-settings.d.ts +0 -4
  209. package/dist/telegram-settings.js +52 -25
  210. package/dist/tidy.d.ts +130 -0
  211. package/dist/tidy.js +503 -0
  212. package/dist/toolroll-update-ui.d.ts +8 -4
  213. package/dist/toolroll-update-ui.js +55 -45
  214. package/dist/tools-ui.d.ts +5 -3
  215. package/dist/tools-ui.js +107 -42
  216. package/dist/work-index.d.ts +24 -0
  217. package/dist/work-index.js +56 -30
  218. package/dist/workspace-ui.d.ts +1 -1
  219. package/dist/workspace-ui.js +8 -4
  220. package/package.json +1 -1
package/dist/flows-ui.js CHANGED
@@ -24,8 +24,11 @@ import { callWords, receiptWords } from "./subagent-tools.js";
24
24
  import { undoFor } from "./subagent-week.js";
25
25
  import { flowCardTaskLine } from "./flow-card-task.js";
26
26
  import { unattendedViewOf } from "./flow-unattended.js";
27
+ import { browserViewOf } from "./flow-browser.js";
27
28
  import { readFlowSecrets } from "./flow-secrets.js";
28
29
  import { html, joinHtml, postForm } from "./html.js";
30
+ import { acts, btn, chip, count, disclose, doc, docHeader, dock, empty, field, group, infoLine, input as input_, note, notice, overlay, overlayTrigger, pageHeader, phoneHeader, row, segLinks, select, status } from "./server/kit.js";
31
+ import { agoWords } from "./lead-voice.js";
29
32
  const projectName = (repo) => repo.split(/[\\/]/).filter(Boolean).pop() ?? repo;
30
33
  /** v126: what happens to deliveries turned away (refused with 503), as far as each sender is known to say: nothing
31
34
  * here claims a loss that a sender's own retries may still make good. */
@@ -61,40 +64,149 @@ function hookView(trigger, config, dir) {
61
64
  return { ready, needsSecret: sender === "sentry" || sender === "vercel", sender, makesSecret: sender === "standard" || sender === "github-style", warning, readerNote: [readerNote, repeats].filter(one => one !== null).join(" ") || null, dropped,
62
65
  scope: sender === "sentry" || sender === "vercel" ? { project: config.project ?? null, team: config.team ?? null } : null };
63
66
  }
64
- export const FLOWS_CSS = `.flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:10px 0 0}.flows .flow-import{margin-top:18px;padding-top:14px;border-top:1px solid var(--so-line)}.flows .flow-import h3{font-size:.95rem;margin:0}.flows .flow-terms{list-style:none;padding:0;margin:8px 0 16px;display:grid;gap:10px}.flows .flow-terms li{white-space:pre-line;overflow-wrap:anywhere}.flows .flow-terms pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.8rem;margin:6px 0 0}.flows .flow-import-preview h2{font-size:1rem;margin:20px 0 4px}.flows .flow-untrusted{font-weight:600}.flows button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}.flows fieldset{border:0;padding:0;margin:0;display:grid;gap:10px;min-width:0}.flows legend{font-weight:600;padding:0;margin-bottom:6px}.flows textarea[hidden]{display:none}.flows button.secondary{background:transparent;color:var(--foreground);border:1px solid var(--border)}@media(max-width:600px){.flows input,.flows select{font-size:16px}}@media(max-width:760px){.flows .card{padding:10px 12px;margin:8px 0;position:relative}.flows .flow-row{row-gap:2px}.flows .flow-row h2 a::after{content:"";position:absolute;inset:0;border-radius:inherit}.flows .flow-counts{line-height:1.35}}`;
65
- /** The flows a person can open, with how many cards wait in each, and the new-flow form. */
66
- export function flowsListHtml(store, flows, projects, canCreate, problem) {
67
- const rows = flows.map(flow => {
68
- const cards = store.flowCards(flow.id, false);
69
- const definition = flowDefinitionOf(flow);
70
- const waiting = cards.filter(card => ["approval", "choose"].includes(definition?.stages.find(one => one.id === card.stage)?.kind ?? "")).length;
71
- const buttons = store.flowTriggers(flow.id).filter(one => one.state === "active").flatMap(one => { const config = triggerConfigOf(one); return config?.kind === "button" ? [{ id: one.id, label: config.label }] : []; });
72
- const trouble = troubleWords(flowInsights(store, flow, new Date(), 7)) ?? nightlyHealthOf(store, flow);
73
- return html `<article class="card"><div class="flow-row"><h2><a href="/flows/${flow.id}">${flow.name}</a></h2><span class="flow-counts">${projectName(flow.repo)} · ${cards.length} card${cards.length === 1 ? "" : "s"} in progress${waiting > 0 ? ` · ${waiting} waiting for a decision` : ""}${trouble === null ? "" : ` · ${trouble}`}</span></div>${buttons.length > 0 && html `<p class="flow-buttons">${joinHtml(buttons.map(one => html `<a class="button-link" href="/flows/${flow.id}?start=${one.id}">${one.label}</a>`), " ")}</p>`}</article>`;
67
+ /** The flow page's fallback rules, plus the Flows list and the import preview (kit pieces; only layout here). */
68
+ export const FLOWS_CSS = `.flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}.flows fieldset{border:0;padding:0;margin:0;display:grid;gap:10px;min-width:0}.flows legend{font-weight:600;padding:0;margin-bottom:6px}.flows textarea[hidden]{display:none}.flows button.secondary{background:transparent;color:var(--foreground);border:1px solid var(--border)}@media(max-width:600px){.flows input,.flows select{font-size:16px}}@media(max-width:760px){.flows .card{padding:10px 12px;margin:8px 0;position:relative}}` +
69
+ // The list: the phone's project bar under the phone header; notices and the example in the page's gutter.
70
+ `.fl-pbar{display:flex;gap:8px;align-items:center;padding:10px 16px;border-bottom:1px solid var(--so-line)}.fl-pbar form{flex:1;display:flex;gap:8px;margin:0;min-width:0}.fl-pbar .k-selw{flex:1;min-width:0}` +
71
+ `.fl-say{padding:12px 24px 0}.fl-try{display:grid;gap:10px;justify-items:start;padding:0 24px 24px}.fl-try form{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0}` +
72
+ // The import drawer: the file chooser is a labelled outline button over the native input.
73
+ `.fl-imp{display:grid;gap:18px;margin:0}.fl-file{display:flex;align-items:center;gap:10px;min-width:0}.fl-file input[type=file]{position:absolute;width:1px;height:1px;opacity:0}.fl-file label{cursor:pointer}.fl-file label:focus-within{outline:2px solid var(--so-ring);outline-offset:2px}.fl-file .k-note{min-width:0;overflow-wrap:anywhere}` +
74
+ `.fl-imp textarea[hidden]{display:none}.fl-terms{list-style:none;padding:0;margin:0;display:grid;gap:8px;font-size:13px;line-height:1.5}.fl-terms li{white-space:pre-line;overflow-wrap:anywhere}` +
75
+ `.fl-terms pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0 0}.fl-pv-form{display:grid;gap:16px;margin:0}.fl-pv-form fieldset{border:0;padding:0;margin:0;display:grid;gap:14px;min-width:0}.fl-pv-form legend{font:600 13px/1.3 var(--so-font-sans);padding:0;margin-bottom:10px}` +
76
+ `@media(max-width:760px){.fl-say{padding:12px 16px 0}.fl-try{padding:0 16px 24px}.fl-file label{min-height:44px}}`;
77
+ /** "in 12 min", "in 5 h", "in 3 days": when a trigger next looks. */
78
+ function nextWords(at, now) {
79
+ const minutes = Math.max(0, Math.round((Date.parse(at) - now.getTime()) / 60_000));
80
+ if (!Number.isFinite(minutes) || minutes < 1)
81
+ return "now";
82
+ if (minutes < 60)
83
+ return `in ${minutes} min`;
84
+ const hours = Math.round(minutes / 60);
85
+ return hours < 24 ? `in ${hours} h` : `in ${Math.round(hours / 24)} day${Math.round(hours / 24) === 1 ? "" : "s"}`;
86
+ }
87
+ /** What starts a flow, in a few words: its active triggers, else the paused ones, else that cards are added by hand. */
88
+ function triggerWords(store, triggers) {
89
+ const shown = triggers.filter(one => one.state === "active");
90
+ const pick = shown.length > 0 ? shown : triggers.filter(one => one.state === "paused");
91
+ const words = pick.flatMap(one => {
92
+ const config = triggerConfigOf(one);
93
+ if (config === null)
94
+ return [];
95
+ return [config.kind === "button" ? `When someone presses ${config.label}` : triggerHeadline(config, store).name];
74
96
  });
75
- const projectField = projects.length === 1 ? html `<input type="hidden" name="repo" value="${projects[0]}">` : html `<label>Project<select name="repo">${projects.map(repo => html `<option value="${repo}">${projectName(repo)}</option>`)}</select></label>`;
76
- // A flow file (flow-share.ts): chosen here or fetched from a gist, previewed in plain words before anything is made.
77
- const importForm = postForm("/flows/import", html `<h3>Or import a flow file</h3>${projectField}<label>File<input type="file" accept=".json,application/json" data-flow-file></label><textarea name="document" hidden data-flow-document></textarea><label>Or its address<input name="url" type="url" inputmode="url" placeholder="https://gist.github.com/…"></label><p class="problem" role="alert" data-flow-file-note hidden></p><button>Preview import</button>`, { attrs: { class: "flow-import", "data-flow-import": true } });
78
- // A new flow starts from the gallery (Flows → New): templates grouped by what they're for.
79
- const create = canCreate && projects.length > 0 && html `<p class="flow-buttons"><a class="button-link" href="/flows/new">New flow</a></p>${importForm}`;
80
- // No flows yet: one click makes a working example with a sample question in it.
81
- const example = flows.length === 0 && canCreate && projects.length > 0
82
- ? postForm("/flows/example", html `<h2>See a flow work</h2><p class="meta">Claude drafts a reply to a sample customer question; you approve it here or in your chat app.</p>${projectField}<button>Try an example</button>`, { attrs: { class: "card flow-example" } })
83
- : null;
84
- const intro = html `<p class="meta">A flow is your process drawn as zones. Cards move through them: agents do the work, people approve, and the team hears about it.</p>${canCreate && projects.length > 0 && html `<p class="flow-chat">Describe how work should move and your lead drafts the flow for you to confirm, or start from a template. <a href="/chat?draft=${encodeURIComponent("Make a flow for ")}">Describe it in chat</a></p>`}<p class="flow-chat">subagents can decide and handle cards for you, within rules you write. <a href="/settings/lead/subagents">Subagents</a> · or set one up with its flow in one click: <a href="/kits">Starter kits</a></p>`;
85
- return html `<section class="flows">${problem !== null && html `<p class="problem" role="alert">${problem}</p>`}${intro}${create}${rows.length > 0 ? rows : example ?? html `<p class="meta">No flows yet.</p>`}</section>`;
97
+ if (words.length === 0)
98
+ return "Cards added by hand";
99
+ return words.length === 1 ? words[0] : `${words[0]} and ${words.length - 1} more`;
100
+ }
101
+ const FLOW_LIST_GROUPS = [["needs", "Needs you"], ["running", "Running"], ["on", "Turned on"], ["paused", "Paused"]];
102
+ /** One flow as a quiet row: what the store knows now (cards waiting on a decision, cards in progress, its triggers). */
103
+ function flowListRow(store, flow, now, options) {
104
+ const cards = store.flowCards(flow.id, false);
105
+ const definition = flowDefinitionOf(flow);
106
+ const kindOf = (card) => definition?.stages.find(one => one.id === card.stage)?.kind ?? "";
107
+ const waiting = cards.filter(card => ["approval", "choose"].includes(kindOf(card)));
108
+ const triggers = store.flowTriggers(flow.id).filter(one => one.state !== "removed");
109
+ const active = triggers.filter(one => one.state === "active");
110
+ const button = active.flatMap(one => { const config = triggerConfigOf(one); return config?.kind === "button" ? [{ id: one.id, label: config.label }] : []; })[0];
111
+ const trouble = troubleWords(flowInsights(store, flow, now, 7)) ?? nightlyHealthOf(store, flow);
112
+ const starts = triggerWords(store, triggers);
113
+ const next = active.map(one => one.nextAt).filter((one) => one !== null).sort()[0];
114
+ const meta = html `${options.showProject ? html `<span>${projectName(flow.repo)}</span>` : ""}${next !== undefined && active.length > 0 && waiting.length === 0 ? html `<span class="k-num">Next ${nextWords(next, now)}</span>` : ""}`;
115
+ const start = button !== undefined && options.canAct ? btn(button.label, { sm: true, href: `/flows/${flow.id}?start=${button.id}` }) : html ``;
116
+ const plural = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
117
+ const tail = trouble === null ? "" : ` · ${trouble}`;
118
+ if (waiting.length > 0) {
119
+ const first = waiting[0];
120
+ const ask = kindOf(first) === "choose" ? chip("Choose", "ask") : chip("Approve", "ask");
121
+ const said = waiting.length === 1 ? `${first.title} waits for your decision` : `${plural(waiting.length, "card")} wait for your decision`;
122
+ return { group: "needs", row: row({ href: `/flows/${flow.id}`, chip: ask, title: flow.name, desc: `${starts} · ${said}${tail}`, meta, wait: true,
123
+ action: btn("Review", { sm: true, href: `/flows/${flow.id}?card=${first.id}`, attrs: { "aria-label": `Review ${first.title}` } }), attrs: { "data-flow": flow.id } }) };
124
+ }
125
+ if (cards.length > 0) {
126
+ return { group: "running", row: row({ href: `/flows/${flow.id}`, chip: status("running"), title: flow.name, desc: `${starts} · ${plural(cards.length, "card")} in progress${tail}`, meta, action: start, attrs: { "data-flow": flow.id } }) };
127
+ }
128
+ if (active.length > 0) {
129
+ const failing = active.find(one => one.failures > 0);
130
+ const last = active.filter(one => one.lastAt !== null).sort((a, b) => (b.lastAt ?? "").localeCompare(a.lastAt ?? ""))[0];
131
+ const chipNow = failing !== undefined || trouble !== null ? status("failed") : last === undefined ? chip("Not run yet", "", { dot: false }) : status("finished");
132
+ const lastWords = failing !== undefined ? `last check failed ${agoWords(failing.lastAt ?? failing.updatedAt, now)}${failing.lastOutcome ? `: ${failing.lastOutcome}` : ""}`
133
+ : last === undefined ? "not run yet" : `last ran ${agoWords(last.lastAt, now)}${last.lastOutcome ? `: ${last.lastOutcome}` : ""}`;
134
+ return { group: "on", row: row({ href: `/flows/${flow.id}`, chip: chipNow, title: flow.name, desc: `${starts} · ${lastWords}${tail}`, meta, action: start, attrs: { "data-flow": flow.id } }) };
135
+ }
136
+ const paused = triggers.length > 0;
137
+ return { group: "paused", row: row({ href: `/flows/${flow.id}`, chip: chip(paused ? "Paused" : "Off", "", { dot: false }), title: flow.name,
138
+ desc: paused ? `${starts} · paused${tail}` : `${starts} · nothing starts it on its own${tail}`, meta, attrs: { "data-flow": flow.id } }) };
139
+ }
140
+ /** The project field of a list form: a select when there's a choice. */
141
+ const projectSelect = (id, projects, current) => projects.length === 1
142
+ ? html `<input type="hidden" name="repo" value="${projects[0]}">`
143
+ : field({ id, label: "Project", control: select({ id, name: "repo", items: projects.map(repo => [repo, projectName(repo)]), current: current ?? projects[0], wide: true }) });
144
+ /** The import form: a flow file chosen here or fetched from a gist, previewed in plain words before anything is made. */
145
+ function importFormHtml(projects, current) {
146
+ return postForm("/flows/import", html `${note("A flow file from another Toolroll, a teammate or a gist. Nothing runs until you import it, and any script it brings waits for your approval.")}\
147
+ ${projectSelect("fl-imp-project", projects, current)}\
148
+ <div class="k-field"><span class="k-label">File</span><div class="fl-file"><label class="k-btn k-btn-outline k-btn-sm"><input type="file" accept=".json,application/json" data-flow-file><span class="k-face">Choose a file</span></label><span class="k-note" data-flow-file-name>No file chosen</span></div></div>\
149
+ <textarea name="document" hidden data-flow-document></textarea>\
150
+ ${field({ id: "fl-imp-url", label: "Or its address", control: input_({ id: "fl-imp-url", name: "url", type: "url", attrs: { inputmode: "url", placeholder: "https://gist.github.com/…" } }) })}\
151
+ <p class="k-notice" role="alert" data-flow-file-note hidden></p>${acts(btn("Preview import", { type: "submit" }))}`, { attrs: { class: "fl-imp", "data-flow-import": true } });
152
+ }
153
+ /**
154
+ * Flows: the top bar (a project filter, More, New flow), then each flow as a quiet row under Needs you, Running,
155
+ * Turned on and Paused. Words come only from what the store knows now: cards waiting on a decision, cards in
156
+ * progress, and the triggers (on, paused, when they last looked and what they found).
157
+ */
158
+ export function flowsListHtml(store, flows, projects, canCreate, problem, filter = null, now = new Date()) {
159
+ const scope = filter !== null && projects.includes(filter) ? filter : null;
160
+ const shown = scope === null ? flows : flows.filter(one => one.repo === scope);
161
+ const making = canCreate && projects.length > 0;
162
+ const href = (repo) => repo === null ? "/flows" : `/flows?project=${encodeURIComponent(repo)}`;
163
+ const seg = projects.length > 1 ? segLinks("Project", scope ?? "all", [{ key: "all", label: "All", href: "/flows" }, ...projects.map(repo => ({ key: repo, label: projectName(repo), href: href(repo) }))]) : html ``;
164
+ const more = (id, phone) => overlayTrigger("More", "fl-more", { variant: phone ? "outline" : "ghost", sm: !phone, iconOnly: "more", ...(id === "" ? {} : { attrs: { id } }) });
165
+ const create = making ? btn("New flow", { variant: "primary", sm: true, lead: "plus", href: "/flows/new" }) : html ``;
166
+ const header = pageHeader("Flows", { right: html `${seg}${more("", false)}${create}` });
167
+ // On a phone the filter is a select (the form posts nothing: it's an address), beside More.
168
+ const phoneBar = html `<div class="fl-pbar k-phone-only">${projects.length > 1 ? html `<form method="get" action="/flows">${select({ name: "project", label: "Project", items: [["", "All projects"], ...projects.map(repo => [repo, projectName(repo)])], current: scope ?? "", wide: true, attrs: { "data-k-autosubmit": "" } })}<noscript>${btn("Show", { sm: true, type: "submit" })}</noscript></form>` : html `<span class="k-grow"></span>`}${more("", true)}</div>`;
169
+ const menu = overlay({ id: "fl-more", kind: "menu", title: "More", body: html `${making ? html `<button type="button" data-k-open="fl-import" aria-haspopup="dialog" aria-controls="fl-import">Import a flow file…</button>` : ""}<a href="/flows/new?group=kits">Starter kits</a><a href="/settings/lead/subagents">Subagents</a>` });
170
+ const importDrawer = making ? overlay({ id: "fl-import", kind: "drawer", title: "Import a flow file", body: importFormHtml(projects, scope) }) : html ``;
171
+ const say = problem !== null ? html `<div class="fl-say">${notice(problem)}</div>` : html ``;
172
+ let body;
173
+ if (flows.length === 0) {
174
+ const example = making ? html `<div class="fl-try">${postForm("/flows/example", html `<span class="k-note">Or see one work: Claude drafts a reply to a sample customer question for you to approve.</span>${projects.length === 1 ? html `<input type="hidden" name="repo" value="${projects[0]}">` : select({ name: "repo", label: "Project", items: projects.map(repo => [repo, projectName(repo)]), sm: true })}${btn("Try an example", { sm: true, type: "submit" })}`)}</div>` : html ``;
175
+ const words = { title: "No flows yet.", text: "A flow repeats work for you: run your tests every night, turn labelled issues into pull requests, draft replies to support mail." };
176
+ body = html `${empty(making ? { ...words, action: acts(btn("Browse templates", { sm: true, href: "/flows/new" }), overlayTrigger("Import a flow file", "fl-import", { variant: "ghost", sm: true })) } : words)}${example}`;
177
+ }
178
+ else if (shown.length === 0) {
179
+ body = empty({ title: `No flows in ${projectName(scope)}.`, action: btn("Show all projects", { variant: "ghost", sm: true, href: "/flows" }) });
180
+ }
181
+ else {
182
+ const rows = shown.map(flow => flowListRow(store, flow, now, { canAct: canCreate, showProject: scope === null && projects.length > 1 }));
183
+ body = html `<div class="fl-rows" aria-label="Flows">${FLOW_LIST_GROUPS.map(([key, label]) => {
184
+ const mine = rows.filter(one => one.group === key);
185
+ return mine.length === 0 ? "" : html `${group(label, count(mine.length))}${mine.map(one => one.row)}`;
186
+ })}</div>`;
187
+ }
188
+ return html `<div class="fl-page" data-kit-page>${header}${phoneBar}${say}${body}${menu}${importDrawer}${making ? dock(btn("New flow", { variant: "primary", lead: "plus", href: "/flows/new" })) : ""}</div>`;
86
189
  }
87
- /** Reads the chosen flow file into the form (the server reads it again, whole, before anything is made). */
88
- export const FLOW_IMPORT_SCRIPT = `(function(){var form=document.querySelector('[data-flow-import]');if(!form)return;var input=form.querySelector('[data-flow-file]'),text=form.querySelector('[data-flow-document]'),note=form.querySelector('[data-flow-file-note]'),address=form.querySelector('[name=url]');function say(words){note.textContent=words;note.hidden=words==='';}var reading=null;input.addEventListener('change',function(){text.value='';say('');var file=input.files&&input.files[0];if(!file){reading=null;return;}if(file.size>${FLOW_FILE_MAX_BYTES}){say('That file is too big: a flow file is at most ${FLOW_FILE_MAX_BYTES / 1024} KB.');input.value='';reading=null;return;}reading=file.text().then(function(value){text.value=value;});});form.addEventListener('submit',function(event){if(reading!==null&&text.value===''){event.preventDefault();reading.then(function(){form.requestSubmit();});return;}if(text.value===''&&address.value.trim()===''){event.preventDefault();say('Choose a flow file or give its address.');}});})();`;
89
- /** An import's preview: the plan in plain words, what it asks for, and one Import button after the terms. */
190
+ /** Reads the chosen flow file into the form (the server reads it again, whole, before anything is made), and sends a
191
+ * filter select's form when it changes. */
192
+ export const FLOW_IMPORT_SCRIPT = `(function(){document.querySelectorAll('select[data-k-autosubmit]').forEach(function(s){s.addEventListener('change',function(){if(s.form)s.form.requestSubmit();});});var form=document.querySelector('[data-flow-import]');if(!form)return;var input=form.querySelector('[data-flow-file]'),text=form.querySelector('[data-flow-document]'),note=form.querySelector('[data-flow-file-note]'),named=form.querySelector('[data-flow-file-name]'),address=form.querySelector('[name=url]');function say(words){note.textContent=words;note.hidden=words==='';}var reading=null;input.addEventListener('change',function(){text.value='';say('');var file=input.files&&input.files[0];if(named)named.textContent=file?file.name:'No file chosen';if(!file){reading=null;return;}if(file.size>${FLOW_FILE_MAX_BYTES}){say('That file is too big: a flow file is at most ${FLOW_FILE_MAX_BYTES / 1024} KB.');input.value='';if(named)named.textContent='No file chosen';reading=null;return;}reading=file.text().then(function(value){text.value=value;});});form.addEventListener('submit',function(event){if(reading!==null&&text.value===''){event.preventDefault();reading.then(function(){form.requestSubmit();});return;}if(text.value===''&&address.value.trim()===''){event.preventDefault();say('Choose a flow file or give its address.');}});})();`;
193
+ /** An import's preview, a reading page: the plan in plain words, what it asks for, and one Import button after the terms. */
90
194
  export function flowImportHtml(input) {
91
195
  const { plan, file } = input;
92
- const fields = file.parameters.map(one => html `<label><span>${one.about}${one.optional === true && html ` <span class="meta">(optional)</span>`}</span><input name="param.${one.id}" value="${input.values[one.id] ?? plan?.values[one.id] ?? one.default ?? ""}" maxlength="200" autocomplete="off"></label>`);
93
- const list = (items) => items.length === 0 ? "" : html `<ul class="flow-terms">${items.map(term => html `<li>${term}</li>`)}</ul>`;
196
+ const fields = file.parameters.map((one, index) => field({ id: `fl-param-${index}`, label: one.optional === true ? `${one.about} (optional)` : one.about,
197
+ control: input_({ id: `fl-param-${index}`, name: `param.${one.id}`, value: input.values[one.id] ?? plan?.values[one.id] ?? one.default ?? "", attrs: { maxlength: 200, autocomplete: "off" } }) }));
198
+ const list = (items) => items.length === 0 ? "" : html `<ul class="fl-terms">${items.map(term => html `<li>${term}</li>`)}</ul>`;
199
+ const section = (title, body) => html `<section class="k-sec"><h3>${title}</h3>${body}</section>`;
94
200
  // The steps, what they mean, the triggers and the scripts; each script's code one tap away, as on the Scripts panel.
95
- const terms = plan === null ? "" : html `${plan.parts.about !== null && html `<p>${plan.parts.about}</p>`}<p class="flow-untrusted">Its instructions come from the file, not from you: read what each step is asked before you import.</p>${list(plan.parts.steps)}${plan.parts.triggers.length > 0 && html `<h2>Triggers</h2>${list(plan.parts.triggers)}`}${plan.parts.scripts.length + plan.parts.kept.length > 0 && html `<h2>Scripts</h2><ul class="flow-terms">${plan.scripts.add.map((one, index) => html `<li>${plan.parts.scripts[index]}${one.body !== undefined && html `<details><summary>Show the script</summary><pre>${one.body}</pre></details>`}</li>`)}${plan.parts.kept.map(one => html `<li>${one}</li>`)}</ul>`}<h2>Good to know</h2>${list([...plan.parts.notes, ...(plan.parts.needs === null ? [] : [plan.parts.needs])])}`;
96
- const form = postForm("/flows/import", html `<textarea name="document" hidden>${input.document}</textarea>${fields.length > 0 && html `<fieldset class="flow-import-fields"><legend>Fill in</legend>${fields}</fieldset>`}<p class="flow-buttons">${plan !== null && html `<button name="confirm" value="yes" class="primary">Import flow</button>`}${fields.length > 0 && html `<button name="confirm" value="" class="${plan === null ? "" : "secondary"}">Update preview</button>`}<a href="/flows">Cancel</a></p>`, { attrs: { class: "card" }, hidden: { repo: input.repo, previewed: input.previewed } });
97
- return html `<section class="flows flow-import-preview">${input.problem !== null && html `<p class="problem" role="alert">${input.problem}</p>`}<p class="meta">Into ${projectName(input.repo)}. Nothing is made until you import it.</p>${terms}${form}</section>`;
201
+ const terms = plan === null ? "" : html `${plan.parts.about !== null && html `<p class="k-note">${plan.parts.about}</p>`}\
202
+ ${section("Steps", html `${infoLine("Its instructions come from the file, not from you: read what each step is asked before you import.")}${list(plan.parts.steps)}`)}\
203
+ ${plan.parts.triggers.length > 0 && section("Triggers", list(plan.parts.triggers))}\
204
+ ${plan.parts.scripts.length + plan.parts.kept.length > 0 && section("Scripts", html `<ul class="fl-terms">${plan.scripts.add.map((one, index) => html `<li>${plan.parts.scripts[index]}${one.body !== undefined && disclose("Show the script", html `<pre class="k-mono">${one.body}</pre>`)}</li>`)}${plan.parts.kept.map(one => html `<li>${one}</li>`)}</ul>`)}\
205
+ ${section("Good to know", list([...plan.parts.notes, ...(plan.parts.needs === null ? [] : [plan.parts.needs])]))}`;
206
+ const importButton = plan !== null ? btn("Import flow", { variant: "primary", type: "submit", docked: true, attrs: { name: "confirm", value: "yes" } }) : html ``;
207
+ const form = postForm("/flows/import", html `<textarea name="document" hidden>${input.document}</textarea>${fields.length > 0 && html `<fieldset><legend>Fill in</legend>${fields}</fieldset>`}\
208
+ ${acts(importButton, fields.length > 0 ? btn(plan === null ? "Preview" : "Update preview", { variant: plan === null ? "primary" : "outline", type: "submit", attrs: { name: "confirm", value: "" } }) : "", btn("Cancel", { variant: "ghost", href: "/flows" }))}`, { attrs: { class: "fl-pv-form", id: "fl-pv-form" }, hidden: { repo: input.repo, previewed: input.previewed } });
209
+ return html `<div class="fl-page" data-kit-page>${phoneHeader(`Import ${file.name}`, { back: "/flows" })}${doc(docHeader({ title: `Import ${file.name}`, back: ["/flows", "Flows"], meta: `Into ${projectName(input.repo)}. Nothing is made until you import it.`, same: true }), input.problem !== null ? notice(input.problem) : "", terms, form)}${plan !== null ? dock(btn("Import flow", { variant: "primary", type: "submit", attrs: { form: "fl-pv-form", name: "confirm", value: "yes" } })) : ""}</div>`;
98
210
  }
99
211
  const historyText = (event, title, sorts = new Set()) => {
100
212
  const who = event.actor === "flow" ? "" : ` by ${event.actor}`;
@@ -114,6 +226,8 @@ const historyText = (event, title, sorts = new Set()) => {
114
226
  export function flowView(store, flow, viewer, selectedCard, setup = { dir: null, repos: [] }) {
115
227
  const definition = flowDefinitionOf(flow);
116
228
  const stages = definition?.stages ?? [];
229
+ const browserNow = browserViewOf(store, flow);
230
+ const browser = browserNow === null ? null : { ...browserNow, canApprove: viewer.approver && store.accountOf(viewer.name)?.projects === null };
117
231
  const title = (id) => stages.find(one => one.id === id)?.title ?? id;
118
232
  const sortZones = new Set(stages.filter(one => one.kind === "sort").map(one => one.id));
119
233
  const decisions = sortZones.size === 0 ? new Map() : store.flowSortDecisions(flow.id);
@@ -179,10 +293,15 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
179
293
  // After research: the same numbered items the message carried, each beside its screenshot.
180
294
  ...(content.items === undefined ? {} : { items: content.items.map((item, index) => ({ number: index + 1, title: item.title, lines: whyLines(item.why), source: item.source, url: item.url,
181
295
  image: item.shot === null || content.shots === null ? null : { src: `/r/${content.shots.run}/evidence/${item.shot}`, caption: itemCaption(index + 1, item) } })) }) });
296
+ const browserWait = card.state === "active" && stage?.kind === "check" && stage.browser === true && browser !== null && browser.status !== "allowed"
297
+ && card.waiting !== null && card.waiting.startsWith(`${stage.title} starts a browser`);
182
298
  return {
183
299
  id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state,
184
300
  waiting: card.state === "active" && card.task !== null && (stage?.kind === "task" || stage?.kind === "report")
185
- ? canReadTask ? flowCardTaskLine(store, card.task, taskRepo, now, secretsFor(taskRepo)) : "Task unavailable" : card.waiting,
301
+ ? canReadTask ? flowCardTaskLine(store, card.task, taskRepo, now, secretsFor(taskRepo)) : "Task unavailable"
302
+ // Waiting for browser use: the page offers Allow browser itself, so the command line the CLI shows is left off.
303
+ : browserWait ? card.waiting.replace(/ Approve it with: toolroll flows browser \d+ on$/, "") : card.waiting,
304
+ ...(browserWait ? { browserWait: true } : {}),
186
305
  task: task === null ? null : { id: task, href: `/t/${encodeURIComponent(task)}`,
187
306
  // Its running attempt's composer, for an approver who may read the task (steer-controls.ts).
188
307
  ...(() => {
@@ -252,7 +371,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
252
371
  requestSecrets: flowSecretNames(setup.dir, flow.repo),
253
372
  tools: projectToolsOf(store, flow.repo).map(tool => ({ name: tool.name, about: tool.spec.about, functions: tool.lastTest?.ok === true ? tool.lastTest.tools : [],
254
373
  ready: toolStanding(tool, secretsSetFor(flow.repo, tool.spec, setup.toolHome)).ready })),
255
- scripts: store.flowScripts(flow.repo).map(script => ({ name: script.name, about: script.about, body: script.body, timeoutMinutes: script.timeoutMinutes, version: script.version, savedBy: script.savedBy, savedAt: script.savedAt,
374
+ scripts: store.flowScripts(flow.repo).map(script => ({ name: script.name, about: script.about, body: script.body, timeoutMinutes: script.timeoutMinutes, version: script.version, digest: script.digest, savedBy: script.savedBy, savedAt: script.savedAt,
256
375
  language: script.language, file: script.file, held: script.held !== null,
257
376
  usedHere: stages.filter(stage => stage.kind === "check" && stage.script === script.name).map(stage => stage.title) })),
258
377
  start: definition?.start ?? stages[0]?.id ?? "",
@@ -262,6 +381,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
262
381
  live: flowFingerprint(store, flow.id),
263
382
  canEdit: viewer.approver,
264
383
  unattended: unattendedViewOf(store, flow, viewer, setup.now ?? now),
384
+ browser,
265
385
  approvers: store.listApprovers().map(one => one.name).filter(name => store.accountCanAccess(name, flow.repo)),
266
386
  projects: [flow.repo, ...setup.repos.filter(one => one !== flow.repo)].map(path => ({ path, name: projectName(path) })),
267
387
  kinds: FLOW_STAGE_KINDS.map(kind => ({ kind, label: FLOW_KIND_WORDS[kind].label, about: FLOW_KIND_WORDS[kind].about })),
@@ -271,7 +391,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
271
391
  /** The page the canvas replaces when scripts can't run: each zone and its cards, in order. */
272
392
  export function flowFallbackHtml(view) {
273
393
  const own = view.unattended.active;
274
- return html `<section class="flows flow-fallback"><p class="meta">${view.flow.project}</p>${own !== null && html `<p data-flow-unattended>Runs on its own until ${own.untilWords}</p>`}<ol>${view.stages.map(stage => {
394
+ return html `<section class="flows flow-fallback"><p class="meta">${view.flow.project}</p>${own !== null && html `<p data-flow-unattended>Runs on its own until ${own.untilWords}</p>`}${view.browser !== null && html `<p data-flow-browser="${view.browser.status}">${view.browser.words}</p>`}<ol>${view.stages.map(stage => {
275
395
  const cards = view.cards.filter(card => card.stage === stage.id && card.state === "active");
276
396
  return html `<li><strong>${stage.title}</strong> · ${FLOW_KIND_WORDS[stage.kind].label}${cards.length > 0 && html `<ul>${cards.map(card => html `<li>${card.title}${card.waiting !== null && ` — ${card.waiting}`}</li>`)}</ul>`}</li>`;
277
397
  })}</ol></section>`;
package/dist/flows.js CHANGED
@@ -224,7 +224,9 @@ function canonicalStage(input, index) {
224
224
  close: kind === "update" ? stage["close"] !== false : null,
225
225
  script: kind === "check" ? said(stage["script"]) : null,
226
226
  // Left out, a script runs where every script ran before v90: in a copy of the card's work.
227
- ...(kind === "check" ? { ...(runIn === undefined ? {} : { runIn }), ...(routes === undefined ? {} : { routes }), ...(secrets === undefined ? {} : { secrets }) } : {}),
227
+ ...(kind === "check" ? { ...(runIn === undefined ? {} : { runIn }), ...(routes === undefined ? {} : { routes }), ...(secrets === undefined ? {} : { secrets }),
228
+ // v144: a script that starts a browser says so; false or none is a script that doesn't (and keeps its digest).
229
+ ...(!given(stage["browser"]) || stage["browser"] === false ? {} : { browser: stage["browser"] }) } : {}),
228
230
  sort: kind === "sort" ? canonicalSort(stage["sort"]) : null,
229
231
  ...(kind === "request" ? { request: { method, url: said(request["url"]), headers: Object.fromEntries(Object.entries(isRecord(request["headers"]) ? request["headers"] : {}).flatMap(([name, value]) => { const one = said(value); return one === null ? [] : [[name, one]]; })), body: method === "GET" || method === "DELETE" ? null : said(request["body"]) } } : {}),
230
232
  ...(kind === "email" ? { email: { to: said(email["to"]), subject: said(email["subject"]), body: said(email["body"]) } } : {}),
@@ -732,6 +734,7 @@ export function flowFromSteps(input, previous = null) {
732
734
  ...(step.runIn !== undefined ? { runIn: step.runIn } : old?.runIn === undefined ? {} : { runIn: old.runIn }),
733
735
  ...(step.routes !== undefined ? routesOf(step.routes) : old?.routes === undefined ? {} : { routes: old.routes }),
734
736
  ...(step.secrets !== undefined ? step.secrets.length === 0 ? {} : { secrets: step.secrets } : old?.secrets === undefined ? {} : { secrets: old.secrets }),
737
+ ...((step.browser ?? old?.browser) === true ? { browser: true } : {}),
735
738
  } : {}),
736
739
  sort: kind === "sort" ? sortFromStep(step, old?.sort ?? null, find) : null,
737
740
  ...(kind === "request" ? { request: { method: step.method ?? old?.request?.method ?? "POST", url: step.url ?? old?.request?.url ?? "", headers: step.headers ?? old?.request?.headers ?? {}, body: step.body ?? old?.request?.body ?? null } } : {}),
@@ -912,7 +915,7 @@ export function flowTerms(definition, previous) {
912
915
  lines.push("Opens a pull request for the card's built result, then waits for CI.", `Checks pass → ${stage.merge === undefined ? to(stage.next) : `merges it (${stage.merge}) and deletes its branch, only when a person approved it${approval === undefined ? "" : ` at ${approval.title}`} since it was built. Then → ${to(stage.next)}`}`, `Checks fail → ${stage.onFail === null ? "waits there, naming the failing check." : `${to(stage.onFail)} The failing check is named on the card.`}`);
913
916
  }
914
917
  else if (stage.kind === "check")
915
- lines.push(`Runs the project's script “${stage.script}” with no AI.`, `Passes → ${to(stage.next)}${stage.onFail === null ? " Fails → waits there." : ` Fails → ${to(stage.onFail)}`}`);
918
+ lines.push(`Runs the project's script “${stage.script}” with no AI.`, ...(stage.browser === true ? ["Starts a browser (Playwright's Chromium): it runs only once a person approves browser use for this flow."] : []), `Passes → ${to(stage.next)}${stage.onFail === null ? " Fails → waits there." : ` Fails → ${to(stage.onFail)}`}`);
916
919
  else if (stage.kind === "wait" && stage.wait !== undefined)
917
920
  lines.push(stage.wait.for === "hours" ? `Waits until it's between ${stage.wait.from} and ${stage.wait.to}${stage.wait.timeZone === undefined ? "" : ` (${stage.wait.timeZone})`}; cards that arrive then go straight on. Then → ${to(stage.next)}`
918
921
  : stage.wait.for === "time" ? `Waits ${durationWords(stage.wait.minutes)}. Then → ${to(stage.next)}`
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Settings → Integrations: which integrations work. One row each: its name
2
+ * Settings → Integrations: which integrations work. One record each: its name
3
3
  * and state, the account it's connected as, when it last worked and last
4
4
  * failed, what uses it, and one action. The list comes from saved checks; a
5
- * render never waits on one (see integrations.ts).
5
+ * render never waits on one (see integrations.ts). Built from the revamp kit;
6
+ * the Settings frame adds the title.
6
7
  */
7
8
  import { type Html } from "./html.js";
8
9
  import { type Integration, type SecretsKept } from "./integrations.js";
9
- export declare const INTEGRATIONS_CSS: string;
10
- export declare function integrationsHtml(list: readonly Integration[], csrf: string, notice: {
10
+ /** The page is kit pieces now; nothing of the old look is left. Kept as an export for the shared stylesheet. */
11
+ export declare const INTEGRATIONS_CSS = "";
12
+ export declare function integrationsHtml(list: readonly Integration[], csrf: string, said: {
11
13
  said?: string | null;
12
14
  problem?: string | null;
13
15
  checking?: boolean;
@@ -1,46 +1,35 @@
1
1
  /**
2
- * Settings → Integrations: which integrations work. One row each: its name
2
+ * Settings → Integrations: which integrations work. One record each: its name
3
3
  * and state, the account it's connected as, when it last worked and last
4
4
  * failed, what uses it, and one action. The list comes from saved checks; a
5
- * render never waits on one (see integrations.ts).
5
+ * render never waits on one (see integrations.ts). Built from the revamp kit;
6
+ * the Settings frame adds the title.
6
7
  */
7
8
  import { html, joinHtml, postForm } from "./html.js";
8
9
  import { STATE_WORDS, secretsNote } from "./integrations.js";
9
10
  import { whenUtc } from "./when-html.js";
10
11
  import { brandMarkHtml } from "./brand-mark.js";
12
+ import { btn, chip, disclose, infoLine, note, notice, settingsRecord, settingsSection } from "./server/kit.js";
11
13
  const when = (at) => whenUtc(at);
12
- export const INTEGRATIONS_CSS = `.integrations{max-width:760px;min-width:0}.integrations h2{margin:24px 0 6px;font-size:.9375rem}` +
13
- `.integrations .integration{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 16px;align-items:start;padding:12px 0;border-top:1px solid var(--so-line);min-width:0}` +
14
- `.integrations .integration>:not(.integration-action),.integrations .integration-body>*{grid-column:1}.integrations .integration-body{display:contents}.integrations .integration:first-of-type{border-top:0}.integrations .integration-head{margin:0;display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}` +
15
- `.integrations .integration .integration-body>*{margin-left:calc(var(--brand-mark) + 10px)}.integrations .integration-head strong{overflow-wrap:anywhere}.integrations .integration p{margin:2px 0 0;min-width:0;overflow-wrap:anywhere}` +
16
- `.integrations .integration-action{grid-column:2;grid-row:1 / span 3;align-self:center;margin:0}.integrations .integration-action button,.integrations .integration-action .button-link{min-height:44px;white-space:nowrap}` +
17
- `.integrations .integration .integration-action button{width:auto}.integrations .integration .integration-action a.integration-quiet,.integrations .integration .integration-action button.integration-quiet{background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line)}` +
18
- `.integrations .integration-fix{color:var(--so-danger)}.integrations code{overflow-wrap:anywhere}.integrations details{margin-top:4px;font-size:.8125rem}.integrations summary{cursor:pointer;padding:4px 0}` +
19
- `.integration-state{display:inline-flex;align-items:center;gap:6px;font-size:.75rem;font-weight:500;padding:0 8px;border-radius:5px;line-height:20px;white-space:nowrap}` +
20
- `.integration-state i{width:6px;height:6px;border-radius:50%;background:currentColor}` +
21
- `.integration-state--connected{color:var(--so-success);background:var(--so-success-soft)}.integration-state--broken{color:var(--so-danger);background:var(--so-danger-soft)}` +
22
- `.integration-state--not-set-up,.integration-state--checking{color:var(--so-muted);background:var(--so-neutral-soft)}` +
23
- // A phone: the action sits at the right of the name line, and the facts under it run on as one line.
24
- `@media (max-width:760px){.integrations h2{margin:16px 0 0}.integrations .integration{gap:0 12px;padding:6px 0 8px}.integrations .integration-head{min-height:44px}` +
25
- `.integrations .integration-action{grid-row:1;align-self:center}.integrations .integration-body{display:block;grid-column:1 / -1;line-height:1.35;padding-left:calc(var(--brand-mark) + 10px)}.integrations .integration .integration-body>*{margin-left:0}` +
26
- `.integrations .integration-body>p{margin:0}.integrations .integration-body>p.meta{display:inline}.integrations .integration-body>p.meta+p.meta::before{content:" · "}` +
27
- `.integrations .integration-body>:not(.meta){margin-top:4px}.integrations .integration-body>:not(.meta)+p.meta{display:block;margin-top:2px}.integrations details{margin-top:2px}}`;
14
+ /** The page is kit pieces now; nothing of the old look is left. Kept as an export for the shared stylesheet. */
15
+ export const INTEGRATIONS_CSS = "";
28
16
  const GROUPS = [
29
17
  ["chat", "Chat"], ["code", "Code and issues"], ["mail", "Email"], ["tools", "MCP tools"], ["monitoring", "Monitoring"], ["agents", "Agents"],
30
18
  ];
31
19
  function action(one, csrf) {
32
20
  const a = one.action;
21
+ // One harmless read-only check of this integration, now (the handler sends nothing).
33
22
  if (a.kind === "test") {
34
- return csrf === "" ? "" : postForm("/settings/integrations/test", html `<button type="submit">Send test</button>`, { attrs: { class: "integration-action" }, hidden: { key: one.key } });
23
+ return csrf === "" ? "" : postForm("/settings/integrations/test", btn("Check connection", { sm: true, type: "submit" }), { hidden: { key: one.key } });
35
24
  }
36
25
  // A project's youiyoux requests: one switch for that project.
37
26
  if (a.kind === "switch") {
38
- return csrf === "" ? "" : postForm("/settings/integrations/youiyoux", html `<button type="submit"${a.on ? "" : html ` class="integration-quiet"`}>${a.label}</button>`, { attrs: { class: "integration-action" }, hidden: { repo: a.repo, on: a.on ? "1" : "0" } });
27
+ return csrf === "" ? "" : postForm("/settings/integrations/youiyoux", btn(a.label, { sm: true, type: "submit" }), { hidden: { repo: a.repo, on: a.on ? "1" : "0" } });
39
28
  }
40
29
  // A command is run where Toolroll runs; a page is linked. Fix and Set up both say which.
41
- // Fix is the one filled action on the page; Set up stays quiet so a list of unused services doesn't shout.
30
+ // Fix is the louder of the two; Set up stays quiet so a list of unused services doesn't shout.
42
31
  if (a.href !== null)
43
- return html `<p class="integration-action"><a class="button-link${a.kind === "setup" ? " integration-quiet" : ""}" href="${a.href}">${a.label}</a></p>`;
32
+ return btn(a.label, { sm: true, href: a.href, variant: a.kind === "setup" ? "ghost" : "outline" });
44
33
  return "";
45
34
  }
46
35
  /** Which mark a row shows: email is Gmail's only when the account is a Gmail address; a custom tool is a letter. */
@@ -49,33 +38,38 @@ function markFor(one) {
49
38
  return null;
50
39
  return one.key === "email" && /@(gmail|googlemail)\.com$/i.test(one.account ?? "") ? "gmail" : one.key;
51
40
  }
41
+ /** Its state as one of the kit's status chips: working, broken, not set up, or still being checked. */
42
+ function stateChip(one) {
43
+ if (!one.checked && one.state === "connected")
44
+ return chip("Checking", "live");
45
+ return chip(STATE_WORDS[one.state], one.state === "connected" ? "done" : one.state === "broken" ? "failed" : "");
46
+ }
52
47
  function row(one, csrf) {
53
- const badge = !one.checked && one.state === "connected"
54
- ? html `<span class="integration-state integration-state--checking"><i aria-hidden="true"></i>Checking</span>`
55
- : html `<span class="integration-state integration-state--${one.state}"><i aria-hidden="true"></i>${STATE_WORDS[one.state]}</span>`;
56
48
  const facts = [one.account, one.detail, one.credentials].filter(Boolean).join(" · ");
57
49
  const command = one.action.kind === "setup" || one.action.kind === "fix" ? one.action.command : null;
58
50
  const words = one.action.kind === "fix" || one.action.kind === "switch" ? one.action.words : null;
59
- const fix = words !== null ? html `<p class="integration-fix" role="status">${words}</p>` : "";
60
- const run = command === null || (one.action.kind === "fix" && one.action.words.includes(command)) ? "" : html `<p class="meta">Run <code>${command}</code> on the computer running Toolroll.</p>`;
51
+ const fix = words !== null ? notice(words) : "";
52
+ const run = command === null || (one.action.kind === "fix" && one.action.words.includes(command)) ? "" : note(html `Run <code class="k-mono">${command}</code> on the computer running Toolroll.`);
61
53
  const seen = [one.lastSuccessAt === null ? null : html `Last success ${when(one.lastSuccessAt)}`, one.usedBy.length === 0 ? null : html `Used by ${one.usedBy.join(", ")}`].filter(Boolean);
62
- const history = one.state === "not-set-up" || seen.length === 0 ? "" : html `<p class="meta">${joinHtml(seen, " · ")}</p>`;
54
+ const history = one.state === "not-set-up" || seen.length === 0 ? "" : note(joinHtml(seen, " · "));
63
55
  const lastError = one.lastError === null || one.state === "not-set-up" ? "" : one.state === "broken" && words === one.lastError
64
- ? html `<p class="meta">Failed ${when(one.lastErrorAt ?? "")}</p>`
65
- : html `<details><summary>Last error ${one.lastErrorAt === null ? "" : when(one.lastErrorAt)}</summary><p class="meta">${one.lastError}</p></details>`;
66
- return html `<div class="integration" data-integration="${one.key}" data-state="${one.state}"><p class="integration-head">${brandMarkHtml(markFor(one), one.name, one.state === "connected")}<strong>${one.name}</strong> ${badge}</p>${action(one, csrf)}<div class="integration-body">${facts === "" ? "" : html `<p class="meta">${facts}</p>`}${fix}${run}${history}${lastError}</div></div>`;
56
+ ? note(html `Failed ${when(one.lastErrorAt ?? "")}`)
57
+ : disclose(`Last error${one.lastErrorAt === null ? "" : ` ${one.lastErrorAt.slice(0, 16).replace("T", " ")} UTC`}`, note(one.lastError));
58
+ const more = joinHtml([fix, run, history, lastError]);
59
+ return settingsRecord({ title: html `${brandMarkHtml(markFor(one), one.name, one.state === "connected")}<span>${one.name}</span> ${stateChip(one)}`,
60
+ sub: facts, action: action(one, csrf), attrs: { "data-integration": one.key, "data-state": one.state }, more });
67
61
  }
68
- export function integrationsHtml(list, csrf, notice) {
69
- const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
62
+ export function integrationsHtml(list, csrf, said) {
63
+ const answer = said.problem ? notice(said.problem) : said.said ? infoLine(said.said) : "";
70
64
  const broken = list.filter(one => one.state === "broken").length;
71
- const summary = broken > 0 ? html `<p><strong>${broken} need${broken === 1 ? "s" : ""} fixing.</strong></p>`
72
- : notice.checking || list.some(one => !one.checked) ? html `<p class="meta" role="status">Checking in the background. Refresh in a few seconds.</p>` : "";
65
+ const summary = broken > 0 ? html `<p class="k-note"><b>${broken} need${broken === 1 ? "s" : ""} fixing.</b></p>`
66
+ : said.checking || list.some(one => !one.checked) ? html `<p class="k-note" role="status">Checking in the background. Refresh in a few seconds.</p>` : "";
67
+ // No keychain here, or Toolroll's folder for secrets can't be used: said once, plainly.
68
+ const kept = secretsNote(list, said.secrets);
69
+ const secrets = kept === null ? "" : kept.problem ? notice(kept.words) : html `<p class="k-note" role="note">${kept.words}</p>`;
73
70
  const sections = GROUPS.map(([group, title]) => {
74
71
  const rows = list.filter(one => one.group === group);
75
- return rows.length === 0 ? "" : html `<h2>${title}</h2>${rows.map(one => row(one, csrf))}`;
72
+ return rows.length === 0 ? "" : settingsSection({ id: `integrations-${group}`, title, body: joinHtml(rows.map(one => row(one, csrf))) });
76
73
  });
77
- // No keychain here, or Toolroll's folder for secrets can't be used: said once, plainly.
78
- const said = secretsNote(list, notice.secrets);
79
- const kept = said === null ? "" : said.problem ? html `<p class="problem integration-secrets" role="alert">${said.words}</p>` : html `<p class="meta integration-secrets" role="note">${said.words}</p>`;
80
- return html `<section class="integrations">${note}${summary}${kept}${sections}</section>`;
74
+ return joinHtml([answer, summary, secrets, ...sections]);
81
75
  }
@@ -13,7 +13,7 @@ export type IntegrationAction = ({
13
13
  label: "Set up";
14
14
  } & IntegrationPlace) | {
15
15
  kind: "test";
16
- label: "Send test";
16
+ label: "Check connection";
17
17
  } | ({
18
18
  kind: "fix";
19
19
  label: "Fix";
@@ -124,7 +124,7 @@ export declare function renderIntegrations(list: readonly Integration[], secrets
124
124
  export type IntegrationMonitor = {
125
125
  /** The list now, from the last answers saved. It only reads: a page calls it on every render (docs/plans/get-side-effects.md). */
126
126
  list(): Integration[];
127
- /** Check now and wait (Send test, Check now, or the first answer in a test). */
127
+ /** Check now and wait (Check connection, Check now, or the first answer in a test). */
128
128
  check(keys?: readonly string[]): Promise<Integration[]>;
129
129
  /** The background pass: start a check when the last one is older than the cache. Never called from a page. */
130
130
  refreshIfDue(): void;
@@ -327,7 +327,7 @@ function targets(io) {
327
327
  const list = [];
328
328
  list.push({
329
329
  key: "telegram", group: "chat", name: "Telegram", configured: telegram !== null, account: null,
330
- setup: place("/settings#telegram-token"), fix: place("/settings#telegram-token"), usedBy: chatUses("telegram"), activity: telegramActivity(store),
330
+ setup: place("/settings/alerts#telegram-token"), fix: place("/settings/alerts#telegram-token"), usedBy: chatUses("telegram"), activity: telegramActivity(store),
331
331
  probe: telegram === null ? null : check => probeTelegram(check, telegram.token),
332
332
  });
333
333
  list.push({
@@ -371,7 +371,7 @@ function targets(io) {
371
371
  : { okAt: str(mailWatch["updated_at"]), error: null, errorAt: null, failing: false };
372
372
  list.push({
373
373
  key: "email", group: "mail", name: "Email", configured: email !== null || google !== null, account: email?.from ?? google?.address ?? null,
374
- setup: place("/settings#email"), fix: place("/settings#email"), usedBy: [...flowWords(emailUses)], activity: merge(triggerActivity(emailUses), watchActivity),
374
+ setup: place("/settings/alerts#email"), fix: place("/settings/alerts#email"), usedBy: [...flowWords(emailUses)], activity: merge(triggerActivity(emailUses), watchActivity),
375
375
  probe: email === null ? null : check => probeEmail(check, { host: email.host, port: email.port, secure: email.secure }, email.imap),
376
376
  });
377
377
  // Each project's MCP tools: the server starts and lists its tools.
@@ -432,7 +432,7 @@ function targets(io) {
432
432
  for (const provider of AGENTS) {
433
433
  list.push({
434
434
  key: `agent:${provider}`, group: "agents", name: providerName(provider), configured: true, account: null,
435
- setup: place("/settings#providers"), fix: place("/settings#providers"), usedBy: ["Tasks"], activity: NO_ACTIVITY,
435
+ setup: place("/settings/agents#providers"), fix: place("/settings/agents#providers"), usedBy: ["Tasks"], activity: NO_ACTIVITY,
436
436
  probe: check => probeAgent(check, provider),
437
437
  });
438
438
  }
@@ -458,7 +458,7 @@ function combine(target, row) {
458
458
  if (failing !== null) {
459
459
  return { ...base, ...common, state: "broken", action: { kind: "fix", label: "Fix", words: scrubIntegrationText(failing ?? "The last check failed."), ...target.fix } };
460
460
  }
461
- return { ...base, ...common, state: "connected", action: { kind: "test", label: "Send test" } };
461
+ return { ...base, ...common, state: "connected", action: { kind: "test", label: "Check connection" } };
462
462
  }
463
463
  /**
464
464
  * youiyoux change requests, one row per project that uses youiyoux (youiyoux-intake.ts): Connected with how many of
package/dist/kits-ui.d.ts CHANGED
@@ -1,18 +1,16 @@
1
1
  /**
2
- * Starter kits pages: the gallery — each kit's promise, what it sets
3
- * up, one button — and a kit's own page: its checklist, with the next step on
4
- * each line (connect email, connect a tool, try a sample card).
2
+ * Starter kits: the "With an agent" rows of New flow (each kit's promise, what it sets up, one button to its page)
3
+ * and a kit's own page, a reading page: what it sets up, then its checklist with the next step on each line
4
+ * (connect email, connect a tool, try a sample card).
5
5
  */
6
6
  import type { Store } from "./store.js";
7
7
  import { type Kit } from "./kits.js";
8
8
  import { type Html } from "./html.js";
9
9
  export declare const KITS_CSS: string;
10
- /** The gallery: every kit, set up in a project with one button (or opened, when it's already there). */
11
- export declare function kitsGalleryHtml(store: Store, projects: readonly string[], projectName: (repo: string) => string, canSetUp: boolean, notice: {
12
- problem?: string | null;
13
- }): Html;
14
- /** A kit's own page: what's set up, and the next step on each line. */
15
- export declare function kitPageHtml(store: Store, kit: Kit, repo: string, dir: string | null, canSetUp: boolean, notice: {
10
+ /** "With an agent": every kit as a row; Set it up (or Open, once it's there) goes to the kit's page for `repo`. */
11
+ export declare function kitRowsHtml(store: Store, repo: string | null): Html;
12
+ /** A kit's own page: what it sets up, Set up (once), then the next step on each line. */
13
+ export declare function kitPageHtml(store: Store, kit: Kit, repo: string, dir: string | null, canSetUp: boolean, notice_: {
16
14
  said?: string | null;
17
15
  problem?: string | null;
18
- }): Html;
16
+ }, projectName?: (repo: string) => string): Html;