This commit is contained in:
2026-07-07 15:28:28 +02:00
parent 499f91e16a
commit 32c8bbe0a9
52 changed files with 3420 additions and 328 deletions
+37 -7
View File
@@ -59,6 +59,11 @@
margin-bottom: var(--space-xl);
}
.ds-degraded {
border-left: 4px solid var(--warning, #d97706);
color: var(--text-primary);
}
.ds-input {
width: 100%;
background: var(--bg-input);
@@ -438,13 +443,6 @@
line-height: 1.6;
}
.ds-gaps {
margin: 0;
padding-left: var(--space-lg);
color: var(--text-secondary);
line-height: 1.6;
}
.ds-sources {
margin: 0;
padding-left: var(--space-lg);
@@ -458,6 +456,38 @@
margin-left: var(--space-sm);
}
.ds-cite {
color: var(--accent, #2563eb);
font-weight: 600;
font-size: 0.85em;
text-decoration: none;
vertical-align: super;
line-height: 0;
padding: 0 1px;
}
.ds-cite:hover {
text-decoration: underline;
}
.ds-finding-cites {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
.ds-sources li {
scroll-margin-top: var(--space-xl);
}
.ds-sources li:target {
background: var(--bg-highlight, rgba(37, 99, 235, 0.12));
border-radius: var(--radius-input);
outline: 2px solid var(--accent, #2563eb);
outline-offset: 2px;
}
.ds-chat-pane {
background: var(--bg-card);
border: 1px solid var(--border);
+2
View File
@@ -41,6 +41,7 @@ import { LiveNotifications } from "./LiveNotifications.js";
import { PresenceManager } from "./PresenceManager.js";
import { OnlineUsers } from "./OnlineUsers.js";
import { LocalTime } from "./LocalTime.js";
import { ScrollMemory } from "./ScrollMemory.js";
import { GameFarm } from "./GameFarm.js";
import { Accessibility } from "./Accessibility.js";
@@ -92,6 +93,7 @@ class Application {
this.presence = new PresenceManager(this.pubsub);
this.onlineUsers = new OnlineUsers(this.pubsub);
this.localTime = new LocalTime();
this.scrollMemory = new ScrollMemory();
this.gameFarm = new GameFarm();
}
}
+15 -3
View File
@@ -11,6 +11,7 @@ export class ContainerInstance {
this.status = root.dataset.status;
this.base = `/projects/${this.slug}/containers`;
this.name = root.dataset.name || this.uid;
this.canManage = root.dataset.canManage === "1";
this.detailPoll = null;
this.logPoll = null;
}
@@ -32,8 +33,10 @@ export class ContainerInstance {
}
bind() {
this.q("#ci-exec-run").addEventListener("click", () => this.execOnce());
this.q("#ci-term-toggle").addEventListener("click", () => this.openTerminal());
const execRun = this.q("#ci-exec-run");
if (execRun) execRun.addEventListener("click", () => this.execOnce());
const termToggle = this.q("#ci-term-toggle");
if (termToggle) termToggle.addEventListener("click", () => this.openTerminal());
const form = document.getElementById("ci-schedule-form");
if (form) {
form.addEventListener("submit", (e) => { e.preventDefault(); this.addSchedule(form); });
@@ -62,6 +65,11 @@ export class ContainerInstance {
// ---------------- actions ----------------
renderActions(status) {
if (!this.canManage) {
const box = this.q("#ci-actions");
box.innerHTML = `<span class="cm-muted">View only. This container is managed by its owner.</span>`;
return;
}
const running = status === "running";
const paused = status === "paused";
const spec = [
@@ -174,6 +182,7 @@ export class ContainerInstance {
updateTerminalAvailability() {
const toggle = this.q("#ci-term-toggle");
if (!toggle) return;
const running = this.status === "running";
toggle.disabled = !running;
toggle.title = running ? "" : "Start the instance to open an interactive shell";
@@ -239,11 +248,14 @@ export class ContainerInstance {
list.innerHTML = `<li class="cm-muted ci-schedule-empty">No schedules.</li>`;
return;
}
const removeBtn = (s) => this.canManage
? `<button type="button" class="btn btn-secondary btn-sm" data-schedule-delete="${this.escape(s.uid)}">Delete</button>`
: "";
list.innerHTML = schedules.map((s) => `<li class="ci-schedule" data-sid="${this.escape(s.uid)}">
<span class="ci-schedule-action">${this.escape(s.action)}</span>
<span class="ci-schedule-next">next ${this.escape(s.next_run_at || "-")}</span>
<span class="cm-muted">runs ${this.escape(s.run_count || 0)}</span>
<button type="button" class="btn btn-secondary btn-sm" data-schedule-delete="${this.escape(s.uid)}">Delete</button>
${removeBtn(s)}
</li>`).join("");
}
}
+24 -9
View File
@@ -10,6 +10,7 @@ export class ContainerList {
this.endpoint = root.dataset.endpoint;
this.body = document.getElementById("cm-admin-rows");
this.pollMs = 4000;
this.instances = [];
}
init() {
@@ -21,10 +22,22 @@ export class ContainerList {
});
const pubsub = window.app && window.app.pubsub;
if (pubsub) {
pubsub.subscribe("container.list", (data) => this.render(data.instances || []));
pubsub.subscribe("container.list", (data) => {
if (data.partial) this.merge(data.instances || []);
else this.render(data.instances || []);
});
}
}
merge(updates) {
const byUid = new Map(this.instances.map((inst) => [inst.uid, inst]));
for (const update of updates) {
const current = byUid.get(update.uid);
byUid.set(update.uid, current ? { ...update, can_manage: current.can_manage } : update);
}
this.render([...byUid.values()]);
}
toast(message, type) {
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
}
@@ -117,6 +130,7 @@ export class ContainerList {
}
render(instances) {
this.instances = instances;
if (!this.body) return;
if (!instances.length) {
this.body.innerHTML = `<tr><td colspan="8" class="admin-empty">No container instances yet. Create one above or open a project's container manager.</td></tr>`;
@@ -138,6 +152,14 @@ export class ContainerList {
const boot = inst.start_on_boot
? `<span class="cm-badge cm-running">on</span>`
: `<span class="cm-muted">off</span>`;
const actions = inst.can_manage === false
? `<span class="cm-muted">view only</span>`
: `<button class="admin-btn admin-btn-sm" data-cm-action="start">Start</button>
<button class="admin-btn admin-btn-sm" data-cm-action="stop">Stop</button>
<button class="admin-btn admin-btn-sm" data-cm-action="restart">Restart</button>
<button class="admin-btn admin-btn-sm" data-cm-action="terminal">Terminal</button>
<a class="admin-btn admin-btn-sm" href="/admin/containers/${uid}/edit">Edit</a>
<button class="admin-btn admin-btn-sm admin-btn-danger" data-cm-action="delete">Delete</button>`;
return `<tr class="cm-row" data-uid="${uid}" data-slug="${slug}" data-name="${name}">
<td><a class="cm-row-name" href="/admin/containers/${uid}">${name}</a></td>
<td>${project}</td>
@@ -146,14 +168,7 @@ export class ContainerList {
<td>${ingress}</td>
<td>${this.escape(inst.restart_policy || "never")}</td>
<td>${boot}</td>
<td class="cm-actions">
<button class="admin-btn admin-btn-sm" data-cm-action="start">Start</button>
<button class="admin-btn admin-btn-sm" data-cm-action="stop">Stop</button>
<button class="admin-btn admin-btn-sm" data-cm-action="restart">Restart</button>
<button class="admin-btn admin-btn-sm" data-cm-action="terminal">Terminal</button>
<a class="admin-btn admin-btn-sm" href="/admin/containers/${uid}/edit">Edit</a>
<button class="admin-btn admin-btn-sm admin-btn-danger" data-cm-action="delete">Delete</button>
</td>
<td class="cm-actions">${actions}</td>
</tr>`;
}
}
+2 -2
View File
@@ -13,8 +13,8 @@ const PHASE_ORDER = [
];
const AGENT_LABELS = {
summarizer: "Summarizer",
critic: "Critic",
summarizer: "Report writer",
extractor: "Findings extractor",
linker: "Linker",
};
+198
View File
@@ -0,0 +1,198 @@
// retoor <retoor@molodetz.nl>
export class ScrollMemory {
constructor() {
this.positionsKey = "dp-scroll:positions";
this.trailKey = "dp-scroll:trail";
this.intentKey = "dp-scroll:intent";
this.maxEntries = 50;
this.maxTrail = 20;
this.maxAgeMs = 60 * 60 * 1000;
this.intentAgeMs = 30 * 1000;
this.restoreWindowMs = 4000;
this.stableFramesNeeded = 10;
this.saveDelayMs = 200;
this.saveTimer = null;
this.backSelector = "a.back-link, a[data-scroll-back], .breadcrumb a";
if (!this.storageAvailable()) return;
history.scrollRestoration = "manual";
this.url = this.normalize(location.href);
this.previousUrl = this.recordTrail();
this.upgradeBackLinks();
this.bindSave();
this.bindIntent();
window.addEventListener("pageshow", (e) => {
if (!e.persisted) return;
this.takeIntent();
this.previousUrl = this.recordTrail();
});
this.restoreIfNeeded();
}
storageAvailable() {
try {
const probe = "dp-scroll:probe";
sessionStorage.setItem(probe, "1");
sessionStorage.removeItem(probe);
return true;
} catch {
return false;
}
}
normalize(href) {
const url = new URL(href, location.href);
return url.pathname + url.search;
}
readJson(key, fallback) {
try {
const raw = sessionStorage.getItem(key);
if (!raw) return fallback;
const value = JSON.parse(raw);
return value === null || typeof value !== "object" ? fallback : value;
} catch {
return fallback;
}
}
writeJson(key, value) {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {}
}
positions() {
return this.readJson(this.positionsKey, {});
}
prune(positions) {
const now = Date.now();
const entries = Object.entries(positions).filter(([, v]) => Array.isArray(v) && now - v[1] <= this.maxAgeMs);
entries.sort((a, b) => b[1][1] - a[1][1]);
return Object.fromEntries(entries.slice(0, this.maxEntries));
}
savePosition() {
const y = Math.round(window.scrollY);
const positions = this.prune(this.positions());
if (y > 0) {
positions[this.url] = [y, Date.now()];
} else {
delete positions[this.url];
}
this.writeJson(this.positionsKey, positions);
}
recordTrail() {
const trail = this.readJson(this.trailKey, []);
if (trail[trail.length - 1] !== this.url) trail.push(this.url);
while (trail.length > this.maxTrail) trail.shift();
this.writeJson(this.trailKey, trail);
return trail.length > 1 ? trail[trail.length - 2] : null;
}
upgradeBackLinks() {
if (!this.previousUrl) return;
const previous = new URL(this.previousUrl, location.origin);
document.querySelectorAll("a.back-link, a[data-scroll-back]").forEach((link) => {
const href = link.getAttribute("href");
if (!href) return;
const target = new URL(href, location.href);
if (target.origin !== location.origin || target.search || target.hash) return;
if (target.pathname !== previous.pathname) return;
if (this.normalize(target.href) === this.previousUrl) return;
link.setAttribute("href", this.previousUrl);
});
}
bindSave() {
window.addEventListener("scroll", () => {
if (this.saveTimer) return;
this.saveTimer = setTimeout(() => {
this.saveTimer = null;
this.savePosition();
}, this.saveDelayMs);
}, { passive: true });
window.addEventListener("pagehide", () => this.savePosition());
document.addEventListener("visibilitychange", () => {
if (document.hidden) this.savePosition();
});
}
bindIntent() {
document.addEventListener("click", (e) => {
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const link = e.target instanceof Element ? e.target.closest("a[href]") : null;
if (!link || (link.target && link.target !== "_self")) return;
const target = new URL(link.getAttribute("href"), location.href);
if (target.origin !== location.origin) return;
const destination = this.normalize(target.href);
if (destination === this.url) return;
if (!this.positions()[destination]) return;
if (destination !== this.previousUrl && !link.matches(this.backSelector)) return;
this.writeJson(this.intentKey, { url: destination, t: Date.now() });
});
}
takeIntent() {
const intent = this.readJson(this.intentKey, null);
try {
sessionStorage.removeItem(this.intentKey);
} catch {}
if (!intent || typeof intent.url !== "string") return null;
if (Date.now() - (intent.t || 0) > this.intentAgeMs) return null;
return intent.url;
}
restoreIfNeeded() {
const intent = this.takeIntent();
if (location.hash) return;
const nav = performance.getEntriesByType("navigation")[0];
const type = nav ? nav.type : "navigate";
if (type !== "back_forward" && type !== "reload" && intent !== this.url) return;
const saved = this.positions()[this.url];
if (!saved) return;
const [y, t] = saved;
if (y <= 0 || Date.now() - t > this.maxAgeMs) return;
this.applyScroll(y);
}
applyScroll(y) {
const doc = document.scrollingElement || document.documentElement;
const deadline = performance.now() + this.restoreWindowMs;
const cancelEvents = ["wheel", "touchstart", "keydown", "pointerdown"];
let cancelled = false;
let stableFrames = 0;
let lastHeight = 0;
const cancel = () => { cancelled = true; };
cancelEvents.forEach((name) => window.addEventListener(name, cancel, { once: true, passive: true }));
const cleanup = () => cancelEvents.forEach((name) => window.removeEventListener(name, cancel));
const step = () => {
if (cancelled) {
cleanup();
return;
}
const limit = Math.max(0, doc.scrollHeight - window.innerHeight);
const target = Math.min(y, limit);
if (Math.abs(window.scrollY - target) > 1) this.scrollInstant(target);
stableFrames = doc.scrollHeight === lastHeight && target === y ? stableFrames + 1 : 0;
lastHeight = doc.scrollHeight;
if (stableFrames >= this.stableFramesNeeded || performance.now() > deadline) {
cleanup();
return;
}
requestAnimationFrame(step);
};
step();
}
scrollInstant(top) {
try {
window.scrollTo({ top, left: 0, behavior: "instant" });
} catch {
window.scrollTo(0, top);
}
}
}