toolroll 0.9.86 → 0.9.88

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 (173) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/dist/acp-check.d.ts +9 -0
  3. package/dist/acp-check.js +40 -28
  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/backup-ui.d.ts +5 -4
  10. package/dist/backup-ui.js +65 -32
  11. package/dist/browser/terminal.css +1 -1
  12. package/dist/browser/terminal.js +57 -54
  13. package/dist/browser/workspace.css +2 -2
  14. package/dist/browser/workspace.js +35 -70
  15. package/dist/browser-crew.js +1 -1
  16. package/dist/browser-workspace.d.ts +329 -183
  17. package/dist/browser-workspace.js +10 -9
  18. package/dist/chat-controls.d.ts +1 -1
  19. package/dist/chat-controls.js +1 -1
  20. package/dist/chat-settings-page.d.ts +29 -9
  21. package/dist/chat-settings-page.js +59 -28
  22. package/dist/check-levels-ui.d.ts +2 -1
  23. package/dist/check-levels-ui.js +67 -58
  24. package/dist/contracts/cli.d.ts +43 -0
  25. package/dist/contracts/cli.js +4 -0
  26. package/dist/contracts/console-api.d.ts +1 -1
  27. package/dist/contracts/console-api.js +1 -1
  28. package/dist/credentials-ui.d.ts +5 -3
  29. package/dist/credentials-ui.js +58 -31
  30. package/dist/design/kit.css +722 -0
  31. package/dist/design/kit.d.ts +208 -0
  32. package/dist/design/kit.js +177 -0
  33. package/dist/design/tokens.css +31 -1
  34. package/dist/discord-settings.js +8 -4
  35. package/dist/export-ui.d.ts +6 -4
  36. package/dist/export-ui.js +18 -8
  37. package/dist/flow-changed.d.ts +14 -0
  38. package/dist/flow-changed.js +53 -0
  39. package/dist/flow-gallery-ui.d.ts +41 -4
  40. package/dist/flow-gallery-ui.js +95 -63
  41. package/dist/flow-starters-ui.d.ts +3 -9
  42. package/dist/flow-starters-ui.js +12 -23
  43. package/dist/flow-unattended-words.d.ts +20 -0
  44. package/dist/flow-unattended-words.js +24 -0
  45. package/dist/flow-unattended.d.ts +9 -5
  46. package/dist/flow-unattended.js +45 -23
  47. package/dist/flows-cli.js +7 -3
  48. package/dist/flows-ui.d.ts +12 -6
  49. package/dist/flows-ui.js +141 -30
  50. package/dist/integrations-ui.d.ts +6 -4
  51. package/dist/integrations-ui.js +34 -40
  52. package/dist/integrations.d.ts +2 -2
  53. package/dist/integrations.js +4 -4
  54. package/dist/kits-ui.d.ts +8 -10
  55. package/dist/kits-ui.js +29 -33
  56. package/dist/kits.js +1 -1
  57. package/dist/knowledge-ui.js +76 -19
  58. package/dist/ledger-view.d.ts +2 -0
  59. package/dist/ledger-view.js +41 -38
  60. package/dist/models-ui.d.ts +8 -3
  61. package/dist/models-ui.js +82 -59
  62. package/dist/monitoring-ui.d.ts +4 -2
  63. package/dist/monitoring-ui.js +44 -13
  64. package/dist/needs-you.d.ts +1 -1
  65. package/dist/needs-you.js +8 -0
  66. package/dist/operate-remote-arguments.js +1 -0
  67. package/dist/operate-remote.js +1 -1
  68. package/dist/operate.d.ts +1 -1
  69. package/dist/operate.js +38 -4
  70. package/dist/passwordless.d.ts +1 -1
  71. package/dist/passwordless.js +1 -1
  72. package/dist/people-audit-ui.d.ts +2 -1
  73. package/dist/people-audit-ui.js +25 -23
  74. package/dist/policy-ui.d.ts +3 -2
  75. package/dist/policy-ui.js +27 -21
  76. package/dist/policy.d.ts +3 -0
  77. package/dist/policy.js +14 -1
  78. package/dist/project-delete-ui.d.ts +4 -2
  79. package/dist/project-delete-ui.js +135 -126
  80. package/dist/project-knowledge.d.ts +10 -10
  81. package/dist/publish.d.ts +11 -0
  82. package/dist/publish.js +16 -3
  83. package/dist/pull-request-ui.d.ts +2 -1
  84. package/dist/pull-request-ui.js +39 -28
  85. package/dist/recipe-ui.d.ts +4 -2
  86. package/dist/recipe-ui.js +14 -4
  87. package/dist/release-adapters.d.ts +3 -0
  88. package/dist/release-adapters.js +22 -2
  89. package/dist/release-cli.js +81 -9
  90. package/dist/release.d.ts +23 -0
  91. package/dist/release.js +46 -4
  92. package/dist/releases.js +1 -1
  93. package/dist/request-budget-ui.d.ts +4 -2
  94. package/dist/request-budget-ui.js +30 -19
  95. package/dist/retention-ui.d.ts +4 -3
  96. package/dist/retention-ui.js +15 -12
  97. package/dist/scope.d.ts +3 -0
  98. package/dist/scope.js +6 -0
  99. package/dist/serve.d.ts +0 -1
  100. package/dist/serve.js +51 -7
  101. package/dist/server/agents-computers.d.ts +9 -0
  102. package/dist/server/agents-computers.js +67 -0
  103. package/dist/server/chat.js +16 -16
  104. package/dist/server/chrome.d.ts +1 -1
  105. package/dist/server/chrome.js +19 -9
  106. package/dist/server/console.css +24 -12
  107. package/dist/server/flows.js +64 -36
  108. package/dist/server/home.d.ts +35 -0
  109. package/dist/server/home.js +104 -0
  110. package/dist/server/http.d.ts +3 -0
  111. package/dist/server/http.js +5 -0
  112. package/dist/server/kit-standalone.d.ts +22 -0
  113. package/dist/server/kit-standalone.js +70 -0
  114. package/dist/server/kit.d.ts +419 -0
  115. package/dist/server/kit.js +369 -0
  116. package/dist/server/pages.d.ts +2 -1
  117. package/dist/server/pages.js +213 -174
  118. package/dist/server/people-tokens.js +26 -85
  119. package/dist/server/render-history.d.ts +51 -0
  120. package/dist/server/render-history.js +228 -0
  121. package/dist/server/render-new-task.d.ts +29 -0
  122. package/dist/server/render-new-task.js +96 -0
  123. package/dist/server/render-next.d.ts +63 -0
  124. package/dist/server/render-next.js +109 -0
  125. package/dist/server/render-pages.d.ts +22 -190
  126. package/dist/server/render-pages.js +123 -575
  127. package/dist/server/render-people.d.ts +24 -3
  128. package/dist/server/render-people.js +93 -72
  129. package/dist/server/render-projects.d.ts +103 -0
  130. package/dist/server/render-projects.js +247 -0
  131. package/dist/server/render-results.d.ts +5 -24
  132. package/dist/server/render-results.js +48 -185
  133. package/dist/server/render-settings.d.ts +5 -74
  134. package/dist/server/render-settings.js +102 -493
  135. package/dist/server/render-tasks.d.ts +35 -5
  136. package/dist/server/render-tasks.js +114 -85
  137. package/dist/server/route-table.js +20 -5
  138. package/dist/server/settings.js +276 -255
  139. package/dist/server/task-views.d.ts +8 -0
  140. package/dist/server/task-views.js +85 -2
  141. package/dist/server/tasks.d.ts +8 -0
  142. package/dist/server/tasks.js +95 -40
  143. package/dist/server/terminals.js +1 -1
  144. package/dist/server/welcome.d.ts +22 -0
  145. package/dist/server/welcome.js +69 -0
  146. package/dist/settings-groups-ui.d.ts +279 -0
  147. package/dist/settings-groups-ui.js +615 -0
  148. package/dist/settings-kit.d.ts +42 -6
  149. package/dist/settings-kit.js +69 -7
  150. package/dist/setup-guide.d.ts +2 -2
  151. package/dist/skills-ui.js +59 -7
  152. package/dist/slack-settings.js +12 -5
  153. package/dist/spend-ui.d.ts +45 -26
  154. package/dist/spend-ui.js +124 -56
  155. package/dist/sso-ui.d.ts +5 -2
  156. package/dist/sso-ui.js +63 -31
  157. package/dist/storage-ui.d.ts +3 -2
  158. package/dist/storage-ui.js +56 -50
  159. package/dist/store.d.ts +112 -3
  160. package/dist/store.js +292 -20
  161. package/dist/surface.js +5 -1
  162. package/dist/teams-settings.d.ts +2 -1
  163. package/dist/teams-settings.js +24 -41
  164. package/dist/telegram-settings.d.ts +0 -4
  165. package/dist/telegram-settings.js +52 -25
  166. package/dist/toolroll-update-ui.d.ts +8 -4
  167. package/dist/toolroll-update-ui.js +55 -45
  168. package/dist/tools-ui.d.ts +5 -3
  169. package/dist/tools-ui.js +107 -42
  170. package/dist/work-index.js +11 -7
  171. package/dist/workspace-ui.d.ts +1 -1
  172. package/dist/workspace-ui.js +7 -4
  173. package/package.json +1 -1
