@songsid/agend 2.3.0-beta.21 → 2.3.0-beta.23
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/README.md +2 -2
- package/README.zh-TW.md +2 -2
- package/dist/agent-cli.js +4 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +9 -0
- package/dist/agent-endpoint.js +15 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-whats-new.d.ts +5 -0
- package/dist/agent-whats-new.js +16 -1
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/auth-api.d.ts +7 -0
- package/dist/auth-api.js +10 -3
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/codex-package.d.ts +30 -0
- package/dist/backend/codex-package.js +133 -0
- package/dist/backend/codex-package.js.map +1 -0
- package/dist/backend/codex.d.ts +2 -0
- package/dist/backend/codex.js +9 -2
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/grok.js +56 -3
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +9 -1
- package/dist/backend/kiro.js +102 -1
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/opencode-permission.js +1 -1
- package/dist/backend/opencode-permission.js.map +1 -1
- package/dist/backend/opencode.js +2 -1
- package/dist/backend/opencode.js.map +1 -1
- package/dist/binary-probe.js +1 -1
- package/dist/bug-report-cli.js +1 -1
- package/dist/bundled-skills.d.ts +7 -3
- package/dist/bundled-skills.js +16 -7
- package/dist/bundled-skills.js.map +1 -1
- package/dist/channel/adapters/discord.js +3 -3
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-server.js +12 -4
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.d.ts +3 -1
- package/dist/channel/mcp-tools.js +38 -2
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/child-process.d.ts +21 -0
- package/dist/child-process.js +54 -0
- package/dist/child-process.js.map +1 -0
- package/dist/cli.js +167 -182
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +1 -1
- package/dist/command-table.js +1 -1
- package/dist/command-table.js.map +1 -1
- package/dist/config-validator.js +28 -2
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.js +16 -2
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +14 -0
- package/dist/daemon.js +45 -8
- package/dist/daemon.js.map +1 -1
- package/dist/darwin-memory.js +1 -1
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +42 -0
- package/dist/doctor.js.map +1 -1
- package/dist/export-import.js +1 -1
- package/dist/export-import.js.map +1 -1
- package/dist/fleet-level-config.d.ts +1 -1
- package/dist/fleet-level-config.js +2 -0
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-manager.d.ts +73 -2
- package/dist/fleet-manager.js +400 -33
- package/dist/fleet-manager.js.map +1 -1
- package/dist/full-restart.d.ts +5 -5
- package/dist/full-restart.js +5 -5
- package/dist/full-restart.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +1 -1
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +8 -9
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +319 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +529 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +19 -4
- package/dist/general-knowledge/steering/core-rules.md +1 -1
- package/dist/instance-config-impact.js +13 -3
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.js +6 -6
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.js +2 -1
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +137 -10
- package/dist/locale.js.map +1 -1
- package/dist/login-flows.js +1 -1
- package/dist/login-shell.js +3 -1
- package/dist/login-shell.js.map +1 -1
- package/dist/ls-rows.js +1 -1
- package/dist/needs-you-hub.d.ts +98 -1
- package/dist/needs-you-hub.js +313 -44
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you-live.d.ts +5 -2
- package/dist/needs-you-live.js +6 -3
- package/dist/needs-you-live.js.map +1 -1
- package/dist/needs-you.d.ts +58 -3
- package/dist/needs-you.js +36 -3
- package/dist/needs-you.js.map +1 -1
- package/dist/outbound-handlers.js +25 -10
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +3 -3
- package/dist/provider-secret-registry.d.ts +5 -3
- package/dist/provider-secret-registry.js +8 -2
- package/dist/provider-secret-registry.js.map +1 -1
- package/dist/public-link-progress.d.ts +12 -1
- package/dist/public-link-progress.js +5 -10
- package/dist/public-link-progress.js.map +1 -1
- package/dist/public-tunnel-config.d.ts +11 -0
- package/dist/public-tunnel-config.js +68 -0
- package/dist/public-tunnel-config.js.map +1 -0
- package/dist/public-web-gateway.d.ts +2 -0
- package/dist/public-web-gateway.js +3 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +13 -2
- package/dist/public-web-link.js +37 -10
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart.js +14 -8
- package/dist/quickstart.js.map +1 -1
- package/dist/resource-report.js +1 -1
- package/dist/scheduler/task-board.d.ts +41 -1
- package/dist/scheduler/task-board.js +150 -4
- package/dist/scheduler/task-board.js.map +1 -1
- package/dist/sd-notify.js +2 -1
- package/dist/sd-notify.js.map +1 -1
- package/dist/secret-store.d.ts +2 -0
- package/dist/secret-store.js +21 -0
- package/dist/secret-store.js.map +1 -1
- package/dist/service-installer.d.ts +69 -0
- package/dist/service-installer.js +181 -0
- package/dist/service-installer.js.map +1 -1
- package/dist/service-owner.d.ts +44 -0
- package/dist/service-owner.js +48 -0
- package/dist/service-owner.js.map +1 -0
- package/dist/settings-api.d.ts +5 -0
- package/dist/settings-api.js +32 -10
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +86 -3
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +6 -1
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.d.ts +2 -0
- package/dist/settings-http-confirmation.js +4 -3
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/settings-request-capability.d.ts +4 -1
- package/dist/settings-request-capability.js +11 -3
- package/dist/settings-request-capability.js.map +1 -1
- package/dist/setup-host.js +1 -1
- package/dist/setup-wizard.js +1 -1
- package/dist/setup-wizard.js.map +1 -1
- package/dist/task-board-summary.d.ts +101 -0
- package/dist/task-board-summary.js +177 -0
- package/dist/task-board-summary.js.map +1 -0
- package/dist/task-board-waker.d.ts +8 -1
- package/dist/task-board-waker.js +15 -6
- package/dist/task-board-waker.js.map +1 -1
- package/dist/tmux-control.js +1 -1
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-exec-helper.d.ts +5 -1
- package/dist/tmux-exec-helper.js +13 -21
- package/dist/tmux-exec-helper.js.map +1 -1
- package/dist/tmux-manager.js +1 -1
- package/dist/tmux-read.js +1 -1
- package/dist/tmux-read.js.map +1 -1
- package/dist/topic-commands.d.ts +3 -0
- package/dist/topic-commands.js +29 -18
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.js +5 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +2 -0
- package/dist/transcript-sources.js +4 -0
- package/dist/transcript-sources.js.map +1 -1
- package/dist/tunnel/cloudflared-install.js +3 -1
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +7 -16
- package/dist/tunnel/cloudflared.js +65 -13
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/named-diagnostics.d.ts +13 -0
- package/dist/tunnel/named-diagnostics.js +52 -0
- package/dist/tunnel/named-diagnostics.js.map +1 -0
- package/dist/tunnel/types.d.ts +2 -1
- package/dist/tunnel/types.js.map +1 -1
- package/dist/types.d.ts +12 -0
- package/dist/ui/app-effects.js +10 -4
- package/dist/ui/app.html +2 -0
- package/dist/ui/chat-render.js +39 -0
- package/dist/ui/chat-store.js +8 -0
- package/dist/ui/chat-strings.js +5 -2
- package/dist/ui/chat-thread.js +15 -8
- package/dist/ui/effect-trial-offer.js +2 -31
- package/dist/ui/effects-waker.js +2 -70
- package/dist/ui/fleet-canvas.js +14 -6
- package/dist/ui/fleet-route-scene.js +86 -21
- package/dist/ui/fleet-task-card.js +5 -5
- package/dist/ui/fleet-task-common.js +41 -7
- package/dist/ui/fleet-task-work.js +107 -0
- package/dist/ui/fleet-tasks.js +46 -12
- package/dist/ui/fleet-traffic.js +57 -10
- package/dist/ui/icons/icon-192.png +0 -0
- package/dist/ui/icons/icon-512.png +0 -0
- package/dist/ui/image-lightbox.js +39 -1
- package/dist/ui/image-zoom.js +242 -0
- package/dist/ui/panel-chat.js +24 -13
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +6 -2
- package/dist/ui/panel-needs.js +21 -6
- package/dist/ui/panel-settings.js +90 -15
- package/dist/ui/preview-panel.js +2 -1
- package/dist/ui/search-dialog.js +2 -1
- package/dist/ui/settings-appearance.js +159 -9
- package/dist/ui/settings-dialogs.js +33 -17
- package/dist/ui/settings-discord.js +6 -2
- package/dist/ui/settings-guests.js +2 -1
- package/dist/ui/settings-learn.js +1 -0
- package/dist/ui/settings-model.js +2 -11
- package/dist/ui/settings-mods.js +6 -3
- package/dist/ui/settings-strings.js +80 -0
- package/dist/ui/settings-token.js +3 -1
- package/dist/ui/settings-web-authority.js +6 -3
- package/dist/ui/settings-wizard.js +13 -7
- package/dist/ui/shared/app-i18n.js +2 -2
- package/dist/ui/shared/app-needs.js +2 -2
- package/dist/ui/shared/app-store.js +2 -2
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +125 -28
- package/dist/ui/shared/app.js +5 -1
- package/dist/ui/shared/effect-app-runtime.js +7 -5
- package/dist/ui/shared/effect-chat-runtime.js +2 -7
- package/dist/ui/shared/effect-cursor.js +3 -2
- package/dist/ui/shared/effect-display-metadata.js +39 -0
- package/dist/ui/shared/effect-registry.js +4 -2
- package/dist/ui/shared/effect-runtime-loader.js +3 -1
- package/dist/ui/shared/effect-stock-trust.js +1 -1
- package/dist/ui/shared/feature-flags.js +127 -0
- package/dist/ui/shared/identity-color.js +6 -3
- package/dist/ui/shared/instance-nav.js +3 -2
- package/dist/ui/shared/text-undo.js +210 -0
- package/dist/ui/shared/ui-dialog.js +4 -3
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/signin.html +2 -0
- package/dist/update-dispatch.js +1 -1
- package/dist/update-launch.d.ts +1 -1
- package/dist/update-launch.js +1 -2
- package/dist/update-launch.js.map +1 -1
- package/dist/usage/providers.js +1 -1
- package/dist/usage/providers.js.map +1 -1
- package/dist/view-api.js +1 -1
- package/dist/web-api.d.ts +12 -2
- package/dist/web-api.js +266 -18
- package/dist/web-api.js.map +1 -1
- package/dist/web-app-icon-worker.d.ts +1 -0
- package/dist/web-app-icon-worker.js +15 -0
- package/dist/web-app-icon-worker.js.map +1 -0
- package/dist/web-app-icon.d.ts +107 -0
- package/dist/web-app-icon.js +256 -0
- package/dist/web-app-icon.js.map +1 -0
- package/dist/web-appearance-tools.d.ts +1 -0
- package/dist/web-appearance-tools.js +7 -3
- package/dist/web-appearance-tools.js.map +1 -1
- package/dist/web-appearance.d.ts +4 -10
- package/dist/web-appearance.js +4 -3
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-chat-history.d.ts +37 -0
- package/dist/web-chat-history.js +57 -2
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-experimental-http.d.ts +2 -0
- package/dist/web-experimental-http.js +12 -0
- package/dist/web-experimental-http.js.map +1 -0
- package/dist/web-experimental.d.ts +11 -0
- package/dist/web-experimental.js +49 -0
- package/dist/web-experimental.js.map +1 -0
- package/dist/web-guest-access.d.ts +1 -0
- package/dist/web-guest-access.js +11 -3
- package/dist/web-guest-access.js.map +1 -1
- package/dist/web-icons.d.ts +33 -1
- package/dist/web-icons.js +83 -11
- package/dist/web-icons.js.map +1 -1
- package/dist/web-mod-store.d.ts +2 -0
- package/dist/web-mod-store.js +10 -0
- package/dist/web-mod-store.js.map +1 -1
- package/dist/web-png.d.ts +29 -0
- package/dist/web-png.js +321 -0
- package/dist/web-png.js.map +1 -0
- package/dist/web-preview.d.ts +2 -0
- package/dist/web-preview.js +2 -0
- package/dist/web-preview.js.map +1 -1
- package/dist/web-reactions.d.ts +34 -0
- package/dist/web-reactions.js +47 -0
- package/dist/web-reactions.js.map +1 -0
- package/dist/web-settings-patch.d.ts +2 -1
- package/dist/web-settings-patch.js +5 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-settings-tools.d.ts +1 -0
- package/dist/web-settings-tools.js +14 -10
- package/dist/web-settings-tools.js.map +1 -1
- package/dist/web-terminal.js +1 -1
- package/dist/web-theme-store.d.ts +2 -2
- package/dist/web-theme-store.js +6 -3
- package/dist/web-theme-store.js.map +1 -1
- package/package.json +1 -1
package/dist/ui/shared/app.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
// #1665 first: the host's preferences go into this browser's cache before any module below reads its key.
|
|
9
9
|
import { bootPrefs } from "./app-prefs.js";
|
|
10
10
|
import { html, render } from "./app-html.js";
|
|
11
|
-
import { Shell, handleKey, handleSearchKey, showDialog, closeDrawer, openDrawer, shellStore, retryable, setNewInstanceOpener } from "./app-shell.js";
|
|
11
|
+
import { Shell, handleKey, handleSearchKey, showDialog, closeDrawer, openDrawer, shellStore, retryable, setNewInstanceOpener, isMacLike } from "./app-shell.js";
|
|
12
12
|
import { installSwipe } from "./app-swipe.js";
|
|
13
|
+
import { installTextUndo } from "./text-undo.js";
|
|
13
14
|
import { startRouter, navStore, navigate } from "./app-nav.js";
|
|
14
15
|
import { createStream } from "./app-stream.js";
|
|
15
16
|
import { appStore, applyStatus, applyActivity, guestPage } from "./app-store.js";
|
|
@@ -125,6 +126,9 @@ if (!guest) installOwnerCss();
|
|
|
125
126
|
document.addEventListener("keydown", handleKey);
|
|
126
127
|
// #2293: Ctrl+K / ⌘K (and Ctrl+F in a chat) — an admin's full app only; the server refuses a guest's search anyway.
|
|
127
128
|
if (mode === "full" && !guest) document.addEventListener("keydown", handleSearchKey);
|
|
129
|
+
// #2342: Ctrl/⌘+Z and Ctrl+Shift+Z / Ctrl+Y / ⌘⇧Z in every text field, one history per field (the browser's is one
|
|
130
|
+
// for the whole page, and any script write wipes it).
|
|
131
|
+
installTextUndo({ mac: isMacLike() });
|
|
128
132
|
// #1605: on a phone (the sidebar is a drawer), swipe right in the main area to open it, left to close it — the same
|
|
129
133
|
// open/close as ☰ and the scrim. Nothing that owns a sideways drag (code, tables, the terminal, inputs, dialogs) and
|
|
130
134
|
// never the left edge (iOS: "back").
|
|
@@ -13,7 +13,7 @@ const episodes = new WeakSet();
|
|
|
13
13
|
|
|
14
14
|
/** `current` belongs to the page/session episode. Route cleanup only releases its contribution. */
|
|
15
15
|
export function createAppEffectRuntime({ window: host, current, observe, onChange = () => {}, onDispose = () => {}, resolveOwnerTarget = null, onOwnerUnavailable = () => {}, factories = {} }) {
|
|
16
|
-
let dead = false, error = null, loader = null, registry = null, writer = null, source = null, personal = null, trial = null, pending = null, lastMetadata = null, serial = 0, revision = 0, stamp = null, syncing = false, queued = false;
|
|
16
|
+
let dead = false, claimed = false, error = null, loader = null, registry = null, writer = null, source = null, personal = null, trial = null, pending = null, lastMetadata = null, serial = 0, revision = 0, stamp = null, syncing = false, queued = false;
|
|
17
17
|
const bindings = new Map(), profiles = new Map();
|
|
18
18
|
let lastContext = null, playbackUnavailable = null;
|
|
19
19
|
const identity = effectPlaybackIdentity(host), auth = host?.AgendAuth;
|
|
@@ -26,7 +26,7 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
|
|
|
26
26
|
};
|
|
27
27
|
const placementIdentity = p => JSON.stringify([p.by, p.manifest.layer, p.params]);
|
|
28
28
|
const clock = () => host.performance.now();
|
|
29
|
-
let offAuth = null;
|
|
29
|
+
let offAuth = null, disposed = null;
|
|
30
30
|
const snapshot = s => {
|
|
31
31
|
const effects = s?.metadata?.effects ?? [], known = registry?.snapshot?.() ?? [];
|
|
32
32
|
const rows = effects.map(p => {
|
|
@@ -76,16 +76,18 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
|
|
|
76
76
|
if (pending?.source === s) safe(() => pending.controller.abort());
|
|
77
77
|
revision++; stamp = null;
|
|
78
78
|
}
|
|
79
|
-
function dispose() {
|
|
80
|
-
if (dead) return; dead = true; discard(source);
|
|
79
|
+
function dispose(restartable = false) {
|
|
80
|
+
if (dead) return disposed; dead = true; discard(source);
|
|
81
81
|
safe(() => trial?.dispose()); safe(() => pending?.controller.abort()); safe(() => registry?.dispose?.()); safe(() => loader?.dispose?.()); safe(() => personal?.dispose()); safe(() => writer?.dispose()); safe(() => offAuth?.());
|
|
82
82
|
safe(() => host?.removeEventListener?.("pagehide", dispose));
|
|
83
83
|
for (const row of profiles.values()) safe(() => row.collector.dispose()); profiles.clear(); bindings.clear(); safe(() => onDispose({ error }));
|
|
84
|
+
disposed = Promise.all([safe(() => loader?.whenDisposed?.()), safe(() => registry?.whenDisposed?.())]).then(() => { if (restartable === true && claimed) episodes.delete(host); });
|
|
85
|
+
return disposed;
|
|
84
86
|
}
|
|
85
87
|
function fail(reason) { if (dead) return; error = reason; dispose(); safe(() => onChange({ error, rows: [], effects: [], loading: false })); }
|
|
86
88
|
const unavailable = { ok: false, sync() {}, changedChat() {}, reply() {}, detachChat() {}, attachChat() { return null; }, attachTrial() { return null; }, stopTrial() {}, trialSnapshot: () => ({ rows: [] }), snapshot: () => snapshot(null), dispose };
|
|
87
89
|
if (!host || typeof host !== "object" || episodes.has(host) || !identity || typeof current !== "function" || typeof observe !== "function" || typeof auth?.onSessionEnd !== "function" || typeof auth?.sessionEnded !== "function") return unavailable;
|
|
88
|
-
episodes.add(host); // Claim before invoking current/auth callbacks: one attempt per Window episode.
|
|
90
|
+
episodes.add(host); claimed = true; // Claim before invoking current/auth callbacks: one attempt per Window episode.
|
|
89
91
|
if (!alive()) { dispose(); return unavailable; }
|
|
90
92
|
try {
|
|
91
93
|
offAuth = auth.onSessionEnd(dispose);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { chatEffectIdentity } from "./effect-display-metadata.js";
|
|
1
2
|
/** #1629: one committed local Chat lease joins the reviewed host-only effect primitives. */
|
|
2
3
|
import { createEffectRuntimeLoader } from "./effect-runtime-loader.js";
|
|
3
4
|
import { createEffectRegistry } from "./effect-registry.js";
|
|
@@ -43,13 +44,7 @@ export function createEffectWriter(host, current) {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/** Frozen, server-rendered session identity only. A header-token/public/View load has no identity. */
|
|
46
|
-
export
|
|
47
|
-
try {
|
|
48
|
-
const b = host.document.body.dataset, x = JSON.parse(host.document.documentElement.getAttribute("data-prefs") || "null");
|
|
49
|
-
if (b.mode !== "full" || b.tier === "guest" || b.publicLink === "1" || b.webTransport === "poll" || !["person", "operator"].includes(x?.ownerScope) || x.unreadable === true || typeof x.ownerEpoch !== "string" || !/^[a-f0-9]{16}$/.test(x.ownerEpoch)) return null;
|
|
50
|
-
return Object.freeze({ epoch: x.ownerEpoch, scope: x.ownerScope });
|
|
51
|
-
} catch { return null; }
|
|
52
|
-
}
|
|
47
|
+
export { chatEffectIdentity } from "./effect-display-metadata.js";
|
|
53
48
|
/** Chat-only public/guest capability stays separate from personal preferences and management. */
|
|
54
49
|
export function effectPlaybackIdentity(host) {
|
|
55
50
|
try {
|
|
@@ -25,9 +25,10 @@ export function createEffectCursor({ window: host, current, readImage, onChange
|
|
|
25
25
|
const unavailable = { ok: false, update() {}, snapshot: () => ({ status: "unavailable", id: null }), dispose() {} };
|
|
26
26
|
if (!host?.document || owners.has(host) || ![current, readImage, onChange, host.getComputedStyle, host.Blob, host.URL?.createObjectURL, host.URL?.revokeObjectURL, host.AbortController, host.setTimeout, host.clearTimeout, host.performance?.now].every(x => typeof x === "function")) return unavailable;
|
|
27
27
|
const token = {}; owners.set(host, token);
|
|
28
|
+
let finishDisposed; const disposed = new Promise(resolve => { finishDisposed = resolve; });
|
|
28
29
|
const doc = host.document; let dead = false, previous = null, wanted = null, pending = null, active = null, blocked = null, generation = 0, checking = false, notifying = false, status = "off";
|
|
29
30
|
const safe = fn => { try { return fn(); } catch { return null; } };
|
|
30
|
-
const release = () => { if (dead && !pending
|
|
31
|
+
const release = () => { if (dead && !pending) { if (owners.get(host) === token) owners.delete(host); finishDisposed(); } };
|
|
31
32
|
function alive() {
|
|
32
33
|
if (dead || checking) return false;
|
|
33
34
|
checking = true; const value = safe(() => current() === true && host.document === doc); checking = false;
|
|
@@ -121,5 +122,5 @@ export function createEffectCursor({ window: host, current, readImage, onChange
|
|
|
121
122
|
}
|
|
122
123
|
start(); tell();
|
|
123
124
|
}
|
|
124
|
-
return { ok: true, update, snapshot, dispose };
|
|
125
|
+
return { ok: true, update, snapshot, dispose, whenDisposed: () => disposed };
|
|
125
126
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** #1629: closed host display metadata. Neither this object nor its token grants a trial read. */
|
|
2
|
+
const KEYS = ["v", "offer", "id", "title", "expiresAt"], ID = /^[0-9a-f]{32}$/;
|
|
3
|
+
const FORBIDDEN = /(?!\u200d)[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
|
4
|
+
const VISIBLE = /[^\p{M}\p{C}\p{Z}\u115f\u1160\u3164\uffa0\u2800]/u;
|
|
5
|
+
let graphemes = null;
|
|
6
|
+
export function checkEffectTrialOffer(value) {
|
|
7
|
+
try {
|
|
8
|
+
if (!value || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return null;
|
|
9
|
+
const keys = Reflect.ownKeys(value);
|
|
10
|
+
if (keys.length !== KEYS.length || keys.some(k => typeof k !== "string" || !KEYS.includes(k))) return null;
|
|
11
|
+
const x = Object.create(null);
|
|
12
|
+
for (const key of KEYS) {
|
|
13
|
+
const d = Object.getOwnPropertyDescriptor(value, key);
|
|
14
|
+
if (!d || !("value" in d) || !d.enumerable) return null;
|
|
15
|
+
x[key] = d.value;
|
|
16
|
+
}
|
|
17
|
+
if (x.v !== 1 || typeof x.offer !== "string" || !ID.test(x.offer) || typeof x.id !== "string" || !ID.test(x.id)
|
|
18
|
+
|| typeof x.title !== "string" || x.title.length > 400 || x.title.normalize("NFC") !== x.title || x.title.trim() !== x.title
|
|
19
|
+
|| FORBIDDEN.test(x.title) || !VISIBLE.test(x.title) || !Number.isSafeInteger(x.expiresAt) || x.expiresAt < 0) return null;
|
|
20
|
+
graphemes ??= new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
21
|
+
let count = 0; for (const _part of graphemes.segment(x.title)) if (++count > 40) return null;
|
|
22
|
+
if (count < 1) return null;
|
|
23
|
+
return Object.freeze({ v: 1, offer: x.offer, id: x.id, title: x.title, expiresAt: x.expiresAt });
|
|
24
|
+
} catch { return null; }
|
|
25
|
+
}
|
|
26
|
+
/** Role and sender are host fields, never inferred from a message's text. Start must separately authorize. */
|
|
27
|
+
export function effectTrialMessage(message) {
|
|
28
|
+
if (!message || message.role !== "status" || message.sender !== "agend" || typeof message.instance !== "string"
|
|
29
|
+
|| message.instance.length < 1 || message.instance.length > 128) return null;
|
|
30
|
+
return checkEffectTrialOffer(message.effectTrial);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function chatEffectIdentity(host) {
|
|
34
|
+
try {
|
|
35
|
+
const b = host.document.body.dataset, x = JSON.parse(host.document.documentElement.getAttribute("data-prefs") || "null");
|
|
36
|
+
if (b.mode !== "full" || b.tier === "guest" || b.publicLink === "1" || b.webTransport === "poll" || !["person", "operator"].includes(x?.ownerScope) || x.unreadable === true || typeof x.ownerEpoch !== "string" || !/^[a-f0-9]{16}$/.test(x.ownerEpoch)) return null;
|
|
37
|
+
return Object.freeze({ epoch: x.ownerEpoch, scope: x.ownerScope });
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
@@ -42,6 +42,7 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
|
|
|
42
42
|
try { if (!host?.document || !host.document.body || registries.has(host) || typeof load !== "function" || typeof stockSnapshot !== "function" || typeof onChange !== "function" || typeof onProfile !== "function" || typeof onMount !== "function" || typeof onRetire !== "function" || typeof clock !== "function" || !safety || ["allowed", "begin", "finish", "pause", "resume"].some(k => typeof safety[k] !== "function") || ["requestAnimationFrame", "cancelAnimationFrame", "setTimeout", "clearTimeout", "MutationObserver", "ResizeObserver", "AbortController"].some(k => typeof host[k] !== "function")) return { ok: false, reason: "registry_unsupported" }; } catch { return { ok: false, reason: "registry_unsupported" }; }
|
|
43
43
|
let dead = false, blocked = null, revision = 0, last = null, lastView = null, frameSerial = 0, context = null, descriptors = [], raf = null, timer = null;
|
|
44
44
|
const slots = new Map(), loads = new Set(), paused = new Map(), offs = [], observed = new Set(), failures = new Map(), results = new Map();
|
|
45
|
+
let finishDisposed; const disposed = new Promise(resolve => { finishDisposed = resolve; });
|
|
45
46
|
let readingStock = false, stockFailure = "stock_evidence_invalid", warned = null;
|
|
46
47
|
let root, geometry, observer, resize, longTasks = null, longTasksSupported = false;
|
|
47
48
|
const time = () => {
|
|
@@ -342,7 +343,7 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
|
|
|
342
343
|
}).catch(() => { if (current(s)) retire(s, "resource_unavailable"); }).finally(() => {
|
|
343
344
|
if (request.timer !== null) host.clearTimeout(request.timer);
|
|
344
345
|
loads.delete(request); if (s.request === request) s.request = null;
|
|
345
|
-
if (!dead) reconcile(); else if (loads.size === 0
|
|
346
|
+
if (!dead) reconcile(); else if (loads.size === 0) { if (registries.get(host) === api) registries.delete(host); finishDisposed(); }
|
|
346
347
|
});
|
|
347
348
|
}
|
|
348
349
|
schedule(); tell();
|
|
@@ -530,11 +531,12 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
|
|
|
530
531
|
}
|
|
531
532
|
return sent;
|
|
532
533
|
},
|
|
534
|
+
whenDisposed: () => disposed,
|
|
533
535
|
dispose() {
|
|
534
536
|
if (dead) return; dead = true; revision++; stopAll("disposed");
|
|
535
537
|
if (raf !== null) host.cancelAnimationFrame(raf); if (timer !== null) host.clearTimeout(timer);
|
|
536
538
|
observer.disconnect(); resize.disconnect(); longTasks?.disconnect(); for (const off of offs) off();
|
|
537
|
-
geometry.dispose(); root.remove(); observed.clear(); if (loads.size === 0
|
|
539
|
+
geometry.dispose(); root.remove(); observed.clear(); if (loads.size === 0) { if (registries.get(host) === api) registries.delete(host); finishDisposed(); }
|
|
538
540
|
},
|
|
539
541
|
};
|
|
540
542
|
// Read-only host geometry seam; no selector guessing among poster and live roots.
|
|
@@ -143,7 +143,8 @@ export function createEffectRuntimeLoader({ window: host, ownerEpoch, current, o
|
|
|
143
143
|
if (!host || typeof host !== "object" || loaders.has(host) || typeof host.fetch !== "function" || typeof host.AbortController !== "function" || typeof host.setTimeout !== "function" || typeof host.clearTimeout !== "function" || typeof ownerEpoch !== "string" || !EPOCH.test(ownerEpoch) || typeof current !== "function" || typeof onRevoke !== "function") return { ok: false };
|
|
144
144
|
const reservation = {}; loaders.set(host, reservation);
|
|
145
145
|
let dead = false, revoked = false, previous = null; const pending = new Set();
|
|
146
|
-
const
|
|
146
|
+
let finishDisposed; const disposed = new Promise(resolve => { finishDisposed = resolve; });
|
|
147
|
+
const releaseWindow = () => { if (dead && pending.size === 0) { if (loaders.get(host) === reservation) loaders.delete(host); finishDisposed(); } };
|
|
147
148
|
// Terminal state is latched before callbacks. Abort releases logical callers, never physical reservations.
|
|
148
149
|
function revoke(reason) {
|
|
149
150
|
if (dead || revoked) return;
|
|
@@ -215,6 +216,7 @@ export function createEffectRuntimeLoader({ window: host, ownerEpoch, current, o
|
|
|
215
216
|
}
|
|
216
217
|
const ownerRead = (signal, settled = false) => run("placements", `/ui/effects/owner?ownerEpoch=${ownerEpoch}`, checkEffectOwnerSlots, signal, settled);
|
|
217
218
|
return { ok: true,
|
|
219
|
+
whenDisposed: () => disposed,
|
|
218
220
|
owner(signal) { return ownerRead(signal); },
|
|
219
221
|
placements(instance, signal) { return placementRead(instance, signal); },
|
|
220
222
|
resource(input, signal) { return resourceRead(input, signal); },
|
|
@@ -11,7 +11,7 @@ const SPECS = [
|
|
|
11
11
|
["confirmation", ".shell > .pending-stack, .shell .p-settings > .panel-body > .col > .pending-stack.inline"], ["preview", ".shell .chat-split > .pv-panel, .shell .chat-split > .pv-divider"],
|
|
12
12
|
["preview-menu", ".shell .thread details.pv-menu"], ["palette", ".shell .dock .cmd-palette"],
|
|
13
13
|
["drop", ".shell .p-chat > .drop-overlay"],
|
|
14
|
-
["thread-control", ".shell .chat-main > .amb-note, .shell .p-chat .jump-latest, .shell .thread .msg-tools, .shell .thread .st-more, .shell .thread .cb-head, .shell .thread .cb-more, .shell .thread .tc-head, .shell .thread .tc-more, .shell .thread .pv-head, .shell .thread .pv-note, .shell .thread .pv-banner, .shell .thread .effect-trial-offer, .shell .thread .click-chip"],
|
|
14
|
+
["thread-control", ".shell .chat-main > .amb-note, .shell .p-chat .jump-latest, .shell .thread .msg-tools, .shell .thread .msg > div.msg-actions, .shell .thread .msg > button.msg-quote, .shell .thread .st-more, .shell .thread .cb-head, .shell .thread .cb-more, .shell .thread .tc-head, .shell .thread .tc-more, .shell .thread .pv-head, .shell .thread .pv-note, .shell .thread .pv-banner, .shell .thread .effect-trial-offer, .shell .thread .click-chip"],
|
|
15
15
|
["link", ".shell .thread .md a, .shell .thread .st-text > a.st-link, .shell .dock .prompts .txt > a.st-link"], ["attachment", ".shell .thread .atts"], ["media", ".shell .thread .att-media"],
|
|
16
16
|
["panel-navigation", ".shell .p-fleet > .seg, .shell .p-settings > .seg"],
|
|
17
17
|
["panel-content", ".shell .p-fleet > .panel-body, .shell .p-needs > .panel-body, .shell .p-settings > .panel-body, .shell .p-log > .panel-body, .shell .p-details > .panel-body, .shell .p-view > .panel-body, .shell .p-view > .v-term, .shell .p-view > .v-id, .shell .p-view > .v-card"],
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Product feature policy only: this module imports no experimental runtime.
|
|
2
|
+
import { appStore } from "./app-store.js";
|
|
3
|
+
import { navStore } from "./app-nav.js";
|
|
4
|
+
import { ownerEffectsChosen, onOwnerEffectsChanged } from "./app-prefs.js";
|
|
5
|
+
import { register } from "./app-i18n.js";
|
|
6
|
+
import { html, useEffect, useState } from "./app-html.js";
|
|
7
|
+
register("cwControls", { en: { title: "Effects" }, "zh-TW": { title: "效果" } });
|
|
8
|
+
export function canvasWorkerEnabled() { return appStore.get().canvasWorker === true; }
|
|
9
|
+
export function onCanvasWorkerChange(fn) {
|
|
10
|
+
let value = canvasWorkerEnabled();
|
|
11
|
+
return appStore.subscribe(() => { const next = canvasWorkerEnabled(); if (next !== value) { value = next; fn(next); } });
|
|
12
|
+
}
|
|
13
|
+
export function useCanvasWorker() {
|
|
14
|
+
const [value, setValue] = useState(canvasWorkerEnabled);
|
|
15
|
+
useEffect(() => { setValue(canvasWorkerEnabled()); return onCanvasWorkerChange(setValue); }, []);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
// Never evaluate the optional module before admission. An obsolete import may finish, but cannot mount.
|
|
19
|
+
export function experimentalComponent(load, select) {
|
|
20
|
+
return function ExperimentalComponent(props) {
|
|
21
|
+
const enabled = useCanvasWorker(), [component, setComponent] = useState(null);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let current = true;
|
|
24
|
+
if (enabled) load().then(module => { if (current && canvasWorkerEnabled()) setComponent(() => select(module)); }, () => {});
|
|
25
|
+
return () => { current = false; };
|
|
26
|
+
}, [enabled]);
|
|
27
|
+
return enabled && component ? html`<${component} ...${props} />` : null;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** effect_chat_v of a chat with no placements: sha256(JSON.stringify([])), first 32 hex (fleet-manager's status row). */
|
|
32
|
+
export const EMPTY_CHAT_EFFECTS = "4f53cda18c2baa0c0354bb5f9a3ecbe5";
|
|
33
|
+
|
|
34
|
+
let mod = null, waking = null, chatStore = null, started = false, generation = 0, awakeGeneration = -1, retiring = Promise.resolve();
|
|
35
|
+
const awakeListeners = new Set();
|
|
36
|
+
|
|
37
|
+
/** Whether `name`'s chat has Canvas Worker placements (as the status rows say). */
|
|
38
|
+
export function chatHasEffects(name, app = appStore.get()) {
|
|
39
|
+
const v = name ? app.instances?.find(i => i.name === name)?.effect_chat_v : undefined;
|
|
40
|
+
return canvasWorkerEnabled() && typeof v === "string" && /^[a-f0-9]{32}$/.test(v) && v !== EMPTY_CHAT_EFFECTS;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The running effects owner, or null while dormant (or when it could not start: as getAppEffects always was). */
|
|
44
|
+
export function getAppEffects(host) { return canvasWorkerEnabled() && mod ? mod.getAppEffects(host) : null; }
|
|
45
|
+
|
|
46
|
+
/** Whether the runtime has been asked to start on this page. */
|
|
47
|
+
export function effectsAwake() { return waking !== null; }
|
|
48
|
+
/** Whether its module has loaded (started, or found it could not: getAppEffects() then says which). */
|
|
49
|
+
export function effectsLoaded() { return mod !== null && awakeGeneration === generation; }
|
|
50
|
+
|
|
51
|
+
/** Follow every completed runtime episode, including a replacement after an experimental off/on. */
|
|
52
|
+
export function onEffectsAwake(fn) {
|
|
53
|
+
if (typeof fn !== "function") return () => {};
|
|
54
|
+
awakeListeners.add(fn);
|
|
55
|
+
if (effectsLoaded()) { try { fn(); } catch { /* a listener's error is its own */ } }
|
|
56
|
+
return () => awakeListeners.delete(fn);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Load lazily; each explicitly enabled episode owns its startup and cancellation. */
|
|
60
|
+
export function wakeEffects() {
|
|
61
|
+
if (!canvasWorkerEnabled()) return Promise.resolve(null);
|
|
62
|
+
if (waking) return waking;
|
|
63
|
+
const episode = generation;
|
|
64
|
+
waking = (mod ? Promise.resolve(mod) : import("/ui/js/app-effects.js")).then(async m => {
|
|
65
|
+
mod = m;
|
|
66
|
+
await retiring;
|
|
67
|
+
if (episode !== generation || !canvasWorkerEnabled()) return null;
|
|
68
|
+
const owner = chatStore ? m.startAppEffects({ store: chatStore }) : null;
|
|
69
|
+
awakeGeneration = episode;
|
|
70
|
+
for (const fn of [...awakeListeners]) { try { fn(); } catch { /* the rest still hear */ } }
|
|
71
|
+
return owner;
|
|
72
|
+
}, () => null);
|
|
73
|
+
return waking;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Wake when the open chat has placements or the person has a personal effect. */
|
|
77
|
+
function check() {
|
|
78
|
+
if (!canvasWorkerEnabled()) {
|
|
79
|
+
generation++;
|
|
80
|
+
const owner = mod?.getAppEffects();
|
|
81
|
+
if (owner) retiring = Promise.resolve(owner.dispose?.(true));
|
|
82
|
+
waking = null;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (waking) return;
|
|
86
|
+
const route = navStore.get().route;
|
|
87
|
+
if ((route?.panel === "chat" && chatHasEffects(route.instance)) || ownerEffectsChosen()) void wakeEffects();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Boot (once per page, with the Chat store the runtime binds to). Starts nothing but the watch. */
|
|
91
|
+
export function startEffectsWaker({ store }) {
|
|
92
|
+
chatStore = store;
|
|
93
|
+
if (started) return;
|
|
94
|
+
started = true;
|
|
95
|
+
appStore.subscribe(check); navStore.subscribe(check); onOwnerEffectsChanged(check);
|
|
96
|
+
check();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Full Canvas Worker metadata stays in the experimental graph, never in ordinary chat boot.
|
|
100
|
+
let metadata = null, metadataPending = null;
|
|
101
|
+
const metadataListeners = new Set();
|
|
102
|
+
export function onCanvasMetadataChange(fn) { metadataListeners.add(fn); return () => metadataListeners.delete(fn); }
|
|
103
|
+
export function loadCanvasWorkerMetadata() {
|
|
104
|
+
if (!canvasWorkerEnabled()) return Promise.resolve(null);
|
|
105
|
+
if (metadata) return Promise.resolve(metadata);
|
|
106
|
+
if (metadataPending?.episode === generation) return metadataPending.promise;
|
|
107
|
+
const episode = generation, op = { episode, promise: null };
|
|
108
|
+
metadataPending = op;
|
|
109
|
+
op.promise = import("./effect-display-metadata.js").then(value => {
|
|
110
|
+
if (episode !== generation || !canvasWorkerEnabled()) return null;
|
|
111
|
+
metadata = value;
|
|
112
|
+
for (const fn of [...metadataListeners]) { try { fn(); } catch { /* independent viewers */ } }
|
|
113
|
+
appStore.set({ canvasMetadataEpoch: episode });
|
|
114
|
+
return metadata;
|
|
115
|
+
}, () => null).finally(() => { if (metadataPending === op) metadataPending = null; });
|
|
116
|
+
return op.promise;
|
|
117
|
+
}
|
|
118
|
+
export function readCanvasTrialMessage(message) {
|
|
119
|
+
if (!canvasWorkerEnabled()) return null;
|
|
120
|
+
if (!metadata) { void loadCanvasWorkerMetadata(); return undefined; }
|
|
121
|
+
return metadata.effectTrialMessage(message);
|
|
122
|
+
}
|
|
123
|
+
export function canvasWorkerMenuAvailable(host) {
|
|
124
|
+
if (!canvasWorkerEnabled()) return false;
|
|
125
|
+
if (!metadata) { void loadCanvasWorkerMetadata(); return false; }
|
|
126
|
+
return metadata.chatEffectIdentity(host) !== null;
|
|
127
|
+
}
|
|
@@ -196,6 +196,8 @@ function ColorDialog({ name, it, onClose }) {
|
|
|
196
196
|
}, [name]);
|
|
197
197
|
// The preview: the instance as it would be drawn with the choice here.
|
|
198
198
|
const preview = identityOf({ ...(it || {}), color: { ...((it && it.color) || {}), person: color || undefined }, color_show: { ...((it && it.color_show) || {}), person: places || undefined } }, { off: false });
|
|
199
|
+
// #2342: a swatch (or "no pick") is a choice, not text: it starts the hex field's undo history afresh (undoing the
|
|
200
|
+
// text back to a typed colour would leave the box and the chosen colour apart — Save would send the other one).
|
|
199
201
|
const pick = (c) => { setColor(c); setHex(c && HEX.test(c) ? c : ""); setError(""); };
|
|
200
202
|
const togglePlace = (p) => {
|
|
201
203
|
const base = places || preview.show;
|
|
@@ -211,7 +213,8 @@ function ColorDialog({ name, it, onClose }) {
|
|
|
211
213
|
onClose();
|
|
212
214
|
} catch (err) { setError(t("color.failed", (err && err.message) || "network")); setBusy(false); }
|
|
213
215
|
}
|
|
214
|
-
|
|
216
|
+
// The text as typed (#2342: trimming it in the box would rewrite it, losing its undo); spaces around it are ignored.
|
|
217
|
+
const onHex = (v) => { setHex(v); const w = v.trim(), c = /^#[0-9a-fA-F]{6}$/.test(w) ? w.toLowerCase() : null; if (c) { setColor(c); setError(""); } };
|
|
215
218
|
const off = colorsOff();
|
|
216
219
|
return html`<${Dialog} title=${t("color.title", name)} onClose=${onClose} busy=${busy}
|
|
217
220
|
actions=${html`<button type="button" class="btn" disabled=${busy || !loaded} onClick=${() => save({ color: null, color_show: null })}>${t("color.reset")}</button>
|
|
@@ -226,8 +229,8 @@ function ColorDialog({ name, it, onClose }) {
|
|
|
226
229
|
ref=${(el) => { if (el && el.style) { el.style.setProperty("--sw-light", p.light); el.style.setProperty("--sw-dark", p.dark); } }}
|
|
227
230
|
onClick=${() => pick(`preset:${p.id}`)}></button>`)}
|
|
228
231
|
<label class="idc-custom">${t("color.custom")}
|
|
229
|
-
<input type="color" value=${HEX.test(hex) ? hex : "#808080"} onInput=${(e) => onHex(e.currentTarget.value)} aria-label=${t("color.custom")} />
|
|
230
|
-
<input type="text" class="idc-hex" inputmode="text" spellcheck=${false} maxlength="7" placeholder="#rrggbb" value=${hex} onInput=${(e) => onHex(e.currentTarget.value
|
|
232
|
+
<input type="color" value=${HEX.test(hex.trim()) ? hex.trim() : "#808080"} onInput=${(e) => onHex(e.currentTarget.value)} aria-label=${t("color.custom")} />
|
|
233
|
+
<input type="text" class="idc-hex" inputmode="text" spellcheck=${false} maxlength="7" placeholder="#rrggbb" value=${hex} onInput=${(e) => onHex(e.currentTarget.value)} aria-label=${t("color.hex")} /></label>
|
|
231
234
|
<button type="button" class="btn btn-sm" disabled=${!color} onClick=${() => pick(null)}>${t("color.noPick")}</button>
|
|
232
235
|
</fieldset>
|
|
233
236
|
<fieldset class="idc-places"><legend>${t("color.places")}</legend>
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// Public (/assets/): an anonymous View reader gets the same list, fed by View's roster read instead of status frames.
|
|
10
10
|
import { html, useLayoutEffect, useRef, useState } from "./app-html.js";
|
|
11
11
|
import { t } from "./app-i18n.js";
|
|
12
|
+
import { recordEdit } from "./text-undo.js";
|
|
12
13
|
import { appStore, createStore, guestPage, useStore } from "./app-store.js";
|
|
13
14
|
import { chatPath, detailsPath, logPath, viewPath } from "./app-route.js";
|
|
14
15
|
import { Icon } from "./ui-icons.js";
|
|
@@ -648,9 +649,9 @@ export function InstanceNav({ route, items, loaded = true, exec = {}, awaiting =
|
|
|
648
649
|
<label class="v-filter-box"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("filterPh")}</span>
|
|
649
650
|
<input ref=${filterRef} id="filterInput" type="text" autocomplete="off" spellcheck=${false} placeholder=${tn("filterPh")} value=${f.q}
|
|
650
651
|
onInput=${(e) => setNavFilter({ q: e.target.value })}
|
|
651
|
-
onKeyDown=${(e) => { if (e.key === "Escape" && f.q) { e.preventDefault(); e.stopPropagation(); setNavFilter({ q: "" }); } }} />
|
|
652
|
+
onKeyDown=${(e) => { if (e.key === "Escape" && f.q) { e.preventDefault(); e.stopPropagation(); recordEdit(e.target); setNavFilter({ q: "" }); } }} />
|
|
652
653
|
${f.q ? html`<button type="button" class="icon-btn v-filter-clear" aria-label=${tn("filterClear")} title=${tn("filterClear")}
|
|
653
|
-
onClick=${() => { setNavFilter({ q: "" }); filterRef.current && filterRef.current.focus(); }}><${Icon} name="close" size=${14} /></button>` : null}
|
|
654
|
+
onClick=${() => { recordEdit(filterRef.current); setNavFilter({ q: "" }); filterRef.current && filterRef.current.focus(); }}><${Icon} name="close" size=${14} /></button>` : null}
|
|
654
655
|
</label>
|
|
655
656
|
<div class="nav-facets">
|
|
656
657
|
<div class="nav-facet-groups">
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// #2342: undo and redo in every text field — one history per field, kept here instead of the browser's.
|
|
2
|
+
//
|
|
3
|
+
// The browser's own undo is one stack for the whole page (Chromium and WebKit alike): Ctrl+Z in one box undoes the
|
|
4
|
+
// last edit in whichever box had it, and any script write to a field's value (a completion, a clear, a re-render
|
|
5
|
+
// that differs from the box) wipes the stack. So the keys are taken here, but only in a text field:
|
|
6
|
+
// - undo: Ctrl+Z (⌘Z on a Mac); redo: Ctrl+Shift+Z or Ctrl+Y (⌘⇧Z on a Mac, where Ctrl+Y is not redo). Anywhere else
|
|
7
|
+
// (a button, the page, a select) nothing is taken; a key with Alt, or mid-IME-composition, is left alone too.
|
|
8
|
+
// - a field's steps are recorded from its edits (beforeinput, input): typing is grouped into one step until a pause
|
|
9
|
+
// (UNDO.PAUSE_MS), a space, a different kind of edit (typing ↔ deleting), a paste, a line break or the caret moving
|
|
10
|
+
// elsewhere; an IME composition is one step, recorded when it ends.
|
|
11
|
+
// - the browser's own Undo and Redo commands (its Edit menu, a context menu: a trusted beforeinput historyUndo /
|
|
12
|
+
// historyRedo) go through the same history: cancelled and done here. Where a browser cannot cancel them (or a script
|
|
13
|
+
// runs execCommand("undo"), with no beforeinput at all), its own undo goes ahead and what it leaves resets the field's
|
|
14
|
+
// history (never a step that would undo the undo).
|
|
15
|
+
// - a script's change to a field's value the person should be able to undo (a completion, a pick, putting text back)
|
|
16
|
+
// calls recordEdit(field) first: the change becomes a step. Any other change (a clear after send, a reload after
|
|
17
|
+
// save) resets the field's history, as the browser's own undo does — a sent message is never undone back into the box.
|
|
18
|
+
// - undo and redo write the value and the caret back and dispatch `input`, so the page's state (Preact) follows.
|
|
19
|
+
// - password fields are left to the browser: no secret is kept here.
|
|
20
|
+
|
|
21
|
+
import { useState } from "./app-html.js";
|
|
22
|
+
|
|
23
|
+
export const UNDO = { PAUSE_MS: 1000, MAX_STEPS: 100 };
|
|
24
|
+
|
|
25
|
+
const TEXT_TYPES = new Set(["text", "search", "url", "email", "tel", "number", ""]);
|
|
26
|
+
/** A field this keeps a history for: a textarea, or a text-like input (never a password) that is not read-only or disabled. */
|
|
27
|
+
export function isTextField(el) {
|
|
28
|
+
if (!el || el.readOnly || el.disabled) return false;
|
|
29
|
+
const tag = (el.tagName || "").toLowerCase();
|
|
30
|
+
if (tag === "textarea") return true;
|
|
31
|
+
return tag === "input" && TEXT_TYPES.has((el.getAttribute && el.getAttribute("type") || el.type || "").toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const histories = new WeakMap();
|
|
35
|
+
/** The value and the caret of `el`. A number input has no selection: its caret is null. */
|
|
36
|
+
function snap(el) {
|
|
37
|
+
let s = null, e = null;
|
|
38
|
+
try { s = el.selectionStart; e = el.selectionEnd; } catch { /* a number input */ }
|
|
39
|
+
return { v: String(el.value ?? ""), s: s ?? null, e: e ?? null };
|
|
40
|
+
}
|
|
41
|
+
function historyFor(el) {
|
|
42
|
+
let h = histories.get(el);
|
|
43
|
+
if (!h) { h = { undo: [], redo: [], last: snap(el), kind: null, at: 0, marked: null, composing: false }; histories.set(el, h); }
|
|
44
|
+
return h;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Bring the history up to the field's value: a change made by a script since the last edit is a step when recordEdit
|
|
48
|
+
* marked it, and otherwise resets the history (what the browser does too).
|
|
49
|
+
*/
|
|
50
|
+
function sync(el, h) {
|
|
51
|
+
const now = String(el.value ?? "");
|
|
52
|
+
if (now === h.last.v) { h.marked = null; return; }
|
|
53
|
+
if (h.marked) { push(h.undo, h.marked); h.redo = []; }
|
|
54
|
+
else { h.undo = []; h.redo = []; }
|
|
55
|
+
h.marked = null;
|
|
56
|
+
h.last = snap(el);
|
|
57
|
+
h.kind = null;
|
|
58
|
+
}
|
|
59
|
+
function push(list, s) {
|
|
60
|
+
list.push(s);
|
|
61
|
+
if (list.length > UNDO.MAX_STEPS) list.shift();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The kind of an edit, for grouping: consecutive typing (or deleting) of one kind is one step. */
|
|
65
|
+
function kindOf(e) {
|
|
66
|
+
const t = e.inputType || "";
|
|
67
|
+
if (t === "insertText") return e.data && /\s/.test(e.data) ? "space" : "type";
|
|
68
|
+
if (t === "deleteContentBackward" || t === "deleteContentForward") return "delete";
|
|
69
|
+
return "other"; // a paste, a drop, a cut, a line break, a word delete: its own step
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Called before a script changes `el`'s value in a way the person may undo: the value now is the step to go back to. */
|
|
73
|
+
export function recordEdit(el) {
|
|
74
|
+
if (!isTextField(el)) return;
|
|
75
|
+
const h = historyFor(el);
|
|
76
|
+
sync(el, h);
|
|
77
|
+
h.marked = snap(el);
|
|
78
|
+
h.kind = null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The history kept for `el` (tests). */
|
|
82
|
+
export function historyOf(el) {
|
|
83
|
+
const h = histories.get(el);
|
|
84
|
+
return h ? { undo: h.undo.map((s) => s.v), redo: h.redo.map((s) => s.v), last: h.last.v } : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Put snapshot `s` into `el` and tell the page (an `input` event, as an edit would). */
|
|
88
|
+
function apply(el, s, type, win) {
|
|
89
|
+
el.value = s.v;
|
|
90
|
+
if (s.s != null) { try { el.setSelectionRange(s.s, s.e ?? s.s); } catch { /* a number input */ } }
|
|
91
|
+
const Ev = win && typeof win.InputEvent === "function" ? win.InputEvent : win && win.Event;
|
|
92
|
+
if (Ev) el.dispatchEvent(new Ev("input", { bubbles: true, ...(Ev === win.InputEvent ? { inputType: type } : {}) }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Undo (or redo) on `el`. False when there is nothing to do.
|
|
97
|
+
*/
|
|
98
|
+
export function step(el, redo, win = typeof window !== "undefined" ? window : null) {
|
|
99
|
+
if (!isTextField(el)) return false;
|
|
100
|
+
const h = historyFor(el);
|
|
101
|
+
sync(el, h);
|
|
102
|
+
const from = redo ? h.redo : h.undo, to = redo ? h.undo : h.redo;
|
|
103
|
+
if (!from.length) return false;
|
|
104
|
+
to.push(h.last);
|
|
105
|
+
h.last = from.pop();
|
|
106
|
+
h.kind = null;
|
|
107
|
+
h.applying = true;
|
|
108
|
+
try { apply(el, h.last, redo ? "historyRedo" : "historyUndo", win); } finally { h.applying = false; }
|
|
109
|
+
h.last = snap(el);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Which history key `e` is, or null: "undo", "redo". `mac`: ⌘ is the modifier and Ctrl+Y is not redo. */
|
|
114
|
+
export function historyKey(e, mac) {
|
|
115
|
+
if (e.altKey || e.isComposing || e.keyCode === 229) return null;
|
|
116
|
+
const mod = mac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey;
|
|
117
|
+
if (!mod) return null;
|
|
118
|
+
const k = (e.key || "").toLowerCase();
|
|
119
|
+
if (k === "z") return e.shiftKey ? "redo" : "undo";
|
|
120
|
+
if (k === "y" && !e.shiftKey && !mac) return "redo";
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Keep a history for every text field under `doc` and take the undo/redo keys in them. Returns an uninstall function.
|
|
126
|
+
*/
|
|
127
|
+
export function installTextUndo({ doc = document, win = typeof window !== "undefined" ? window : null, mac = false } = {}) {
|
|
128
|
+
const field = (e) => (isTextField(e.target) ? e.target : null);
|
|
129
|
+
const onBefore = (e) => {
|
|
130
|
+
const el = field(e);
|
|
131
|
+
// WebKit commits a composition after compositionend (deleteCompositionText, insertFromComposition): its step is
|
|
132
|
+
// the one compositionstart began.
|
|
133
|
+
if (!el || e.isComposing || /Composition/.test(e.inputType || "")) return;
|
|
134
|
+
const h = historyFor(el);
|
|
135
|
+
if (h.applying || h.composing) return; // a browser that does not flag its composition events
|
|
136
|
+
if (e.inputType === "historyUndo" || e.inputType === "historyRedo") {
|
|
137
|
+
if (e.cancelable) { e.preventDefault(); step(el, e.inputType === "historyRedo", win); }
|
|
138
|
+
else h.native = true;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
sync(el, h);
|
|
142
|
+
const kind = kindOf(e), now = Date.now(), at = snap(el);
|
|
143
|
+
// The same run of typing (or deleting) goes on: one step. A space ends a word's step (the next word is a new one).
|
|
144
|
+
// Anything else starts a new step from the value now.
|
|
145
|
+
const same = kind === "space" ? h.kind === "type" : kind !== "other" && kind === h.kind;
|
|
146
|
+
const goesOn = same && now - h.at < UNDO.PAUSE_MS && at.s === h.last.s && at.e === h.last.e;
|
|
147
|
+
if (!goesOn) { push(h.undo, at); h.redo = []; }
|
|
148
|
+
h.kind = kind;
|
|
149
|
+
h.at = now;
|
|
150
|
+
};
|
|
151
|
+
const onInput = (e) => {
|
|
152
|
+
const el = field(e);
|
|
153
|
+
if (!el) return;
|
|
154
|
+
const h = historyFor(el);
|
|
155
|
+
if (h.applying) return;
|
|
156
|
+
// The browser's own undo or redo went ahead (one it could not cancel, or a script's execCommand("undo"), which has no
|
|
157
|
+
// beforeinput): what it left is a change of its own — the history starts afresh.
|
|
158
|
+
if (h.native || e.inputType === "historyUndo" || e.inputType === "historyRedo") { h.native = false; h.undo = []; h.redo = []; h.kind = null; }
|
|
159
|
+
h.last = snap(el); // mid-composition too: the value the next edit starts from
|
|
160
|
+
};
|
|
161
|
+
const onCompStart = (e) => {
|
|
162
|
+
const el = field(e);
|
|
163
|
+
if (!el) return;
|
|
164
|
+
const h = historyFor(el);
|
|
165
|
+
sync(el, h);
|
|
166
|
+
push(h.undo, snap(el)); h.redo = [];
|
|
167
|
+
h.composing = true; h.kind = null;
|
|
168
|
+
};
|
|
169
|
+
const onCompEnd = (e) => {
|
|
170
|
+
const el = field(e);
|
|
171
|
+
if (!el) return;
|
|
172
|
+
const h = historyFor(el);
|
|
173
|
+
h.composing = false;
|
|
174
|
+
h.last = snap(el); // WebKit's last input comes after compositionend: onInput updates it again
|
|
175
|
+
};
|
|
176
|
+
const onKey = (e) => {
|
|
177
|
+
const el = field(e);
|
|
178
|
+
if (!el) return;
|
|
179
|
+
const which = historyKey(e, mac);
|
|
180
|
+
if (!which || historyFor(el).composing) return;
|
|
181
|
+
e.preventDefault(); // never the browser's page-wide stack, even when this field has no step
|
|
182
|
+
step(el, which === "redo", win);
|
|
183
|
+
};
|
|
184
|
+
doc.addEventListener("beforeinput", onBefore, true);
|
|
185
|
+
doc.addEventListener("input", onInput, true);
|
|
186
|
+
doc.addEventListener("compositionstart", onCompStart, true);
|
|
187
|
+
doc.addEventListener("compositionend", onCompEnd, true);
|
|
188
|
+
doc.addEventListener("keydown", onKey, true);
|
|
189
|
+
return () => {
|
|
190
|
+
doc.removeEventListener("beforeinput", onBefore, true);
|
|
191
|
+
doc.removeEventListener("input", onInput, true);
|
|
192
|
+
doc.removeEventListener("compositionstart", onCompStart, true);
|
|
193
|
+
doc.removeEventListener("compositionend", onCompEnd, true);
|
|
194
|
+
doc.removeEventListener("keydown", onKey, true);
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const trimmed = (v) => v.trim();
|
|
199
|
+
/**
|
|
200
|
+
* A field whose owner keeps its value normalized (trimmed, or a number): the box shows the text as typed and the owner
|
|
201
|
+
* gets `norm(text)`. Binding the box to the normalized value would rewrite it while the person types (a space vanishes,
|
|
202
|
+
* "1.50" turns into "1.5") and lose the field's undo history. When the owner's value becomes something the text does not
|
|
203
|
+
* normalize to (a clear, a load), the box shows `show(value)`.
|
|
204
|
+
* Returns [text for the box, onText(typed) → the normalized value].
|
|
205
|
+
*/
|
|
206
|
+
export function useTypedText(value, norm = trimmed, show = (v) => (v == null ? "" : String(v))) {
|
|
207
|
+
const [raw, setRaw] = useState(null);
|
|
208
|
+
const text = raw !== null && Object.is(norm(raw), value) ? raw : show(value);
|
|
209
|
+
return [text, (typed) => { setRaw(typed); return norm(typed); }];
|
|
210
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// #1408 §7: one dialog for the whole app — the native <dialog> opened with showModal(), so the browser traps focus,
|
|
2
2
|
// makes the rest inert and closes it on Esc. Focus goes back to whatever opened it. On a phone it is a bottom sheet
|
|
3
|
-
// with its own × (app.css). Text and buttons only; no style attribute (#1300).
|
|
3
|
+
// with its own × (app.css). Text and buttons only; no style attribute (#1300). `drawer`: on a desk it comes in from the
|
|
4
|
+
// right edge at full height, the page still in view beside it (the task board's card); a phone keeps the bottom sheet.
|
|
4
5
|
import { html, useEffect, useLayoutEffect, useRef } from "./app-html.js";
|
|
5
6
|
import { Icon } from "./ui-icons.js";
|
|
6
7
|
import { t } from "./app-i18n.js";
|
|
@@ -9,7 +10,7 @@ import { t } from "./app-i18n.js";
|
|
|
9
10
|
* <${Dialog} title="Create task" onClose=${…} actions=${html`…`}>…body…</${Dialog}>
|
|
10
11
|
* Rendered = open; the parent unmounts it to close. `busy` keeps Esc and the backdrop from closing it mid-request.
|
|
11
12
|
*/
|
|
12
|
-
export function Dialog({ title, onClose, actions, children, busy = false, wide = false, label }) {
|
|
13
|
+
export function Dialog({ title, onClose, actions, children, busy = false, wide = false, drawer = false, label }) {
|
|
13
14
|
const ref = useRef(null);
|
|
14
15
|
const opener = useRef(null);
|
|
15
16
|
const busyRef = useRef(busy);
|
|
@@ -35,7 +36,7 @@ export function Dialog({ title, onClose, actions, children, busy = false, wide =
|
|
|
35
36
|
d.addEventListener("click", onClick);
|
|
36
37
|
return () => { d.removeEventListener("cancel", onCancel); d.removeEventListener("click", onClick); };
|
|
37
38
|
}, [onClose]);
|
|
38
|
-
return html`<dialog data-trust="dialog" ref=${ref} class=${`dlg${wide ? " dlg-wide" : ""}`} aria-label=${label || title}>
|
|
39
|
+
return html`<dialog data-trust="dialog" ref=${ref} class=${`dlg${wide ? " dlg-wide" : ""}${drawer ? " dlg-drawer" : ""}`} aria-label=${label || title}>
|
|
39
40
|
<div class="dlg-box">
|
|
40
41
|
<div class="dlg-head"><h2 class="dlg-title">${title}</h2>
|
|
41
42
|
<button type="button" class="icon-btn dlg-x" aria-label=${t("app.close")} title=${t("app.close")} onClick=${() => { if (!busyRef.current) onClose(); }}><${Icon} name="close" /></button></div>
|