slidemind 0.3.20 → 0.4.1
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +592 -16
- package/LICENSE +4 -0
- package/README.md +203 -8
- package/THIRD-PARTY-NOTICES.md +201 -0
- package/bin/slidemind.mjs +50 -2
- package/package.json +5 -3
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,1886 @@
|
|
|
1
|
+
// The workspace page. Plain script, no build step. State lives in this closure;
|
|
2
|
+
// every screen is re-rendered from a fresh GET (SSE only says "re-GET").
|
|
3
|
+
//
|
|
4
|
+
// Boot: exchange `#bootstrap=<key>` (the workspace's link key, valid every time
|
|
5
|
+
// it is clicked) for the session cookie + CSRF token, strip
|
|
6
|
+
// the fragment, then route on location.pathname:
|
|
7
|
+
// / library + inbox
|
|
8
|
+
// /deck/<id>[/plan|/preview] deck tabs — the address follows the tab, so
|
|
9
|
+
// the link an agent posts opens the view itself
|
|
10
|
+
// /deck/<id>/tasks/<taskId> an older task link: opens that task's tab
|
|
11
|
+
//
|
|
12
|
+
// The workspace restarts under the page (a crash, a reboot, every CLI update):
|
|
13
|
+
// the page notices (a dropped event stream, a failed request), says so in ONE
|
|
14
|
+
// sentence, polls /health and picks up where it was — same cookie, same port —
|
|
15
|
+
// without a new link. Nothing the user typed is dropped on the way.
|
|
16
|
+
//
|
|
17
|
+
// Pure helpers hang off window.SlidemindApp so the test can call them in-page.
|
|
18
|
+
(function () {
|
|
19
|
+
"use strict";
|
|
20
|
+
const API = "/api/v1";
|
|
21
|
+
const AUTOSAVE_MS = 500;
|
|
22
|
+
const RETRY_MS = 2000; // a busy deck: the autosave tries again by itself
|
|
23
|
+
const POLL_MS = window.SLIDEMIND_POLL_MS ?? 30_000; // visible shared plan: GET-only refresh, never a PUT
|
|
24
|
+
const HEALTH_MS = window.SLIDEMIND_HEALTH_MS ?? 3_000; // offline: how often the page asks whether the workspace is back
|
|
25
|
+
const BUSY_HINT_MS = 60_000; // a deck still busy after this long: tell the user how to nudge the agent
|
|
26
|
+
const RECENT_TYPING_MS = 15_000; // keys in the Markdown editor this recently: an agent's ask waits in a toast
|
|
27
|
+
const OFFLINE_TEXT = "The workspace stopped — your work is kept. This page reconnects by itself.";
|
|
28
|
+
const SIGNED_OUT_TEXT = "Your workspace signed you out — ask your agent to open the slidemind workspace.";
|
|
29
|
+
const $app = document.getElementById("app");
|
|
30
|
+
const $conn = document.getElementById("conn");
|
|
31
|
+
const $banner = document.getElementById("conn-banner");
|
|
32
|
+
// the shell (top bar + deck sidebar) lives in index.html and is refreshed alongside every screen
|
|
33
|
+
const $library = document.getElementById("library");
|
|
34
|
+
const $more = document.getElementById("more");
|
|
35
|
+
const $search = document.getElementById("library-search");
|
|
36
|
+
const $inboxCount = document.getElementById("inbox-count");
|
|
37
|
+
const $inboxLink = document.getElementById("inbox-link");
|
|
38
|
+
const $metaTitle = document.getElementById("meta-title");
|
|
39
|
+
const $metaSub = document.getElementById("meta-sub");
|
|
40
|
+
const $topActions = document.getElementById("top-actions");
|
|
41
|
+
let csrf = window.SLIDEMIND_CSRF ?? null;
|
|
42
|
+
let sse = null;
|
|
43
|
+
let current = null; // { kind:"home"|"deck", deckId?, taskId?, tab? }
|
|
44
|
+
let planEditor = null; // the core-plan editor state while the Plan tab is open
|
|
45
|
+
let planFrame = null; // the plan iframe while the Plan tab is open (adapter page or the deck's plan.html)
|
|
46
|
+
let planSaving = null; // the plan frame's save in flight, so an answer can wait for it
|
|
47
|
+
let poll = null; // the 30s refresh timer for a visible shared plan
|
|
48
|
+
let planView = null; // { setMode } of the core plan tab, so an agent's ask can bring the page view forward
|
|
49
|
+
const answered = new Set(); // task ids this page answered: their continuation note survives re-renders
|
|
50
|
+
const deckNames = new Map(); // deck id → name, from the library, for toasts about decks not on screen
|
|
51
|
+
|
|
52
|
+
// ---------- pure helpers ----------
|
|
53
|
+
const h = (tag, attrs = {}, ...kids) => {
|
|
54
|
+
const el = document.createElement(tag);
|
|
55
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
56
|
+
if (v === null || v === undefined || v === false) continue;
|
|
57
|
+
if (k === "class") el.className = v;
|
|
58
|
+
else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
|
|
59
|
+
else el.setAttribute(k, v === true ? "" : v);
|
|
60
|
+
}
|
|
61
|
+
for (const kid of kids.flat()) if (kid !== null && kid !== undefined) el.append(kid.nodeType ? kid : document.createTextNode(String(kid)));
|
|
62
|
+
return el;
|
|
63
|
+
};
|
|
64
|
+
/** An inline 24×24 icon; `button svg` sizes it. innerHTML is ours, never user text. */
|
|
65
|
+
const icon = (d) => {
|
|
66
|
+
const el = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
67
|
+
el.setAttribute("viewBox", "0 0 24 24");
|
|
68
|
+
el.innerHTML = d;
|
|
69
|
+
return el;
|
|
70
|
+
};
|
|
71
|
+
const ICON_CHEVRON = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="m9 6 6 6-6 6"/>';
|
|
72
|
+
const ICON_REVEAL = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M7 17 17 7M8 7h9v9"/>';
|
|
73
|
+
const ICON_FOLDER = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>';
|
|
74
|
+
// Lucide copy / check: the copy icon people know from every chat and code block
|
|
75
|
+
const ICON_COPY = '<rect width="14" height="14" x="8" y="8" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2"/><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>';
|
|
76
|
+
const ICON_CHECK = '<path fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5"/>';
|
|
77
|
+
const ICON_TRASH = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M10 11v6M14 11v6"/>';
|
|
78
|
+
const LABEL = { approve: "Approve", "request-changes": "Request changes", done: "Done", comment: "Comment", resolve: "Resolve" };
|
|
79
|
+
/** Which phase (tab) a task belongs to and how the inbox names it; gate kinds are answered inside the gate itself. */
|
|
80
|
+
const PHASE = {
|
|
81
|
+
"plan-approval": { label: "Planning", tab: "plan", gate: true, ask: "The agent asks you to approve the plan. Approve it or send changes in the review below." },
|
|
82
|
+
"plan-edit": { label: "Plan editing", tab: "plan", gate: false, ask: "The agent asks you to edit the plan. Switch to Markdown, edit, then mark it done." },
|
|
83
|
+
"preview-review": { label: "Preview review", tab: "preview", gate: true, ask: "The agent asks you to review the slides. Pin comments, then approve or send changes in the review below." },
|
|
84
|
+
"sync-conflict": { label: "Sync conflict", tab: "plan", gate: true, ask: "The shared plan changed where you changed it. Resolve it in the panel below." },
|
|
85
|
+
};
|
|
86
|
+
const phaseOf = (kind) => PHASE[kind] ?? { label: kind, tab: "plan", gate: false, ask: "The agent asks you to answer this task." };
|
|
87
|
+
/** The tab a question is asked in: its phase's, or the deck's first when the deck has no such tab — never nowhere. */
|
|
88
|
+
const tabOf = (task, tabs) => (tabs.includes(phaseOf(task.kind).tab) ? phaseOf(task.kind).tab : tabs[0]);
|
|
89
|
+
const actionLabel = (a) => LABEL[a] ?? a;
|
|
90
|
+
/**
|
|
91
|
+
* An answer's button: what the click decides, in two words. The click also
|
|
92
|
+
* copies the message the user pastes to their agent; the dialog right after
|
|
93
|
+
* it says so — once, never on the button and never again under the bar (doc 21).
|
|
94
|
+
*/
|
|
95
|
+
const answerLabel = (a) => ({ approve: "Approve", "request-changes": "Send changes", comment: "Send comment", done: "Done", resolve: "Resolve" })[a] ?? actionLabel(a);
|
|
96
|
+
const fmtTime = (iso) => (iso ? new Date(iso).toLocaleString() : "never");
|
|
97
|
+
/** The library chip for a deck's layout generation; null for a current one. */
|
|
98
|
+
const formatChip = (d) => (d?.format === "legacy" ? { text: "Legacy", cls: "bad" } : d?.format === "unknown" ? { text: "empty", cls: "" } : null);
|
|
99
|
+
/** Which tabs a deck view offers; only capabilities the view lists. A legacy deck has none (the header says why). */
|
|
100
|
+
function tabsFor(view) {
|
|
101
|
+
const tabs = [];
|
|
102
|
+
if (view.deck?.format === "legacy") return tabs;
|
|
103
|
+
if (view.adapter?.blocker || (view.views ?? []).some((v) => v.id === "plan") || view.adapter?.id === "core") tabs.push("plan");
|
|
104
|
+
if (view.preview !== false) tabs.push("preview");
|
|
105
|
+
return tabs;
|
|
106
|
+
}
|
|
107
|
+
/** What a refused request means to the person at the page: one plain sentence, never a code. */
|
|
108
|
+
const PLAIN = {
|
|
109
|
+
offline: OFFLINE_TEXT,
|
|
110
|
+
workspace_stopping: OFFLINE_TEXT,
|
|
111
|
+
stopping: OFFLINE_TEXT,
|
|
112
|
+
unauthorized: SIGNED_OUT_TEXT,
|
|
113
|
+
csrf_required: SIGNED_OUT_TEXT,
|
|
114
|
+
deck_busy: "Your agent is working on this deck right now — try again in a moment.",
|
|
115
|
+
approval_in_progress: "An approval of this plan is still being saved — your edits are kept; try again in a moment.",
|
|
116
|
+
missing_path: "This deck's folder is gone — it was moved or deleted.",
|
|
117
|
+
unknown_deck: "This deck is no longer in your library.",
|
|
118
|
+
not_found: "This isn't there any more.",
|
|
119
|
+
not_implemented: "This needs a newer workspace — ask your agent to open the slidemind workspace.",
|
|
120
|
+
extension_missing: "This deck needs a slidemind extension that isn't installed on this computer — ask your agent to set it up.",
|
|
121
|
+
extension_incompatible: "This deck needs a newer version of its slidemind extension — ask your agent to update it.",
|
|
122
|
+
login_required: "Your Microsoft 365 sign-in is missing or expired — ask your agent to sign you in to Microsoft 365, then try again.",
|
|
123
|
+
};
|
|
124
|
+
const plainError = (err) => PLAIN[err?.code] ?? err?.message ?? "Something went wrong.";
|
|
125
|
+
/** A deck whose plan lives on SharePoint (polls, refreshes on focus). */
|
|
126
|
+
const isShared = (view) => ["shared", "blocked"].includes(view?.remote?.plan?.state);
|
|
127
|
+
/** The queue's conflict record, if the plan is blocked on one. */
|
|
128
|
+
const conflictOf = (view) => view?.sync?.conflict ?? view?.remote?.sync?.conflict ?? null;
|
|
129
|
+
const jitter = (ms) => ms + Math.floor(Math.random() * ms * 0.2) - Math.floor(ms * 0.1);
|
|
130
|
+
/**
|
|
131
|
+
* Save status chip from a PUT result / sync state. `dirty`: true while a save
|
|
132
|
+
* is on its way or retries by itself, "failed" when it stopped and the
|
|
133
|
+
* message next to it says why. A plan nobody shares is simply "Saved" — the
|
|
134
|
+
* user's computer is the only place it could be.
|
|
135
|
+
*/
|
|
136
|
+
function saveStatus(sync, dirty) {
|
|
137
|
+
if (dirty === "failed") return { text: "Not saved", cls: "warn" };
|
|
138
|
+
if (dirty) return { text: "Saving…", cls: "" };
|
|
139
|
+
if (!sync) return { text: "Saved", cls: "ok" };
|
|
140
|
+
if (sync.state === "pending" || sync.state === "sending" || sync.state === "applying") return { text: "Saving to SharePoint…", cls: "" };
|
|
141
|
+
if (sync.state === "blocked" || sync.state === "outcome-unknown" || sync.conflict) return { text: "Action needed", cls: "bad" };
|
|
142
|
+
if (sync.state === "idle" && sync.lastConfirmedAt) return { text: "Saved to SharePoint", cls: "ok" };
|
|
143
|
+
return { text: "Saved", cls: "ok" };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Copy text to the clipboard: the clipboard API, else a selected textarea
|
|
147
|
+
* (no permission needed — it works in Codex's own browser pane too). Resolves
|
|
148
|
+
* true only when a path REALLY copied: the page never says "Copied" otherwise.
|
|
149
|
+
*/
|
|
150
|
+
function copyText(text) {
|
|
151
|
+
const fallback = () => {
|
|
152
|
+
try {
|
|
153
|
+
const ta = document.createElement("textarea");
|
|
154
|
+
ta.value = text;
|
|
155
|
+
ta.setAttribute("readonly", "");
|
|
156
|
+
ta.style.cssText = "position:fixed;top:-1000px;opacity:0;";
|
|
157
|
+
document.body.append(ta);
|
|
158
|
+
ta.select();
|
|
159
|
+
const ok = document.execCommand("copy") === true;
|
|
160
|
+
ta.remove();
|
|
161
|
+
return ok;
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
if (!navigator.clipboard?.writeText) return Promise.resolve(fallback());
|
|
168
|
+
return navigator.clipboard.writeText(text).then(() => true, () => fallback());
|
|
169
|
+
} catch {
|
|
170
|
+
return Promise.resolve(fallback());
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** A deck is unfinished until a .pptx sits at its root (what build-pptx / adopt leave there). */
|
|
174
|
+
const unfinished = (d) => !d.built;
|
|
175
|
+
const newestFirst = (tasks) => [...tasks].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
176
|
+
const backupKey = (deckId, baseHash) => `plan:${deckId}:${baseHash}`;
|
|
177
|
+
|
|
178
|
+
const SEP = /[\\/]/;
|
|
179
|
+
/** `/Users/me/x` -> `~/x`; a path outside home is left alone. The page has no homedir of its own — the deck list ships it. */
|
|
180
|
+
function tilde(p, home) {
|
|
181
|
+
if (!home || !p.startsWith(home)) return p;
|
|
182
|
+
const rest = p.slice(home.length);
|
|
183
|
+
return rest === "" || SEP.test(rest[0]) ? "~" + rest : p;
|
|
184
|
+
}
|
|
185
|
+
/** Sidebar headings: the shortest trailing segments that tell these folders apart.
|
|
186
|
+
* Every deck folder is called "decks" (SLIDEMIND_DECKS / ./decks), so the basename alone names nothing. */
|
|
187
|
+
function groupLabels(dirs, home) {
|
|
188
|
+
if (dirs.length < 2) return dirs.map((d) => tilde(d, home)); // nothing to tell apart: name the folder in full
|
|
189
|
+
const segs = dirs.map((d) => tilde(d, home).split(SEP).filter(Boolean));
|
|
190
|
+
// ponytail: O(groups² · depth); groups are a handful — index the tails if that ever changes
|
|
191
|
+
return dirs.map((_, i) => {
|
|
192
|
+
for (let n = 1; n <= segs[i].length; n++) {
|
|
193
|
+
const tail = segs[i].slice(-n).join("/");
|
|
194
|
+
if (segs.some((s, j) => j !== i && s.slice(-n).join("/") === tail)) continue;
|
|
195
|
+
if (n < segs[i].length) return "…/" + tail;
|
|
196
|
+
return segs[i][0] === "~" ? tail : "/" + tail;
|
|
197
|
+
}
|
|
198
|
+
return tilde(dirs[i], home);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
/** What a folder is CALLED in the sidebar: the distinguishing label minus its "…/" and the
|
|
202
|
+
* "decks" basename every deck folder shares, so "…/alpha/decks" reads "alpha". The full path
|
|
203
|
+
* stays on the row's tooltip; a folder that is only "decks" keeps that name. */
|
|
204
|
+
function folderTitle(label) {
|
|
205
|
+
const segs = label.replace(/^…\//, "").split(SEP).filter(Boolean);
|
|
206
|
+
if (segs.length > 1 && segs[segs.length - 1].toLowerCase() === "decks" && segs[segs.length - 2] !== "~") segs.pop();
|
|
207
|
+
return segs.length ? segs.join("/") : label;
|
|
208
|
+
}
|
|
209
|
+
/** Where a deck lives, in the top bar: the folder holding it, by its own name (the
|
|
210
|
+
* sidebar heading's word for it); the full path is the tooltip. */
|
|
211
|
+
function deckFolder(p, home) {
|
|
212
|
+
const segs = tilde(p, home).split(SEP).filter(Boolean).slice(0, -1);
|
|
213
|
+
if (segs.length > 1 && segs[segs.length - 1].toLowerCase() === "decks" && segs[segs.length - 2] !== "~") segs.pop();
|
|
214
|
+
return segs.length ? segs[segs.length - 1] : "/";
|
|
215
|
+
}
|
|
216
|
+
const revealLabel = () => (/Mac/i.test(navigator.platform) ? "Reveal in Finder" : /Win/i.test(navigator.platform) ? "Show in Explorer" : "Open folder");
|
|
217
|
+
|
|
218
|
+
window.SlidemindApp = { tabsFor, saveStatus, answerLabel, newestFirst, actionLabel, backupKey, isShared, conflictOf, formatChip, unfinished, phaseOf, tabOf, groupLabels, folderTitle, deckFolder, tilde, plainError };
|
|
219
|
+
|
|
220
|
+
// ---------- transport ----------
|
|
221
|
+
class ApiError extends Error {
|
|
222
|
+
constructor(status, error) {
|
|
223
|
+
super(error?.message ?? `HTTP ${status}`);
|
|
224
|
+
this.status = status;
|
|
225
|
+
this.code = error?.code ?? "http_error";
|
|
226
|
+
this.details = error?.details ?? null;
|
|
227
|
+
this.retryable = !!error?.retryable;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function api(method, p, body) {
|
|
231
|
+
const headers = {};
|
|
232
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
233
|
+
if (method !== "GET") headers["X-CSRF-Token"] = csrf ?? "";
|
|
234
|
+
let res;
|
|
235
|
+
try {
|
|
236
|
+
res = await fetch(API + p, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), credentials: "same-origin" });
|
|
237
|
+
} catch (err) {
|
|
238
|
+
checkConnection(); // the banner + the /health poll take it from here
|
|
239
|
+
throw new ApiError(0, { code: "offline", message: OFFLINE_TEXT });
|
|
240
|
+
}
|
|
241
|
+
let json = null;
|
|
242
|
+
try {
|
|
243
|
+
json = await res.json();
|
|
244
|
+
} catch {
|
|
245
|
+
// not JSON (e.g. 404 from the shell)
|
|
246
|
+
}
|
|
247
|
+
if (!res.ok || json?.ok === false) {
|
|
248
|
+
const err = new ApiError(res.status, json?.error ?? { code: res.status === 501 ? "not_implemented" : "http_error", message: `HTTP ${res.status}` });
|
|
249
|
+
// signed out, or a workspace on its way out: either way the connection says so, once
|
|
250
|
+
if (res.status === 401 || err.code === "workspace_stopping" || err.code === "stopping") checkConnection();
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
return json;
|
|
254
|
+
}
|
|
255
|
+
/** A failure the connection banner already explains: the screen keeps what it shows. */
|
|
256
|
+
const connectionLost = (err) => ["offline", "unauthorized", "workspace_stopping", "stopping"].includes(err?.code);
|
|
257
|
+
const errorBox = (err, extra) => h("p", { class: "error", "data-code": err.code }, plainError(err), extra ? " " : "", extra ?? "");
|
|
258
|
+
|
|
259
|
+
// ---------- connection: live / offline / signed out ----------
|
|
260
|
+
// One owner for "can this page talk to its workspace": the event stream's
|
|
261
|
+
// error and every failed request end up here. Offline polls /health; back,
|
|
262
|
+
// it re-reads the session (the cookie survives a restart) and reconnects.
|
|
263
|
+
let connState = "connecting"; // connecting | live | offline | signed-out
|
|
264
|
+
let started = false; // the page is up (start() ran): only then is there an event stream to bring back
|
|
265
|
+
let connTimer = null;
|
|
266
|
+
let connChecking = null;
|
|
267
|
+
const reconnectHooks = new Set(); // work to resume once the workspace answers again (an unsaved plan)
|
|
268
|
+
function setBanner(text, cls) {
|
|
269
|
+
if (!$banner) return;
|
|
270
|
+
$banner.textContent = text ?? "";
|
|
271
|
+
$banner.className = `conn-banner ${cls ?? ""}`;
|
|
272
|
+
$banner.hidden = !text;
|
|
273
|
+
}
|
|
274
|
+
function setConn(text, cls) {
|
|
275
|
+
$conn.textContent = text;
|
|
276
|
+
$conn.className = `chip ${cls}`;
|
|
277
|
+
}
|
|
278
|
+
function goOffline() {
|
|
279
|
+
connState = "offline";
|
|
280
|
+
setConn("Workspace offline — reconnecting…", "warn");
|
|
281
|
+
setBanner(OFFLINE_TEXT, "warn");
|
|
282
|
+
clearTimeout(connTimer);
|
|
283
|
+
connTimer = setTimeout(checkConnection, HEALTH_MS);
|
|
284
|
+
}
|
|
285
|
+
function goSignedOut() {
|
|
286
|
+
connState = "signed-out";
|
|
287
|
+
if (sse) sse.close();
|
|
288
|
+
sse = null;
|
|
289
|
+
setConn("Signed out", "bad");
|
|
290
|
+
setBanner(SIGNED_OUT_TEXT, "bad");
|
|
291
|
+
// a sign-in in another tab of this browser sets the same cookie: notice it
|
|
292
|
+
clearTimeout(connTimer);
|
|
293
|
+
connTimer = setTimeout(checkConnection, HEALTH_MS * 2);
|
|
294
|
+
}
|
|
295
|
+
function goLive() {
|
|
296
|
+
connState = "live";
|
|
297
|
+
clearTimeout(connTimer);
|
|
298
|
+
setBanner(null);
|
|
299
|
+
if (started && !sse) connectEvents(); // its onopen refreshes what the page shows
|
|
300
|
+
// whatever failed while it was away (or on a one-off blip) goes again now
|
|
301
|
+
for (const fn of [...reconnectHooks]) fn();
|
|
302
|
+
}
|
|
303
|
+
/** Is the workspace up, and is this page still signed in to it? Settles the connection state. */
|
|
304
|
+
function checkConnection() {
|
|
305
|
+
if (connChecking) return connChecking;
|
|
306
|
+
connChecking = (async () => {
|
|
307
|
+
let health = null;
|
|
308
|
+
try {
|
|
309
|
+
const r = await fetch("/health", { cache: "no-store" });
|
|
310
|
+
health = r.ok ? await r.json() : null;
|
|
311
|
+
} catch {
|
|
312
|
+
health = null;
|
|
313
|
+
}
|
|
314
|
+
if (!health || health.draining) return goOffline();
|
|
315
|
+
let r;
|
|
316
|
+
try {
|
|
317
|
+
r = await fetch(API + "/session", { credentials: "same-origin", cache: "no-store" });
|
|
318
|
+
} catch {
|
|
319
|
+
return goOffline();
|
|
320
|
+
}
|
|
321
|
+
if (r.status === 401) return goSignedOut();
|
|
322
|
+
if (!r.ok) return goOffline();
|
|
323
|
+
const j = await r.json().catch(() => null);
|
|
324
|
+
if (j?.csrfToken) {
|
|
325
|
+
csrf = j.csrfToken;
|
|
326
|
+
try {
|
|
327
|
+
sessionStorage.setItem("slidemind.csrf", csrf);
|
|
328
|
+
} catch {
|
|
329
|
+
// per tab only
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
goLive();
|
|
333
|
+
})().finally(() => (connChecking = null));
|
|
334
|
+
return connChecking;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------- attention: the agent just asked ----------
|
|
338
|
+
// "● " in the tab title until the user is back on the page.
|
|
339
|
+
let unseen = false;
|
|
340
|
+
let baseTitle = document.title;
|
|
341
|
+
function setTitle(t) {
|
|
342
|
+
baseTitle = t;
|
|
343
|
+
document.title = (unseen ? "● " : "") + t;
|
|
344
|
+
}
|
|
345
|
+
function markSeen() {
|
|
346
|
+
if (!unseen || document.visibilityState === "hidden") return;
|
|
347
|
+
unseen = false;
|
|
348
|
+
document.title = baseTitle;
|
|
349
|
+
}
|
|
350
|
+
// One toast per deck + kind: a repeated ask replaces it, never stacks.
|
|
351
|
+
const attnToasts = new Map();
|
|
352
|
+
function deckToast(key, text, run) {
|
|
353
|
+
attnToasts.get(key)?.remove();
|
|
354
|
+
attnToasts.set(key, toast(text, "attn", { label: "Open", run, sticky: true }));
|
|
355
|
+
}
|
|
356
|
+
function attentionToast(e, run) {
|
|
357
|
+
const what = e.kind === "preview-review" ? "slides" : "plan";
|
|
358
|
+
deckToast(`${e.deckId}:${e.kind}`, `Your agent asks you to review the ${what} of ${e.deckName ?? "a deck"}`, run);
|
|
359
|
+
}
|
|
360
|
+
const deckName = async (deckId) => deckNames.get(deckId) ?? (await api("GET", `/decks/${deckId}`).then((v) => v.deck.name, () => null)) ?? "a deck";
|
|
361
|
+
/**
|
|
362
|
+
* The agent opened a deck (`slidemind workspace <deck>` with this page already
|
|
363
|
+
* up): the page goes there — unless the user is typing in the Markdown
|
|
364
|
+
* editor, then the deck waits in a toast.
|
|
365
|
+
*/
|
|
366
|
+
async function focusDeck(e) {
|
|
367
|
+
unseen = true;
|
|
368
|
+
setTitle(baseTitle);
|
|
369
|
+
if (current?.kind === "deck" && current.deckId === e.deckId) return;
|
|
370
|
+
const href = `/deck/${e.deckId}`;
|
|
371
|
+
if (!userEditing()) return navigate(href);
|
|
372
|
+
deckToast(`${e.deckId}:focus`, `Your agent opened ${await deckName(e.deckId)}`, () => navigate(href));
|
|
373
|
+
}
|
|
374
|
+
/** Typing in the Markdown editor (unsaved, or keys in the last seconds): nothing may move the page under it. */
|
|
375
|
+
function userEditing() {
|
|
376
|
+
const ed = planEditor;
|
|
377
|
+
return !!ed && (ed.dirty || (ed.editing && Date.now() - (ed.typedAt ?? 0) < RECENT_TYPING_MS));
|
|
378
|
+
}
|
|
379
|
+
/** Move to a tab of the deck on screen; edits typed in the plan are saved first, never dropped. */
|
|
380
|
+
async function showTab(t) {
|
|
381
|
+
if (planEditor?.dirty) await planEditor.flush?.();
|
|
382
|
+
if (planEditor?.dirty && !confirm("You have unsaved plan changes. Leave anyway?")) return;
|
|
383
|
+
current.tab = t;
|
|
384
|
+
refresh(true);
|
|
385
|
+
}
|
|
386
|
+
async function attention(e) {
|
|
387
|
+
const tab = e.kind === "preview-review" ? "preview" : "plan";
|
|
388
|
+
unseen = true;
|
|
389
|
+
setTitle(baseTitle);
|
|
390
|
+
const href = `/deck/${e.deckId}/${tab}`;
|
|
391
|
+
const here = () => current?.kind === "deck" && current.deckId === e.deckId;
|
|
392
|
+
// bring the question forward: its tab, and the page view of the plan
|
|
393
|
+
const show = () => {
|
|
394
|
+
if (!here()) return navigate(href);
|
|
395
|
+
if (current.tab !== tab) return showTab(tab);
|
|
396
|
+
if (tab === "plan") planView?.setMode("view");
|
|
397
|
+
};
|
|
398
|
+
if (here() && !userEditing()) {
|
|
399
|
+
// already on its tab, the gate's own heartbeat moves it into the new round (keeping what the user typed)
|
|
400
|
+
show();
|
|
401
|
+
return refreshDeckChrome();
|
|
402
|
+
}
|
|
403
|
+
attentionToast({ ...e, deckName: await deckName(e.deckId) }, show);
|
|
404
|
+
if (here()) refreshDeckChrome();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Every screen renders into one scrolling page column under the top bar. */
|
|
408
|
+
const page = (...kids) => h("div", { class: "page" }, ...kids);
|
|
409
|
+
// the deck page lays out as rows + one flexible tab body, so the plan/preview frame keeps the screen
|
|
410
|
+
const deckPage = (...kids) => h("div", { class: "page deck" }, ...kids);
|
|
411
|
+
/** replaceChildren that drops null/undefined (the DOM would print them as "null"). */
|
|
412
|
+
const fill = ($el, ...kids) => $el.replaceChildren(...kids.flat().filter((k) => k !== null && k !== undefined));
|
|
413
|
+
const chip = (text, cls = "", attrs = {}) => h("span", { class: `chip ${cls}`, ...attrs }, text);
|
|
414
|
+
/** The SharePoint mark + label; a link when the item has one. */
|
|
415
|
+
const sharepointChip = (link, text = "SharePoint", note = null) => h(link ? "a" : "span", { class: "chip sp", "data-tip": [link ? "Open on SharePoint" : null, note].filter(Boolean).join("\n") || null, ...(link ? { href: link, target: "_blank", rel: "noopener" } : {}) }, h("img", { src: "/ui/sharepoint.svg", alt: "" }), h("span", { class: "lbl" }, text));
|
|
416
|
+
/** The top bar is the page's one header: its name, a line under it (text or a node), and the page's own buttons and chips beside it. */
|
|
417
|
+
const setMeta = (title, sub, actions = []) => {
|
|
418
|
+
$metaTitle.textContent = title;
|
|
419
|
+
fill($metaSub, sub);
|
|
420
|
+
fill($topActions, actions);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
async function boot() {
|
|
424
|
+
// index.html already exchanged the link key (window.SLIDEMIND_CSRF)
|
|
425
|
+
// or re-read this tab's token from the cookie's session.
|
|
426
|
+
if (!csrf) {
|
|
427
|
+
await checkConnection();
|
|
428
|
+
if (connState !== "live") {
|
|
429
|
+
$app.replaceChildren(page(h("p", { class: "error", id: "signed-out" }, connState === "signed-out" ? SIGNED_OUT_TEXT : OFFLINE_TEXT)));
|
|
430
|
+
// the connection loop keeps trying; once it answers, the page starts for real
|
|
431
|
+
reconnectHooks.add(function started() {
|
|
432
|
+
reconnectHooks.delete(started);
|
|
433
|
+
start();
|
|
434
|
+
});
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
start();
|
|
439
|
+
}
|
|
440
|
+
function start() {
|
|
441
|
+
started = true;
|
|
442
|
+
document.getElementById("add-folder").addEventListener("click", addFolder);
|
|
443
|
+
sidebarGrip();
|
|
444
|
+
$search.addEventListener("input", () => {
|
|
445
|
+
libraryQuery = $search.value;
|
|
446
|
+
loadLibrary();
|
|
447
|
+
});
|
|
448
|
+
$more.addEventListener("click", () => loadLibrary(true));
|
|
449
|
+
sidebarKeys();
|
|
450
|
+
for (const b of document.querySelectorAll("#library-filter button")) {
|
|
451
|
+
b.addEventListener("click", () => {
|
|
452
|
+
libraryFilter = b.dataset.filter;
|
|
453
|
+
try {
|
|
454
|
+
localStorage.setItem("slidemind.library.filter", libraryFilter);
|
|
455
|
+
} catch {
|
|
456
|
+
// ignore
|
|
457
|
+
}
|
|
458
|
+
loadLibrary();
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
connectEvents();
|
|
462
|
+
window.addEventListener("popstate", route);
|
|
463
|
+
document.addEventListener("click", (e) => {
|
|
464
|
+
const a = e.target.closest("a[data-nav]");
|
|
465
|
+
if (!a || e.metaKey || e.ctrlKey) return;
|
|
466
|
+
e.preventDefault();
|
|
467
|
+
navigate(a.getAttribute("href"));
|
|
468
|
+
});
|
|
469
|
+
const onVisible = () => {
|
|
470
|
+
if (document.visibilityState === "hidden") return;
|
|
471
|
+
markSeen();
|
|
472
|
+
if (current?.kind === "deck" && current.shared) remoteRefresh(current.deckId);
|
|
473
|
+
};
|
|
474
|
+
window.addEventListener("focus", onVisible);
|
|
475
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
476
|
+
document.addEventListener("pointerdown", markSeen);
|
|
477
|
+
document.addEventListener("keydown", markSeen);
|
|
478
|
+
window.addEventListener("beforeunload", (e) => {
|
|
479
|
+
if (planEditor?.dirty) {
|
|
480
|
+
e.preventDefault();
|
|
481
|
+
e.returnValue = "";
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
route();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// The stream is recreated by us, never left to the browser: after a restart
|
|
488
|
+
// its retry would meet a 401 or a closed stream and stop for good while the
|
|
489
|
+
// chip still said "reconnecting". An error hands over to checkConnection,
|
|
490
|
+
// which brings it back once the workspace answers (with a growing pause
|
|
491
|
+
// while it keeps failing, so a flapping workspace is not hammered).
|
|
492
|
+
let sseFailures = 0;
|
|
493
|
+
let sseOpened = 0;
|
|
494
|
+
function connectEvents() {
|
|
495
|
+
if (sse) sse.close();
|
|
496
|
+
const es = new EventSource(API + "/events");
|
|
497
|
+
sse = es;
|
|
498
|
+
es.onopen = () => {
|
|
499
|
+
sseOpened = Date.now();
|
|
500
|
+
connState = "live";
|
|
501
|
+
setConn("Connected", "ok");
|
|
502
|
+
// reconnect: never trust what we may have missed
|
|
503
|
+
refresh();
|
|
504
|
+
};
|
|
505
|
+
es.onerror = () => {
|
|
506
|
+
if (sse !== es) return;
|
|
507
|
+
es.close();
|
|
508
|
+
sse = null;
|
|
509
|
+
// a stream that held for a while was healthy: start the pause over
|
|
510
|
+
sseFailures = Date.now() - sseOpened > 30_000 ? 1 : sseFailures + 1;
|
|
511
|
+
setConn("Reconnecting…", "warn");
|
|
512
|
+
setTimeout(checkConnection, Math.min(1000 * 2 ** (sseFailures - 1), 15_000) - 1000);
|
|
513
|
+
};
|
|
514
|
+
es.onmessage = (ev) => {
|
|
515
|
+
let e = null;
|
|
516
|
+
try {
|
|
517
|
+
e = JSON.parse(ev.data);
|
|
518
|
+
} catch {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (!current) return;
|
|
522
|
+
if (e.type === "attention") return attention(e);
|
|
523
|
+
if (e.type === "focus") return focusDeck(e);
|
|
524
|
+
if (current.kind === "home") return refresh();
|
|
525
|
+
if (e.deckId && e.deckId !== current.deckId) return refreshShell(); // another deck moved: sidebar + inbox badge only
|
|
526
|
+
if (e.type === "tasks") {
|
|
527
|
+
// a task moved: redraw the header + "your turn" and the shell, never the gate frame
|
|
528
|
+
refreshShell();
|
|
529
|
+
refreshDeckChrome();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// a plan / preview change: the header follows; a live gate frame follows by itself
|
|
533
|
+
// (its heartbeat sees the new round and reloads when the user is not mid-edit)
|
|
534
|
+
refresh();
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function navigate(href) {
|
|
539
|
+
if (planEditor?.dirty && !confirm("You have unsaved plan changes. Leave anyway?")) return;
|
|
540
|
+
history.pushState(null, "", href);
|
|
541
|
+
route();
|
|
542
|
+
}
|
|
543
|
+
function route() {
|
|
544
|
+
planEditor = null;
|
|
545
|
+
planFrame = null;
|
|
546
|
+
planView = null;
|
|
547
|
+
clearTimeout(poll);
|
|
548
|
+
poll = null;
|
|
549
|
+
// /deck/<id>/plan|preview names the tab; an older /tasks/<id> link opens that task's tab
|
|
550
|
+
const m = location.pathname.match(/^\/deck\/([^/]+)(?:\/(plan|preview)\b|\/tasks\/([^/]+))?/);
|
|
551
|
+
current = m ? { kind: "deck", deckId: m[1], taskId: m[3] ?? null, tab: m[2] ?? (m[3] ? null : current?.deckId === m[1] ? current.tab : "plan") } : { kind: "home" };
|
|
552
|
+
refresh(true);
|
|
553
|
+
}
|
|
554
|
+
let refreshing = null;
|
|
555
|
+
/**
|
|
556
|
+
* Re-GET and re-render. An advisory refresh never wipes a dirty plan buffer
|
|
557
|
+
* and never reloads a live gate frame (it keeps its own state and follows its
|
|
558
|
+
* own heartbeat): it redraws the deck's header, tab marks and "your turn".
|
|
559
|
+
*/
|
|
560
|
+
function refresh(force = false) {
|
|
561
|
+
if (!current) return Promise.resolve();
|
|
562
|
+
refreshShell();
|
|
563
|
+
if (!force && current?.kind === "deck" && hasLiveFrame()) return refreshDeckChrome();
|
|
564
|
+
if (refreshing) return refreshing;
|
|
565
|
+
refreshing = (current.kind === "home" ? renderHome() : renderDeck())
|
|
566
|
+
.catch((err) => {
|
|
567
|
+
if (connectionLost(err)) return; // the banner says it; what is on screen stays until the workspace is back
|
|
568
|
+
$app.replaceChildren(page(errorBox(err)));
|
|
569
|
+
})
|
|
570
|
+
.finally(() => (refreshing = null));
|
|
571
|
+
return refreshing;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ---------- shell: deck sidebar + inbox badge ----------
|
|
575
|
+
let libraryQuery = "";
|
|
576
|
+
let libraryCursor = null;
|
|
577
|
+
let libraryFilter = "all"; // all | unfinished — remembered per browser
|
|
578
|
+
let libraryHome = null; // the daemon's home dir, for ~-collapsing a path outside the sidebar
|
|
579
|
+
let collapsed = new Set(); // folder paths folded shut — remembered per browser
|
|
580
|
+
try {
|
|
581
|
+
libraryFilter = localStorage.getItem("slidemind.library.filter") === "unfinished" ? "unfinished" : "all";
|
|
582
|
+
collapsed = new Set(JSON.parse(localStorage.getItem("slidemind.library.collapsed") || "[]"));
|
|
583
|
+
} catch {
|
|
584
|
+
// per-viewer convenience only
|
|
585
|
+
}
|
|
586
|
+
const rememberCollapsed = () => {
|
|
587
|
+
try {
|
|
588
|
+
localStorage.setItem("slidemind.library.collapsed", JSON.stringify([...collapsed]));
|
|
589
|
+
} catch {
|
|
590
|
+
// ignore
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
/** The tab on screen holds a gate frame (or a dirty plan buffer) that a re-render would reset. */
|
|
594
|
+
function hasLiveFrame() {
|
|
595
|
+
if (current?.tab === "plan") return !!(planEditor?.dirty || planFrame);
|
|
596
|
+
if (current?.tab === "preview") return !!document.getElementById("preview-view");
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
function refreshShell() {
|
|
600
|
+
$inboxLink.setAttribute("aria-current", current?.kind === "home" ? "page" : "false");
|
|
601
|
+
loadLibrary();
|
|
602
|
+
api("GET", "/tasks?status=pending").then((r) => ($inboxCount.textContent = r.tasks.length ? String(r.tasks.length) : ""), () => ($inboxCount.textContent = ""));
|
|
603
|
+
}
|
|
604
|
+
function deckRow(d, { nested = false } = {}) {
|
|
605
|
+
const fmt = formatChip(d);
|
|
606
|
+
const active = current?.kind === "deck" && current.deckId === d.id;
|
|
607
|
+
// the link carries only the name (its ::after stretches over the row); the status and the bin stay outside it
|
|
608
|
+
return h(
|
|
609
|
+
"li",
|
|
610
|
+
{ class: `deck-row${d.missing ? " missing" : ""}${nested ? " nested" : ""}`, "data-format": d.format ?? "", "aria-current": active ? "page" : "false", "data-tip": d.missing ? `${d.path}\nThis folder is not there any more` : d.path },
|
|
611
|
+
h("span", { class: `deck-ico${d.remote ? " sp" : ""}`, "data-tip": d.remote ? "On SharePoint" : null }),
|
|
612
|
+
h("a", { href: `/deck/${d.id}`, "data-nav": true, "data-side-item": true, class: "deck-name", "data-tip": d.path }, d.name ?? d.path),
|
|
613
|
+
d.remote ? h("img", { src: "/ui/sharepoint.svg", alt: "On SharePoint", "data-tip": "On SharePoint", class: "sp-mark" }) : null,
|
|
614
|
+
h(
|
|
615
|
+
"span",
|
|
616
|
+
{ class: "deck-sub" },
|
|
617
|
+
d.taskCount ? h("span", { class: "badge", "data-tip": `${d.taskCount} question${d.taskCount === 1 ? "" : "s"} waiting for you` }, String(d.taskCount)) : null,
|
|
618
|
+
d.missing ? h("span", { class: "deck-state" }, "missing") : null,
|
|
619
|
+
fmt ? chip(fmt.text, fmt.cls, { "data-legacy": d.format === "legacy" ? "" : null }) : null,
|
|
620
|
+
),
|
|
621
|
+
// drops the registry entry only — the deck folder is the user's, and no route deletes it
|
|
622
|
+
h("button", { class: "deck-x", "data-tip": "Remove from library", "aria-label": `Remove ${d.name ?? d.path} from the library`, onclick: (e) => {
|
|
623
|
+
e.preventDefault(); // the row's stretched link would otherwise open the deck
|
|
624
|
+
e.stopPropagation();
|
|
625
|
+
removeDeckDialog(d);
|
|
626
|
+
} }, icon(ICON_TRASH)),
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
/** A folder heading: the disclosure toggles its decks, the arrow reveals it in the OS file manager. */
|
|
630
|
+
function groupRow(dir, decks, label, open) {
|
|
631
|
+
const title = folderTitle(label);
|
|
632
|
+
const full = tilde(dir, libraryHome);
|
|
633
|
+
const $toggle = h(
|
|
634
|
+
"button",
|
|
635
|
+
{ class: "side-group-name", "data-side-item": true, "aria-expanded": String(open), "data-tip": full, onclick: () => {
|
|
636
|
+
if (collapsed.has(dir)) collapsed.delete(dir);
|
|
637
|
+
else collapsed.add(dir);
|
|
638
|
+
rememberCollapsed();
|
|
639
|
+
loadLibrary();
|
|
640
|
+
} },
|
|
641
|
+
icon(ICON_CHEVRON),
|
|
642
|
+
h("span", { class: "side-group-txt" }, title),
|
|
643
|
+
h("span", { class: "side-group-n" }, String(decks.length)),
|
|
644
|
+
);
|
|
645
|
+
return h(
|
|
646
|
+
"li",
|
|
647
|
+
{ class: `side-group${open ? "" : " closed"}`, "data-dir": dir },
|
|
648
|
+
$toggle,
|
|
649
|
+
// the folder is revealed through one of its OWN decks: no route takes a bare path
|
|
650
|
+
h("button", { class: "icon side-group-reveal", "data-tip": `${revealLabel()}\n${full}`, "aria-label": `${revealLabel()}: ${full}`, onclick: () => api("POST", `/decks/${decks[0].id}/reveal`, { parent: true }).catch((err) => toast(plainError(err), "bad")) }, icon(ICON_REVEAL)),
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
async function loadLibrary(append = false) {
|
|
654
|
+
try {
|
|
655
|
+
const r = await api("GET", `/decks?q=${encodeURIComponent(libraryQuery)}&limit=100${append && libraryCursor ? `&cursor=${libraryCursor}` : ""}`);
|
|
656
|
+
libraryCursor = r.nextCursor;
|
|
657
|
+
libraryHome = r.home ?? libraryHome;
|
|
658
|
+
for (const d of r.decks) deckNames.set(d.id, d.name ?? d.path);
|
|
659
|
+
// grouped by parent folder: the folder heading is what tells two "q3-review"s apart
|
|
660
|
+
const parent = (p) => p.replace(/[\\/][^\\/]+$/, "");
|
|
661
|
+
// a deck whose folder is gone is not a deck to open: it stays out of the list
|
|
662
|
+
// (its registry entry is forgotten from the page its link still reaches)
|
|
663
|
+
const present = r.decks.filter((d) => !d.missing);
|
|
664
|
+
const shown = libraryFilter === "unfinished" ? present.filter(unfinished) : present;
|
|
665
|
+
const groups = new Map();
|
|
666
|
+
for (const d of shown) {
|
|
667
|
+
const key = parent(d.path);
|
|
668
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
669
|
+
groups.get(key).push(d);
|
|
670
|
+
}
|
|
671
|
+
// one folder only = nothing to tell apart: no heading
|
|
672
|
+
const entries = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
673
|
+
const labels = groupLabels(entries.map(([dir]) => dir), r.home);
|
|
674
|
+
// a folded folder hides its decks — except while searching (a match must show) and
|
|
675
|
+
// the one holding the deck on screen (it is revealed, as any file tree does)
|
|
676
|
+
const rows = entries.flatMap(([dir, decks], i) => {
|
|
677
|
+
if (groups.size < 2) return decks.map((d) => deckRow(d));
|
|
678
|
+
const open = !collapsed.has(dir) || !!libraryQuery || decks.some((d) => current?.kind === "deck" && current.deckId === d.id);
|
|
679
|
+
return [groupRow(dir, decks, labels[i], open), ...(open ? decks.map((d) => deckRow(d, { nested: true })) : [])];
|
|
680
|
+
});
|
|
681
|
+
const scanning = r.scanning?.length ? h("li", { class: "side-empty", id: "library-scanning" }, h("span", { class: "spin" }), " Scanning your decks…") : null;
|
|
682
|
+
const scanned = r.roots?.length ? ` Scanned: ${r.roots.join(", ")}.` : "";
|
|
683
|
+
if (append) $library.append(...rows);
|
|
684
|
+
else $library.replaceChildren(...[scanning, ...rows, rows.length || scanning ? null : h("li", { class: "side-empty", id: "library-empty" }, libraryQuery ? "No decks match." : libraryFilter === "unfinished" && present.length ? "Every deck here has its .pptx." : `No decks yet. Add a local folder or run a slidemind command in a deck.${scanned}`)].filter(Boolean));
|
|
685
|
+
for (const b of document.querySelectorAll("#library-filter button")) b.setAttribute("aria-pressed", String(b.dataset.filter === libraryFilter));
|
|
686
|
+
$more.hidden = !libraryCursor;
|
|
687
|
+
} catch (err) {
|
|
688
|
+
if (!connectionLost(err)) $library.replaceChildren(h("li", { class: "side-empty" }, errorBox(err)));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---------- home: the inbox ----------
|
|
693
|
+
async function renderHome() {
|
|
694
|
+
// fetched before anything is replaced: offline, the page on screen stays, its header with it
|
|
695
|
+
const r = await api("GET", "/tasks?status=pending");
|
|
696
|
+
setTitle("Inbox · slidemind");
|
|
697
|
+
setMeta("Inbox", "Everything your agent asked you, newest first");
|
|
698
|
+
const rows = newestFirst(r.tasks).map((t) =>
|
|
699
|
+
h(
|
|
700
|
+
"li",
|
|
701
|
+
{ class: "task", "data-kind": t.kind, "data-tip": t.deckPath ?? null },
|
|
702
|
+
// in-page navigation to the card itself (it opens its tab, marked as the one clicked)
|
|
703
|
+
h("div", { class: "card-head" }, h("a", { href: `/deck/${t.deckId}/tasks/${t.id}`, "data-nav": true }, `${phaseOf(t.kind).label} · ${t.deckName ?? "deck"}`), h("span", { class: "path" }, fmtTime(t.createdAt))),
|
|
704
|
+
),
|
|
705
|
+
);
|
|
706
|
+
const $inbox = h("ul", { class: "list", id: "inbox" }, ...(rows.length ? rows : [h("li", { class: "muted", id: "inbox-empty" }, "Nothing waiting for you.")]));
|
|
707
|
+
$app.replaceChildren(page($inbox));
|
|
708
|
+
}
|
|
709
|
+
// ---------- sidebar keyboard: ⌘K or / finds, ↑↓ walk the list, Enter opens ----------
|
|
710
|
+
function sidebarKeys() {
|
|
711
|
+
const typing = (el) => el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
|
|
712
|
+
const items = () => [...document.querySelectorAll("#side [data-side-item]")].filter((el) => el.offsetParent !== null);
|
|
713
|
+
const step = (from, dir) => {
|
|
714
|
+
const all = items();
|
|
715
|
+
const i = all.indexOf(from);
|
|
716
|
+
const next = all[i < 0 ? (dir > 0 ? 0 : all.length - 1) : Math.min(Math.max(i + dir, 0), all.length - 1)];
|
|
717
|
+
next?.focus();
|
|
718
|
+
};
|
|
719
|
+
document.addEventListener("keydown", (e) => {
|
|
720
|
+
if (e.defaultPrevented) return;
|
|
721
|
+
const inSearch = e.target === $search;
|
|
722
|
+
if (((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") || (e.key === "/" && !typing(e.target) && !e.metaKey && !e.ctrlKey && !e.altKey)) {
|
|
723
|
+
e.preventDefault();
|
|
724
|
+
// on a small screen the list is a drawer: open it first so the field is on screen
|
|
725
|
+
if (window.innerWidth <= 1000 && !document.body.classList.contains("side-open")) document.getElementById("side-toggle")?.click();
|
|
726
|
+
$search.focus();
|
|
727
|
+
$search.select();
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (inSearch) {
|
|
731
|
+
if (e.key === "Escape") {
|
|
732
|
+
if ($search.value) {
|
|
733
|
+
$search.value = "";
|
|
734
|
+
libraryQuery = "";
|
|
735
|
+
loadLibrary();
|
|
736
|
+
} else $search.blur();
|
|
737
|
+
e.preventDefault();
|
|
738
|
+
} else if (e.key === "ArrowDown") {
|
|
739
|
+
e.preventDefault();
|
|
740
|
+
step(null, 1);
|
|
741
|
+
}
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const item = e.target.closest?.("#side [data-side-item]");
|
|
745
|
+
if (!item) return;
|
|
746
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
747
|
+
e.preventDefault();
|
|
748
|
+
step(item, e.key === "ArrowDown" ? 1 : -1);
|
|
749
|
+
} else if (e.key === "ArrowRight" && item.getAttribute("aria-expanded") === "false") item.click();
|
|
750
|
+
else if (e.key === "ArrowLeft" && item.getAttribute("aria-expanded") === "true") item.click();
|
|
751
|
+
else if (e.key === "ArrowLeft" && item.matches("a.deck-name")) {
|
|
752
|
+
let li = item.closest("li")?.previousElementSibling; // up to the folder this deck sits in
|
|
753
|
+
while (li && !li.classList.contains("side-group")) li = li.previousElementSibling;
|
|
754
|
+
li?.querySelector(".side-group-name")?.focus();
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ---------- sidebar width: drag the full-height grip on its right edge ----------
|
|
760
|
+
function sidebarGrip() {
|
|
761
|
+
const $side = document.getElementById("side");
|
|
762
|
+
const $grip = document.getElementById("side-grip");
|
|
763
|
+
if (!$side || !$grip) return;
|
|
764
|
+
// Small screens: the list is a drawer behind the top-bar button; picking a
|
|
765
|
+
// deck or tapping outside closes it.
|
|
766
|
+
const $toggle = document.getElementById("side-toggle");
|
|
767
|
+
const setOpen = (open) => {
|
|
768
|
+
document.body.classList.toggle("side-open", open);
|
|
769
|
+
$toggle?.setAttribute("aria-expanded", String(open));
|
|
770
|
+
};
|
|
771
|
+
$toggle?.addEventListener("click", () => setOpen(!document.body.classList.contains("side-open")));
|
|
772
|
+
document.addEventListener("click", (e) => {
|
|
773
|
+
if (!document.body.classList.contains("side-open")) return;
|
|
774
|
+
if (e.target.closest("#side-toggle")) return;
|
|
775
|
+
if (!e.target.closest("#side") || e.target.closest("a[data-nav]")) setOpen(false);
|
|
776
|
+
});
|
|
777
|
+
const apply = (w) => document.documentElement.style.setProperty("--sm-side", `${Math.round(Math.min(Math.max(w, 200), window.innerWidth / 2))}px`);
|
|
778
|
+
try {
|
|
779
|
+
const saved = Number(localStorage.getItem("slidemind.sidebar"));
|
|
780
|
+
if (saved) apply(saved);
|
|
781
|
+
} catch {
|
|
782
|
+
// per-viewer convenience only
|
|
783
|
+
}
|
|
784
|
+
$grip.addEventListener("pointerdown", (e) => {
|
|
785
|
+
if (e.button !== 0) return;
|
|
786
|
+
e.preventDefault();
|
|
787
|
+
$grip.setPointerCapture(e.pointerId);
|
|
788
|
+
document.body.classList.add("resizing");
|
|
789
|
+
const move = (ev) => apply(ev.clientX);
|
|
790
|
+
const up = () => {
|
|
791
|
+
$grip.removeEventListener("pointermove", move);
|
|
792
|
+
$grip.removeEventListener("pointerup", up);
|
|
793
|
+
$grip.removeEventListener("pointercancel", up);
|
|
794
|
+
document.body.classList.remove("resizing");
|
|
795
|
+
try {
|
|
796
|
+
localStorage.setItem("slidemind.sidebar", String($side.getBoundingClientRect().width));
|
|
797
|
+
} catch {
|
|
798
|
+
// ignore
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
$grip.addEventListener("pointermove", move);
|
|
802
|
+
$grip.addEventListener("pointerup", up);
|
|
803
|
+
$grip.addEventListener("pointercancel", up);
|
|
804
|
+
});
|
|
805
|
+
$grip.addEventListener("dblclick", () => {
|
|
806
|
+
apply(272);
|
|
807
|
+
try {
|
|
808
|
+
localStorage.removeItem("slidemind.sidebar");
|
|
809
|
+
} catch {
|
|
810
|
+
// ignore
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ---------- dialogs (native <dialog>, styled like the gate popover) ----------
|
|
816
|
+
/**
|
|
817
|
+
* One text field + Cancel/submit. `run(value)` is awaited on submit: it
|
|
818
|
+
* resolves to close, or throws to show the error inside the dialog and keep
|
|
819
|
+
* what was typed. Closing without submitting runs nothing.
|
|
820
|
+
*/
|
|
821
|
+
function dialog({ title, hint, placeholder, submit = "OK", multiline = false, body = null, input = true, danger = false, run }) {
|
|
822
|
+
// `input: false` makes it a confirm — same popover, no field, `run()` takes no value
|
|
823
|
+
const $in = input ? h(multiline ? "textarea" : "input", { type: "text", id: "dialog-input", placeholder, spellcheck: multiline ? "true" : "false", autocomplete: "off" }) : null;
|
|
824
|
+
const $err = h("p", { class: "error", id: "dialog-error", hidden: true });
|
|
825
|
+
const $ok = h("button", { class: danger ? "danger" : "primary", type: "submit" }, submit);
|
|
826
|
+
const $dlg = h(
|
|
827
|
+
"dialog",
|
|
828
|
+
{ class: "dlg", id: "dialog" },
|
|
829
|
+
h(
|
|
830
|
+
"form",
|
|
831
|
+
{
|
|
832
|
+
onsubmit: async (e) => {
|
|
833
|
+
e.preventDefault();
|
|
834
|
+
const value = $in ? $in.value.trim() : null;
|
|
835
|
+
if ($in && !value) return $in.focus();
|
|
836
|
+
$ok.disabled = true;
|
|
837
|
+
$err.hidden = true;
|
|
838
|
+
try {
|
|
839
|
+
await run(value);
|
|
840
|
+
$dlg.close();
|
|
841
|
+
} catch (err) {
|
|
842
|
+
$err.textContent = plainError(err);
|
|
843
|
+
$err.hidden = false;
|
|
844
|
+
($in ?? $ok).focus();
|
|
845
|
+
} finally {
|
|
846
|
+
$ok.disabled = false;
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
h("h3", {}, title),
|
|
851
|
+
hint ? h("p", { class: "muted" }, hint) : null,
|
|
852
|
+
$in,
|
|
853
|
+
body ? body($in) : null,
|
|
854
|
+
$err,
|
|
855
|
+
h("div", { class: "row end" }, h("button", { type: "button", onclick: () => $dlg.close() }, "Cancel"), $ok),
|
|
856
|
+
),
|
|
857
|
+
);
|
|
858
|
+
$dlg.addEventListener("close", () => $dlg.remove());
|
|
859
|
+
document.body.append($dlg);
|
|
860
|
+
$dlg.showModal();
|
|
861
|
+
($in ?? $ok).focus();
|
|
862
|
+
return $dlg;
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* A short bottom-right note that fades by itself. With `action` it carries one
|
|
866
|
+
* button (`{ label, run }`); `sticky` keeps it until clicked or dismissed.
|
|
867
|
+
*/
|
|
868
|
+
function toast(text, cls = "", action = null) {
|
|
869
|
+
const $t = h("div", { class: `toast ${cls}`, role: "status" }, h("span", {}, text));
|
|
870
|
+
if (action) {
|
|
871
|
+
$t.append(
|
|
872
|
+
h("button", { class: "mini primary", onclick: () => ($t.remove(), action.run()) }, action.label),
|
|
873
|
+
h("button", { class: "icon toast-x", "data-tip": "Dismiss", "aria-label": "Dismiss", onclick: () => $t.remove() }, icon('<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/>')),
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
document.body.append($t);
|
|
877
|
+
if (!action?.sticky) setTimeout(() => $t.remove(), 5000);
|
|
878
|
+
return $t;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ---------- tooltip ----------
|
|
882
|
+
// Every hint on this page is a `data-tip`, never a native `title` (the OS
|
|
883
|
+
// draws that one late, small and unstyled). One tooltip serves them all: it
|
|
884
|
+
// appears after a short delay — at once when the pointer moves from one hint
|
|
885
|
+
// to the next — sits under its element (over it when there is no room; at the
|
|
886
|
+
// pointer for a tall element like the sidebar grip), and goes on any click,
|
|
887
|
+
// key, scroll or re-render. Keyboard focus shows it too. It lives in the top
|
|
888
|
+
// layer when the browser has one, so a hint inside a modal dialog is not
|
|
889
|
+
// painted under it.
|
|
890
|
+
const TIP_DELAY_MS = 450;
|
|
891
|
+
const TIP_WARM_MS = 500; // a hint closed this recently: the next one shows without the delay
|
|
892
|
+
const TIP_GAP = 6;
|
|
893
|
+
const TIP_MARGIN = 8;
|
|
894
|
+
const $tip = h("div", { class: "tip", id: "sm-tip", role: "tooltip", popover: "manual" });
|
|
895
|
+
const tipLayer = typeof $tip.showPopover === "function";
|
|
896
|
+
let tipFor = null; // the element whose hint is showing, or waiting out the delay
|
|
897
|
+
let tipMuted = null; // the element just clicked: no hint until the pointer has left it
|
|
898
|
+
let tipTimer = 0;
|
|
899
|
+
let tipWatch = 0;
|
|
900
|
+
let tipClosedAt = 0; // when the pointer last moved off a showing hint
|
|
901
|
+
let tipPointer = null; // { x, y } while the pointer is on the page
|
|
902
|
+
function armTip(el, byPointer) {
|
|
903
|
+
if (el === tipFor) return;
|
|
904
|
+
if ($tip.classList.contains("on")) tipClosedAt = Date.now();
|
|
905
|
+
hideTip();
|
|
906
|
+
if (!el) return;
|
|
907
|
+
tipFor = el;
|
|
908
|
+
tipTimer = setTimeout(() => showTip(el, byPointer), Date.now() - tipClosedAt < TIP_WARM_MS ? 0 : TIP_DELAY_MS);
|
|
909
|
+
}
|
|
910
|
+
function showTip(el, byPointer) {
|
|
911
|
+
const text = el.getAttribute("data-tip");
|
|
912
|
+
if (!text || !el.isConnected) return hideTip();
|
|
913
|
+
// a path wraps after its separators, not inside a folder name
|
|
914
|
+
$tip.replaceChildren(...text.split(/(?<=[\\/])/).flatMap((part, i) => (i ? [h("wbr"), part] : [part])));
|
|
915
|
+
$tip.classList.remove("on");
|
|
916
|
+
if (!$tip.isConnected) document.body.append($tip);
|
|
917
|
+
if (tipLayer) {
|
|
918
|
+
// re-opened, so it lands above a modal dialog opened since it last showed
|
|
919
|
+
if ($tip.matches(":popover-open")) $tip.hidePopover();
|
|
920
|
+
$tip.showPopover();
|
|
921
|
+
}
|
|
922
|
+
// measured at the origin: where it last stood would narrow its fit-content width
|
|
923
|
+
$tip.style.left = $tip.style.top = "0px";
|
|
924
|
+
const r = el.getBoundingClientRect();
|
|
925
|
+
const at = byPointer && tipPointer && r.height > 48 ? { left: tipPointer.x, right: tipPointer.x, top: tipPointer.y, bottom: tipPointer.y + 16 } : r;
|
|
926
|
+
const t = $tip.getBoundingClientRect();
|
|
927
|
+
const vw = document.documentElement.clientWidth;
|
|
928
|
+
const vh = document.documentElement.clientHeight;
|
|
929
|
+
const left = Math.max(TIP_MARGIN, Math.min((at.left + at.right) / 2 - t.width / 2, vw - TIP_MARGIN - t.width));
|
|
930
|
+
const below = at.bottom + TIP_GAP;
|
|
931
|
+
const above = at.top - TIP_GAP - t.height;
|
|
932
|
+
const top = below + t.height <= vh - TIP_MARGIN || above < TIP_MARGIN ? below : above;
|
|
933
|
+
$tip.style.left = `${Math.round(left)}px`;
|
|
934
|
+
$tip.style.top = `${Math.round(top)}px`;
|
|
935
|
+
$tip.classList.add("on");
|
|
936
|
+
if (!el.hasAttribute("aria-describedby")) el.setAttribute("aria-describedby", "sm-tip");
|
|
937
|
+
// a re-render replaces the element under the pointer without a pointer event
|
|
938
|
+
clearInterval(tipWatch);
|
|
939
|
+
tipWatch = setInterval(() => {
|
|
940
|
+
if (!el.isConnected || !el.getAttribute("data-tip")) hideTip();
|
|
941
|
+
else if (el.getAttribute("data-tip") !== $tip.textContent) showTip(el, byPointer);
|
|
942
|
+
}, 250);
|
|
943
|
+
}
|
|
944
|
+
function hideTip() {
|
|
945
|
+
clearTimeout(tipTimer);
|
|
946
|
+
clearInterval(tipWatch);
|
|
947
|
+
$tip.classList.remove("on");
|
|
948
|
+
if (tipLayer && $tip.matches(":popover-open")) $tip.hidePopover();
|
|
949
|
+
if (tipFor?.getAttribute("aria-describedby") === "sm-tip") tipFor.removeAttribute("aria-describedby");
|
|
950
|
+
tipFor = null;
|
|
951
|
+
}
|
|
952
|
+
document.addEventListener("pointermove", (e) => (tipPointer = { x: e.clientX, y: e.clientY }), { passive: true });
|
|
953
|
+
document.addEventListener("pointerover", (e) => {
|
|
954
|
+
if (e.pointerType === "touch") return;
|
|
955
|
+
const el = e.target.closest?.("[data-tip]") ?? null;
|
|
956
|
+
if (el && el === tipMuted) return;
|
|
957
|
+
tipMuted = null;
|
|
958
|
+
armTip(el, true);
|
|
959
|
+
});
|
|
960
|
+
document.addEventListener("pointerout", (e) => {
|
|
961
|
+
if (e.relatedTarget) return;
|
|
962
|
+
tipPointer = null; // left the window
|
|
963
|
+
hideTip();
|
|
964
|
+
});
|
|
965
|
+
document.addEventListener("pointerdown", (e) => {
|
|
966
|
+
tipMuted = e.target.closest?.("[data-tip]") ?? null;
|
|
967
|
+
hideTip();
|
|
968
|
+
}, { capture: true, passive: true });
|
|
969
|
+
document.addEventListener("focusin", (e) => {
|
|
970
|
+
const el = e.target.closest?.("[data-tip]");
|
|
971
|
+
if (el && e.target.matches(":focus-visible")) armTip(el, false);
|
|
972
|
+
});
|
|
973
|
+
document.addEventListener("focusout", (e) => {
|
|
974
|
+
if (tipFor && tipFor.contains(e.target)) hideTip();
|
|
975
|
+
});
|
|
976
|
+
for (const type of ["keydown", "scroll", "wheel"]) document.addEventListener(type, () => hideTip(), { capture: true, passive: true });
|
|
977
|
+
if (typeof window.addEventListener === "function") for (const type of ["resize", "blur"]) window.addEventListener(type, () => hideTip());
|
|
978
|
+
|
|
979
|
+
/** A Browse… button: the daemon opens the OS folder dialog and the answer fills the field. */
|
|
980
|
+
function browseButton($in) {
|
|
981
|
+
const $msg = h("span", { class: "muted", id: "fs-pick-note" }, "opens your system's folder dialog");
|
|
982
|
+
const $browse = h("button", { type: "button", id: "fs-browse", onclick: async () => {
|
|
983
|
+
$browse.disabled = true;
|
|
984
|
+
$msg.textContent = "Choose the folder in the dialog that just opened…";
|
|
985
|
+
try {
|
|
986
|
+
const r = await api("POST", "/fs/pick", {});
|
|
987
|
+
if (r.path) $in.value = r.path;
|
|
988
|
+
$msg.textContent = r.path ? "" : "No folder chosen.";
|
|
989
|
+
} catch (err) {
|
|
990
|
+
$msg.textContent = err.status === 404 ? PLAIN.not_implemented : plainError(err);
|
|
991
|
+
} finally {
|
|
992
|
+
$browse.disabled = false;
|
|
993
|
+
}
|
|
994
|
+
} }, "Browse…");
|
|
995
|
+
return h("div", { class: "row" }, $browse, $msg);
|
|
996
|
+
}
|
|
997
|
+
/** The deck goes to the OS trash, entry and folder; `trash: false` drops the entry only. */
|
|
998
|
+
function removeDeckDialog(deck, { trash = true, after = null } = {}) {
|
|
999
|
+
dialog({
|
|
1000
|
+
title: trash ? `Delete ${deck.name ?? deck.path}?` : `Remove ${deck.name ?? deck.path} from the library?`,
|
|
1001
|
+
input: false,
|
|
1002
|
+
danger: true,
|
|
1003
|
+
submit: trash ? "Move to Trash" : "Remove",
|
|
1004
|
+
body: () => h("p", { class: "path" }, tilde(deck.path, libraryHome)),
|
|
1005
|
+
run: async () => {
|
|
1006
|
+
await api("DELETE", `/decks/${deck.id}`, trash ? { files: "trash" } : {});
|
|
1007
|
+
if (after) after();
|
|
1008
|
+
else if (current?.kind === "deck" && current.deckId === deck.id) navigate("/");
|
|
1009
|
+
else loadLibrary();
|
|
1010
|
+
},
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
function addFolder() {
|
|
1014
|
+
dialog({
|
|
1015
|
+
title: "Add a local folder",
|
|
1016
|
+
hint: "The deck folder is the one that holds .slidemind/.",
|
|
1017
|
+
placeholder: "/Users/you/decks/q3-review",
|
|
1018
|
+
submit: "Add deck",
|
|
1019
|
+
body: browseButton,
|
|
1020
|
+
run: async (p) => {
|
|
1021
|
+
const r = await api("POST", "/decks", { path: p });
|
|
1022
|
+
navigate(`/deck/${r.deckId}`);
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
/** Local deck → SharePoint folder: the share link of the folder is all the daemon needs. */
|
|
1027
|
+
function connectSharePoint(view) {
|
|
1028
|
+
dialog({
|
|
1029
|
+
title: "Connect a SharePoint folder",
|
|
1030
|
+
hint: "Paste the share link of the folder this deck should live in. In SharePoint open the folder, click Share, then Copy link. The plan record and the published PPTX go there.",
|
|
1031
|
+
placeholder: "https://contoso.sharepoint.com/:f:/s/Projects/EiXk3…?e=abc123",
|
|
1032
|
+
submit: "Connect",
|
|
1033
|
+
run: async (url) => {
|
|
1034
|
+
try {
|
|
1035
|
+
await api("POST", `/decks/${view.deck.id}/remote/connect`, { folderRef: url });
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
if (err.code === "not_implemented") throw new Error("Connecting a folder needs a newer workspace — ask your agent to open the slidemind workspace.");
|
|
1038
|
+
throw new Error(plainError(err));
|
|
1039
|
+
}
|
|
1040
|
+
toast("Folder connected. The plan is shared there once it is editable.");
|
|
1041
|
+
refresh(); // the header says where it lives now; the frame keeps its state
|
|
1042
|
+
},
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ---------- view chrome: a framed view's MESSAGES, shown by this page ----------
|
|
1047
|
+
// A framed view (the plan gate, the review gate, a pack's plan page) keeps its
|
|
1048
|
+
// own navigation — its tabs, its title line, its switches — but what it has to
|
|
1049
|
+
// SAY to the user is this page's to show: the status line ("your agent is
|
|
1050
|
+
// listening"), every banner and notice (an approval and when, a warning, a
|
|
1051
|
+
// failed save), and the hand-off dialog. The view publishes them as one
|
|
1052
|
+
// `chrome` object and this page renders them in its chrome — the status beside
|
|
1053
|
+
// the save state, notices in a stack above the frame — so a message never sits
|
|
1054
|
+
// inside the frame it is about. A click on any of it goes back to the view as
|
|
1055
|
+
// an event. The CLI's own gates post it on the window (same origin, checked by
|
|
1056
|
+
// source); a pack page posts it on its MessagePort (plan-bridge.js). Every
|
|
1057
|
+
// publish replaces the last.
|
|
1058
|
+
const CHROME_EVENT = "slidemind-view-chrome-event";
|
|
1059
|
+
let viewChrome = { frame: null, send: null, chrome: null, dismissed: new Set() };
|
|
1060
|
+
const str = (v, max = 400) => (typeof v === "string" || typeof v === "number" ? String(v).slice(0, max) : "");
|
|
1061
|
+
const few = (v, max = 12) => (Array.isArray(v) ? v.slice(0, max).filter((x) => x && typeof x === "object") : []);
|
|
1062
|
+
const TONES = new Set(["info", "ok", "warn", "error"]);
|
|
1063
|
+
/** Only plain strings and known shapes get through: a view never injects markup or unbounded text. */
|
|
1064
|
+
function cleanChrome(c) {
|
|
1065
|
+
if (!c || typeof c !== "object") return null;
|
|
1066
|
+
const tone = (t) => (TONES.has(t) ? t : "info");
|
|
1067
|
+
const acts = (a) => few(a, 4).map((x) => ({ id: str(x.id, 60), label: str(x.label, 80), primary: !!x.primary })).filter((x) => x.id && x.label);
|
|
1068
|
+
return {
|
|
1069
|
+
status: c.status && typeof c.status === "object" && str(c.status.text) ? cleanStatus(c.status) : null,
|
|
1070
|
+
notices: few(c.notices).map((n) => ({ id: str(n.id, 60), tone: tone(n.tone), title: str(n.title, 300), text: str(n.text, 1000), say: str(n.say, 40), spin: !!n.spin, dismiss: !!n.dismiss, copy: str(n.copy, 4000), actions: acts(n.actions) })).filter((n) => n.id && (n.title || n.text)),
|
|
1071
|
+
dialog: c.dialog && typeof c.dialog === "object" ? { title: str(c.dialog.title, 200), text: str(c.dialog.text, 1000), say: str(c.dialog.say, 40), copy: str(c.dialog.copy, 4000), message: str(c.dialog.message, 4000), actions: acts(c.dialog.actions) } : null,
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
/** The status line, with what it stands for: `items` ({tag, text, kind}) preferred, else `detail`'s lines ("heading\ntag · text\n…"). */
|
|
1075
|
+
function cleanStatus(st) {
|
|
1076
|
+
const kind = (k) => (["add", "remove", "edit", "deck"].includes(k) ? k : "");
|
|
1077
|
+
let items = few(st.items, 40).map((i) => ({ tag: str(i.tag, 40), text: str(i.text, 300), kind: kind(i.kind) })).filter((i) => i.text);
|
|
1078
|
+
let heading = str(st.heading, 200);
|
|
1079
|
+
const detail = str(st.detail, 2000);
|
|
1080
|
+
if (!items.length && detail.includes("\n")) {
|
|
1081
|
+
const [first, ...rest] = detail.split("\n");
|
|
1082
|
+
heading ||= first;
|
|
1083
|
+
items = rest.filter(Boolean).slice(0, 40).map((line) => {
|
|
1084
|
+
const at = line.indexOf(" · ");
|
|
1085
|
+
return at > 0 ? { tag: line.slice(0, at), text: line.slice(at + 3), kind: "" } : { tag: "", text: line, kind: "" };
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
return { text: str(st.text, 200), tone: ["ok", "warn", "bad", "accent"].includes(st.tone) ? st.tone : "", spin: !!st.spin, detail, heading, items, action: str(st.action, 60) };
|
|
1089
|
+
}
|
|
1090
|
+
/** Bind the view frame on screen: `send(event)` is how its chrome talks back. */
|
|
1091
|
+
function bindViewChrome(frame, send) {
|
|
1092
|
+
viewChrome = { frame, send, chrome: null, dismissed: new Set() };
|
|
1093
|
+
renderViewChrome();
|
|
1094
|
+
}
|
|
1095
|
+
function unbindViewChrome() {
|
|
1096
|
+
viewChrome = { frame: null, send: null, chrome: null, dismissed: new Set() };
|
|
1097
|
+
renderViewChrome();
|
|
1098
|
+
}
|
|
1099
|
+
function setViewChrome(frame, chrome) {
|
|
1100
|
+
if (viewChrome.frame !== frame) return;
|
|
1101
|
+
viewChrome.chrome = cleanChrome(chrome);
|
|
1102
|
+
renderViewChrome();
|
|
1103
|
+
}
|
|
1104
|
+
const chromeEvent = (kind, id) => viewChrome.send?.({ kind, id });
|
|
1105
|
+
// the CLI's own gates: same origin, and only the frame on screen is heard
|
|
1106
|
+
if (typeof window.addEventListener === "function") window.addEventListener("message", (e) => {
|
|
1107
|
+
const d = e.data;
|
|
1108
|
+
if (!d || d.type !== "slidemind-view-chrome" || e.origin !== location.origin) return;
|
|
1109
|
+
if (!viewChrome.frame || e.source !== viewChrome.frame.contentWindow) return;
|
|
1110
|
+
setViewChrome(viewChrome.frame, d.chrome);
|
|
1111
|
+
});
|
|
1112
|
+
const copyButton = (text, label = "Copy message") => {
|
|
1113
|
+
const $b = h("button", { class: "mini", onclick: async () => ($b.textContent = (await copyText(text)) ? "Copied" : "Select it and press ⌘C") }, label);
|
|
1114
|
+
return $b;
|
|
1115
|
+
};
|
|
1116
|
+
const sayNode = (say) => (say ? h("span", {}, " ", h("kbd", { class: "say" }, say)) : null);
|
|
1117
|
+
function renderViewChrome() {
|
|
1118
|
+
const c = viewChrome.chrome;
|
|
1119
|
+
const $meta = document.getElementById("view-meta");
|
|
1120
|
+
if ($meta) {
|
|
1121
|
+
const st = c?.status;
|
|
1122
|
+
const wasOpen = !!document.getElementById("view-status-pop");
|
|
1123
|
+
const listed = st && (st.items.length || st.heading);
|
|
1124
|
+
// a status that names an action (the hand-off: see the message again) is a button that runs it
|
|
1125
|
+
const acts = st && !listed && st.action;
|
|
1126
|
+
const $chip = st
|
|
1127
|
+
? h(listed || acts ? "button" : "span", { class: `chip view-status ${st.tone}${listed || acts ? " listed" : ""}`, id: "view-status", "data-tip": st.text + (st.detail && !listed ? `\n${st.detail}` : ""), ...(listed ? { type: "button", "aria-haspopup": "dialog", "aria-expanded": String(wasOpen), onclick: (e) => { e.stopPropagation(); toggleStatusPop(); } } : acts ? { type: "button", "data-view-action": st.action, onclick: () => chromeEvent("action", st.action) } : {}) }, st.spin ? h("span", { class: "spin" }) : null, h("span", { class: "view-status-txt" }, st.text))
|
|
1128
|
+
: null;
|
|
1129
|
+
fill($meta, $chip);
|
|
1130
|
+
if (wasOpen && listed) renderStatusPop(); // the list follows the chrome while it is open
|
|
1131
|
+
else document.getElementById("view-status-pop")?.remove();
|
|
1132
|
+
}
|
|
1133
|
+
const $notices = document.getElementById("view-notices");
|
|
1134
|
+
if ($notices) {
|
|
1135
|
+
const shown = (c?.notices ?? []).filter((n) => !viewChrome.dismissed.has(n.id));
|
|
1136
|
+
fill(
|
|
1137
|
+
$notices,
|
|
1138
|
+
shown.map((n) =>
|
|
1139
|
+
h(
|
|
1140
|
+
"div",
|
|
1141
|
+
{ class: `view-notice ${n.tone}`, "data-notice": n.id, role: n.tone === "error" || n.tone === "warn" ? "alert" : "status" },
|
|
1142
|
+
n.spin ? h("span", { class: "spin" }) : null,
|
|
1143
|
+
h("div", { class: "txt" }, n.title ? h("b", {}, n.title) : null, n.text || n.say ? h("span", {}, n.text, sayNode(n.say)) : null),
|
|
1144
|
+
h(
|
|
1145
|
+
"div",
|
|
1146
|
+
{ class: "acts" },
|
|
1147
|
+
n.actions.map((a) => h("button", { class: `mini${a.primary ? " primary" : ""}`, "data-view-action": a.id, onclick: () => chromeEvent("action", a.id) }, a.label)),
|
|
1148
|
+
n.copy ? copyButton(n.copy) : null,
|
|
1149
|
+
n.dismiss ? h("button", { class: "icon notice-x", "data-tip": "Dismiss", "aria-label": "Dismiss", onclick: () => {
|
|
1150
|
+
viewChrome.dismissed.add(n.id);
|
|
1151
|
+
chromeEvent("dismiss", n.id);
|
|
1152
|
+
renderViewChrome();
|
|
1153
|
+
} }, "×") : null,
|
|
1154
|
+
),
|
|
1155
|
+
),
|
|
1156
|
+
),
|
|
1157
|
+
);
|
|
1158
|
+
$notices.hidden = !shown.length;
|
|
1159
|
+
}
|
|
1160
|
+
renderViewDialog(c?.dialog ?? null);
|
|
1161
|
+
}
|
|
1162
|
+
// the status line's list (what the changes are), opened from the chip, closed by a click elsewhere or Escape
|
|
1163
|
+
function renderStatusPop() {
|
|
1164
|
+
const st = viewChrome.chrome?.status;
|
|
1165
|
+
document.getElementById("view-status-pop")?.remove();
|
|
1166
|
+
if (!st) return;
|
|
1167
|
+
const $pop = h(
|
|
1168
|
+
"div",
|
|
1169
|
+
{ class: "view-pop", id: "view-status-pop", role: "dialog", "aria-label": st.heading || st.text },
|
|
1170
|
+
st.heading ? h("p", { class: "view-pop-head" }, st.heading) : null,
|
|
1171
|
+
st.items.length ? h("ul", { class: "view-pop-list" }, st.items.map((i) => h("li", { "data-kind": i.kind || null }, i.tag ? h("b", {}, i.tag) : null, h("span", {}, i.text)))) : h("p", { class: "muted" }, st.detail || st.text),
|
|
1172
|
+
);
|
|
1173
|
+
document.getElementById("view-meta")?.append($pop);
|
|
1174
|
+
document.getElementById("view-status")?.setAttribute("aria-expanded", "true");
|
|
1175
|
+
}
|
|
1176
|
+
function toggleStatusPop() {
|
|
1177
|
+
if (document.getElementById("view-status-pop")) {
|
|
1178
|
+
document.getElementById("view-status-pop").remove();
|
|
1179
|
+
document.getElementById("view-status")?.setAttribute("aria-expanded", "false");
|
|
1180
|
+
} else renderStatusPop();
|
|
1181
|
+
}
|
|
1182
|
+
document.addEventListener("click", (e) => {
|
|
1183
|
+
if (!document.getElementById("view-status-pop") || e.target.closest("#view-status-pop")) return;
|
|
1184
|
+
toggleStatusPop();
|
|
1185
|
+
});
|
|
1186
|
+
document.addEventListener("keydown", (e) => {
|
|
1187
|
+
if (e.key === "Escape" && document.getElementById("view-status-pop")) toggleStatusPop();
|
|
1188
|
+
});
|
|
1189
|
+
// the view's one modal (the hand-off: "now tell your agent"), as this page's own dialog
|
|
1190
|
+
let viewDialogKey = null;
|
|
1191
|
+
function renderViewDialog(d) {
|
|
1192
|
+
const key = d ? JSON.stringify(d) : null;
|
|
1193
|
+
if (key === viewDialogKey) return;
|
|
1194
|
+
viewDialogKey = key;
|
|
1195
|
+
document.getElementById("view-dialog")?.remove();
|
|
1196
|
+
document.getElementById("handoff-dialog")?.remove();
|
|
1197
|
+
if (!d) return;
|
|
1198
|
+
// the gate's hand-over: the same dialog a pack's task card shows
|
|
1199
|
+
if (d.message) {
|
|
1200
|
+
const close = d.actions.find((a) => a.id === "handoff-close") ?? d.actions[d.actions.length - 1];
|
|
1201
|
+
showHandoff({ message: d.message, onClose: () => close && chromeEvent("action", close.id) });
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
const $dlg = h(
|
|
1205
|
+
"dialog",
|
|
1206
|
+
{ class: "dlg view-dialog", id: "view-dialog" },
|
|
1207
|
+
h("h3", {}, d.title),
|
|
1208
|
+
d.text || d.say ? h("p", { class: "big" }, d.text, sayNode(d.say)) : null,
|
|
1209
|
+
h(
|
|
1210
|
+
"div",
|
|
1211
|
+
{ class: "row end" },
|
|
1212
|
+
d.copy ? copyButton(d.copy) : null,
|
|
1213
|
+
d.actions.map((a) => h("button", { class: a.primary ? "primary" : "", "data-view-action": a.id, onclick: () => chromeEvent("action", a.id) }, a.label)),
|
|
1214
|
+
),
|
|
1215
|
+
);
|
|
1216
|
+
// Escape = the view's own close action, so the view's state follows
|
|
1217
|
+
$dlg.addEventListener("cancel", (e) => {
|
|
1218
|
+
e.preventDefault();
|
|
1219
|
+
const close = d.actions.find((a) => a.id === "close") ?? d.actions[d.actions.length - 1];
|
|
1220
|
+
if (close) chromeEvent("action", close.id);
|
|
1221
|
+
});
|
|
1222
|
+
document.body.append($dlg);
|
|
1223
|
+
$dlg.showModal();
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// ---------- deck ----------
|
|
1227
|
+
// Opening a deck and the focus the browser gives the new page arrive
|
|
1228
|
+
// together: one refresh in flight answers both.
|
|
1229
|
+
const remoteInFlight = new Set();
|
|
1230
|
+
/** GET-only: ask the daemon to fetch the latest shared plan and merge; it never PUTs. */
|
|
1231
|
+
async function remoteRefresh(deckId) {
|
|
1232
|
+
if (remoteInFlight.has(deckId)) return;
|
|
1233
|
+
remoteInFlight.add(deckId);
|
|
1234
|
+
try {
|
|
1235
|
+
await api("POST", `/decks/${deckId}/remote/refresh`, {});
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
if (err.code !== "not_implemented") console.warn("refresh", err.code, err.message);
|
|
1238
|
+
} finally {
|
|
1239
|
+
remoteInFlight.delete(deckId);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
function schedulePoll(view) {
|
|
1243
|
+
clearTimeout(poll);
|
|
1244
|
+
poll = null;
|
|
1245
|
+
if (!isShared(view) || current?.tab !== "plan") return;
|
|
1246
|
+
poll = setTimeout(async () => {
|
|
1247
|
+
if (document.visibilityState !== "hidden" && current?.kind === "deck" && current.deckId === view.deck.id && current.tab === "plan") await remoteRefresh(view.deck.id);
|
|
1248
|
+
schedulePoll(view);
|
|
1249
|
+
}, jitter(POLL_MS));
|
|
1250
|
+
}
|
|
1251
|
+
/** Blocker text for an adapter the deck cannot use here. */
|
|
1252
|
+
const blockerText = (b) => PLAIN[b.code] ?? b.message ?? "This deck's plan can't be opened here.";
|
|
1253
|
+
const LEGACY_BANNER = "This deck uses an older folder layout that couldn't be moved to the new one automatically. Its files are untouched — ask your agent to open this deck, it can finish the move.";
|
|
1254
|
+
|
|
1255
|
+
// Why a connected folder does not hold the presentation yet — plain words, the next build retries.
|
|
1256
|
+
const UPLOAD_FAILED = {
|
|
1257
|
+
name_exists: "A file with this name is already in the folder, so nothing was replaced. Ask your agent to publish under another name.",
|
|
1258
|
+
deck_busy: "Your agent was working on the deck. The next build uploads it.",
|
|
1259
|
+
login_required: "Your Microsoft 365 sign-in has expired. Ask your agent to sign you in again; the next build uploads it.",
|
|
1260
|
+
write_disabled: "Saving to SharePoint is switched off for your organization.",
|
|
1261
|
+
publish_failed: "The presentation could not be uploaded yet. The next build uploads it.",
|
|
1262
|
+
};
|
|
1263
|
+
/**
|
|
1264
|
+
* The deck's header is the top bar — its name, the folder it lives in (a click
|
|
1265
|
+
* reveals it) and its chips beside them — and the banners are the page's, under
|
|
1266
|
+
* it. Built the same way by the first render and by every advisory refresh,
|
|
1267
|
+
* which swaps it in place without touching the tab body (a gate frame keeps its state).
|
|
1268
|
+
*/
|
|
1269
|
+
function deckHead(view) {
|
|
1270
|
+
const remote = view.remote;
|
|
1271
|
+
const planState = remote?.plan?.state ?? "none";
|
|
1272
|
+
const pptxState = remote?.pptx?.state ?? "none";
|
|
1273
|
+
// one SharePoint chip, linking to the item, worded by what lives there
|
|
1274
|
+
const spLink = remote?.pptx?.link ?? remote?.plan?.link ?? remote?.destination?.webUrl ?? null;
|
|
1275
|
+
// never a code: a blocked sync's reason is in the conflict panel / the agent's output, the chip only says it needs a look
|
|
1276
|
+
// a folder connected after the build uploads that build; how it went is the folder's chip
|
|
1277
|
+
const upload = pptxState === "none" ? remote?.destination?.upload ?? null : null;
|
|
1278
|
+
const spText = pptxState === "published" ? "SharePoint · published" : pptxState === "bound" ? "SharePoint · linked" : planState === "shared" ? "SharePoint · plan shared" : planState === "blocked" ? "SharePoint · needs attention" : planState === "destination-only" ? (upload?.state === "uploading" ? "SharePoint · uploading" : upload?.state === "failed" ? "SharePoint · not uploaded" : "SharePoint · folder connected") : null;
|
|
1279
|
+
const legacy = view.deck.format === "legacy";
|
|
1280
|
+
const where = tilde(view.deck.path, libraryHome);
|
|
1281
|
+
// when it last synced is the chip's tooltip: the save chip by the tabs already says whether it did
|
|
1282
|
+
const synced = upload?.state === "failed" ? UPLOAD_FAILED[upload.reason] ?? UPLOAD_FAILED.publish_failed : upload?.state === "uploading" ? "Uploading the presentation to this folder" : spText ? `Last synced ${fmtTime(remote?.plan?.lastConfirmedAt ?? remote?.pptx?.lastConfirmedAt ?? view.sync?.lastConfirmedAt)}` : null;
|
|
1283
|
+
return {
|
|
1284
|
+
title: view.deck.name ?? where,
|
|
1285
|
+
sub: h("button", { class: "meta-folder", id: "reveal", "data-tip": `${revealLabel()}\n${where}`, "aria-label": `${revealLabel()}: ${where}`, onclick: () => api("POST", `/decks/${view.deck.id}/reveal`, {}).catch((err) => toast(plainError(err), "bad")) }, icon(ICON_FOLDER), deckFolder(view.deck.path, libraryHome)),
|
|
1286
|
+
actions: [
|
|
1287
|
+
view.pptx ? openPptxButton(view) : null,
|
|
1288
|
+
spText ? sharepointChip(spLink, spText, synced) : null,
|
|
1289
|
+
!spText && !legacy ? h("button", { class: "mini", id: "connect-sharepoint", "aria-label": "Connect SharePoint folder", onclick: () => connectSharePoint(view) }, h("img", { src: "/ui/sharepoint.svg", alt: "", class: "sp-ico" }), h("span", { class: "lbl" }, "Connect SharePoint folder")) : null,
|
|
1290
|
+
// the PRESENTATION's policy: a deployment without the shared-plan service still saves the .pptx
|
|
1291
|
+
remote?.publishWrites === "disabled" ? chip("SharePoint · read-only", "warn", { "data-tip": "slidemind can read from SharePoint here but does not save to it" }) : null,
|
|
1292
|
+
view.adapter.blocker ? chip("Can't open here", "bad") : null,
|
|
1293
|
+
legacy ? h("button", { class: "mini", id: "remove-deck", onclick: () => removeDeckDialog(view.deck, { trash: false, after: () => navigate("/") }) }, "Remove from library") : null,
|
|
1294
|
+
],
|
|
1295
|
+
banners: [
|
|
1296
|
+
view.adapter.blocker ? h("p", { class: "error", id: "deck-blocker", "data-code": view.adapter.blocker.code }, blockerText(view.adapter.blocker)) : null,
|
|
1297
|
+
legacy ? h("p", { class: "error", id: "legacy-banner", "data-code": "unsupported_format" }, LEGACY_BANNER) : null,
|
|
1298
|
+
],
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
/** The tab buttons, each with its ✓ once that phase is approved and the count of questions waiting in it (the sidebar's badge). */
|
|
1302
|
+
function deckTabs(view, tabs) {
|
|
1303
|
+
const waiting = (t) => view.tasks.filter((k) => k.status === "pending" && tabOf(k, tabs) === t).length;
|
|
1304
|
+
return h(
|
|
1305
|
+
"div",
|
|
1306
|
+
{ class: "tabs", role: "tablist" },
|
|
1307
|
+
tabs.map((t) => {
|
|
1308
|
+
const n = waiting(t);
|
|
1309
|
+
return h("button", { role: "tab", "aria-selected": String(t === current.tab), "data-tab": t, onclick: () => {
|
|
1310
|
+
if (planEditor?.dirty && !confirm("You have unsaved plan changes. Leave anyway?")) return;
|
|
1311
|
+
current.tab = t;
|
|
1312
|
+
refresh(true);
|
|
1313
|
+
} }, t[0].toUpperCase() + t.slice(1), (t === "plan" && view.stage?.plan?.approved) || (t === "preview" && view.stage?.review?.approved) ? h("span", { class: "tab-ok", "data-tip": "Approved" }, "✓") : null, n ? h("span", { class: "badge", "data-tip": `${n} question${n === 1 ? "" : "s"} waiting for you` }, String(n)) : null);
|
|
1314
|
+
}),
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
async function renderDeck() {
|
|
1318
|
+
const { deckId } = current;
|
|
1319
|
+
let view;
|
|
1320
|
+
try {
|
|
1321
|
+
view = await api("GET", `/decks/${deckId}`);
|
|
1322
|
+
} catch (err) {
|
|
1323
|
+
if (connectionLost(err)) throw err; // the banner says it; the deck on screen stays
|
|
1324
|
+
const gone = err.code === "missing_path" ? { id: deckId, name: err.details?.name ?? deckNames.get(deckId) ?? null, path: err.details?.path ?? "" } : null;
|
|
1325
|
+
setMeta(gone?.name ?? "Deck", "");
|
|
1326
|
+
$app.replaceChildren(
|
|
1327
|
+
page(
|
|
1328
|
+
errorBox(err, err.code === "missing_path" || err.code === "unknown_deck" ? h("a", { href: "/", "data-nav": true }, "Back to the inbox") : null),
|
|
1329
|
+
gone ? h("div", { class: "row" }, h("button", { id: "forget-deck", onclick: () => removeDeckDialog(gone, { trash: false, after: () => navigate("/") }) }, "Forget this deck")) : null,
|
|
1330
|
+
),
|
|
1331
|
+
);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
setTitle(`${view.deck.name ?? "deck"} · slidemind`);
|
|
1335
|
+
const head = deckHead(view);
|
|
1336
|
+
setMeta(head.title, head.sub, head.actions);
|
|
1337
|
+
const tabs = tabsFor(view);
|
|
1338
|
+
if (current.taskId && !current.tab) current.tab = phaseOf(view.tasks.find((t) => t.id === current.taskId)?.kind).tab; // a task link opens its phase
|
|
1339
|
+
if (!tabs.includes(current.tab)) current.tab = tabs[0];
|
|
1340
|
+
// the address names the tab on screen, so a reload or a copied address opens the same view
|
|
1341
|
+
if (current.tab) {
|
|
1342
|
+
const want = `/deck/${deckId}/${current.tab}`;
|
|
1343
|
+
if (location.pathname !== want) history.replaceState(null, "", want);
|
|
1344
|
+
}
|
|
1345
|
+
const opened = !current.shared && current.opened !== deckId;
|
|
1346
|
+
current.shared = isShared(view);
|
|
1347
|
+
if (opened && current.shared) {
|
|
1348
|
+
current.opened = deckId;
|
|
1349
|
+
remoteRefresh(deckId); // on open: fetch the latest, merge under lock, then SSE re-renders
|
|
1350
|
+
}
|
|
1351
|
+
// the tab's tools sit by the tabs; the right end holds only the two state chips, so a
|
|
1352
|
+
// chip whose text changes moves nothing but its own edge
|
|
1353
|
+
const $tabRow = tabs.length ? h("div", { class: "row tab-row" }, deckTabs(view, tabs), h("div", { class: "row", id: "tab-tools" }), h("div", { class: "row", id: "tab-meta" }, h("span", { class: "row", id: "view-meta" }))) : null;
|
|
1354
|
+
const $notices = h("div", { id: "view-notices", class: "view-notices", hidden: true });
|
|
1355
|
+
const $body = h("section", { id: "tab-body" });
|
|
1356
|
+
// the tab body is rebuilt: a plan editor or frame from before is gone with it (renderPlan sets them anew)
|
|
1357
|
+
planEditor = null;
|
|
1358
|
+
planFrame = null;
|
|
1359
|
+
planView = null;
|
|
1360
|
+
unbindViewChrome();
|
|
1361
|
+
fill($app, deckPage(...head.banners, $tabRow, $notices, $body));
|
|
1362
|
+
if (!tabs.length) {
|
|
1363
|
+
renderYourTurn(view, $body, null);
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (current.tab === "plan") await renderPlan(view, $body);
|
|
1367
|
+
else if (current.tab === "preview") renderPreview(view, $body);
|
|
1368
|
+
renderYourTurn(view, $body, current.tab);
|
|
1369
|
+
schedulePoll(view);
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* An advisory refresh of the deck on screen: header chips, tab marks, the
|
|
1373
|
+
* conflict panel and "your turn" follow the server;
|
|
1374
|
+
* the tab body's frame is left alone.
|
|
1375
|
+
*/
|
|
1376
|
+
let chromeSeq = 0;
|
|
1377
|
+
async function refreshDeckChrome() {
|
|
1378
|
+
if (current?.kind !== "deck") return;
|
|
1379
|
+
const { deckId } = current;
|
|
1380
|
+
const seq = ++chromeSeq;
|
|
1381
|
+
let view;
|
|
1382
|
+
try {
|
|
1383
|
+
view = await api("GET", `/decks/${deckId}`);
|
|
1384
|
+
} catch {
|
|
1385
|
+
return; // the next event or the reconnect tries again
|
|
1386
|
+
}
|
|
1387
|
+
if (seq !== chromeSeq || current?.kind !== "deck" || current.deckId !== deckId) return;
|
|
1388
|
+
const $page = document.querySelector(".page.deck");
|
|
1389
|
+
if (!$page) return;
|
|
1390
|
+
const head = deckHead(view);
|
|
1391
|
+
setMeta(head.title, head.sub, head.actions);
|
|
1392
|
+
for (const id of ["deck-blocker", "legacy-banner"]) document.getElementById(id)?.remove();
|
|
1393
|
+
$page.prepend(...head.banners.filter(Boolean));
|
|
1394
|
+
const tabs = tabsFor(view);
|
|
1395
|
+
const $tabs = document.querySelector(".tab-row .tabs");
|
|
1396
|
+
if ($tabs && tabs.includes(current.tab)) $tabs.replaceWith(deckTabs(view, tabs));
|
|
1397
|
+
const $body = document.getElementById("tab-body");
|
|
1398
|
+
if (!$body) return;
|
|
1399
|
+
if (current.tab === "plan" && planFrame) {
|
|
1400
|
+
const $panel = document.getElementById("conflict-panel");
|
|
1401
|
+
const c = conflictOf(view);
|
|
1402
|
+
if (($panel?.dataset.conflict ?? null) !== (c ? `${c.conflictId}:${c.expectedHash ?? ""}` : null)) {
|
|
1403
|
+
$panel?.remove();
|
|
1404
|
+
renderConflict(view, $body);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
redrawYourTurn(view);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// ---------- conflicts (7.5) ----------
|
|
1411
|
+
/**
|
|
1412
|
+
* Mine / theirs / merged from the queue's conflict record. Resolving sends the
|
|
1413
|
+
* CURRENT conflict id + expected local hash; a stale one is refused by the
|
|
1414
|
+
* daemon, the panel re-renders with the new ids and nothing typed is lost.
|
|
1415
|
+
*/
|
|
1416
|
+
function renderConflict(view, $body) {
|
|
1417
|
+
const c = conflictOf(view);
|
|
1418
|
+
if (!c) return null;
|
|
1419
|
+
const $msg = h("p", { class: "muted", id: "conflict-message" });
|
|
1420
|
+
const show = (v) => (typeof v === "string" ? v : JSON.stringify(v ?? null, null, 2));
|
|
1421
|
+
const paths = (c.conflicts ?? []).map((x) => h("li", {}, h("code", {}, x.path ?? "?"), x.mine !== undefined ? ` — mine: ${show(x.mine).slice(0, 80)}` : "", x.theirs !== undefined ? ` · theirs: ${show(x.theirs).slice(0, 80)}` : ""));
|
|
1422
|
+
const pick = (label, value) => h("button", { "data-resolve": label, onclick: () => resolve(value) }, label);
|
|
1423
|
+
async function resolve(value) {
|
|
1424
|
+
for (const b of $panel.querySelectorAll("button")) b.disabled = true;
|
|
1425
|
+
$msg.textContent = "";
|
|
1426
|
+
try {
|
|
1427
|
+
await api("POST", `/decks/${view.deck.id}/conflicts/resolve`, { conflictId: c.conflictId, expectedHash: c.expectedHash, value });
|
|
1428
|
+
$msg.textContent = "Resolved. Syncing the chosen version.";
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
for (const b of $panel.querySelectorAll("button")) b.disabled = false;
|
|
1431
|
+
$msg.textContent = err.code === "stale_subject" ? "The plan changed again while you decided; refreshing the conflict. Your unsaved text is kept." : plainError(err);
|
|
1432
|
+
if (err.code === "stale_subject") setTimeout(refreshDeckChrome, 500); // the panel follows the new conflict; the gate frame stays
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
const $panel = h(
|
|
1436
|
+
"div",
|
|
1437
|
+
{ class: "banner conflict", id: "conflict-panel", "data-conflict": `${c.conflictId}:${c.expectedHash ?? ""}` },
|
|
1438
|
+
h("strong", {}, "The shared plan changed where you changed it."),
|
|
1439
|
+
h("p", { class: "muted" }, "Pick the version to keep. Nothing is lost until you choose."),
|
|
1440
|
+
paths.length ? h("ul", { class: "list", id: "conflict-paths" }, paths) : null,
|
|
1441
|
+
h("div", { class: "row" }, pick("Keep mine", c.local ?? c.current?.value ?? c.mine), pick("Take theirs", c.current?.value ?? c.theirs ?? c.current), pick("Use merged", c.merged)),
|
|
1442
|
+
h("details", {}, h("summary", {}, "Show both versions"), h("pre", { class: "copy", id: "conflict-mine" }, show(c.local ?? c.mine)), h("pre", { class: "copy", id: "conflict-theirs" }, show(c.current?.value ?? c.theirs ?? c.current))),
|
|
1443
|
+
$msg,
|
|
1444
|
+
);
|
|
1445
|
+
$body.prepend($panel);
|
|
1446
|
+
return $panel;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
async function renderPlan(view, $body) {
|
|
1450
|
+
if (view.adapter.blocker) {
|
|
1451
|
+
$body.replaceChildren(h("p", { class: "error", "data-code": view.adapter.blocker.code }, blockerText(view.adapter.blocker)));
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
if (view.adapter.id !== "core") return renderAdapterPlan(view, $body);
|
|
1455
|
+
// core deck: plain Markdown editor over PUT /plan
|
|
1456
|
+
let plan;
|
|
1457
|
+
try {
|
|
1458
|
+
plan = await api("GET", `/decks/${view.deck.id}/plan`);
|
|
1459
|
+
} catch (err) {
|
|
1460
|
+
if (connectionLost(err)) throw err;
|
|
1461
|
+
$body.replaceChildren(errorBox(err));
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
// No plan.md yet: the agent writes the first one (the plan route has no
|
|
1465
|
+
// "create" — a save needs the version it replaces), so there is nothing to
|
|
1466
|
+
// frame or edit; the page says so and refreshes when the plan arrives.
|
|
1467
|
+
if (plan.value === null || plan.value === undefined) {
|
|
1468
|
+
fill($body, h("div", { class: "empty", id: "plan-empty" }, h("div", { class: "brand" }, ...brandMark()), h("p", { class: "muted" }, "Your agent hasn't written a plan for this deck yet.")));
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
const ed = { base: { hash: plan.hash, value: plan.value ?? "" }, dirty: false, timer: null, sync: plan.sync ?? null, saving: false, busySince: null };
|
|
1472
|
+
planEditor = ed;
|
|
1473
|
+
const restored = (() => {
|
|
1474
|
+
try {
|
|
1475
|
+
return sessionStorage.getItem(backupKey(view.deck.id, plan.hash));
|
|
1476
|
+
} catch {
|
|
1477
|
+
return null;
|
|
1478
|
+
}
|
|
1479
|
+
})();
|
|
1480
|
+
const $status = h("span", { class: "chip save", id: "save-status" });
|
|
1481
|
+
const $msg = h("p", { class: "muted", id: "save-message" });
|
|
1482
|
+
const $ta = h("textarea", { class: "plan", id: "plan-editor", spellcheck: "false" });
|
|
1483
|
+
$ta.value = restored ?? ed.base.value;
|
|
1484
|
+
if (restored !== null && restored !== ed.base.value) ed.dirty = true;
|
|
1485
|
+
const setStatus = () => {
|
|
1486
|
+
const s = saveStatus(ed.sync, ed.dirty && ed.failed ? "failed" : ed.dirty);
|
|
1487
|
+
$status.textContent = s.text;
|
|
1488
|
+
$status.className = `chip save ${s.cls}`;
|
|
1489
|
+
};
|
|
1490
|
+
const backup = () => {
|
|
1491
|
+
try {
|
|
1492
|
+
if (ed.dirty) sessionStorage.setItem(backupKey(view.deck.id, ed.base.hash), $ta.value);
|
|
1493
|
+
else sessionStorage.removeItem(backupKey(view.deck.id, ed.base.hash));
|
|
1494
|
+
} catch {
|
|
1495
|
+
// storage may be unavailable; the buffer stays in the textarea
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
async function save() {
|
|
1499
|
+
clearTimeout(ed.timer);
|
|
1500
|
+
if (ed.saving) return;
|
|
1501
|
+
if ($ta.value === ed.base.value) {
|
|
1502
|
+
ed.dirty = false;
|
|
1503
|
+
backup();
|
|
1504
|
+
setStatus();
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
ed.saving = true;
|
|
1508
|
+
ed.failed = false;
|
|
1509
|
+
$msg.textContent = "";
|
|
1510
|
+
try {
|
|
1511
|
+
const r = await api("PUT", `/decks/${view.deck.id}/plan`, { operationId: crypto.randomUUID(), base: ed.base, value: $ta.value });
|
|
1512
|
+
const oldKey = backupKey(view.deck.id, ed.base.hash);
|
|
1513
|
+
ed.base = { hash: r.hash, value: r.value };
|
|
1514
|
+
ed.sync = r.sync ?? null;
|
|
1515
|
+
ed.dirty = $ta.value !== r.value;
|
|
1516
|
+
ed.busySince = null;
|
|
1517
|
+
try {
|
|
1518
|
+
sessionStorage.removeItem(oldKey);
|
|
1519
|
+
} catch {
|
|
1520
|
+
// ignore
|
|
1521
|
+
}
|
|
1522
|
+
backup();
|
|
1523
|
+
// the gate page shows the saved plan by itself (a new subject is a new round for it)
|
|
1524
|
+
if (ed.dirty) ed.timer = setTimeout(save, AUTOSAVE_MS);
|
|
1525
|
+
} catch (err) {
|
|
1526
|
+
// keep the buffer, say why
|
|
1527
|
+
ed.dirty = true;
|
|
1528
|
+
backup();
|
|
1529
|
+
if (err.code === "deck_busy" || err.code === "approval_in_progress") {
|
|
1530
|
+
ed.busySince ??= Date.now();
|
|
1531
|
+
const long = Date.now() - ed.busySince > BUSY_HINT_MS;
|
|
1532
|
+
$msg.textContent = "Your agent is working on this deck right now — your text is kept and saved automatically as soon as it is free.";
|
|
1533
|
+
ed.timer = setTimeout(save, RETRY_MS); // autosave retries by itself
|
|
1534
|
+
} else if (err.code === "local_conflict") {
|
|
1535
|
+
$msg.replaceChildren("Someone else changed the plan. Your text is kept. ", h("button", { onclick: () => discardTo(err.details?.current) }, "Discard mine and take theirs"));
|
|
1536
|
+
} else if (connectionLost(err)) {
|
|
1537
|
+
// saved by itself once the workspace answers again (or signs this tab back in)
|
|
1538
|
+
$msg.textContent = "Your text is kept in this tab and saved as soon as the workspace is back.";
|
|
1539
|
+
reconnectHooks.add(resume);
|
|
1540
|
+
} else $msg.textContent = plainError(err);
|
|
1541
|
+
if (err.code !== "deck_busy" && err.code !== "approval_in_progress") ed.busySince = null;
|
|
1542
|
+
// busy and offline retry by themselves ("Saving…"); anything else waits for the user ("Not saved")
|
|
1543
|
+
ed.failed = !(err.code === "deck_busy" || err.code === "approval_in_progress" || connectionLost(err));
|
|
1544
|
+
} finally {
|
|
1545
|
+
ed.saving = false;
|
|
1546
|
+
setStatus();
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
function resume() {
|
|
1550
|
+
reconnectHooks.delete(resume);
|
|
1551
|
+
if (planEditor === ed && ed.dirty) save();
|
|
1552
|
+
}
|
|
1553
|
+
// wait out an autosave already on the wire, then save what is left
|
|
1554
|
+
ed.flush = async () => {
|
|
1555
|
+
for (let i = 0; i < 100 && ed.saving; i++) await new Promise((r) => setTimeout(r, 50));
|
|
1556
|
+
if (ed.dirty) await save();
|
|
1557
|
+
};
|
|
1558
|
+
function discardTo(cur) {
|
|
1559
|
+
if (!cur) return refresh();
|
|
1560
|
+
ed.base = { hash: cur.hash, value: cur.value };
|
|
1561
|
+
$ta.value = cur.value;
|
|
1562
|
+
ed.dirty = false;
|
|
1563
|
+
ed.failed = false;
|
|
1564
|
+
backup();
|
|
1565
|
+
$msg.textContent = "";
|
|
1566
|
+
setStatus();
|
|
1567
|
+
}
|
|
1568
|
+
$ta.addEventListener("input", () => {
|
|
1569
|
+
ed.typedAt = Date.now();
|
|
1570
|
+
ed.dirty = $ta.value !== ed.base.value;
|
|
1571
|
+
backup();
|
|
1572
|
+
setStatus();
|
|
1573
|
+
clearTimeout(ed.timer);
|
|
1574
|
+
ed.timer = setTimeout(save, AUTOSAVE_MS);
|
|
1575
|
+
});
|
|
1576
|
+
$ta.addEventListener("blur", () => ed.dirty && save());
|
|
1577
|
+
// The page is the deck's own plan.html carrying the live gate; the Markdown
|
|
1578
|
+
// editor sits behind a toggle. This page never reloads that frame: the gate
|
|
1579
|
+
// follows every change itself (its heartbeat), keeping what the user typed.
|
|
1580
|
+
// same-origin on purpose: this is the CLI's own page carrying the live gate, whose decision POST rides the session cookie
|
|
1581
|
+
const $frame = h("iframe", { class: "view", id: "plan-view", sandbox: "allow-scripts allow-same-origin allow-modals", title: "Plan", src: `${API}/decks/${view.deck.id}/render/plan` });
|
|
1582
|
+
const $editor = h("div", { class: "plan-editor", id: "plan-editor-panel", hidden: true }, $msg, $ta);
|
|
1583
|
+
// page ⟷ markdown: a real switch between two icons; the Markdown side saves by itself
|
|
1584
|
+
const ICON_PAGE = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M7 8h10M7 12h10M7 16h6"/>';
|
|
1585
|
+
const ICON_CODE = '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="m16 18 6-6-6-6M8 6l-6 6 6 6"/>';
|
|
1586
|
+
const $check = h("input", { type: "checkbox", role: "switch", id: "plan-edit-toggle", "aria-label": "Edit the plan as Markdown", onchange: () => setMode($check.checked ? "edit" : "view") });
|
|
1587
|
+
const $iconPage = h("span", { class: "sw-ico on", "data-tip": "Plan page" }, icon(ICON_PAGE));
|
|
1588
|
+
const $iconCode = h("span", { class: "sw-ico", "data-tip": "Markdown" }, icon(ICON_CODE));
|
|
1589
|
+
const $toggle = h("label", { class: "switch" }, $iconPage, $check, h("span", { class: "sw-track" }), $iconCode);
|
|
1590
|
+
function setMode(mode) {
|
|
1591
|
+
const editing = mode === "edit";
|
|
1592
|
+
ed.editing = editing;
|
|
1593
|
+
$editor.hidden = !editing;
|
|
1594
|
+
$frame.hidden = editing;
|
|
1595
|
+
$check.checked = editing;
|
|
1596
|
+
$iconPage.classList.toggle("on", !editing);
|
|
1597
|
+
$iconCode.classList.toggle("on", editing);
|
|
1598
|
+
if (editing) $ta.focus();
|
|
1599
|
+
else if (ed.dirty) save();
|
|
1600
|
+
}
|
|
1601
|
+
planFrame = $frame;
|
|
1602
|
+
planView = { setMode };
|
|
1603
|
+
bindViewChrome($frame, (event) => $frame.contentWindow?.postMessage({ type: CHROME_EVENT, event }, location.origin));
|
|
1604
|
+
fill(document.getElementById("tab-tools") ?? h("div"), $toggle);
|
|
1605
|
+
fill(document.getElementById("tab-meta") ?? h("div"), h("span", { class: "row", id: "view-meta" }), $status);
|
|
1606
|
+
$body.replaceChildren($editor, $frame);
|
|
1607
|
+
renderConflict(view, $body);
|
|
1608
|
+
setStatus();
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function renderAdapterPlan(view, $body) {
|
|
1612
|
+
const $frame = h("iframe", { class: "view", id: "plan-view", sandbox: "allow-scripts", title: "Plan" });
|
|
1613
|
+
const $status = h("span", { class: "chip save ok", id: "save-status" }, "Saved");
|
|
1614
|
+
const $msg = h("p", { class: "muted", id: "save-message" });
|
|
1615
|
+
fill(document.getElementById("tab-meta") ?? h("div"), h("span", { class: "row", id: "view-meta" }), $status);
|
|
1616
|
+
$body.replaceChildren($msg, $frame);
|
|
1617
|
+
if (!window.PlanBridge) {
|
|
1618
|
+
$msg.className = "error";
|
|
1619
|
+
$msg.textContent = "This plan view could not load — reload the page; if it stays, ask your agent to open the slidemind workspace.";
|
|
1620
|
+
$msg.dataset.code = "plan_bridge_missing";
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
const deckId = view.deck.id;
|
|
1624
|
+
const setStatus = (r, dirty) => {
|
|
1625
|
+
const s = saveStatus(r?.sync ?? null, dirty);
|
|
1626
|
+
$status.textContent = s.text;
|
|
1627
|
+
$status.className = `chip save ${s.cls}`;
|
|
1628
|
+
};
|
|
1629
|
+
const wrap = (fn) => async (...args) => {
|
|
1630
|
+
try {
|
|
1631
|
+
const r = await fn(...args);
|
|
1632
|
+
$msg.textContent = "";
|
|
1633
|
+
return r;
|
|
1634
|
+
} catch (err) {
|
|
1635
|
+
const plain = err.code === "deck_busy" ? "Your agent is working on this deck right now — your edits are kept; they save once it is free." : plainError(err);
|
|
1636
|
+
// a view that shows its own messages (it publishes chrome) says it once, in its notice;
|
|
1637
|
+
// the line above the frame is for a view that does not
|
|
1638
|
+
$msg.textContent = viewChrome.frame === $frame && viewChrome.chrome ? "" : plain;
|
|
1639
|
+
setStatus(null, err.code === "deck_busy" || connectionLost(err) ? true : "failed");
|
|
1640
|
+
// the view gets the plain sentence, never a server's wording; the code still says what happened —
|
|
1641
|
+
// a lost connection as the transport's own "disconnected", which this page's banner already says
|
|
1642
|
+
throw { code: connectionLost(err) ? "disconnected" : err.code, message: plain, details: err.details };
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
const bridgeApi = {
|
|
1646
|
+
read: wrap(() => api("GET", `/decks/${deckId}/plan`)),
|
|
1647
|
+
save: wrap(async ({ base, value }) => {
|
|
1648
|
+
const saving = api("PUT", `/decks/${deckId}/plan`, { operationId: crypto.randomUUID(), base, value });
|
|
1649
|
+
planSaving = saving;
|
|
1650
|
+
const r = await saving.finally(() => planSaving === saving && (planSaving = null));
|
|
1651
|
+
setStatus(r, false);
|
|
1652
|
+
return r;
|
|
1653
|
+
}),
|
|
1654
|
+
validate: wrap((payload) => api("POST", `/decks/${deckId}/actions/plan.validate`, { operationId: crypto.randomUUID(), payload: payload ?? {} })),
|
|
1655
|
+
action: wrap((name, payload) => api("POST", `/decks/${deckId}/actions/${encodeURIComponent(name)}`, { operationId: crypto.randomUUID(), payload: payload ?? {} })),
|
|
1656
|
+
respondTask: wrap((taskId, { action, payload, expectedHash }) => api("POST", `/decks/${deckId}/tasks/${encodeURIComponent(taskId)}/respond`, { operationId: crypto.randomUUID(), expectedHash: expectedHash ?? null, action, payload: payload ?? {} })),
|
|
1657
|
+
dirty: (d) => setStatus(null, d),
|
|
1658
|
+
};
|
|
1659
|
+
$frame.src = `${API}/decks/${deckId}/render/plan`;
|
|
1660
|
+
planFrame = $frame;
|
|
1661
|
+
const session = window.PlanBridge.connect($frame, { viewId: "plan", deckId, api: bridgeApi, onChrome: (chrome) => setViewChrome($frame, chrome), onDirty: (d) => setStatus(null, d) });
|
|
1662
|
+
bindViewChrome($frame, (event) => session.notify({ type: "chrome-event", event }));
|
|
1663
|
+
renderConflict(view, $body);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/** The header's own mark + wordmark, cloned for an empty state. */
|
|
1667
|
+
const brandMark = () => {
|
|
1668
|
+
const brand = document.querySelector("header .brand");
|
|
1669
|
+
return brand ? [...brand.children].map((n) => n.cloneNode(true)) : [];
|
|
1670
|
+
};
|
|
1671
|
+
|
|
1672
|
+
/** Hand the built .pptx to PowerPoint on this machine (the daemon owns the shell). */
|
|
1673
|
+
function openPptxButton(view) {
|
|
1674
|
+
const $b = h("button", { class: "mini primary", id: "open-pptx", "data-tip": view.pptx, onclick: async () => {
|
|
1675
|
+
$b.disabled = true;
|
|
1676
|
+
try {
|
|
1677
|
+
await api("POST", `/decks/${view.deck.id}/open-pptx`, {});
|
|
1678
|
+
toast("Opening in PowerPoint…");
|
|
1679
|
+
} catch (err) {
|
|
1680
|
+
toast(plainError(err), "bad");
|
|
1681
|
+
} finally {
|
|
1682
|
+
$b.disabled = false;
|
|
1683
|
+
}
|
|
1684
|
+
} }, "Open in PowerPoint");
|
|
1685
|
+
return $b;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function renderPreview(view, $body) {
|
|
1689
|
+
// no presentation.html on disk: the frame would show the API's 404 body
|
|
1690
|
+
if (!view.preview) {
|
|
1691
|
+
fill($body, h("div", { class: "empty", id: "preview-empty" }, h("div", { class: "brand" }, ...brandMark()), h("p", { class: "muted" }, "No preview has been created yet.")));
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
// a preview older than its slides is the review gate's to say (it knows whether the agent is listening) — never a second banner here
|
|
1695
|
+
// same-origin on purpose: the CLI's own preview page — its per-slide thumbnail frames need the session cookie, and the review gate inside it POSTs the decision
|
|
1696
|
+
const $frame = h("iframe", { class: "view", id: "preview-view", sandbox: "allow-scripts allow-same-origin allow-modals", title: "Preview", src: `${API}/decks/${view.deck.id}/preview/presentation.html` });
|
|
1697
|
+
fill($body, $frame);
|
|
1698
|
+
bindViewChrome($frame, (event) => $frame.contentWindow?.postMessage({ type: CHROME_EVENT, event }, location.origin));
|
|
1699
|
+
$frame.addEventListener("error", () => $body.prepend(h("p", { class: "error" }, "No preview built yet.")));
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* The words of the hand-over: the same after every answer (doc 21). The title
|
|
1704
|
+
* is the one thing to do; the line under it follows the copy icon — nothing is
|
|
1705
|
+
* copied until the user clicks it (the clipboard is theirs), and "Copied" only
|
|
1706
|
+
* when a copy path really succeeded.
|
|
1707
|
+
*/
|
|
1708
|
+
const pasteKey = () => (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || "") ? "⌘V" : "Ctrl+V");
|
|
1709
|
+
const HANDOFF_TITLE = "Paste this into your agent chat";
|
|
1710
|
+
const handoffLine = (copied) =>
|
|
1711
|
+
copied === true ? `Copied — press ${pasteKey()} there and send.` : copied === false ? "Select the message, copy it, paste it there and send." : `Copy it, press ${pasteKey()} there and send.`;
|
|
1712
|
+
/** The copy icon: a click copies, the icon turns into a check for a moment; `onCopied(ok)` reports the outcome. */
|
|
1713
|
+
function copyIcon(message, { onCopied = null } = {}) {
|
|
1714
|
+
const tip = (t) => {
|
|
1715
|
+
$copy.dataset.tip = t;
|
|
1716
|
+
$copy.setAttribute("aria-label", t === "Copy" ? "Copy message" : t);
|
|
1717
|
+
};
|
|
1718
|
+
const $copy = h("button", { class: "icon copy-icon", id: "copy-message", type: "button", "data-message": message, onclick: async () => {
|
|
1719
|
+
const ok = await copyText(message);
|
|
1720
|
+
onCopied?.(ok);
|
|
1721
|
+
if (!ok) return tip("Copy failed — select the message");
|
|
1722
|
+
$copy.replaceChildren(icon(ICON_CHECK));
|
|
1723
|
+
$copy.classList.add("is-copied");
|
|
1724
|
+
tip("Copied");
|
|
1725
|
+
clearTimeout($copy._reset);
|
|
1726
|
+
$copy._reset = setTimeout(() => {
|
|
1727
|
+
$copy.replaceChildren(icon(ICON_COPY));
|
|
1728
|
+
$copy.classList.remove("is-copied");
|
|
1729
|
+
tip("Copy");
|
|
1730
|
+
}, 2000);
|
|
1731
|
+
} }, icon(ICON_COPY));
|
|
1732
|
+
tip("Copy");
|
|
1733
|
+
return $copy;
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Once a task was answered here: the state, with the message's copy icon.
|
|
1737
|
+
* Nothing about whether an agent is running — the user does the same thing
|
|
1738
|
+
* every time.
|
|
1739
|
+
*/
|
|
1740
|
+
function doneNote(message) {
|
|
1741
|
+
return h("div", { class: "done handoff" }, h("p", { class: "ok", id: "task-done" }, h("strong", {}, "Answer saved")), message ? copyIcon(message) : null);
|
|
1742
|
+
}
|
|
1743
|
+
/** The hand-over dialog: the one thing to do and the message, its copy icon in the corner; `onClose` runs on Done / Escape. */
|
|
1744
|
+
function showHandoff({ message, onClose = null }) {
|
|
1745
|
+
document.getElementById("handoff-dialog")?.remove();
|
|
1746
|
+
const $msg = h("textarea", { class: "handoff-msg", id: "handoff-message", readonly: true, rows: 2 });
|
|
1747
|
+
$msg.value = message;
|
|
1748
|
+
const $line = h("p", { class: "big" }, handoffLine(null));
|
|
1749
|
+
const $copy = copyIcon(message, {
|
|
1750
|
+
onCopied: (ok) => {
|
|
1751
|
+
$line.textContent = handoffLine(ok);
|
|
1752
|
+
$dlg.dataset.copied = String(ok);
|
|
1753
|
+
if (!ok) $msg.select();
|
|
1754
|
+
},
|
|
1755
|
+
});
|
|
1756
|
+
const close = () => {
|
|
1757
|
+
$dlg.close();
|
|
1758
|
+
$dlg.remove();
|
|
1759
|
+
onClose?.();
|
|
1760
|
+
};
|
|
1761
|
+
const $dlg = h(
|
|
1762
|
+
"dialog",
|
|
1763
|
+
{ class: "dlg view-dialog handoff-dialog", id: "handoff-dialog", "data-copied": "none" },
|
|
1764
|
+
h("h3", {}, HANDOFF_TITLE),
|
|
1765
|
+
$line,
|
|
1766
|
+
h("div", { class: "msgbox" }, $msg, message ? $copy : null),
|
|
1767
|
+
h("div", { class: "row end" }, h("button", { class: "primary", id: "handoff-done", onclick: close }, "Done")),
|
|
1768
|
+
);
|
|
1769
|
+
$dlg.addEventListener("cancel", (e) => {
|
|
1770
|
+
e.preventDefault();
|
|
1771
|
+
close();
|
|
1772
|
+
});
|
|
1773
|
+
document.body.append($dlg);
|
|
1774
|
+
$dlg.showModal();
|
|
1775
|
+
// the whole message shows, however long its path: the box grows to fit it
|
|
1776
|
+
$msg.style.height = `${$msg.scrollHeight + 2}px`;
|
|
1777
|
+
// the next thing to do is the copy: the icon has the focus, so Enter copies
|
|
1778
|
+
if (message) $copy.focus();
|
|
1779
|
+
return $dlg;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/**
|
|
1783
|
+
* "Your turn" — the pending tasks of this phase, at the top of the tab, so the
|
|
1784
|
+
* human sees what is asked before the page below. A gate kind is answered in
|
|
1785
|
+
* the gate itself (approve / comments live there); other kinds carry their
|
|
1786
|
+
* buttons here.
|
|
1787
|
+
*/
|
|
1788
|
+
function renderYourTurn(view, $body, tab) {
|
|
1789
|
+
const tabs = tabsFor(view);
|
|
1790
|
+
const mine = newestFirst(view.tasks.filter((t) => t.status === "pending" && (tab === null || tabOf(t, tabs) === tab)));
|
|
1791
|
+
if (!mine.length) return;
|
|
1792
|
+
const $box = h(
|
|
1793
|
+
"div",
|
|
1794
|
+
{ class: "your-turn", id: "your-turn" },
|
|
1795
|
+
mine.map((t) => {
|
|
1796
|
+
const ph = phaseOf(t.kind);
|
|
1797
|
+
const $out = h("div", { class: "task-result" });
|
|
1798
|
+
return h(
|
|
1799
|
+
"div",
|
|
1800
|
+
{ class: `task turn${t.id === current.taskId ? " mine" : ""}`, id: `task-${t.id}` },
|
|
1801
|
+
// a core deck's gate frame shows the agent's state as its own badge right above: not twice
|
|
1802
|
+
h("div", { class: "card-head" }, h("span", { class: "kind" }, `Your turn · ${ph.label}`), h("span", { class: "path" }, fmtTime(t.createdAt))),
|
|
1803
|
+
h("p", { class: "ask" }, ph.gate && view.adapter?.id !== "core" ? ph.ask.replace(/ in the review below\.| in the panel below\./, ".") : ph.ask),
|
|
1804
|
+
// a core deck answers a gate kind inside the embedded gate; a pack's page may not carry approve controls, so its card keeps the buttons
|
|
1805
|
+
ph.gate && view.adapter?.id === "core" ? null : h("div", { class: "actions row" }, (t.actions ?? []).map((a) => h("button", { "data-action": a, class: a === "approve" || a === "done" ? "primary" : null, onclick: (e) => respond(view, t, a, e.target, $out) }, answerLabel(a)))),
|
|
1806
|
+
// put it away without answering it — the agent that asked is long gone
|
|
1807
|
+
h("button", { class: "icon dismiss", "data-dismiss": t.id, "data-tip": "Put this question away without answering", "aria-label": "Put this question away without answering", onclick: () => dismiss(view, t) }, icon('<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/>')),
|
|
1808
|
+
$out,
|
|
1809
|
+
);
|
|
1810
|
+
}),
|
|
1811
|
+
);
|
|
1812
|
+
$body.prepend($box);
|
|
1813
|
+
}
|
|
1814
|
+
/** Redraw only the "your turn" box from a fresh deck view (the gate frame stays). */
|
|
1815
|
+
function redrawYourTurn(view) {
|
|
1816
|
+
if (current?.kind !== "deck") return;
|
|
1817
|
+
const $body = document.getElementById("tab-body");
|
|
1818
|
+
if (!$body) return;
|
|
1819
|
+
const old = document.getElementById("your-turn");
|
|
1820
|
+
// keep a box that is showing an answer's note until the user moves on
|
|
1821
|
+
if (old && old.querySelector("#task-done")) return;
|
|
1822
|
+
if (old) old.remove();
|
|
1823
|
+
renderYourTurn(view, $body, current.tab);
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* An answered card is not a question any more: the ask, the buttons and the
|
|
1827
|
+
* dismiss go, and what is left is one row saying what to do now.
|
|
1828
|
+
*/
|
|
1829
|
+
function settleCard(task) {
|
|
1830
|
+
const $card = document.getElementById(`task-${task.id}`);
|
|
1831
|
+
if (!$card) return;
|
|
1832
|
+
$card.classList.add("answered");
|
|
1833
|
+
for (const sel of [".ask", ".actions", ".dismiss"]) $card.querySelector(sel)?.remove();
|
|
1834
|
+
$card.querySelector(".card-head .kind")?.after(chip("Saved", "ok"));
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/** Dismiss: the ask is put away (cancelled) without an answer, after the page's own confirm. */
|
|
1838
|
+
function dismiss(view, task) {
|
|
1839
|
+
dialog({
|
|
1840
|
+
title: "Put this question away?",
|
|
1841
|
+
hint: "Your agent asked this earlier. It will not get an answer to it.",
|
|
1842
|
+
input: false,
|
|
1843
|
+
danger: true,
|
|
1844
|
+
submit: "Put away",
|
|
1845
|
+
run: async () => {
|
|
1846
|
+
await api("POST", `/decks/${view.deck.id}/tasks/${encodeURIComponent(task.id)}/dismiss`, { operationId: crypto.randomUUID() });
|
|
1847
|
+
document.getElementById(`task-${task.id}`)?.remove();
|
|
1848
|
+
},
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/** Ask the plan frame to save what is still debounced, then wait for the save. */
|
|
1853
|
+
async function settlePlanEdits() {
|
|
1854
|
+
if (planFrame && window.PlanBridge?.notify(planFrame, { type: "flush" })) await new Promise((r) => setTimeout(r, 150));
|
|
1855
|
+
while (planSaving) await planSaving.catch(() => {});
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
async function respond(view, task, action, $btn, $out) {
|
|
1859
|
+
if (action === "comment" || action === "request-changes") {
|
|
1860
|
+
return dialog({ title: actionLabel(action), hint: "Your note goes to the agent with the answer.", placeholder: "What should change?", submit: actionLabel(action), multiline: true, run: (comment) => send({ comment }) });
|
|
1861
|
+
}
|
|
1862
|
+
return send({});
|
|
1863
|
+
async function send(payload) {
|
|
1864
|
+
for (const b of $btn.parentElement.querySelectorAll("button")) b.disabled = true;
|
|
1865
|
+
try {
|
|
1866
|
+
// Edits typed just before the click land first; the task follows them
|
|
1867
|
+
// on the server, so the answer covers the plan as it is on screen.
|
|
1868
|
+
await settlePlanEdits();
|
|
1869
|
+
const r = await api("POST", `/decks/${view.deck.id}/tasks/${encodeURIComponent(task.id)}/respond`, { operationId: crypto.randomUUID(), expectedHash: task.subject?.hash ?? null, action, payload });
|
|
1870
|
+
answered.add(task.id);
|
|
1871
|
+
settleCard(task);
|
|
1872
|
+
// the same hand-over after every answer: the message to paste, copied only by the user's own click
|
|
1873
|
+
const message = r.message ?? "";
|
|
1874
|
+
$out.replaceChildren(doneNote(message));
|
|
1875
|
+
showHandoff({ message });
|
|
1876
|
+
} catch (err) {
|
|
1877
|
+
for (const b of $btn.parentElement.querySelectorAll("button")) b.disabled = false;
|
|
1878
|
+
if (err.code === "stale_subject") $out.replaceChildren(h("p", { class: "error", "data-code": err.code }, "Your agent changed this since it asked — showing the new version."));
|
|
1879
|
+
else $out.replaceChildren(errorBox(err));
|
|
1880
|
+
if (err.code === "stale_subject") setTimeout(refreshDeckChrome, 1500);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
boot();
|
|
1886
|
+
})();
|