@@ -31,7 +31,7 @@ import { lstat, readFile, realpath, stat } from "node:fs/promises";
31
31
  import { createRequire } from "node:module";
32
32
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
33
33
  import { isProtectedWork } from "./approval-policy.js";
34
- import { dollarWords as dollars, scheduleWords, UNATTENDED_RULE_WORDS, unattendedCoverWords, unattendedEndWords, unattendedHeadWords, unattendedLimitWords, untilWords } from "./flow-unattended-words.js";
34
+ import { dollarWords as dollars, scheduleWords, unattendedBudgetOf, unattendedRunsOf, UNATTENDED_RULE_WORDS, unattendedCoverWords, unattendedEndWords, unattendedHeadWords, unattendedLimitWords, untilWords } from "./flow-unattended-words.js";
35
35
  import { MODE_MAX_DAYS } from "./modes.js";
36
36
  import { accountReachesSql } from "./project-access.js";
37
37
  import { canonicalJson } from "./canonical-json.js";
@@ -70,10 +70,8 @@ export const FLOW_ACCEPTANCE = {
70
70
  report: [{ id: "c1", statement: "The report answers the request, citing where in the repository each point comes from", how: null, evidence: ["manual-review"] }],
71
71
  task: [{ id: "c1", statement: "The requested change is made and the project's existing checks still pass", how: null, evidence: ["manual-review"] }],
72
72
  };
