@oharness/server 0.7.0 → 0.8.0
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/dist/browser-pane.d.ts +48 -0
- package/dist/browser-pane.js +185 -0
- package/dist/browser-pane.js.map +1 -0
- package/dist/i18n.js +144 -0
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +20 -0
- package/dist/main.js.map +1 -1
- package/dist/page/client/account.d.ts +8 -0
- package/dist/page/client/account.js +1895 -0
- package/dist/page/client/account.js.map +1 -0
- package/dist/page/client/artifacts.d.ts +7 -0
- package/dist/page/client/artifacts.js +767 -0
- package/dist/page/client/artifacts.js.map +1 -0
- package/dist/page/client/base.d.ts +8 -0
- package/dist/page/client/base.js +400 -0
- package/dist/page/client/base.js.map +1 -0
- package/dist/page/client/browser.d.ts +21 -0
- package/dist/page/client/browser.js +904 -0
- package/dist/page/client/browser.js.map +1 -0
- package/dist/page/client/composer.d.ts +8 -0
- package/dist/page/client/composer.js +1694 -0
- package/dist/page/client/composer.js.map +1 -0
- package/dist/page/client/dialogs.d.ts +7 -0
- package/dist/page/client/dialogs.js +724 -0
- package/dist/page/client/dialogs.js.map +1 -0
- package/dist/page/client/events.d.ts +7 -0
- package/dist/page/client/events.js +288 -0
- package/dist/page/client/events.js.map +1 -0
- package/dist/page/client/index.d.ts +16 -0
- package/dist/page/client/index.js +46 -0
- package/dist/page/client/index.js.map +1 -0
- package/dist/page/client/panels.d.ts +7 -0
- package/dist/page/client/panels.js +659 -0
- package/dist/page/client/panels.js.map +1 -0
- package/dist/page/client/pickers.d.ts +7 -0
- package/dist/page/client/pickers.js +624 -0
- package/dist/page/client/pickers.js.map +1 -0
- package/dist/page/client/routines.d.ts +6 -0
- package/dist/page/client/routines.js +910 -0
- package/dist/page/client/routines.js.map +1 -0
- package/dist/page/client/shell.d.ts +6 -0
- package/dist/page/client/shell.js +690 -0
- package/dist/page/client/shell.js.map +1 -0
- package/dist/page/client/state.d.ts +7 -0
- package/dist/page/client/state.js +1275 -0
- package/dist/page/client/state.js.map +1 -0
- package/dist/page/client/transcript.d.ts +8 -0
- package/dist/page/client/transcript.js +699 -0
- package/dist/page/client/transcript.js.map +1 -0
- package/dist/page/client/usage.d.ts +7 -0
- package/dist/page/client/usage.js +954 -0
- package/dist/page/client/usage.js.map +1 -0
- package/dist/page/style.d.ts +1 -0
- package/dist/page/style.js +2793 -0
- package/dist/page/style.js.map +1 -0
- package/dist/page.js +70 -13583
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +24 -0
- package/dist/server.js +511 -25
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remaining side panels — every artifact, the counts in the sidebar,
|
|
3
|
+
* plugin pages and the memory page — and the navigation between them.
|
|
4
|
+
*
|
|
5
|
+
* One part of the client script; see ./index.ts for how the parts join.
|
|
6
|
+
*/
|
|
7
|
+
export const PANELS = String.raw `
|
|
8
|
+
async function showAllArtifacts() {
|
|
9
|
+
const body = await getJson("/artifacts/all");
|
|
10
|
+
panelBody.replaceChildren();
|
|
11
|
+
if (body.error) return void panelBody.appendChild(panelError(body.error));
|
|
12
|
+
artifactKey = body.viewKey || artifactKey;
|
|
13
|
+
|
|
14
|
+
const panel = el("div", "panel");
|
|
15
|
+
panel.appendChild(el("h2", null, t("nav.artifacts")));
|
|
16
|
+
|
|
17
|
+
const groups = body.sessions || [];
|
|
18
|
+
if (!groups.length) {
|
|
19
|
+
panel.appendChild(el("p", "muted", t("artifacts.none")));
|
|
20
|
+
panel.appendChild(el("p", "muted", t("artifacts.what")));
|
|
21
|
+
panelBody.appendChild(panel);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Flattened once, with each row remembering the session it came from: the
|
|
26
|
+
// grouping is drawn from this order rather than iterated from the groups,
|
|
27
|
+
// so a filtered list keeps its headings without a second pass.
|
|
28
|
+
const rows = [];
|
|
29
|
+
for (const group of groups) {
|
|
30
|
+
for (const artifact of group.artifacts) rows.push({ group, artifact });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const search = el("input", "art-search");
|
|
34
|
+
search.type = "search";
|
|
35
|
+
search.placeholder = t("artifacts.search");
|
|
36
|
+
search.spellcheck = false;
|
|
37
|
+
search.autocomplete = "off";
|
|
38
|
+
panel.appendChild(search);
|
|
39
|
+
|
|
40
|
+
const summary = el("div", "muted small art-summary");
|
|
41
|
+
panel.appendChild(summary);
|
|
42
|
+
|
|
43
|
+
const list = el("div");
|
|
44
|
+
panel.appendChild(list);
|
|
45
|
+
|
|
46
|
+
panel.appendChild(el("p", "muted small art-note", t("artifacts.fromLogs")));
|
|
47
|
+
if (body.more) {
|
|
48
|
+
panel.appendChild(el("p", "muted small art-note", t("artifacts.more", { n: body.more })));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Every keystroke redraws the list, which is affordable because the rows are
|
|
52
|
+
// already in memory: this is a filter over an array, not a request.
|
|
53
|
+
search.addEventListener("input", () => draw(search.value));
|
|
54
|
+
draw("");
|
|
55
|
+
panelBody.appendChild(panel);
|
|
56
|
+
// The panel is reached by a deliberate click, and the first thing anybody
|
|
57
|
+
// does with a list this long is narrow it.
|
|
58
|
+
search.focus();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether one row answers what was typed.
|
|
62
|
+
*
|
|
63
|
+
* Every word has to match something, and the haystack is everything the row
|
|
64
|
+
* shows plus the id the model chose: "chart tmp" finds the chart made in the
|
|
65
|
+
* tmp project, which is how somebody actually remembers an artifact.
|
|
66
|
+
*/
|
|
67
|
+
function matches(entry, terms) {
|
|
68
|
+
const hay = [
|
|
69
|
+
entry.artifact.title,
|
|
70
|
+
entry.artifact.id,
|
|
71
|
+
entry.artifact.kind,
|
|
72
|
+
entry.artifact.language || "",
|
|
73
|
+
entry.group.title || "",
|
|
74
|
+
].join(" ").toLowerCase();
|
|
75
|
+
return terms.every((term) => hay.indexOf(term) >= 0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function draw(query) {
|
|
79
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
80
|
+
const found = terms.length ? rows.filter((entry) => matches(entry, terms)) : rows;
|
|
81
|
+
|
|
82
|
+
// Only while filtering. Unfiltered the count is already in the sidebar,
|
|
83
|
+
// and a line saying it twice is a line that says nothing.
|
|
84
|
+
summary.textContent = terms.length
|
|
85
|
+
? t("artifacts.showing", { n: found.length, total: rows.length })
|
|
86
|
+
: "";
|
|
87
|
+
|
|
88
|
+
list.replaceChildren();
|
|
89
|
+
if (!found.length) {
|
|
90
|
+
list.appendChild(el("p", "muted", t("artifacts.noMatch", { q: query.trim() })));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let session = null;
|
|
95
|
+
for (const entry of found) {
|
|
96
|
+
if (entry.group.session !== session) {
|
|
97
|
+
session = entry.group.session;
|
|
98
|
+
list.appendChild(sessionHeading(entry.group));
|
|
99
|
+
}
|
|
100
|
+
list.appendChild(artifactRow(entry.group, entry.artifact));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Which session the rows under it belong to, and when it was last touched. */
|
|
105
|
+
function sessionHeading(group) {
|
|
106
|
+
const heading = el("div", "art-group");
|
|
107
|
+
heading.appendChild(el("span", null, group.title || t("session.untitled")));
|
|
108
|
+
if (group.current) heading.appendChild(el("span", "tag", t("artifacts.thisSession")));
|
|
109
|
+
if (group.updatedAt) heading.appendChild(el("span", "muted art-when", ago(group.updatedAt)));
|
|
110
|
+
return heading;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function artifactRow(group, artifact) {
|
|
114
|
+
const row = el("div", "row-card");
|
|
115
|
+
const head = el("div", "row-head");
|
|
116
|
+
|
|
117
|
+
const mark = el("span", "art-card-mark");
|
|
118
|
+
mark.appendChild(kindIcon(artifact.kind));
|
|
119
|
+
head.appendChild(mark);
|
|
120
|
+
head.appendChild(el("span", "row-title", artifact.title));
|
|
121
|
+
|
|
122
|
+
const parts = [artifact.kind, formatBytes(artifact.bytes)];
|
|
123
|
+
if (artifact.version > 1) parts.push("v" + artifact.version);
|
|
124
|
+
if (artifact.at) parts.push(ago(artifact.at));
|
|
125
|
+
head.appendChild(el("span", "muted small", parts.join(" · ")));
|
|
126
|
+
// Said here because the panel is where someone goes looking for a link
|
|
127
|
+
// they shared, and a published artifact is the only kind that outlives
|
|
128
|
+
// this process.
|
|
129
|
+
if (artifact.publication) head.appendChild(el("span", "tag", "published"));
|
|
130
|
+
|
|
131
|
+
const open = el("button", "ghost", t("artifacts.open"));
|
|
132
|
+
open.type = "button";
|
|
133
|
+
open.addEventListener("click", () => void openArtifactFrom(group.session, artifact.id));
|
|
134
|
+
head.appendChild(open);
|
|
135
|
+
|
|
136
|
+
row.appendChild(head);
|
|
137
|
+
row.addEventListener("click", (event) => {
|
|
138
|
+
if (event.target !== open) void openArtifactFrom(group.session, artifact.id);
|
|
139
|
+
});
|
|
140
|
+
return row;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Leave the panel and show one artifact, switching sessions if it is elsewhere. */
|
|
145
|
+
async function openArtifactFrom(session, id) {
|
|
146
|
+
if (session !== activeId) await openSession(session);
|
|
147
|
+
setNav(null);
|
|
148
|
+
shelf.classList.add("open");
|
|
149
|
+
// The list the shelf works from belongs to whichever session is now current,
|
|
150
|
+
// so it is reloaded before asking it for this id.
|
|
151
|
+
await loadArtifacts();
|
|
152
|
+
artifactVersion.delete(id);
|
|
153
|
+
await showArtifact(id);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function refreshArtifactCount() {
|
|
157
|
+
const body = await getJson("/artifacts/all");
|
|
158
|
+
const count = document.getElementById("artifact-count");
|
|
159
|
+
if (!count) return;
|
|
160
|
+
const total = (body.sessions || []).reduce((sum, group) => sum + group.artifacts.length, 0);
|
|
161
|
+
count.textContent = total ? String(total) : "";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function refreshRoutineCount() {
|
|
165
|
+
const { routines } = await getJson("/routines");
|
|
166
|
+
const count = document.getElementById("routine-count");
|
|
167
|
+
count.textContent = routines && routines.length ? String(routines.length) : "";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Sidebar entries that open a view of their own rather than a conversation. */
|
|
171
|
+
const PANEL_NAV = new Set(["nav-artifacts", "nav-models", "nav-routines", "nav-settings"]);
|
|
172
|
+
const pluginPages = new Map();
|
|
173
|
+
async function refreshPluginPages() {
|
|
174
|
+
const session = activeId;
|
|
175
|
+
const result = await getJson("/plugin-pages?session=" + encodeURIComponent(session || ""));
|
|
176
|
+
if (activeId !== session) return;
|
|
177
|
+
for (const id of pluginPages.keys()) { document.getElementById("nav-plugin-" + id)?.remove(); PANEL_NAV.delete("nav-plugin-" + id); }
|
|
178
|
+
pluginPages.clear();
|
|
179
|
+
/*
|
|
180
|
+
* Remembered, not put in the sidebar.
|
|
181
|
+
*
|
|
182
|
+
* A plugin page is reached from a row in Settings instead. The sidebar is
|
|
183
|
+
* the set of places a session goes back to, and Memory is not one of them —
|
|
184
|
+
* it is read now and then and adjusted less often than that, which is the
|
|
185
|
+
* same reason usage lives behind a Settings row rather than beside Models.
|
|
186
|
+
* Every one of these becomes a SUB_PANEL, so the Settings row it came from
|
|
187
|
+
* stays lit while it is open.
|
|
188
|
+
*/
|
|
189
|
+
for (const page of result.pages || []) {
|
|
190
|
+
if (page.renderer !== "memory") continue;
|
|
191
|
+
pluginPages.set(page.id, page);
|
|
192
|
+
SUB_PANELS["nav-plugin-" + page.id] = "nav-settings";
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Ids for label/control pairs; only has to be unique within the document. */
|
|
197
|
+
let fieldSeq = 0;
|
|
198
|
+
|
|
199
|
+
async function showMemoryPage(id) {
|
|
200
|
+
const session = activeId;
|
|
201
|
+
const result = await getJson("/plugin-page?id=" + encodeURIComponent(id) + "&session=" + encodeURIComponent(session || ""));
|
|
202
|
+
if (shownPanel !== "nav-plugin-" + id || activeId !== session) return;
|
|
203
|
+
panelBody.replaceChildren(); panelNotes.replaceChildren();
|
|
204
|
+
if (result.error && !result.entries) return void panelBody.appendChild(panelError(result.error));
|
|
205
|
+
const panel = el("div", "panel memory-panel"); panelBody.appendChild(panel);
|
|
206
|
+
panel.appendChild(el("h2", "", t("memory.title")));
|
|
207
|
+
/*
|
|
208
|
+
* One card for what a visit is for: is sync working, how many notes there
|
|
209
|
+
* are, and what to do next. The state is said once, in words, with a line
|
|
210
|
+
* on what it means; an error sits inside it rather than in a separate
|
|
211
|
+
* "could not load" box, which read as the page itself being broken. The
|
|
212
|
+
* same message from both scopes is shown once.
|
|
213
|
+
*/
|
|
214
|
+
const failures = [];
|
|
215
|
+
for (const failure of (result.errors && result.errors.length ? result.errors : result.error ? [{ message: result.error }] : [])) {
|
|
216
|
+
const same = failures.find((one) => memoryText(one) === memoryText(failure));
|
|
217
|
+
if (same) same.scope = "both"; else failures.push({ ...failure });
|
|
218
|
+
}
|
|
219
|
+
const needsSignIn = !result.signedIn || failures.some((one) => one.code === "signInFirst" || one.code === "signInAgain");
|
|
220
|
+
const state = !result.signedIn ? "local" : result.accountChanged ? "account" : !result.enabled ? "off" : result.syncing ? "syncing" : failures.length ? "error" : "on";
|
|
221
|
+
const summaryCard = el("section", "row-card memory-summary");
|
|
222
|
+
panel.appendChild(summaryCard);
|
|
223
|
+
const status = el("div", "memory-state", t("memory.state." + state));
|
|
224
|
+
summaryCard.appendChild(status);
|
|
225
|
+
summaryCard.appendChild(el("p", "muted small", t("memory.stateHint." + state)));
|
|
226
|
+
for (const failure of failures) {
|
|
227
|
+
const text = memoryText(failure);
|
|
228
|
+
const message = !result.userAvailable || !failure.scope || failure.scope === "both" ? text
|
|
229
|
+
: failure.scope === "user" ? t("memory.errorUser", { message: text })
|
|
230
|
+
: t("memory.errorProject", { message: text });
|
|
231
|
+
summaryCard.appendChild(el("p", "small memory-error", message));
|
|
232
|
+
}
|
|
233
|
+
const facts = [];
|
|
234
|
+
const projectCount = result.entries.filter((entry) => entry.scope !== "user").length;
|
|
235
|
+
facts.push(result.userAvailable
|
|
236
|
+
? t("memory.countsScoped", { project: projectCount, user: result.entries.length - projectCount })
|
|
237
|
+
: t("memory.counts", { count: result.entries.length }));
|
|
238
|
+
// The ceiling is only news when it is close.
|
|
239
|
+
if (result.limits && result.entries.length > result.limits.maxNotes * 0.8) facts.push(t("memory.usage", { count: result.entries.length, max: result.limits.maxNotes }));
|
|
240
|
+
if (result.lastSync) facts.push(t("memory.lastSync", { when: new Date(result.lastSync).toLocaleString(LOCALE) }));
|
|
241
|
+
summaryCard.appendChild(el("p", "muted small", facts.join(" · ")));
|
|
242
|
+
if (result.omitted) summaryCard.appendChild(el("p", "muted small", t("memory.omitted", { count: result.omitted })));
|
|
243
|
+
if (result.quota && result.quota.tier === "free" && result.quota.maxNotes) {
|
|
244
|
+
summaryCard.appendChild(el("p", "muted small", t("memory.tierFree", { notes: result.quota.maxNotes, kb: Math.max(1, Math.round((result.quota.maxNoteBytes || 0) / 1024)) })));
|
|
245
|
+
}
|
|
246
|
+
// Where the files are, and how sync behaves, is looked up once; folded away by default.
|
|
247
|
+
const about = el("details", "memory-about small");
|
|
248
|
+
about.appendChild(el("summary", "muted", t("memory.aboutStorage")));
|
|
249
|
+
about.appendChild(el("p", "muted", t("memory.intro")));
|
|
250
|
+
if (result.locations && result.locations.user) {
|
|
251
|
+
about.appendChild(el("p", "muted small", t("memory.projectLocation")));
|
|
252
|
+
about.appendChild(el("p", "mono small memory-path", result.locations.project));
|
|
253
|
+
about.appendChild(el("p", "muted small", t("memory.userLocation")));
|
|
254
|
+
about.appendChild(el("p", "mono small memory-path", result.locations.user));
|
|
255
|
+
} else about.appendChild(el("p", "mono small memory-path", result.location));
|
|
256
|
+
panel.appendChild(about);
|
|
257
|
+
for (const pull of result.recent || []) {
|
|
258
|
+
const details = el("details", "memory-recent small");
|
|
259
|
+
details.appendChild(el("summary", "", t("memory.recentPulled", {
|
|
260
|
+
when: new Date(pull.at).toLocaleString(LOCALE), created: pull.created.length, updated: pull.updated.length, deleted: pull.deleted.length, merged: (pull.merged || []).length,
|
|
261
|
+
})));
|
|
262
|
+
for (const [key, notes] of [["memory.recentCreated", pull.created], ["memory.recentUpdated", pull.updated], ["memory.recentDeleted", pull.deleted], ["memory.recentMerged", pull.merged || []]]) {
|
|
263
|
+
for (const note of notes) details.appendChild(el("div", "muted", t(key, { summary: note.summary })));
|
|
264
|
+
}
|
|
265
|
+
panel.appendChild(details);
|
|
266
|
+
}
|
|
267
|
+
const act = async (input) => {
|
|
268
|
+
try {
|
|
269
|
+
const reply = await api("/plugin-page", { page: id, session, input }, 120000);
|
|
270
|
+
if (reply.error) throw Object.assign(new Error(reply.error), { code: reply.code, params: reply.params });
|
|
271
|
+
await showMemoryPage(id);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (activeId === session && shownPanel === "nav-plugin-" + id) {
|
|
274
|
+
await showMemoryPage(id);
|
|
275
|
+
// In the summary card, in the page's language. The generic "could not
|
|
276
|
+
// load" box read as the page being broken, and its hint about the
|
|
277
|
+
// server's URL token has nothing to do with a refused action.
|
|
278
|
+
const note = el("p", "small memory-error", memoryText(error));
|
|
279
|
+
const card = panelBody.querySelector(".memory-summary");
|
|
280
|
+
(card || panelBody).appendChild(note);
|
|
281
|
+
note.scrollIntoView({ block: "nearest" });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const controls = el("div", "memory-actions");
|
|
286
|
+
if (needsSignIn) {
|
|
287
|
+
const signIn = el("button", "signin-site", t("memory.signIn")); signIn.type = "button";
|
|
288
|
+
signIn.onclick = async () => { const account = await getJson("/account"); openConnect(account && account.provider); };
|
|
289
|
+
controls.appendChild(signIn);
|
|
290
|
+
}
|
|
291
|
+
const toggle = el("button", "ghost", result.enabled ? t("memory.turnOff") : t("memory.turnOn"));
|
|
292
|
+
toggle.setAttribute("role", "switch"); toggle.setAttribute("aria-label", t("memory.switchLabel")); toggle.setAttribute("aria-checked", String(result.enabled));
|
|
293
|
+
toggle.disabled = !result.enabled && (!result.signedIn || result.accountChanged);
|
|
294
|
+
toggle.onclick = async () => { toggle.disabled = true; await act({ action: "enable", enabled: !result.enabled }); toggle.disabled = false; };
|
|
295
|
+
// Signed out, "turn off cloud sync" is a switch for something that is not
|
|
296
|
+
// happening; the sign-in button is the action that means something.
|
|
297
|
+
if (result.signedIn || !result.enabled) controls.appendChild(toggle);
|
|
298
|
+
if (result.signedIn) {
|
|
299
|
+
const sync = el("button", "ghost", t("memory.syncNow")); sync.disabled = !result.ready || result.syncing;
|
|
300
|
+
sync.onclick = async () => { sync.disabled = true; status.textContent = t("memory.syncing"); await act({ action: "sync" }); sync.disabled = false; };
|
|
301
|
+
controls.appendChild(sync);
|
|
302
|
+
}
|
|
303
|
+
const refresh = el("button", "ghost", t("memory.refresh")); refresh.onclick = () => { void showMemoryPage(id); }; controls.appendChild(refresh);
|
|
304
|
+
summaryCard.appendChild(controls);
|
|
305
|
+
// A conflict is what sync could not combine: both sides changed the same part.
|
|
306
|
+
// All three versions are shown, and whatever is set aside is backed up.
|
|
307
|
+
for (const conflict of result.conflicts || []) {
|
|
308
|
+
const row = el("section", "row-card memory-conflict");
|
|
309
|
+
row.appendChild(el("strong", "", t("memory.conflict", { id: conflict.id })));
|
|
310
|
+
const fields = (conflict.fields || []).map((field) => t("memory.field." + field)).join(t("memory.fieldSeparator"));
|
|
311
|
+
if (fields) row.appendChild(el("p", "muted small", t("memory.conflictFields", { fields })));
|
|
312
|
+
const versions = el("div", "memory-versions");
|
|
313
|
+
const shown = [[t("memory.local"), conflict.local], [t("memory.cloud"), conflict.cloud]];
|
|
314
|
+
if (conflict.base) shown.unshift([t("memory.base"), conflict.base]);
|
|
315
|
+
for (const [label, note] of shown) {
|
|
316
|
+
const column = el("div", "memory-version");
|
|
317
|
+
column.appendChild(el("h4", "small", label));
|
|
318
|
+
const text = el("pre", "small", note ? note.summary + "\n\n" + note.body + (note.tags && note.tags.length ? "\n\n" + note.tags.join(", ") : "") : t("memory.deleted"));
|
|
319
|
+
text.style.whiteSpace = "pre-wrap";
|
|
320
|
+
column.appendChild(text);
|
|
321
|
+
versions.appendChild(column);
|
|
322
|
+
}
|
|
323
|
+
row.appendChild(versions);
|
|
324
|
+
const actions = el("div", "memory-actions");
|
|
325
|
+
for (const [choice, label] of [["local", t("memory.keepLocal")], ["cloud", t("memory.takeCloud")], ["both", t("memory.keepBoth")]]) {
|
|
326
|
+
if (choice === "both" && !(conflict.local && conflict.cloud)) continue;
|
|
327
|
+
const button = el("button", "ghost", label); button.type = "button";
|
|
328
|
+
button.onclick = () => { void act({ action: "resolve", scope: conflict.scope, id: conflict.id, choice }); };
|
|
329
|
+
actions.appendChild(button);
|
|
330
|
+
}
|
|
331
|
+
const byHand = el("button", "ghost", t("memory.mergeByHand")); byHand.type = "button";
|
|
332
|
+
actions.appendChild(byHand);
|
|
333
|
+
row.appendChild(actions);
|
|
334
|
+
const start = conflict.local || conflict.cloud || { summary: "", body: "", tags: [] };
|
|
335
|
+
const form = el("form", "memory-editor"); form.hidden = true;
|
|
336
|
+
const summary = el("input", ""); summary.value = start.summary; summary.required = true; summary.setAttribute("aria-label", t("memory.summaryLabel"));
|
|
337
|
+
const body = el("textarea", ""); body.value = start.body; body.rows = 8; body.required = true; body.setAttribute("aria-label", t("memory.bodyLabel"));
|
|
338
|
+
const tags = el("input", ""); tags.value = (start.tags || []).join(", "); tags.setAttribute("aria-label", t("memory.tagsLabel"));
|
|
339
|
+
for (const control of [summary, body, tags]) {
|
|
340
|
+
control.style.cssText = "display:block;width:100%;box-sizing:border-box;margin:8px 0";
|
|
341
|
+
form.appendChild(control);
|
|
342
|
+
}
|
|
343
|
+
const save = el("button", "ghost", t("memory.mergeSave")); save.type = "submit";
|
|
344
|
+
form.appendChild(save);
|
|
345
|
+
form.onsubmit = async (event) => {
|
|
346
|
+
event.preventDefault(); save.disabled = true;
|
|
347
|
+
await act({ action: "resolve", scope: conflict.scope, id: conflict.id, choice: "manual", note: {
|
|
348
|
+
summary: summary.value.trim(), body: body.value.trim(), tags: tags.value.split(/[,,\s]+/).filter(Boolean), type: start.type || "",
|
|
349
|
+
} });
|
|
350
|
+
save.disabled = false;
|
|
351
|
+
};
|
|
352
|
+
byHand.onclick = () => { form.hidden = !form.hidden; };
|
|
353
|
+
row.appendChild(form);
|
|
354
|
+
panel.appendChild(row);
|
|
355
|
+
}
|
|
356
|
+
// Tidying: what a review of the notes found, and a merge waiting to be applied.
|
|
357
|
+
const labelOf = (scope, id) => {
|
|
358
|
+
const found = result.entries.find((entry) => entry.id === id && entry.scope === scope);
|
|
359
|
+
return found ? found.summary : id;
|
|
360
|
+
};
|
|
361
|
+
if (result.proposal) {
|
|
362
|
+
const proposal = result.proposal;
|
|
363
|
+
const card = el("section", "row-card memory-conflict");
|
|
364
|
+
card.appendChild(el("strong", "", t("memory.mergeTitle", { count: proposal.ids.length })));
|
|
365
|
+
card.appendChild(el("p", "muted small", proposal.source === "model" ? t("memory.mergeFromModel") : t("memory.mergeByHandHint")));
|
|
366
|
+
const sources = el("ul", "muted small");
|
|
367
|
+
proposal.ids.forEach((id, index) => {
|
|
368
|
+
sources.appendChild(el("li", "", (index === 0 ? t("memory.mergeKeeps") : t("memory.mergeRemoves")) + labelOf(proposal.scope, id)));
|
|
369
|
+
});
|
|
370
|
+
card.appendChild(sources);
|
|
371
|
+
const form = el("form", "memory-editor");
|
|
372
|
+
const summary = el("input", ""); summary.value = proposal.draft.summary; summary.required = true; summary.setAttribute("aria-label", t("memory.summaryLabel"));
|
|
373
|
+
const body = el("textarea", ""); body.value = proposal.draft.body; body.rows = 10; body.required = true; body.setAttribute("aria-label", t("memory.bodyLabel"));
|
|
374
|
+
const tags = el("input", ""); tags.value = (proposal.draft.tags || []).join(", "); tags.setAttribute("aria-label", t("memory.tagsLabel"));
|
|
375
|
+
for (const control of [summary, body, tags]) {
|
|
376
|
+
control.style.cssText = "display:block;width:100%;box-sizing:border-box;margin:8px 0";
|
|
377
|
+
form.appendChild(control);
|
|
378
|
+
}
|
|
379
|
+
const mergeActions = el("div", "memory-actions");
|
|
380
|
+
const apply = el("button", "ghost", t("memory.mergeApply")); apply.type = "submit"; mergeActions.appendChild(apply);
|
|
381
|
+
const cancel = el("button", "ghost", t("memory.mergeCancel")); cancel.type = "button";
|
|
382
|
+
cancel.onclick = () => { void act({ action: "cancelMerge" }); };
|
|
383
|
+
mergeActions.appendChild(cancel);
|
|
384
|
+
form.appendChild(mergeActions);
|
|
385
|
+
form.onsubmit = async (event) => {
|
|
386
|
+
event.preventDefault();
|
|
387
|
+
if (!confirm(t("memory.mergeConfirm"))) return;
|
|
388
|
+
apply.disabled = true;
|
|
389
|
+
await act({ action: "merge", note: { summary: summary.value.trim(), body: body.value.trim(), tags: tags.value.split(/[,,\s]+/).filter(Boolean), type: proposal.draft.type || "" } });
|
|
390
|
+
apply.disabled = false;
|
|
391
|
+
};
|
|
392
|
+
card.appendChild(form);
|
|
393
|
+
panel.appendChild(card);
|
|
394
|
+
}
|
|
395
|
+
const findings = result.review || [];
|
|
396
|
+
const consolidate = result.consolidate || {};
|
|
397
|
+
const tidy = el("details", "row-card memory-note");
|
|
398
|
+
const tidyHead = el("summary", "");
|
|
399
|
+
tidyHead.appendChild(el("span", "chev"));
|
|
400
|
+
tidyHead.appendChild(el("span", "memory-note-name", t("memory.review", { count: result.reviewTotal || findings.length })));
|
|
401
|
+
tidy.appendChild(tidyHead);
|
|
402
|
+
tidy.appendChild(el("p", "muted small", consolidate.auto ? t("memory.reviewAutoOn") : t("memory.reviewIntro")));
|
|
403
|
+
if (consolidate.lastAuto) {
|
|
404
|
+
tidy.appendChild(el("p", "muted small", t("memory.reviewLastAuto", { when: new Date(consolidate.lastAuto.at).toLocaleString(LOCALE), count: consolidate.lastAuto.merged.length })));
|
|
405
|
+
}
|
|
406
|
+
if (!findings.length) tidy.appendChild(el("p", "muted", t("memory.reviewNone")));
|
|
407
|
+
for (const finding of findings) {
|
|
408
|
+
const row = el("div", "memory-backup");
|
|
409
|
+
const text = el("div", "memory-backup-text");
|
|
410
|
+
text.appendChild(el("div", "", t("memory.finding." + finding.kind, { count: finding.ids.length })));
|
|
411
|
+
const names = finding.ids.map((id) => labelOf(finding.scope, id)).join(" / ");
|
|
412
|
+
text.appendChild(el("div", "muted small", names + (finding.detail && finding.detail.length ? " · " + finding.detail.join(", ") : "")));
|
|
413
|
+
row.appendChild(text);
|
|
414
|
+
const buttons = el("div", "memory-actions");
|
|
415
|
+
if (finding.kind === "duplicate") {
|
|
416
|
+
if (consolidate.modelAvailable) {
|
|
417
|
+
const suggest = el("button", "ghost", t("memory.mergeSuggest")); suggest.type = "button";
|
|
418
|
+
suggest.onclick = async () => {
|
|
419
|
+
suggest.disabled = true; suggest.textContent = t("memory.mergeThinking");
|
|
420
|
+
await act({ action: "propose", scope: finding.scope, ids: finding.ids, model: true });
|
|
421
|
+
};
|
|
422
|
+
buttons.appendChild(suggest);
|
|
423
|
+
}
|
|
424
|
+
const manual = el("button", "ghost", t("memory.mergeManual")); manual.type = "button";
|
|
425
|
+
manual.onclick = () => { void act({ action: "propose", scope: finding.scope, ids: finding.ids, model: false }); };
|
|
426
|
+
buttons.appendChild(manual);
|
|
427
|
+
} else {
|
|
428
|
+
const open = el("button", "ghost", t("memory.reviewOpen")); open.type = "button";
|
|
429
|
+
open.onclick = () => {
|
|
430
|
+
search.value = ""; draw();
|
|
431
|
+
const found = list.querySelector('[data-note-id="' + CSS.escape(finding.ids[0]) + '"]');
|
|
432
|
+
if (found) { found.open = true; found.scrollIntoView({ block: "center" }); }
|
|
433
|
+
};
|
|
434
|
+
buttons.appendChild(open);
|
|
435
|
+
}
|
|
436
|
+
const dismiss = el("button", "ghost", t("memory.reviewDismiss")); dismiss.type = "button";
|
|
437
|
+
dismiss.onclick = () => { void act({ action: "dismiss", key: finding.key }); };
|
|
438
|
+
buttons.appendChild(dismiss);
|
|
439
|
+
row.appendChild(buttons);
|
|
440
|
+
tidy.appendChild(row);
|
|
441
|
+
}
|
|
442
|
+
// Nothing to tidy is not worth a row.
|
|
443
|
+
if (findings.length || consolidate.lastAuto) panel.appendChild(tidy);
|
|
444
|
+
const editor = (note, extra) => {
|
|
445
|
+
const form = el("form", "memory-editor"), summary = el("input", ""), body = el("textarea", ""), tags = el("input", "");
|
|
446
|
+
summary.placeholder = t("memory.summaryPlaceholder"); summary.value = note?.summary || ""; summary.required = true;
|
|
447
|
+
body.placeholder = t("memory.bodyPlaceholder"); body.value = note?.body || ""; body.rows = 6; body.required = true;
|
|
448
|
+
tags.placeholder = t("memory.tagsPlaceholder"); tags.value = (note?.tags || []).join(", ");
|
|
449
|
+
const type = el("select", "");
|
|
450
|
+
for (const [value, label] of [["", t("memory.typeNone")], ["user", t("memory.typeUser")], ["feedback", t("memory.typeFeedback")], ["project", t("memory.typeProject")], ["reference", t("memory.typeReference")]]) {
|
|
451
|
+
const option = el("option", "", label); option.value = value; type.appendChild(option);
|
|
452
|
+
}
|
|
453
|
+
type.value = note?.type || "";
|
|
454
|
+
const scope = el("select", "");
|
|
455
|
+
for (const [value, label] of [["project", t("memory.scopeProject")], ["user", t("memory.scopeUser")]]) {
|
|
456
|
+
const option = el("option", "", label); option.value = value; scope.appendChild(option);
|
|
457
|
+
}
|
|
458
|
+
// A note moves between scopes by being written anew there; editing keeps it where it is.
|
|
459
|
+
scope.value = note?.scope || "project"; scope.disabled = !!note;
|
|
460
|
+
/*
|
|
461
|
+
* A label each, and a line saying what the field is for.
|
|
462
|
+
*
|
|
463
|
+
* Three boxes with only placeholders said nothing at all once they had
|
|
464
|
+
* content in them, which is every note anyone opens to edit. And the fact
|
|
465
|
+
* worth knowing here is not which box is which — it is that only the
|
|
466
|
+
* summary is ever in front of the model, so a vague one makes the note
|
|
467
|
+
* unfindable no matter how good the body is.
|
|
468
|
+
*/
|
|
469
|
+
const labelled = (control, label, hint) => {
|
|
470
|
+
const group = el("div", "memory-field");
|
|
471
|
+
const name = el("label", "memory-label", label);
|
|
472
|
+
control.id = "mem-" + (fieldSeq += 1);
|
|
473
|
+
name.htmlFor = control.id;
|
|
474
|
+
group.appendChild(name);
|
|
475
|
+
if (hint) group.appendChild(el("div", "muted small", hint));
|
|
476
|
+
control.style.cssText = "display:block;width:100%;box-sizing:border-box";
|
|
477
|
+
group.appendChild(control);
|
|
478
|
+
return group;
|
|
479
|
+
};
|
|
480
|
+
form.appendChild(labelled(summary, t("memory.summaryLabel"), t("memory.summaryHint")));
|
|
481
|
+
form.appendChild(labelled(body, t("memory.bodyLabel"), t("memory.bodyHint")));
|
|
482
|
+
form.appendChild(labelled(tags, t("memory.tagsLabel"), t("memory.tagsHint")));
|
|
483
|
+
form.appendChild(labelled(type, t("memory.typeLabel"), ""));
|
|
484
|
+
if (result.userAvailable) form.appendChild(labelled(scope, t("memory.scopeLabel"), note ? "" : t("memory.scopeHint")));
|
|
485
|
+
const actions = el("div", "memory-actions");
|
|
486
|
+
const save = el("button", "ghost", note ? t("memory.save") : t("memory.add")); save.type = "submit"; actions.appendChild(save);
|
|
487
|
+
// Delete belongs beside Save, not stranded under the card: they are the two
|
|
488
|
+
// things this form can do, and stacking them read as two separate sections.
|
|
489
|
+
if (extra) actions.appendChild(extra);
|
|
490
|
+
form.appendChild(actions);
|
|
491
|
+
form.onsubmit = async (event) => { event.preventDefault(); save.disabled = true;
|
|
492
|
+
await act({ action: "save", scope: note ? note.scope : scope.value, ...(note ? { id: note.id, expectedHash: note.hash } : {}), note: { type: type.value, summary: summary.value.trim(), body: body.value.trim(), tags: tags.value.split(/[,,\s]+/).filter(Boolean) } }); save.disabled = false;
|
|
493
|
+
};
|
|
494
|
+
return form;
|
|
495
|
+
};
|
|
496
|
+
// What notes are, said once above the list. They come from conversations,
|
|
497
|
+
// so there is no form for writing one here; existing notes open to edit.
|
|
498
|
+
panel.appendChild(el("div", "section-title", t("memory.notesTitle", { count: result.entries.length })));
|
|
499
|
+
// With no notes the empty state below says all of this, with an example.
|
|
500
|
+
if (result.entries.length) panel.appendChild(el("p", "muted small memory-notes-intro", t("memory.notesIntro")));
|
|
501
|
+
const search = el("input", "memory-search"); search.placeholder = t("memory.search"); search.setAttribute("aria-label", t("memory.search")); search.style.margin = "16px 0"; search.hidden = !result.entries.length; panel.appendChild(search);
|
|
502
|
+
const list = el("div", ""); panel.appendChild(list);
|
|
503
|
+
const draw = () => {
|
|
504
|
+
list.replaceChildren(); const query = search.value.toLowerCase();
|
|
505
|
+
const entries = result.entries.filter((note) => (note.summary + " " + note.body + " " + note.tags.join(" ") + " " + (note.type || "") + " " + note.id).toLowerCase().includes(query));
|
|
506
|
+
if (!entries.length) {
|
|
507
|
+
if (result.entries.length) list.appendChild(el("p", "muted small", t("memory.none")));
|
|
508
|
+
else {
|
|
509
|
+
const empty = el("div", "row-card memory-empty");
|
|
510
|
+
empty.appendChild(el("div", "memory-empty-title", t("memory.emptyTitle")));
|
|
511
|
+
empty.appendChild(el("p", "muted small", t("memory.empty")));
|
|
512
|
+
list.appendChild(empty);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
for (const note of entries) {
|
|
516
|
+
const row = el("details", "row-card memory-note");
|
|
517
|
+
/*
|
|
518
|
+
* Summary and id on one line, the way a settings row is built.
|
|
519
|
+
*
|
|
520
|
+
* The id is how the model refers to this note and how a person quotes it
|
|
521
|
+
* in a bug report, so it stays visible — but it is a handle, not content,
|
|
522
|
+
* and on a line of its own it read as the first thing in the note. On the
|
|
523
|
+
* same line as the summary it also stops being a third left edge: the
|
|
524
|
+
* disclosure marker indents the summary text, so anything under it lined
|
|
525
|
+
* up with neither the marker nor the fields below.
|
|
526
|
+
*/
|
|
527
|
+
const head = el("summary", "");
|
|
528
|
+
head.appendChild(el("span", "chev"));
|
|
529
|
+
head.appendChild(el("span", "memory-note-name", note.summary));
|
|
530
|
+
head.appendChild(el("span", "memory-id mono", note.id));
|
|
531
|
+
const kind = [note.scope === "user" ? t("memory.scopeUser") : "", note.type ? t("memory.type" + note.type.charAt(0).toUpperCase() + note.type.slice(1)) : ""].filter(Boolean).join(" · ");
|
|
532
|
+
if (kind) head.appendChild(el("span", "memory-id", kind));
|
|
533
|
+
row.appendChild(head);
|
|
534
|
+
row.dataset.noteId = note.id;
|
|
535
|
+
/*
|
|
536
|
+
* Links written as [[id]] in a note, both ways.
|
|
537
|
+
*
|
|
538
|
+
* Resolved by the plugin, since only it sees both scopes; the page only
|
|
539
|
+
* draws them. A link to a note that does not exist is shown struck
|
|
540
|
+
* through rather than hidden: it usually names a note someone meant to
|
|
541
|
+
* write.
|
|
542
|
+
*/
|
|
543
|
+
const links = note.links || { out: [], in: [], dangling: [] };
|
|
544
|
+
if (links.out.length || links.in.length || links.dangling.length) {
|
|
545
|
+
const line = el("div", "memory-links small");
|
|
546
|
+
const chip = (target) => {
|
|
547
|
+
const button = el("button", "memory-link", target); button.type = "button";
|
|
548
|
+
button.onclick = () => {
|
|
549
|
+
const found = list.querySelector('[data-note-id="' + CSS.escape(target) + '"]');
|
|
550
|
+
if (found) { found.open = true; found.scrollIntoView({ block: "center" }); }
|
|
551
|
+
};
|
|
552
|
+
return button;
|
|
553
|
+
};
|
|
554
|
+
if (links.out.length || links.dangling.length) {
|
|
555
|
+
line.appendChild(el("span", "", t("memory.linksOut")));
|
|
556
|
+
for (const target of links.out) line.appendChild(chip(target));
|
|
557
|
+
for (const target of links.dangling) { const missing = el("span", "memory-link missing", target); missing.title = t("memory.missing"); line.appendChild(missing); }
|
|
558
|
+
}
|
|
559
|
+
if (links.in.length) {
|
|
560
|
+
line.appendChild(el("span", "", t("memory.linksIn")));
|
|
561
|
+
for (const target of links.in) line.appendChild(chip(target));
|
|
562
|
+
}
|
|
563
|
+
row.appendChild(line);
|
|
564
|
+
}
|
|
565
|
+
const remove = el("button", "ghost", t("memory.delete")); remove.type = "button";
|
|
566
|
+
remove.onclick = () => {
|
|
567
|
+
const linked = links.in.length ? t("memory.confirmDeleteLinked", { count: links.in.length }) : "";
|
|
568
|
+
if (confirm(linked + t("memory.confirmDelete"))) void act({ action: "delete", scope: note.scope, id: note.id, expectedHash: note.hash });
|
|
569
|
+
};
|
|
570
|
+
row.appendChild(editor(note, remove));
|
|
571
|
+
list.appendChild(row);
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
search.oninput = draw; draw();
|
|
575
|
+
// Backups: every version replaced or removed without anyone looking, newest first.
|
|
576
|
+
const backupList = result.backups || [];
|
|
577
|
+
const backups = el("details", "row-card memory-note");
|
|
578
|
+
const backupsHead = el("summary", "");
|
|
579
|
+
backupsHead.appendChild(el("span", "chev"));
|
|
580
|
+
backupsHead.appendChild(el("span", "memory-note-name", t("memory.backups", { count: backupList.length })));
|
|
581
|
+
backups.appendChild(backupsHead);
|
|
582
|
+
if (!backupList.length) backups.appendChild(el("p", "muted", t("memory.backupsNone")));
|
|
583
|
+
for (const backup of backupList) {
|
|
584
|
+
const row = el("div", "memory-backup");
|
|
585
|
+
const text = el("div", "memory-backup-text");
|
|
586
|
+
text.appendChild(el("div", "", backup.summary));
|
|
587
|
+
const scopeLabel = backup.scope === "user" ? t("memory.scopeUser") : backup.scope === "project" ? t("memory.scopeProject") : "";
|
|
588
|
+
text.appendChild(el("div", "muted small", [new Date(backup.backedUpAt).toLocaleString(LOCALE), backup.reason ? t("memory.backupReason." + backup.reason) : "", scopeLabel, backup.id].filter(Boolean).join(" · ")));
|
|
589
|
+
row.appendChild(text);
|
|
590
|
+
const restore = el("button", "ghost", t("memory.restore")); restore.type = "button";
|
|
591
|
+
restore.onclick = () => { if (confirm(t("memory.confirmRestore"))) void act({ action: "restore", file: backup.file }); };
|
|
592
|
+
row.appendChild(restore);
|
|
593
|
+
backups.appendChild(row);
|
|
594
|
+
}
|
|
595
|
+
if (backupList.length) panel.appendChild(backups);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Panels reached from a row inside another panel rather than from the sidebar.
|
|
600
|
+
*
|
|
601
|
+
* A full page, drawn where Models and Routines are drawn, but without a
|
|
602
|
+
* permanent destination in the sidebar: usage is looked at now and then, and a
|
|
603
|
+
* row in the sidebar for it would be a standing invitation to a page most
|
|
604
|
+
* sessions have no reason to open. It keeps the row it came from lit, so the
|
|
605
|
+
* sidebar still says where in the app this is.
|
|
606
|
+
*/
|
|
607
|
+
const SUB_PANELS = { usage: "nav-settings", audit: "nav-settings", permissions: "nav-settings" };
|
|
608
|
+
|
|
609
|
+
/** Which panel the panel view is currently showing, sub-panels included. */
|
|
610
|
+
let shownPanel = null;
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The one switch between the transcript and a panel.
|
|
614
|
+
*
|
|
615
|
+
* Every "back to chat" path already called setNav(null), so the view swap
|
|
616
|
+
* belongs here rather than in each caller: a panel is shown, the transcript is
|
|
617
|
+
* left intact underneath it, and the composer is hidden — there is nothing to
|
|
618
|
+
* send a message to while reading a list of providers.
|
|
619
|
+
*/
|
|
620
|
+
function setNav(active) {
|
|
621
|
+
// Every nav destination funnels through here, so one call covers them all.
|
|
622
|
+
track("panel_opened", { panel: active });
|
|
623
|
+
shownPanel = active;
|
|
624
|
+
const lit = SUB_PANELS[active] || (PANEL_NAV.has(active) ? active : null);
|
|
625
|
+
for (const item of document.querySelectorAll(".nav-item")) item.classList.remove("active");
|
|
626
|
+
if (lit) document.getElementById(lit).classList.add("active");
|
|
627
|
+
|
|
628
|
+
const showing = PANEL_NAV.has(active) || Boolean(SUB_PANELS[active]);
|
|
629
|
+
document.body.classList.toggle("panelled", showing);
|
|
630
|
+
log.hidden = showing;
|
|
631
|
+
panelView.hidden = !showing;
|
|
632
|
+
if (showing) {
|
|
633
|
+
// The diff pane belongs to the conversation, not to a settings page.
|
|
634
|
+
review.classList.remove("open");
|
|
635
|
+
panelView.scrollTop = 0;
|
|
636
|
+
} else {
|
|
637
|
+
panelBody.replaceChildren();
|
|
638
|
+
panelNotes.replaceChildren();
|
|
639
|
+
// A transcript rendered while the log was hidden measures zero, so the
|
|
640
|
+
// scroll it asked for never happened. Ask again now that it has a height.
|
|
641
|
+
if (atBottom) log.scrollTop = log.scrollHeight;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
document.getElementById("nav-artifacts").addEventListener("click", () => {
|
|
646
|
+
setNav("nav-artifacts");
|
|
647
|
+
void showAllArtifacts();
|
|
648
|
+
});
|
|
649
|
+
document.getElementById("nav-routines").addEventListener("click", () => {
|
|
650
|
+
setNav("nav-routines");
|
|
651
|
+
void showRoutines();
|
|
652
|
+
});
|
|
653
|
+
document.getElementById("nav-settings").addEventListener("click", () => {
|
|
654
|
+
setNav("nav-settings");
|
|
655
|
+
void showSettings();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
`;
|
|
659
|
+
//# sourceMappingURL=panels.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"panels.js","sourceRoot":"","sources":["../../../src/page/client/panels.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2oB/B,CAAC"}
|