toolroll 0.9.53 → 0.9.55
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.
- package/CHANGELOG.md +17 -0
- package/README.md +5 -4
- package/dist/agent-onboard.js +4 -1
- package/dist/approval-rules-ui.d.ts +3 -2
- package/dist/approval-rules-ui.js +15 -20
- package/dist/assignment-ui.d.ts +3 -2
- package/dist/assignment-ui.js +4 -11
- package/dist/backup-ui.d.ts +2 -1
- package/dist/backup-ui.js +16 -27
- package/dist/brand-mark.d.ts +3 -2
- package/dist/brand-mark.js +4 -4
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +25 -25
- package/dist/browser-shell.d.ts +2 -1
- package/dist/browser-shell.js +9 -9
- package/dist/browser-workspace.d.ts +2 -3
- package/dist/browser-workspace.js +0 -3
- package/dist/chat-ask.js +8 -8
- package/dist/chat-core.d.ts +171 -0
- package/dist/chat-core.js +288 -0
- package/dist/chat-decide.d.ts +10 -8
- package/dist/chat-decide.js +26 -21
- package/dist/chat-delivery-state.d.ts +51 -75
- package/dist/chat-delivery-state.js +140 -170
- package/dist/chat-delivery.d.ts +21 -2
- package/dist/chat-delivery.js +313 -194
- package/dist/chat-flow.d.ts +29 -1
- package/dist/chat-flow.js +24 -50
- package/dist/chat-messages.d.ts +319 -0
- package/dist/chat-messages.js +841 -0
- package/dist/chat-migration.d.ts +30 -0
- package/dist/chat-migration.js +802 -0
- package/dist/chat-polish.d.ts +5 -4
- package/dist/chat-polish.js +9 -10
- package/dist/chat-quiet.d.ts +3 -0
- package/dist/chat-quiet.js +5 -0
- package/dist/check-levels-ui.d.ts +2 -1
- package/dist/check-levels-ui.js +26 -53
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +9 -3
- package/dist/coding-shipping-ui.d.ts +2 -1
- package/dist/coding-shipping-ui.js +6 -6
- package/dist/coding-ui.d.ts +3 -1
- package/dist/coding-ui.js +30 -29
- package/dist/contracts/chat-content.d.ts +71 -2
- package/dist/contracts/chat-content.js +22 -3
- package/dist/contracts/chat-tables.d.ts +11 -4
- package/dist/contracts/chat-tables.js +165 -87
- package/dist/contracts/cli.d.ts +1 -3
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +6 -5
- package/dist/contracts/discord-callback.js +2 -1
- package/dist/contracts/health.d.ts +1 -3
- package/dist/contracts/slack-callback.js +3 -2
- package/dist/contracts/store-json.d.ts +1 -5
- package/dist/contracts/store-json.js +1 -4
- package/dist/contracts/teams-callback.js +2 -1
- package/dist/control-ui.d.ts +5 -4
- package/dist/control-ui.js +9 -34
- package/dist/credentials-ui.d.ts +5 -4
- package/dist/credentials-ui.js +23 -24
- package/dist/demo-chat.d.ts +3 -3
- package/dist/demo-chat.js +29 -76
- package/dist/discord-chat.js +19 -38
- package/dist/discord-settings.d.ts +3 -2
- package/dist/discord-settings.js +21 -55
- package/dist/discord.js +4 -14
- package/dist/envelope.js +10 -0
- package/dist/evidence-pack.d.ts +2 -1
- package/dist/evidence-pack.js +45 -49
- package/dist/export-ui.d.ts +7 -2
- package/dist/export-ui.js +4 -10
- package/dist/export.js +4 -4
- package/dist/flow-gallery-ui.d.ts +4 -3
- package/dist/flow-gallery-ui.js +28 -43
- package/dist/flow-starters-ui.d.ts +2 -1
- package/dist/flow-starters-ui.js +12 -15
- package/dist/flow-triggers.d.ts +1 -0
- package/dist/flow-triggers.js +16 -14
- package/dist/flows-ui.d.ts +4 -3
- package/dist/flows-ui.js +19 -30
- package/dist/flows.js +2 -1
- package/dist/guides.js +108 -3
- package/dist/html.d.ts +76 -0
- package/dist/html.js +173 -0
- package/dist/integrations-ui.d.ts +2 -1
- package/dist/integrations-ui.js +19 -23
- package/dist/kits-ui.d.ts +5 -4
- package/dist/kits-ui.js +18 -20
- package/dist/knowledge-ui.d.ts +6 -5
- package/dist/knowledge-ui.js +26 -25
- package/dist/lead-context.d.ts +11 -1
- package/dist/lead-context.js +6 -8
- package/dist/ledger-view.d.ts +2 -1
- package/dist/ledger-view.js +27 -29
- package/dist/limits-ui.d.ts +2 -1
- package/dist/limits-ui.js +7 -7
- package/dist/live-bus.d.ts +50 -8
- package/dist/live-bus.js +133 -50
- package/dist/mate-tools.js +3 -2
- package/dist/mcp-oauth.js +35 -36
- package/dist/models-ui.d.ts +2 -1
- package/dist/models-ui.js +25 -35
- package/dist/modes.d.ts +13 -1
- package/dist/modes.js +17 -1
- package/dist/monitoring-ui.d.ts +4 -3
- package/dist/monitoring-ui.js +13 -26
- package/dist/notification-rows.d.ts +30 -0
- package/dist/notification-rows.js +102 -0
- package/dist/openrouter-models.d.ts +2 -1
- package/dist/openrouter-models.js +5 -9
- package/dist/operate.js +16 -9
- package/dist/people-audit-ui.d.ts +2 -1
- package/dist/people-audit-ui.js +13 -13
- package/dist/plane-review.js +5 -11
- package/dist/policy-ui.d.ts +3 -2
- package/dist/policy-ui.js +17 -23
- package/dist/project-delete-ui.d.ts +4 -3
- package/dist/project-delete-ui.js +15 -27
- package/dist/project-delete.js +21 -26
- package/dist/pull-request-ui.d.ts +2 -1
- package/dist/pull-request-ui.js +17 -32
- package/dist/recipe-ui.d.ts +8 -7
- package/dist/recipe-ui.js +39 -32
- package/dist/release-adapters.d.ts +60 -0
- package/dist/release-adapters.js +435 -0
- package/dist/release-cli.d.ts +24 -0
- package/dist/release-cli.js +181 -0
- package/dist/release-gate.d.ts +80 -0
- package/dist/release-gate.js +150 -0
- package/dist/release-links.d.ts +29 -0
- package/dist/release-links.js +102 -0
- package/dist/release.d.ts +331 -0
- package/dist/release.js +550 -0
- package/dist/reply-shape.d.ts +2 -1
- package/dist/reply-shape.js +5 -5
- package/dist/repository-context-ui.d.ts +2 -1
- package/dist/repository-context-ui.js +2 -7
- package/dist/request-budget-ui.d.ts +2 -1
- package/dist/request-budget-ui.js +16 -16
- package/dist/result-review.d.ts +2 -3
- package/dist/result-review.js +3 -9
- package/dist/result-shots.js +2 -2
- package/dist/retention-ui.d.ts +7 -1
- package/dist/retention-ui.js +7 -11
- package/dist/retention.js +1 -1
- package/dist/serve.d.ts +11 -2
- package/dist/serve.js +69 -58
- package/dist/server/chat.d.ts +3 -6
- package/dist/server/chat.js +969 -1006
- package/dist/server/chrome.d.ts +291 -0
- package/dist/server/chrome.js +747 -0
- package/dist/server/console.css +2819 -0
- package/dist/server/flows.d.ts +2 -6
- package/dist/server/flows.js +840 -766
- package/dist/server/guards.d.ts +1 -1
- package/dist/server/guards.js +5 -2
- package/dist/server/handler-context.d.ts +1 -1
- package/dist/server/handler-context.js +1 -1
- package/dist/server/handler-registry.d.ts +8 -6
- package/dist/server/handler-registry.js +14 -2
- package/dist/server/http.d.ts +274 -0
- package/dist/server/http.js +239 -0
- package/dist/server/live.d.ts +34 -0
- package/dist/server/live.js +309 -0
- package/dist/server/pages.d.ts +2 -6
- package/dist/server/pages.js +808 -789
- package/dist/server/people-tokens.d.ts +1 -6
- package/dist/server/people-tokens.js +580 -572
- package/dist/server/remote.d.ts +4 -7
- package/dist/server/remote.js +37 -70
- package/dist/server/render-chat.d.ts +402 -0
- package/dist/server/render-chat.js +927 -0
- package/dist/server/render-pages.d.ts +535 -0
- package/dist/server/render-pages.js +1277 -0
- package/dist/server/render-people.d.ts +32 -0
- package/dist/server/render-people.js +121 -0
- package/dist/server/render-results.d.ts +744 -0
- package/dist/server/render-results.js +2237 -0
- package/dist/server/render-settings.d.ts +147 -0
- package/dist/server/render-settings.js +577 -0
- package/dist/server/render-tasks.d.ts +632 -0
- package/dist/server/render-tasks.js +2345 -0
- package/dist/server/request-context.d.ts +46 -0
- package/dist/server/request-context.js +17 -0
- package/dist/server/route-table.d.ts +25 -3
- package/dist/server/route-table.js +163 -75
- package/dist/server/runtime.d.ts +15 -7
- package/dist/server/runtime.js +7 -1
- package/dist/server/session.d.ts +138 -0
- package/dist/server/session.js +94 -0
- package/dist/server/settings.d.ts +1 -5
- package/dist/server/settings.js +2182 -2117
- package/dist/server/task-views.d.ts +11 -4
- package/dist/server/task-views.js +26 -22
- package/dist/server/tasks.d.ts +9 -9
- package/dist/server/tasks.js +1486 -1418
- package/dist/server-telemetry.d.ts +4 -3
- package/dist/server-telemetry.js +4 -8
- package/dist/skills-ui.d.ts +4 -3
- package/dist/skills-ui.js +14 -17
- package/dist/skills.d.ts +1 -1
- package/dist/skills.js +2 -1
- package/dist/slack-api.d.ts +5 -1
- package/dist/slack-api.js +2 -2
- package/dist/slack-chat.d.ts +9 -4
- package/dist/slack-chat.js +29 -52
- package/dist/slack-settings.d.ts +3 -2
- package/dist/slack-settings.js +23 -57
- package/dist/slack.js +6 -16
- package/dist/spend-ui.d.ts +2 -1
- package/dist/spend-ui.js +9 -32
- package/dist/sso-ui.d.ts +3 -2
- package/dist/sso-ui.js +26 -27
- package/dist/storage-ui.d.ts +2 -1
- package/dist/storage-ui.js +14 -33
- package/dist/store.d.ts +97 -364
- package/dist/store.js +207 -1531
- package/dist/surface.js +14 -3
- package/dist/task-composer.d.ts +2 -1
- package/dist/task-composer.js +7 -7
- package/dist/task-status.d.ts +4 -3
- package/dist/task-status.js +15 -16
- package/dist/team-http.d.ts +5 -9
- package/dist/team-http.js +5 -63
- package/dist/team-ui.d.ts +2 -1
- package/dist/team-ui.js +2 -5
- package/dist/teammate-question.js +12 -12
- package/dist/teammates-ui.d.ts +5 -4
- package/dist/teammates-ui.js +61 -90
- package/dist/teams-chat.js +12 -9
- package/dist/teams-settings.d.ts +3 -2
- package/dist/teams-settings.js +22 -33
- package/dist/teams.js +4 -5
- package/dist/telegram-flow.js +5 -5
- package/dist/telegram-settings.d.ts +3 -2
- package/dist/telegram-settings.js +14 -25
- package/dist/telegram.js +4 -4
- package/dist/toolroll-update-ui.d.ts +3 -2
- package/dist/toolroll-update-ui.js +35 -39
- package/dist/toolroll-update.d.ts +10 -0
- package/dist/toolroll-update.js +50 -3
- package/dist/tools-ui.d.ts +5 -2
- package/dist/tools-ui.js +31 -31
- package/dist/when-html.d.ts +11 -3
- package/dist/when-html.js +5 -6
- package/dist/workspace-revision.js +3 -1
- package/dist/workspace-ui.d.ts +4 -2
- package/dist/workspace-ui.js +10 -10
- package/package.json +1 -1
- package/dist/server/shared.d.ts +0 -3151
- package/dist/server/shared.js +0 -12001
- package/dist/slack-state.d.ts +0 -13
- package/dist/slack-state.js +0 -10
|
@@ -1,47 +1,36 @@
|
|
|
1
|
+
import { html, joinHtml, postForm } from "./html.js";
|
|
1
2
|
/** How long a written reply may wait to be sent before it shows here with a Retry. */
|
|
2
3
|
export const UNSENT_REPLY_MS = 2 * 60_000;
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
.replaceAll('"', """)
|
|
8
|
-
.replaceAll("'", "'");
|
|
9
|
-
export function telegramSettingsHtml(store, botId, who, csrf, options = {}) {
|
|
10
|
-
const hidden = `<input type="hidden" name="csrf" value="${escape(csrf)}">`;
|
|
11
|
-
const password = '<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>';
|
|
12
|
-
const post = (action, content) => `<form method="post" action="/settings/telegram/${action}" class="card">${hidden}${content}</form>`;
|
|
13
|
-
let content = "";
|
|
4
|
+
export function telegramSettingsHtml(store, botId, who, options = {}) {
|
|
5
|
+
const password = html `<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>`;
|
|
6
|
+
const post = (action, content) => postForm(`/settings/telegram/${action}`, content, { attrs: { class: "card" } });
|
|
7
|
+
let content;
|
|
14
8
|
if (botId === null) {
|
|
15
|
-
content =
|
|
9
|
+
content = html `<p>No Telegram bot is connected yet. An installation approver saves the bot token on the <a href="/settings">settings page</a> first.</p>`;
|
|
16
10
|
}
|
|
17
11
|
else {
|
|
18
12
|
const bindings = store.liveTelegramBindings(botId);
|
|
19
13
|
const mine = bindings.filter(binding => binding.approver === who);
|
|
20
14
|
const others = bindings.length - mine.length;
|
|
21
|
-
|
|
15
|
+
const parts = [html `<p><strong>${mine.length > 0 ? "Your phone is paired." : "Your phone is not paired."}</strong> ${others === 0 ? "No teammates are paired yet." : `${others} teammate${others === 1 ? " is" : "s are"} paired.`}</p>`];
|
|
22
16
|
// A reply the assistant wrote that has not reached this person's phone for two minutes.
|
|
23
17
|
const unsent = store.unsentTelegramReplies(botId, who, new Date((options.now ?? new Date()).getTime() - UNSENT_REPLY_MS));
|
|
24
18
|
if (unsent.length > 0) {
|
|
25
19
|
const oldest = unsent[0];
|
|
26
|
-
|
|
27
|
-
`<div class="card" role="status" data-unsent-replies><p><strong>${unsent.length === 1 ? "A reply hasn't reached your phone" : `${unsent.length} replies haven't reached your phone`}</strong></p>` +
|
|
28
|
-
`<p class="meta">Trying since ${escape(oldest.since.slice(11, 16))} UTC${oldest.error === null ? "" : `: ${escape(oldest.error)}`}</p>` +
|
|
29
|
-
`<form method="post" action="/settings/telegram/retry">${hidden}<button type="submit">Retry</button></form></div>`;
|
|
20
|
+
parts.push(html `<div class="card" role="status" data-unsent-replies><p><strong>${unsent.length === 1 ? html `A reply hasn't reached your phone` : html `${unsent.length} replies haven't reached your phone`}</strong></p><p class="meta">Trying since ${oldest.since.slice(11, 16)} UTC${oldest.error === null ? "" : `: ${oldest.error}`}</p>${postForm("/settings/telegram/retry", html `<button type="submit">Retry</button>`)}</div>`);
|
|
30
21
|
}
|
|
31
22
|
if (options.code !== undefined) {
|
|
32
|
-
|
|
33
|
-
"<h2>Pair your phone</h2><p>Send this to the bot in a private Telegram chat within 10 minutes:</p>" +
|
|
34
|
-
`<pre class="pairing-code">/pair ${escape(options.code)}</pre>` +
|
|
35
|
-
'<p class="meta">The code works once. That chat then answers as you, so treat it like your sign-in.</p>';
|
|
23
|
+
parts.push(html `<h2>Pair your phone</h2><p>Send this to the bot in a private Telegram chat within 10 minutes:</p><pre class="pairing-code">/pair ${options.code}</pre><p class="meta">The code works once. That chat then answers as you, so treat it like your sign-in.</p>`);
|
|
36
24
|
}
|
|
37
25
|
else if (mine.length === 0) {
|
|
38
|
-
|
|
26
|
+
parts.push(post("pair", html `${password}<button type="submit">Pair my phone</button>`));
|
|
39
27
|
}
|
|
40
28
|
else {
|
|
41
|
-
|
|
29
|
+
parts.push(post("unpair", html `${password}<p class="meta">This phone will lose access. Its existing Telegram buttons will stop working.</p><button type="submit" class="danger">Unpair my phone</button>`));
|
|
42
30
|
}
|
|
31
|
+
content = joinHtml(parts);
|
|
43
32
|
}
|
|
44
33
|
if (options.problem !== undefined)
|
|
45
|
-
content = `<p role="status">${
|
|
46
|
-
return `<h1>Telegram</h1>${content}<p class="meta">Each teammate pairs their own private chat. To connect a group, a conversation manager sends <code>/team</code> there and chooses a conversation. Everyone in the group can read its replies.</p>`;
|
|
34
|
+
content = html `<p role="status">${options.problem}</p>${content}`;
|
|
35
|
+
return html `<h1>Telegram</h1>${content}<p class="meta">Each teammate pairs their own private chat. To connect a group, a conversation manager sends <code>/team</code> there and chooses a conversation. Everyone in the group can read its replies.</p>`;
|
|
47
36
|
}
|
package/dist/telegram.js
CHANGED
|
@@ -634,7 +634,7 @@ function decideOfferFor(store, binding, target, projects, now, root) {
|
|
|
634
634
|
if (target === null || target === undefined)
|
|
635
635
|
return null;
|
|
636
636
|
const principal = verifyApproverStanding(store, binding.approver, binding.approverGeneration, telegramConversationRepos(store, binding.approver, projects));
|
|
637
|
-
return principal.ok ? decideOffer(store, target, principal.who, now,
|
|
637
|
+
return principal.ok ? decideOffer(store, target, principal.who, now, "telegram", root) : null;
|
|
638
638
|
}
|
|
639
639
|
/** Team-conversation traffic to the chats that follow one — after the outbox, under the same delivery switch, never a problem to raise when nothing follows anything. */
|
|
640
640
|
async function deliverTeam(store, botId, transport, clock, report, readProjects, canDeliver, phoneOrigin) {
|
|
@@ -2518,9 +2518,9 @@ function noteDigestOf(note) {
|
|
|
2518
2518
|
* machine — "never silently answer without the expected note").
|
|
2519
2519
|
*/
|
|
2520
2520
|
function expiredDraftGuard(store, binding, decisionId, now) {
|
|
2521
|
-
const expired = store.
|
|
2522
|
-
.prepare(`SELECT id FROM
|
|
2523
|
-
WHERE binding = ? AND decision = ? AND state IN ('pending','armed') AND
|
|
2521
|
+
const expired = store.telegramChat()
|
|
2522
|
+
.prepare(`SELECT id FROM chat_note_draft
|
|
2523
|
+
WHERE provider = :provider AND binding = ? AND decision = ? AND state IN ('pending','armed') AND expires <= ?`)
|
|
2524
2524
|
.get(binding.id, decisionId, now.toISOString());
|
|
2525
2525
|
if (expired === undefined)
|
|
2526
2526
|
return false;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* and a one-time "What's new" card afterwards. The work itself is the
|
|
5
5
|
* `toolroll update` command (toolroll-update.ts), started as its own job.
|
|
6
6
|
*/
|
|
7
|
+
import { type Html } from "./html.js";
|
|
7
8
|
import type { InstallMethod } from "./install-method.js";
|
|
8
9
|
import { type RuntimeUpdateJournal } from "./toolroll-update.js";
|
|
9
10
|
export declare const UPDATES_CSS: string;
|
|
@@ -29,11 +30,11 @@ export type UpdatesView = {
|
|
|
29
30
|
csrf: string;
|
|
30
31
|
};
|
|
31
32
|
/** The steps, each done, current, failed or waiting. Also the live region's fragment. */
|
|
32
|
-
export declare function updateStepsHtml(j: RuntimeUpdateJournal, running: boolean):
|
|
33
|
+
export declare function updateStepsHtml(j: RuntimeUpdateJournal, running: boolean): Html;
|
|
33
34
|
export declare function updatesHtml(view: UpdatesView, notice: {
|
|
34
35
|
said?: string | null;
|
|
35
36
|
problem?: string | null;
|
|
36
|
-
}):
|
|
37
|
+
}): Html;
|
|
37
38
|
export declare function newerThan(a: string, b: string): boolean;
|
|
38
39
|
/** Polls the steps every two seconds, keeps trying while the console
|
|
39
40
|
* restarts, and reloads once the update has finished. */
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings → Updates: the installed version, one of three ways to update
|
|
3
|
+
* (behind the operator's password), the update's steps live while it runs,
|
|
4
|
+
* and a one-time "What's new" card afterwards. The work itself is the
|
|
5
|
+
* `toolroll update` command (toolroll-update.ts), started as its own job.
|
|
6
|
+
*/
|
|
7
|
+
import { html, postForm, replaceMarkup } from "./html.js";
|
|
1
8
|
import { STEP_WORDS, UPDATE_STEPS, runtimeUpdateTerminal } from "./toolroll-update.js";
|
|
2
|
-
const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
3
9
|
const clock = (iso) => { const d = new Date(iso); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; };
|
|
4
10
|
export const UPDATES_CSS = `.updates{max-width:640px;min-width:0;overflow-wrap:anywhere}.updates .card{margin:0 0 16px}.updates h2{margin:0;font-size:1.0625rem}.updates .card>p{margin:4px 0 0}` +
|
|
5
11
|
`.updates .update-actions{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0 0}.updates .update-actions button{white-space:nowrap}` +
|
|
@@ -23,60 +29,50 @@ export function updateStepsHtml(j, running) {
|
|
|
23
29
|
const items = UPDATE_STEPS.map((step, i) => {
|
|
24
30
|
const state = j.phase === "complete" || i < last ? "done" : i > last ? "waiting" : failed ? "failed" : finished ? "waiting" : "now";
|
|
25
31
|
// Only where it adds something: which work it waits for, or what went wrong.
|
|
26
|
-
const detail = state === "now" && step === "draining" && j.waiting ? `<span class="step-detail">${
|
|
27
|
-
: (state === "now" && step === "draining") || (state === "failed" && j.phase === "rolling-back") ? `<span class="step-detail">${
|
|
28
|
-
return `<li data-step="${step}" data-state="${state}">${
|
|
29
|
-
})
|
|
30
|
-
const title = j.phase === "scheduled" ? `Update to ${
|
|
31
|
-
: j.phase === "rolling-back" ? `Restoring ${
|
|
32
|
-
: j.kind === "rollback" ? `Rolling back to ${
|
|
33
|
-
const stalled = !running && !finished ? `<p class="update-problem" role="alert">The updater stopped. Run <code>toolroll update --resume</code> to continue it.</p>` : "";
|
|
34
|
-
return `<div id="update-live" data-phase="${
|
|
35
|
-
(j.phase === "scheduled" ? `<p class="meta">Running work finishes first. You can close this page.</p>` : `<ol class="update-steps" aria-label="Update steps">${items}</ol>`) + stalled + `</div>`;
|
|
32
|
+
const detail = state === "now" && step === "draining" && j.waiting ? html `<span class="step-detail">${j.waiting.on}.${j.waiting.action ? html ` If nothing of it is running, run <code>${j.waiting.action}</code>.` : ""}</span>`
|
|
33
|
+
: (state === "now" && step === "draining") || (state === "failed" && j.phase === "rolling-back") ? html `<span class="step-detail">${j.detail}</span>` : "";
|
|
34
|
+
return html `<li data-step="${step}" data-state="${state}">${STEP_WORDS[step]}${detail}</li>`;
|
|
35
|
+
});
|
|
36
|
+
const title = j.phase === "scheduled" ? `Update to ${j.to.version} scheduled for ${clock(j.at ?? j.startedAt)}`
|
|
37
|
+
: j.phase === "rolling-back" ? `Restoring ${j.from.version}`
|
|
38
|
+
: j.kind === "rollback" ? `Rolling back to ${j.to.version}` : `Updating to ${j.to.version}`;
|
|
39
|
+
const stalled = !running && !finished ? html `<p class="update-problem" role="alert">The updater stopped. Run <code>toolroll update --resume</code> to continue it.</p>` : "";
|
|
40
|
+
return html `<div id="update-live" data-phase="${j.phase}" data-done="${finished ? 1 : 0}"><h2>${title}</h2>${j.phase === "scheduled" ? html `<p class="meta">Running work finishes first. You can close this page.</p>` : html `<ol class="update-steps" aria-label="Update steps">${items}</ol>`}${stalled}</div>`;
|
|
36
41
|
}
|
|
37
42
|
function outcomeHtml(j) {
|
|
38
43
|
if (j.phase === "complete")
|
|
39
|
-
return j.kind === "rollback" ? `<div class="card" data-update-outcome="rolled-back"><h2>Back on ${
|
|
44
|
+
return j.kind === "rollback" ? html `<div class="card" data-update-outcome="rolled-back"><h2>Back on ${j.to.version}</h2><p class="meta">${j.detail}</p></div>` : "";
|
|
40
45
|
if (j.phase === "cancelled")
|
|
41
|
-
return `<div class="card" data-update-outcome="cancelled"><h2>Update cancelled</h2><p class="meta">Nothing changed.</p></div>`;
|
|
46
|
+
return html `<div class="card" data-update-outcome="cancelled"><h2>Update cancelled</h2><p class="meta">Nothing changed.</p></div>`;
|
|
42
47
|
// Stopped on a finished run it can't show has ended: what is in the way and the one command, nothing more.
|
|
43
48
|
if (j.phase === "refused" && j.waiting)
|
|
44
|
-
return `<div class="card" data-update-outcome="stopped"><h2>Update to ${
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return `<div class="card" data-update-outcome="${e(j.phase)}"><h2>${title}</h2><p class="${j.phase === "needs-attention" ? "update-problem" : ""}" role="alert">${e(j.detail)}</p>` +
|
|
48
|
-
`<details><summary>Steps</summary>${updateStepsHtml(j, false).replace(/<h2>.*?<\/h2>/, "")}</details></div>`;
|
|
49
|
+
return html `<div class="card" data-update-outcome="stopped"><h2>Update to ${j.to.version} stopped: run #${j.waiting.run} is in the way</h2><p>${j.waiting.on}. New work resumed.</p>${j.waiting.action ? html `<p class="meta">If nothing of it is running, run <code>${j.waiting.action}</code>, then update again.</p>` : html `<p class="meta">Update again once it has stopped.</p>`}</div>`;
|
|
50
|
+
const title = j.phase === "refused" ? html `Didn't update to ${j.to.version}` : j.phase === "restored" ? html `Update to ${j.to.version} didn't finish` : "The update needs attention";
|
|
51
|
+
return html `<div class="card" data-update-outcome="${j.phase}"><h2>${title}</h2><p class="${j.phase === "needs-attention" ? "update-problem" : ""}" role="alert">${j.detail}</p><details><summary>Steps</summary>${replaceMarkup(updateStepsHtml(j, false), /<h2>.*?<\/h2>/, () => html ``)}</details></div>`;
|
|
49
52
|
}
|
|
50
53
|
export function updatesHtml(view, notice) {
|
|
51
|
-
const note = notice.problem ? `<p class="update-problem" role="alert">${
|
|
54
|
+
const note = notice.problem ? html `<p class="update-problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
|
|
52
55
|
const j = view.journal;
|
|
53
56
|
const active = j !== null && !runtimeUpdateTerminal(j.phase);
|
|
54
|
-
const
|
|
55
|
-
const whatsNew = view.whatsNew === null ? "" : `<div class="card whats-new" data-whats-new="${e(view.whatsNew.version)}"><h2>What’s new in ${e(view.whatsNew.version)}</h2>` +
|
|
56
|
-
(view.whatsNew.notes.length > 0 ? `<ul>${view.whatsNew.notes.map(line => `<li>${e(line)}</li>`).join("")}</ul>` : "") +
|
|
57
|
-
`<p class="meta"><a href="https://github.com/ap9000/toolroll/releases/tag/v${e(view.whatsNew.version)}" rel="noreferrer">Full release notes</a></p>` +
|
|
58
|
-
`<form method="post" action="/settings/updates/seen">${csrf}<button type="submit">Got it</button></form></div>`;
|
|
57
|
+
const whatsNew = view.whatsNew === null ? "" : html `<div class="card whats-new" data-whats-new="${view.whatsNew.version}"><h2>What’s new in ${view.whatsNew.version}</h2>${view.whatsNew.notes.length > 0 ? html `<ul>${view.whatsNew.notes.map(line => html `<li>${line}</li>`)}</ul>` : ""}<p class="meta"><a href="https://github.com/ap9000/toolroll/releases/tag/v${view.whatsNew.version}" rel="noreferrer">Full release notes</a></p>${postForm("/settings/updates/seen", html `<button type="submit">Got it</button>`)}</div>`;
|
|
59
58
|
if (active) {
|
|
60
|
-
const cancel = ["scheduled", "draining"].includes(j.phase) ?
|
|
61
|
-
return `<section class="updates">${note}<div class="card" id="update-region">${updateStepsHtml(j, view.running)}${cancel}</div><p class="meta stamp" id="update-region-stamp"></p></section>`;
|
|
59
|
+
const cancel = ["scheduled", "draining"].includes(j.phase) ? postForm("/settings/updates/cancel", html `<button type="submit">Cancel update</button>`, { attrs: { class: "update-actions" } }) : "";
|
|
60
|
+
return html `<section class="updates">${note}<div class="card" id="update-region">${updateStepsHtml(j, view.running)}${cancel}</div><p class="meta stamp" id="update-region-stamp"></p></section>`;
|
|
62
61
|
}
|
|
63
62
|
const latest = "version" in view.latest ? view.latest.version : null;
|
|
64
63
|
const newer = latest !== null && newerThan(latest, view.current);
|
|
65
|
-
const state = view.method.kind === "npx" ? `<h2>Toolroll ${
|
|
66
|
-
: view.method.kind === "source" ? `<h2>Toolroll ${
|
|
67
|
-
: newer && view.method.kind === "desktop" ? `<h2>Toolroll ${
|
|
68
|
-
: newer ? `<h2>Toolroll ${
|
|
69
|
-
: "off" in view.latest ? `<h2>Toolroll ${
|
|
70
|
-
: latest === null ? `<h2>Toolroll ${
|
|
71
|
-
: `<h2>Toolroll ${
|
|
64
|
+
const state = view.method.kind === "npx" ? html `<h2>Toolroll ${view.current}</h2><p class="meta">npx runs the latest release each time, so this is current.</p>`
|
|
65
|
+
: view.method.kind === "source" ? html `<h2>Toolroll ${view.current}</h2><p class="meta">This runs from a source checkout. Update it with git.</p>`
|
|
66
|
+
: newer && view.method.kind === "desktop" ? html `<h2>Toolroll ${latest} is available</h2><p class="meta">You have ${view.current}. Update it from the Toolroll app.</p>`
|
|
67
|
+
: newer ? html `<h2>Toolroll ${latest} is available</h2><p class="meta">You have ${view.current}. Running work finishes first, and your current version is kept so you can go back.</p>`
|
|
68
|
+
: "off" in view.latest ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Update checks are off.</p><form method="get" action="/settings/updates" class="update-actions"><input type="hidden" name="check" value="now"><button type="submit">Check now</button></form>`
|
|
69
|
+
: latest === null ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Couldn’t check for a newer release: ${"problem" in view.latest ? view.latest.problem : ""}</p>`
|
|
70
|
+
: html `<h2>Toolroll ${view.current} is up to date</h2>`;
|
|
72
71
|
const form = newer && !["npx", "source", "desktop"].includes(view.method.kind)
|
|
73
|
-
? `<
|
|
74
|
-
`<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
|
|
75
|
-
`<div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button>` +
|
|
76
|
-
`<button type="submit" name="when" value="when-idle">When idle</button><button type="submit" name="when" value="tonight">Tonight (03:00)</button></div></form>`
|
|
72
|
+
? postForm("/settings/updates", html `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button><button type="submit" name="when" value="when-idle">When idle</button><button type="submit" name="when" value="tonight">Tonight (03:00)</button></div>`, { hidden: { version: latest } })
|
|
77
73
|
: "";
|
|
78
|
-
const rollback = view.rollbackTo !== null ? `<p class="meta">To go back to ${
|
|
79
|
-
return `<section class="updates">${note}${whatsNew}${j ? outcomeHtml(j) : ""}<div class="card" data-update-state="${newer ? "available" : "current"}">${state}${form}${rollback}</div></section>`;
|
|
74
|
+
const rollback = view.rollbackTo !== null ? html `<p class="meta">To go back to ${view.rollbackTo}: <code>toolroll update --rollback</code></p>` : "";
|
|
75
|
+
return html `<section class="updates">${note}${whatsNew}${j ? outcomeHtml(j) : ""}<div class="card" data-update-state="${newer ? "available" : "current"}">${state}${form}${rollback}</div></section>`;
|
|
80
76
|
}
|
|
81
77
|
export function newerThan(a, b) {
|
|
82
78
|
const [x, y] = [a, b].map(v => v.split(".").map(Number));
|
|
@@ -103,6 +103,12 @@ export declare function releaseNotes(changelog: string, version: string): string
|
|
|
103
103
|
* by its directory), and the directory after, so a crash never leaves a torn
|
|
104
104
|
* database, catalog or command. */
|
|
105
105
|
export declare function durableRename(temp: string, target: string): void;
|
|
106
|
+
type Moved = {
|
|
107
|
+
into: string;
|
|
108
|
+
where: string;
|
|
109
|
+
rows?: string;
|
|
110
|
+
};
|
|
111
|
+
export declare const CHAT_MOVES: Record<string, Moved>;
|
|
106
112
|
/** The tables a migration may remove whole (v115's removed features). */
|
|
107
113
|
export declare const RETIRED_TABLES: readonly string[];
|
|
108
114
|
/** A saved routine, as its move into a scheduled flow must account for it. */
|
|
@@ -127,6 +133,10 @@ export type TableDigest = {
|
|
|
127
133
|
nonNull: Record<string, number>;
|
|
128
134
|
};
|
|
129
135
|
routines?: RoutineDigest[];
|
|
136
|
+
moved?: {
|
|
137
|
+
rows: number;
|
|
138
|
+
found: number;
|
|
139
|
+
};
|
|
130
140
|
};
|
|
131
141
|
/** Each saved table's columns, row count and last rowid; the append-only ones hashed (deploy-browser's rehearsal check).
|
|
132
142
|
* Bounded: only the ledger and small tables are read row by row. */
|
package/dist/toolroll-update.js
CHANGED
|
@@ -324,9 +324,46 @@ export function durableRename(temp, target) {
|
|
|
324
324
|
* `receives`: the only rows the table may gain are those receipts. `compacted`: rows may go only into summary rows,
|
|
325
325
|
* whose `sum` grows by exactly as many. `retired`: a removed feature's table, which may go whole. `fromRoutines`: rows
|
|
326
326
|
* kept as they were, and the only rows the table may gain are one per saved routine, moved into a scheduled flow (its
|
|
327
|
-
* flow, or its schedule trigger naming that routine) — on only when the routine was approved and running.
|
|
327
|
+
* flow, or its schedule trigger naming that routine) — on only when the routine was approved and running. `moved`:
|
|
328
|
+
* the table may go only when every row it had (or the ones `rows` counts) arrived in `into`: the rows there that
|
|
329
|
+
* `where` picks grew by exactly that many. */
|
|
328
330
|
const LEGACY_DESTINATION = "legacy:single-destination";
|
|
331
|
+
/** v116: every old per-app chat table into the shared chat tables, keyed by provider (chat-migration.ts). Several old
|
|
332
|
+
* tables fan into one shared table; each picks out only its own rows there, so every count is checked on its own. */
|
|
333
|
+
const TELEGRAM_MOVES = {
|
|
334
|
+
telegram_binding: { into: "chat_binding", where: "1" },
|
|
335
|
+
telegram_pairing: { into: "chat_pair", where: "1" },
|
|
336
|
+
telegram_team_chat: { into: "chat_room", where: "1" },
|
|
337
|
+
// An applied update is a receipt; a pushed one still waiting is queued (one already applied was spent with it).
|
|
338
|
+
telegram_update: { into: "chat_event", where: "kind = 'update' AND state = 'done'" },
|
|
339
|
+
telegram_inbox: { into: "chat_event", where: "kind = 'update' AND state = 'queued'", rows: "update_id NOT IN (SELECT update_id FROM telegram_update)" },
|
|
340
|
+
telegram_conversation: { into: "chat_event", where: "kind = 'message'" },
|
|
341
|
+
telegram_conversation_part: { into: "chat_part", where: "1" },
|
|
342
|
+
telegram_proposal_action: { into: "chat_action", where: "proposal IS NOT NULL" },
|
|
343
|
+
telegram_action: { into: "chat_action", where: "decision IS NOT NULL" },
|
|
344
|
+
telegram_flow_action: { into: "chat_flow_action", where: "action IN ('approve','edit','send-back')" },
|
|
345
|
+
telegram_flow_confirm: { into: "chat_flow_action", where: "action IN ('yes','cancel')" },
|
|
346
|
+
telegram_flow_choice: { into: "chat_flow_choice", where: "1" },
|
|
347
|
+
telegram_flow_prompt: { into: "chat_flow_prompt", where: "1" },
|
|
348
|
+
telegram_question_action: { into: "chat_question_action", where: "1" },
|
|
349
|
+
telegram_question_prompt: { into: "chat_question_prompt", where: "1" },
|
|
350
|
+
telegram_decision_message: { into: "chat_message_ref", where: "kind = 'decision'" },
|
|
351
|
+
telegram_task_message: { into: "chat_message_ref", where: "kind = 'task'" },
|
|
352
|
+
telegram_outbound_message: { into: "chat_message_ref", where: "kind = 'notification'" },
|
|
353
|
+
telegram_note_draft: { into: "chat_note_draft", where: "1" },
|
|
354
|
+
// A bot's lease and its rate-limit wait share its one runtime row.
|
|
355
|
+
bridge_lease: { into: "chat_runtime", where: "lease_until IS NOT NULL" },
|
|
356
|
+
telegram_retry: { into: "chat_runtime", where: "retry_at IS NOT NULL" },
|
|
357
|
+
telegram_digest: { into: "chat_digest", where: "1", rows: "id = 1" },
|
|
358
|
+
};
|
|
359
|
+
const APP_TABLES = ["binding", "pair", "event", "part", "action", "progress", "runtime", "room", "meta",
|
|
360
|
+
"flow_action", "flow_prompt", "flow_choice", "flow_note", "question_action", "question_prompt", "ask_action"];
|
|
361
|
+
export const CHAT_MOVES = {
|
|
362
|
+
...Object.fromEntries(Object.entries(TELEGRAM_MOVES).map(([table, move]) => [table, { ...move, where: `provider = 'telegram' AND (${move.where})` }])),
|
|
363
|
+
...Object.fromEntries(["slack", "discord", "teams"].flatMap(app => APP_TABLES.map(suffix => [`${app}_${suffix}`, { into: `chat_${suffix}`, where: `provider = '${app}'` }]))),
|
|
364
|
+
};
|
|
329
365
|
const HISTORY_RULES = {
|
|
366
|
+
...Object.fromEntries(Object.entries(CHAT_MOVES).map(([table, moved]) => [table, { moved }])),
|
|
330
367
|
action_ledger: { appendOnly: true }, ledger_seal: { appendOnly: true }, ledger_checkpoint: { appendOnly: true },
|
|
331
368
|
// v114: a finished run whose process witnesses have all exited keeps one summary row in place of them.
|
|
332
369
|
run_process: { compacted: { into: "run_process_summary", sum: "witnesses" } }, run_process_summary: { appendOnly: true },
|
|
@@ -363,6 +400,8 @@ function receipts(db, name, destination, columns) {
|
|
|
363
400
|
const r = db.prepare("SELECT count(*) AS n" + columns.map(column => ",count(" + quote(column) + ") AS " + quote(column)).join("") + " FROM " + quote(name) + " WHERE destination = ?").get(destination);
|
|
364
401
|
return { count: Number(r["n"]), nonNull: Object.fromEntries(columns.map(column => [column, Number(r[column])])) };
|
|
365
402
|
}
|
|
403
|
+
/** The rows a moved table's rule picks out in its destination (none while there is no such table). */
|
|
404
|
+
const arrived = (db, move) => db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(move.into) ? Number(db.prepare("SELECT count(*) AS n FROM " + quote(move.into) + " WHERE " + move.where).get()["n"]) : 0;
|
|
366
405
|
const summed = (db, into) => db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(into.into) ? Number(db.prepare("SELECT coalesce(sum(" + quote(into.sum) + "),0) AS n FROM " + quote(into.into)).get()["n"]) : 0;
|
|
367
406
|
/** Each saved table's columns, row count and last rowid; the append-only ones hashed (deploy-browser's rehearsal check).
|
|
368
407
|
* Bounded: only the ledger and small tables are read row by row. */
|
|
@@ -388,6 +427,9 @@ export function historySnapshot(db) {
|
|
|
388
427
|
t.summed = summed(db, rule.compacted);
|
|
389
428
|
if (rule?.fromRoutines)
|
|
390
429
|
t.routines = savedRoutines(db);
|
|
430
|
+
if (rule?.moved)
|
|
431
|
+
t.moved = { found: arrived(db, rule.moved),
|
|
432
|
+
rows: rule.moved.rows === undefined ? t.count : Number(db.prepare("SELECT count(*) AS n FROM " + quote(name) + " WHERE " + rule.moved.rows).get()["n"]) };
|
|
391
433
|
return t;
|
|
392
434
|
});
|
|
393
435
|
}
|
|
@@ -450,8 +492,13 @@ export function changedHistory(db, before) {
|
|
|
450
492
|
return got !== undefined && want !== undefined && got.count === want.count && Object.entries(want.nonNull).every(([c, n]) => got.nonNull[c] === n) && t.carry === want.count;
|
|
451
493
|
};
|
|
452
494
|
return before.filter(t => {
|
|
453
|
-
if (!tables.has(t.name))
|
|
454
|
-
|
|
495
|
+
if (!tables.has(t.name)) {
|
|
496
|
+
const rule = HISTORY_RULES[t.name];
|
|
497
|
+
if (rule?.retired === true)
|
|
498
|
+
return false;
|
|
499
|
+
const move = rule?.moved;
|
|
500
|
+
return move === undefined || t.moved === undefined || arrived(db, move) - t.moved.found !== t.moved.rows;
|
|
501
|
+
}
|
|
455
502
|
const rule = HISTORY_RULES[t.name], present = new Set(tableColumns(db, t.name));
|
|
456
503
|
if (t.columns.some(c => !present.has(c) && (!rule?.dropped?.includes(c) || (t.nonNull?.[c] !== 0 && !accounted(rule, t)))))
|
|
457
504
|
return true;
|
package/dist/tools-ui.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Settings → Tools: one project's MCP servers, in plain words. Secrets are
|
|
2
|
+
* entered here and nowhere else, and never shown again. */
|
|
3
|
+
import { type Html } from "./html.js";
|
|
1
4
|
import { type FoundTool, type ProjectTool, type ToolSpec } from "./project-tools.js";
|
|
2
5
|
export declare const TOOLS_CSS = ".tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:center;gap:10px}.tools .tool-heading h2{flex:1;min-width:0}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready=\"false\"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools .tool-note{color:var(--so-warning)}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:\"\";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile .brand-mark{grid-row:span 2}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile>span:not(.brand-mark){font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state=\"connected\"]>span:not(.brand-mark){color:var(--so-success)}.tools .connect-local{grid-column:1/-1}.tools .connect-local>span:not(.brand-mark){-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile:disabled{cursor:default;opacity:.6}@media(max-width:600px){.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local)>span:not(.brand-mark){display:none}.tools .connect-local>span:not(.brand-mark){grid-column:2}.tools .connect-tile{min-height:52px;padding:8px 10px}.tools .connect-tile .brand-mark{grid-row:auto}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}}";
|
|
3
6
|
export type ToolsView = {
|
|
@@ -31,10 +34,10 @@ export type ToolsView = {
|
|
|
31
34
|
}[];
|
|
32
35
|
};
|
|
33
36
|
/** The project chooser: it opens the chosen project at once; without scripts, its button does. */
|
|
34
|
-
export declare function toolsProjectPicker(projects: readonly string[], chosen: string, keep?: Record<string, string>):
|
|
37
|
+
export declare function toolsProjectPicker(projects: readonly string[], chosen: string, keep?: Record<string, string>): Html | "";
|
|
35
38
|
/** Choosing another project marks every form on the page as stale before it reloads, so a tap racing the reload is refused. */
|
|
36
39
|
export declare const TOOLS_PROJECT_SCRIPT = "(function(){var s=document.querySelector('select[data-tools-project]');if(!s)return;s.addEventListener('change',function(){document.querySelectorAll('input[name=shown]').forEach(function(i){i.value=s.value;});});})();";
|
|
37
40
|
export declare function toolsHtml(view: ToolsView, csrf: string, canManage: boolean, message?: {
|
|
38
41
|
said?: string | null;
|
|
39
42
|
problem?: string | null;
|
|
40
|
-
}):
|
|
43
|
+
}): Html;
|
package/dist/tools-ui.js
CHANGED
|
@@ -1,66 +1,66 @@
|
|
|
1
1
|
/** Settings → Tools: one project's MCP servers, in plain words. Secrets are
|
|
2
2
|
* entered here and nowhere else, and never shown again. */
|
|
3
|
+
import { html, postForm } from "./html.js";
|
|
3
4
|
import { brandMarkHtml, TILE_PHONE } from "./brand-mark.js";
|
|
4
5
|
import { toolCommandLine, toolStanding } from "./project-tools.js";
|
|
5
|
-
const
|
|
6
|
-
const hidden = (data) => Object.entries(data).map(([k, v]) => `<input type="hidden" name="${e(k)}" value="${e(v)}">`).join("");
|
|
6
|
+
const hidden = (data) => Object.entries(data).map(([k, v]) => html `<input type="hidden" name="${k}" value="${v}">`);
|
|
7
7
|
export const TOOLS_CSS = `.tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:center;gap:10px}.tools .tool-heading h2{flex:1;min-width:0}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready="false"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools .tool-note{color:var(--so-warning)}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:"";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile .brand-mark{grid-row:span 2}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile>span:not(.brand-mark){font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state="connected"]>span:not(.brand-mark){color:var(--so-success)}.tools .connect-local{grid-column:1/-1}.tools .connect-local>span:not(.brand-mark){-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile:disabled{cursor:default;opacity:.6}${TILE_PHONE}{.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local)>span:not(.brand-mark){display:none}.tools .connect-local>span:not(.brand-mark){grid-column:2}.tools .connect-tile{min-height:52px;padding:8px 10px}.tools .connect-tile .brand-mark{grid-row:auto}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}}`;
|
|
8
8
|
/** The project chooser: it opens the chosen project at once; without scripts, its button does. */
|
|
9
9
|
export function toolsProjectPicker(projects, chosen, keep = {}) {
|
|
10
10
|
if (projects.length < 2)
|
|
11
11
|
return "";
|
|
12
|
-
const options = projects.map(p => `<option value="${
|
|
13
|
-
return `<form class="tools" method="get" action="/settings/tools" data-autosave>${hidden(keep)}<label>Project<select name="repo" data-tools-project>${options}</select></label><button type="submit">Show project</button></form>`;
|
|
12
|
+
const options = projects.map(p => html `<option value="${p}"${p === chosen ? html ` selected` : ""}>${p.split(/[\\/]/).filter(Boolean).at(-1) ?? p}</option>`);
|
|
13
|
+
return html `<form class="tools" method="get" action="/settings/tools" data-autosave>${hidden(keep)}<label>Project<select name="repo" data-tools-project>${options}</select></label><button type="submit">Show project</button></form>`;
|
|
14
14
|
}
|
|
15
15
|
/** Choosing another project marks every form on the page as stale before it reloads, so a tap racing the reload is refused. */
|
|
16
16
|
export const TOOLS_PROJECT_SCRIPT = `(function(){var s=document.querySelector('select[data-tools-project]');if(!s)return;s.addEventListener('change',function(){document.querySelectorAll('input[name=shown]').forEach(function(i){i.value=s.value;});});})();`;
|
|
17
|
-
const password =
|
|
17
|
+
const password = html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`;
|
|
18
18
|
function toolCard(one, base, manage, project, research = null) {
|
|
19
19
|
const { tool, secretsSet } = one;
|
|
20
20
|
const standing = toolStanding(tool, secretsSet);
|
|
21
21
|
// One line per secret: the disclosure is both where it stands and where it is set.
|
|
22
22
|
const secrets = manage ? tool.spec.secrets.map(secret => {
|
|
23
23
|
const set = secretsSet.includes(secret.name);
|
|
24
|
-
return `<details><summary>${set ? `Replace ${
|
|
25
|
-
})
|
|
26
|
-
const tested = tool.lastTest?.ok ? `<p>Tested ${
|
|
27
|
-
const details = `<details><summary>Details</summary><div class="tool-facts"><p>Starts: <code>${
|
|
28
|
-
const failed = tool.lastTest !== null && !tool.lastTest.ok ? `<p class="problem" role="status">${
|
|
24
|
+
return html `<details><summary>${set ? `Replace ${secret.name}` : `Set ${secret.name}${secret.optional ? " (optional)" : ""}`}</summary>${postForm("/settings/tools/change", html `<label>${secret.name}<input type="password" name="value" autocomplete="off" required></label>${password}<button>Save</button>`, { hidden: { ...base, action: "secret", name: tool.name, secret: secret.name } })}</details>`;
|
|
25
|
+
}) : "";
|
|
26
|
+
const tested = tool.lastTest?.ok ? html `<p>Tested ${tool.lastTest.at.slice(0, 16).replace("T", " ")} UTC: ${tool.lastTest.tools.join(", ") || "it lists no tools"}</p>` : "";
|
|
27
|
+
const details = html `<details><summary>Details</summary><div class="tool-facts"><p>Starts: <code>${toolCommandLine(tool.spec)}</code></p><p>From ${tool.source} · added by ${tool.createdBy}</p>${tested}</div></details>`;
|
|
28
|
+
const failed = tool.lastTest !== null && !tool.lastTest.ok ? html `<p class="problem" role="status">${tool.lastTest.problem ?? "The last test failed."}</p>` : "";
|
|
29
29
|
// Connected, but research can't use it (its grant writes): said once, with the one way to fix it.
|
|
30
|
-
const reconnect = research === null || !manage ? "" : `<details><summary>Reconnect read-only</summary
|
|
31
|
-
const limited = research === null ? "" : `<p class="tool-note" role="status">${
|
|
32
|
-
const controls = manage ? `<div class="tool-controls"
|
|
33
|
-
return `<article class="card" id="tool-${
|
|
30
|
+
const reconnect = research === null || !manage ? "" : html `<details><summary>Reconnect read-only</summary>${postForm("/settings/tools/connect", html `${password}<button>Reconnect read-only</button>`, { hidden: { ...base, service: tool.name, access: "read" } })}</details>`;
|
|
31
|
+
const limited = research === null ? "" : html `<p class="tool-note" role="status">${research}</p>${reconnect}`;
|
|
32
|
+
const controls = manage ? html `<div class="tool-controls">${postForm("/settings/tools/change", html `<button aria-label="Test ${tool.name} in ${project}">Test</button>`, { hidden: { ...base, action: "test", name: tool.name } })}${postForm("/settings/tools/change", html `<button class="secondary">Remove from ${project}</button>`, { hidden: { ...base, action: "remove", name: tool.name } })}</div>` : "";
|
|
33
|
+
return html `<article class="card" id="tool-${tool.name}"><div class="tool-heading">${brandMarkHtml(tool.source === "custom" ? null : tool.name, tool.name, standing.ready)}<h2>${tool.name}</h2><span class="tool-state" data-ready="${String(standing.ready)}">${standing.words}</span></div><p>${tool.spec.about}</p>${failed}${limited}${secrets}${details}${controls}</article>`;
|
|
34
34
|
}
|
|
35
35
|
/** A service connected here, offered to the person's other projects: each button signs in again for that project (tokens are never copied). */
|
|
36
36
|
function elsewhere(view, base) {
|
|
37
|
-
const offers = view.connections.filter(one => one.state === "connected").flatMap(one => (view.others ?? []).filter(other => other.open.includes(one.id)).map(other => `<button name="also" value="${
|
|
37
|
+
const offers = view.connections.filter(one => one.state === "connected").flatMap(one => (view.others ?? []).filter(other => other.open.includes(one.id)).map(other => html `<button name="also" value="${`${one.id}:${other.repo}`}" class="secondary">Also connect ${one.label} to ${other.project}</button>`));
|
|
38
38
|
if (offers.length === 0)
|
|
39
39
|
return "";
|
|
40
|
-
return `<details class="tool-elsewhere"><summary>Also connect to another project</summary
|
|
40
|
+
return html `<details class="tool-elsewhere"><summary>Also connect to another project</summary>${postForm("/settings/tools/connect", html `${password}<div class="tool-controls">${offers}</div>`, { hidden: { ...base, ...(view.kit === null ? {} : { kit: view.kit }) } })}</details>`;
|
|
41
41
|
}
|
|
42
42
|
export function toolsHtml(view, csrf, canManage, message = {}) {
|
|
43
43
|
const manage = canManage && csrf !== "";
|
|
44
44
|
// `shown` is the project this page shows; the server refuses a post whose repo differs from it.
|
|
45
|
-
const base = {
|
|
46
|
-
const to = ` to ${
|
|
47
|
-
const intro = `<p>Builds in ${
|
|
48
|
-
const cards = view.tools.length === 0 ?
|
|
49
|
-
const fromList = view.catalog.length === 0 ? "" :
|
|
50
|
-
const custom = `<details><summary>Add your own</summary
|
|
45
|
+
const base = { repo: view.repo, shown: view.repo };
|
|
46
|
+
const to = ` to ${view.project}`;
|
|
47
|
+
const intro = html `<p>Builds in ${view.project} use exactly these tools. MCP servers set up elsewhere on this computer aren't used.</p><details><summary>How tools work</summary><ul><li>A tool you add reaches work you approve from now on. Approve a task again to give it a new tool.</li><li>Removing a tool takes it away from every build right away.</li><li>Secrets stay on this computer, are never shown again, and go only to the tool that needs them.</li><li>Reviewers and the lead chat never get tools. Gemini builds don't use tools yet.</li></ul></details>`;
|
|
48
|
+
const cards = view.tools.length === 0 ? html `<p class="meta">No tools yet. Builds in this project get none.</p>` : view.tools.map(one => toolCard(one, base, manage, view.project, view.connections.find(c => c.id === one.tool.name && c.state === "connected")?.research ?? null));
|
|
49
|
+
const fromList = view.catalog.length === 0 ? "" : postForm("/settings/tools/change", html `<label>Common tools<select name="catalog">${view.catalog.map(one => html `<option value="${one.name}">${one.label}: ${one.about}</option>`)}</select></label>${password}<button>Add${to}</button>`, { hidden: { ...base, action: "add-catalog" } });
|
|
50
|
+
const custom = html `<details><summary>Add your own</summary>${postForm("/settings/tools/change", html `<label>Name<input name="name" pattern="[a-z0-9][a-z0-9_\\-]{0,39}" maxlength="40" required placeholder="for example: postgres"></label><label>It runs as<select name="transport"><option value="stdio">A program on this computer</option><option value="http">A web address</option></select></label><label>Command or address<input name="target" required placeholder="npx -y some-mcp-server@1.2.3, or https://…"></label><label>Secrets it needs (names, comma separated)<input name="secrets" placeholder="for example: DATABASE_URL"></label><p class="meta">For a web address, the first secret is sent as its sign-in token. Set each secret's value after adding.</p>${password}<button>Add${to}</button>`, { hidden: { ...base, action: "add-custom" } })}</details>`;
|
|
51
51
|
// One click: sign in on the service's own page. The password is the same as for adding any tool.
|
|
52
52
|
const tiles = view.connections.map(one => one.state === "taken"
|
|
53
|
-
? `<button type="button" class="connect-tile" id="connect-${
|
|
54
|
-
: `<button name="service" value="${
|
|
53
|
+
? html `<button type="button" class="connect-tile" id="connect-${one.id}" data-state="taken" disabled>${brandMarkHtml(one.id, one.label, false)}<strong>${one.label}</strong><span>Added another way</span></button>`
|
|
54
|
+
: html `<button name="service" value="${one.id}" class="connect-tile${one.local ? " connect-local" : ""}" id="connect-${one.id}" data-state="${one.state}" aria-label="${one.state === "connected" ? `${one.label}, connected${to}. ${one.local ? "Connect again" : "Sign in again"}` : `Connect ${one.label}${to}`}">${brandMarkHtml(one.id.replace(/-desktop$/, ""), one.label, one.state === "connected")}<strong>${one.label}</strong><span>${one.state === "connected" ? "Connected" : one.about}</span></button>`);
|
|
55
55
|
const wanted = view.connections.find(one => one.id === view.wanted && one.state !== "taken");
|
|
56
|
-
const first = wanted === undefined ? "" : `<button name="service" value="${
|
|
56
|
+
const first = wanted === undefined ? "" : html `<button name="service" value="${wanted.id}" class="connect-wanted">${wanted.state === "connected" && !wanted.local ? `Sign in to ${wanted.label} again for ${view.project}` : `Connect ${wanted.label}${to}`}</button>`;
|
|
57
57
|
const connecting = manage && view.connections.length > 0;
|
|
58
58
|
// A Connect that failed says why at its button, focused so it is heard and seen there (not only at the top).
|
|
59
59
|
const atButton = connecting && view.wanted !== null && view.connections.some(one => one.id === view.wanted) && Boolean(message.problem);
|
|
60
|
-
const failed = atButton ? `<p class="problem connect-problem" id="connect-problem" role="alert" tabindex="-1" autofocus>${
|
|
61
|
-
const connect = connecting ? `<section class="card tool-connect" id="connect" aria-labelledby="connect-heading"><h2 id="connect-heading">Connect${to}</h2
|
|
62
|
-
const add = manage ? `<details class="card"${view.tools.length === 0 ?
|
|
63
|
-
const found = manage && view.found.length > 0 ? `<details class="card"><summary>Found on this computer (${view.found.length})</summary><p class="meta">MCP servers your other apps use. Adding one copies its settings, and any secrets it has, into this project.</p
|
|
64
|
-
const note = atButton ? "" : message.problem ? `<p class="problem" role="alert">${
|
|
65
|
-
return `<section class="tools">${note}${intro}${cards}${connect}${add}${found}</section>`;
|
|
60
|
+
const failed = atButton ? html `<p class="problem connect-problem" id="connect-problem" role="alert" tabindex="-1" autofocus>${message.problem}</p>` : "";
|
|
61
|
+
const connect = connecting ? html `<section class="card tool-connect" id="connect" aria-labelledby="connect-heading"><h2 id="connect-heading">Connect${to}</h2>${postForm("/settings/tools/connect", html `${password}${first}${failed}<div class="connect-grid">${tiles}</div>`, { hidden: { ...base, ...(view.kit === null ? {} : { kit: view.kit }) } })}${elsewhere(view, base)}</section>` : "";
|
|
62
|
+
const add = manage ? html `<details class="card"${view.tools.length === 0 ? html ` open` : ""}><summary>Add a tool</summary>${fromList}${custom}</details>` : "";
|
|
63
|
+
const found = manage && view.found.length > 0 ? html `<details class="card"><summary>Found on this computer (${view.found.length})</summary><p class="meta">MCP servers your other apps use. Adding one copies its settings, and any secrets it has, into this project.</p>${postForm("/settings/tools/change", html `${view.found.map(one => html `<label class="tool-found"><input type="checkbox" name="import" value="${one.spec.name}"><span><strong>${one.spec.name}</strong> · ${one.source}<br><code>${toolCommandLine(one.spec)}</code>${one.spec.secrets.length === 0 ? "" : html `<br><span class="meta">Brings ${one.spec.secrets.map(s => s.name).join(", ")}</span>`}</span></label>`)}${password}<button>Add selected${to}</button>`, { hidden: { ...base, action: "import" } })}</details>` : "";
|
|
64
|
+
const note = atButton ? "" : message.problem ? html `<p class="problem" role="alert">${message.problem}</p>` : message.said ? html `<p role="status">${message.said}</p>` : "";
|
|
65
|
+
return html `<section class="tools">${note}${intro}${cards}${connect}${add}${found}</section>`;
|
|
66
66
|
}
|
package/dist/when-html.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A time a person reads. A desk shows the full stamp, as before; a phone (760px and narrower) shows the short
|
|
3
|
+
* one — "16:39" today, "Yesterday 16:39", "Sep 28" otherwise — and the full stamp stays in the title. Stamps are
|
|
4
|
+
* UTC to the minute, so the server counts in UTC; in the browser `localizeTimes` rewords each of these with the same
|
|
5
|
+
* formatter in the viewer's own zone, as the React views do. The CSS that picks one lives in the shared page
|
|
6
|
+
* stylesheet (`.so-when-*` in serve.ts).
|
|
7
|
+
*/
|
|
8
|
+
import { type Html } from "./html.js";
|
|
1
9
|
/** The viewer's own time zone (a browser's); UTC where there is none to read. */
|
|
2
10
|
export declare function viewerZone(): string;
|
|
3
11
|
/** The one formatter for a time a person reads, on every surface: "16:39" today, "Yesterday 16:39" /
|
|
@@ -23,10 +31,10 @@ export declare function localizeTimes(root: ParentNode, now?: Date, zone?: strin
|
|
|
23
31
|
/** The exact second in `zone` ("2026-09-30 16:39:12", with " UTC" when it is UTC): an audit record's stamp. */
|
|
24
32
|
export declare function exactWhen(iso: string, zone?: string): string;
|
|
25
33
|
/** The `<time>` for an exact stamp: its seconds in UTC words on the server, the viewer's zone in a browser. */
|
|
26
|
-
export declare function exactWhenHtml(iso: string):
|
|
34
|
+
export declare function exactWhenHtml(iso: string): Html;
|
|
27
35
|
/** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
|
|
28
|
-
export declare function whenHtml(iso: string | null, full: string, now?: Date):
|
|
36
|
+
export declare function whenHtml(iso: string | null, full: string, now?: Date): Html;
|
|
29
37
|
/** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
|
|
30
38
|
export declare const utcMinute: (iso: string) => string;
|
|
31
39
|
/** Both the desk and phone forms of a UTC stamp. */
|
|
32
|
-
export declare const whenUtc: (iso: string | null, now?: Date) =>
|
|
40
|
+
export declare const whenUtc: (iso: string | null, now?: Date) => Html;
|
package/dist/when-html.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* formatter in the viewer's own zone, as the React views do. The CSS that picks one lives in the shared page
|
|
6
6
|
* stylesheet (`.so-when-*` in serve.ts).
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
import { html } from "./html.js";
|
|
9
9
|
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
10
10
|
const DAY_MS = 86_400_000;
|
|
11
11
|
/** One `Intl.DateTimeFormat` per zone: building one is the slow part, and every time on a page shares a few zones. */
|
|
@@ -121,16 +121,15 @@ export function exactWhen(iso, zone = "UTC") {
|
|
|
121
121
|
}
|
|
122
122
|
/** The `<time>` for an exact stamp: its seconds in UTC words on the server, the viewer's zone in a browser. */
|
|
123
123
|
export function exactWhenHtml(iso) {
|
|
124
|
-
return `<time data-when="exact" datetime="${
|
|
124
|
+
return html `<time data-when="exact" datetime="${iso}" title="${iso}">${exactWhen(iso)}</time>`;
|
|
125
125
|
}
|
|
126
126
|
/** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
|
|
127
127
|
export function whenHtml(iso, full, now = new Date()) {
|
|
128
128
|
if (iso === null || iso === "")
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
-
return `<time data-when datetime="${escape(iso)}" title="${words}"><span class="so-when-full">${words}</span><span class="so-when-short">${escape(shortWhen(iso, now))}</span></time>`;
|
|
129
|
+
return html ``;
|
|
130
|
+
return html `<time data-when datetime="${iso}" title="${full}"><span class="so-when-full">${full}</span><span class="so-when-short">${shortWhen(iso, now)}</span></time>`;
|
|
132
131
|
}
|
|
133
132
|
/** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
|
|
134
133
|
export const utcMinute = (iso) => `${iso.slice(0, 16).replace("T", " ")} UTC`;
|
|
135
134
|
/** Both the desk and phone forms of a UTC stamp. */
|
|
136
|
-
export const whenUtc = (iso, now = new Date()) => iso === null || iso === "" ?
|
|
135
|
+
export const whenUtc = (iso, now = new Date()) => iso === null || iso === "" ? html `` : whenHtml(iso, utcMinute(iso), now);
|