73
- /** The most runs a day a grant may allow. */
74
- export const UNATTENDED_MAX_RUNS = 50;
75
- /** The largest per-attempt budget a grant may name: $100. */
76
- export const UNATTENDED_MAX_BUDGET_MICROUSD = 100_000_000;
73
+ /** The limits a grant may name, and their one parser, live with its words (the flow page validates with them too). */
74
+ export { UNATTENDED_MAX_BUDGET_MICROUSD, UNATTENDED_MAX_RUNS } from "./flow-unattended-words.js";
77
75
  export const unattendedTermsJson = (terms) => canonicalJson(terms);
78
76
  /** sha256 over a domain-separated canonical encoding, 32 hex like the mode digest. */
79
77
  export function unattendedDigestOf(terms) {
@@ -213,10 +211,11 @@ function closeGrant(store, row, by, reason, now) {
213
211
  export function endUnattendedGrants(store, filter, by, reason, now) {
214
212
  const rows = "flow" in filter ? store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE flow = ? AND revoked_at IS NULL").all(filter.flow)
215
213
  : "signer" in filter ? store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE signed_by = ? AND revoked_at IS NULL").all(filter.signer)
216
- // A script saved or removed: the grants in its project that signed a schedule running it (an unreadable one too, to be safe).
214
+ // A script saved or removed: the grants in its project that signed a schedule running it, or whose flow has a step
215
+ // that runs it (pre-authorized work would run the new code unseen); an unreadable one too, to be safe.
217
216
  : "script" in filter ? store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE repo = ? AND revoked_at IS NULL").all(filter.script.repo).filter(row => {
218
217
  const terms = unattendedTermsFromJson(String(row["terms_json"]));
219
- return terms === null || terms.triggers.some(one => one.script?.name === filter.script.name);
218
+ return terms === null || terms.triggers.some(one => one.script?.name === filter.script.name) || flowStepRunsScript(store, Number(row["flow"]), filter.script.name);
220
219
  })
221
220
  // A project's rules reach the flows in it and the flows whose build zones work in it.
222
221
  : store.handle.prepare(`SELECT ug.* FROM flow_unattended_grant ug JOIN flow f ON f.id = ug.flow WHERE ug.revoked_at IS NULL
@@ -605,22 +604,13 @@ export function parseUnattendedAsk(input) {
605
604
  const days = Number(text(input.days));
606
605
  if (!Number.isInteger(days) || days < 1 || days > MODE_MAX_DAYS)
607
606
  return { ok: false, message: `Choose 1 to ${MODE_MAX_DAYS} days: it always ends.` };
608
- const budgetText = text(input.budget).replace(/^\$/, "");
609
- let budgetMicrousd = null;
610
- if (budgetText !== "") {
611
- const value = Number(budgetText);
612
- if (!/^\d+(\.\d{1,2})?$/.test(budgetText) || value <= 0 || Math.round(value * 1_000_000) > UNATTENDED_MAX_BUDGET_MICROUSD)
613
- return { ok: false, message: `Give the budget in dollars, up to ${dollars(UNATTENDED_MAX_BUDGET_MICROUSD)}, or leave it empty.` };
614
- budgetMicrousd = Math.round(value * 1_000_000);
615
- }
616
- const runsText = text(input.runs);
617
- let dailyRunCap = null;
618
- if (runsText !== "") {
619
- const value = Number(runsText);
620
- if (!Number.isInteger(value) || value < 1 || value > UNATTENDED_MAX_RUNS)
621
- return { ok: false, message: `Give runs a day as 1 to ${UNATTENDED_MAX_RUNS}, or leave it empty.` };
622
- dailyRunCap = value;
623
- }
607
+ const budget = unattendedBudgetOf(text(input.budget));
608
+ if (!budget.ok)
609
+ return budget;
610
+ const runs = unattendedRunsOf(text(input.runs));
611
+ if (!runs.ok)
612
+ return runs;
613
+ const budgetMicrousd = budget.value, dailyRunCap = runs.value;
624
614
  const includeText = Array.isArray(input.include) ? input.include.map(text).join(",") : text(input.include);
625
615
  const include = includeText === "" ? [] : includeText.split(/[\s,]+/).filter(one => one !== "").map(Number);
626
616
  if (include.some(id => !Number.isSafeInteger(id) || id < 1))
@@ -772,5 +762,37 @@ export function unattendedViewOf(store, flow, viewer, now) {
772
762
  terms: { head: unattendedHeadWords(viewer.name, FLOW_ACCEPTANCE), rules: [...UNATTENDED_RULE_WORDS] },
773
763
  schedules: candidates.map(one => ({ id: one.id, words: one.words, optional: one.optional })),
774
764
  schedulesDigest: candidatesDigestOf(candidates),
765
+ ended: live === null ? unattendedEndedOf(store, flow.id, now) : null,
775
766
  };
776
767
  }
768
+ /** Why the flow's latest grant ended early, in words, when a change ended it while it would still have run. */
769
+ const ENDED_WORDS = {
770
+ "flow changed": "the flow changed", "script changed": "a script it runs changed", "schedule changed": "a schedule changed",
771
+ "project protected": "the project's approval rules changed", "access changed": "its signer's access changed",
772
+ };
773
+ function unattendedEndedOf(store, flowId, now) {
774
+ const row = store.handle.prepare("SELECT revoked_at, revoke_reason, absolute_expiry FROM flow_unattended_grant WHERE flow = ? ORDER BY id DESC LIMIT 1").get(flowId);
775
+ if (row === undefined || row["revoked_at"] == null)
776
+ return null;
777
+ const why = ENDED_WORDS[String(row["revoke_reason"])];
778
+ return why === undefined || Date.parse(String(row["absolute_expiry"])) <= now.getTime() ? null : { why, at: String(row["revoked_at"]) };
779
+ }
780
+ /** Whether one of the flow's "Run a script" steps runs this script. A drawing that isn't readable and well formed counts,
781
+ * to be safe. */
782
+ function flowStepRunsScript(store, flowId, name) {
783
+ const flow = store.getFlow(flowId);
784
+ if (flow === null)
785
+ return false;
786
+ let drawn;
787
+ try {
788
+ drawn = JSON.parse(flow.definitionJson);
789
+ }
790
+ catch {
791
+ return true;
792
+ }
793
+ // Anything but a well-formed drawing (an object whose stages are a list of objects) fails closed: the grant ends.
794
+ const stages = drawn !== null && typeof drawn === "object" && !Array.isArray(drawn) ? drawn.stages : undefined;
795
+ if (!Array.isArray(stages) || !stages.every(stage => stage !== null && typeof stage === "object" && !Array.isArray(stage)))
796
+ return true;
797
+ return stages.some(stage => stage.kind === "check" && stage.script === name);
798
+ }
package/dist/flows-cli.js CHANGED
@@ -364,8 +364,10 @@ export async function runFlowsCommand(positional, flags, context) {
364
364
  return refuse(project, 'unknown-script', `There's no script called ${name} in ${projectName(project)}.`);
365
365
  if (script.held === null)
366
366
  return ok({ applied: false, repo: project, said: `${script.name} already runs.` }, [`${script.name} already runs.`]);
367
- store.approveFlowScript(project, script.name);
368
- record(project, 'accepted', `script ${script.name} approved`);
367
+ // Bound to the version just read: a script swapped under the same name meanwhile is refused, never approved unseen.
368
+ if (store.approveFlowScript(project, script.name, { version: script.version, digest: script.digest }) !== 'approved')
369
+ return refuse(project, 'changed', `${script.name} changed while this ran. Read it again (toolroll flows show), then approve it. Nothing was approved.`);
370
+ record(project, 'accepted', `script ${script.name} v${script.version} approved`);
369
371
  settle(project);
370
372
  return ok({ applied: true, repo: project, said: `Approved ${script.name}. Zones that run it go on.` }, [`Approved ${script.name}. Zones that run it go on.`]);
371
373
  }
@@ -631,6 +633,8 @@ function describeFlow(store, flow, now) {
631
633
  }),
632
634
  cards: store.flowCards(flow.id, true).slice(0, 10).map(card => cardOf(definition, card)),
633
635
  unattended: unattendedJson(store, flow, now),
636
+ // The latest grant a change ended early (the flow, a script, a schedule): why and when, as the flow page says it.
637
+ ...(() => { const ended = unattendedViewOf(store, flow, { name: flow.owner, approver: true }, now).ended; return ended === null ? {} : { unattendedEnded: ended }; })(),
634
638
  // Browser use, only for a flow with a zone that starts one: the same status line the flow page shows.
635
639
  ...(() => { const view = browserViewOf(store, flow); return view === null ? {} : { browser: { status: view.status, words: view.words, signedBy: view.signedBy, signedAt: view.signedAt } }; })(),
636
640
  };
@@ -647,7 +651,7 @@ function showLines(flow) {
647
651
  return [
648
652
  `#${flow.id} ${flow.name} · ${flow.project} · owner ${flow.owner}`,
649
653
  flow.unattended === null
650
- ? `Runs on its own: no · turn on: toolroll flows unattended ${flow.id} on --days N`
654
+ ? `Runs on its own: no${'unattendedEnded' in flow && flow.unattendedEnded !== undefined ? ` (it ended ${untilWords(flow.unattendedEnded.at)}: ${flow.unattendedEnded.why})` : ''} · turn on: toolroll flows unattended ${flow.id} on --days N`
651
655
  : `Runs on its own until ${untilWords(flow.unattended.until)} · revoke: toolroll flows unattended ${flow.id} off`,
652
656
  ...(flow.unattended === null ? [] : [` Covers: ${flow.unattended.covers.join('; ')}`]),
653
657
  ...(flow.browser === undefined ? [] : [`${flow.browser.words} · ${flow.browser.status === 'allowed' ? `turn off: toolroll flows browser ${flow.id} off` : `allow: toolroll flows browser ${flow.id} on`}`]),
@@ -2,12 +2,18 @@ import type { BrowserFlowView } from "./browser-workspace.js";
2
2
  import type { FlowRow, Store } from "./store.js";
3
3
  import { type FlowFile, type FlowImportPlan } from "./flow-share.js";
4
4
  import { type Html } from "./html.js";
5
- export declare 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}}";
6
- /** The flows a person can open, with how many cards wait in each, and the new-flow form. */
7
- export declare function flowsListHtml(store: Store, flows: readonly FlowRow[], projects: readonly string[], canCreate: boolean, problem: string | null): Html;
8
- /** Reads the chosen flow file into the form (the server reads it again, whole, before anything is made). */
9
- export declare 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>262144){say('That file is too big: a flow file is at most 256 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.');}});})();";
10
- /** An import's preview: the plan in plain words, what it asks for, and one Import button after the terms. */
5
+ /** The flow page's fallback rules, plus the Flows list and the import preview (kit pieces; only layout here). */
6
+ export declare const FLOWS_CSS: string;
7
+ /**
8
+ * Flows: the top bar (a project filter, More, New flow), then each flow as a quiet row under Needs you, Running,
9
+ * Turned on and Paused. Words come only from what the store knows now: cards waiting on a decision, cards in
10
+ * progress, and the triggers (on, paused, when they last looked and what they found).
11
+ */
12
+ export declare function flowsListHtml(store: Store, flows: readonly FlowRow[], projects: readonly string[], canCreate: boolean, problem: string | null, filter?: string | null, now?: Date): Html;
13
+ /** Reads the chosen flow file into the form (the server reads it again, whole, before anything is made), and sends a
14
+ * filter select's form when it changes. */
15
+ export declare 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>262144){say('That file is too big: a flow file is at most 256 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.');}});})();";
16
+ /** An import's preview, a reading page: the plan in plain words, what it asks for, and one Import button after the terms. */
11
17
  export declare function flowImportHtml(input: {
12
18
  plan: FlowImportPlan | null;
13
19
  file: FlowFile;
package/dist/flows-ui.js CHANGED
@@ -27,6 +27,8 @@ import { unattendedViewOf } from "./flow-unattended.js";
27
27
  import { browserViewOf } from "./flow-browser.js";
28
28
  import { readFlowSecrets } from "./flow-secrets.js";
29
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";
30
32
  const projectName = (repo) => repo.split(/[\\/]/).filter(Boolean).pop() ?? repo;
31
33
  /** v126: what happens to deliveries turned away (refused with 503), as far as each sender is known to say: nothing
32
34
  * here claims a loss that a sender's own retries may still make good. */
@@ -62,40 +64,149 @@ function hookView(trigger, config, dir) {
62
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,
63
65
  scope: sender === "sentry" || sender === "vercel" ? { project: config.project ?? null, team: config.team ?? null } : null };
64
66
  }
65
- 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}}`;
66
- /** The flows a person can open, with how many cards wait in each, and the new-flow form. */
67
- export function flowsListHtml(store, flows, projects, canCreate, problem) {
68
- const rows = flows.map(flow => {
69
- const cards = store.flowCards(flow.id, false);
70
- const definition = flowDefinitionOf(flow);
71
- const waiting = cards.filter(card => ["approval", "choose"].includes(definition?.stages.find(one => one.id === card.stage)?.kind ?? "")).length;
72
- 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 }] : []; });
73
- const trouble = troubleWords(flowInsights(store, flow, new Date(), 7)) ?? nightlyHealthOf(store, flow);
74
- 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];
75
96
  });
76
- 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>`;
77
- // A flow file (flow-share.ts): chosen here or fetched from a gist, previewed in plain words before anything is made.
78
- 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 } });
79
- // A new flow starts from the gallery (Flows → New): templates grouped by what they're for.
80
- const create = canCreate && projects.length > 0 && html `<p class="flow-buttons"><a class="button-link" href="/flows/new">New flow</a></p>${importForm}`;
81
- // No flows yet: one click makes a working example with a sample question in it.
82
- const example = flows.length === 0 && canCreate && projects.length > 0
83
- ? 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" } })
84
- : null;
85
- 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>`;
86
- 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>`;
87
189
  }
88
- /** Reads the chosen flow file into the form (the server reads it again, whole, before anything is made). */
89
- 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.');}});})();`;
90
- /** 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. */
91
194
  export function flowImportHtml(input) {
92
195
  const { plan, file } = input;
93
- 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>`);
94
- 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>`;
95
200
  // The steps, what they mean, the triggers and the scripts; each script's code one tap away, as on the Scripts panel.
96
- 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])])}`;
97
- 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 } });
98
- 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>`;
99
210
  }
100
211
  const historyText = (event, title, sorts = new Set()) => {
101
212
  const who = event.actor === "flow" ? "" : ` by ${event.actor}`;
@@ -260,7 +371,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
260
371
  requestSecrets: flowSecretNames(setup.dir, flow.repo),
261
372
  tools: projectToolsOf(store, flow.repo).map(tool => ({ name: tool.name, about: tool.spec.about, functions: tool.lastTest?.ok === true ? tool.lastTest.tools : [],
262
373
  ready: toolStanding(tool, secretsSetFor(flow.repo, tool.spec, setup.toolHome)).ready })),
263
- 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,
264
375
  language: script.language, file: script.file, held: script.held !== null,
265
376
  usedHere: stages.filter(stage => stage.kind === "check" && stage.script === script.name).map(stage => stage.title) })),
266
377
  start: definition?.start ?? stages[0]?.id ?? "",
@@ -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;