ompchamber 0.6.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/README.md +24 -8
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.5c0447cefe.css +2 -0
- package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
- package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
- package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
- package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
- package/dist/client/static/js/async/c.797d933882.js +1 -0
- package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
- package/dist/client/static/js/async/{g.8515cbc6f7.js → g.d1c2fe09aa.js} +2 -2
- package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
- package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
- package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
- package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
- package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
- package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
- package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
- package/dist/client/static/js/async/xn.6360379f71.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
- package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
- package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
- package/package.json +3 -1
- package/src/cli/lib/args.js +1 -1
- package/src/cli/lib/commands/restart.js +11 -0
- package/src/cli/lib/commands/update.js +21 -7
- package/src/cli/lib/runtime.js +6 -3
- package/src/client/components/common/MarkdownRenderer.tsx +69 -27
- package/src/client/components/common/code-editor/history.test.ts +118 -0
- package/src/client/components/common/code-editor/history.ts +101 -0
- package/src/client/components/common/code-editor/index.tsx +56 -66
- package/src/client/components/common/code-surface/index.tsx +84 -11
- package/src/client/components/common/code-surface/measure.ts +66 -17
- package/src/client/components/common/diagram-viewer/index.tsx +258 -0
- package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
- package/src/client/components/settings/LazyModal.tsx +1 -1
- package/src/client/components/settings/Modal.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
- package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
- package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
- package/src/client/components/workspace/file-explorer/index.tsx +18 -5
- package/src/client/hooks/chat/omp/index.ts +4 -109
- package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
- package/src/client/hooks/chat/omp/session-list.tsx +4 -0
- package/src/client/hooks/chat/timeline/actions.ts +0 -9
- package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
- package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
- package/src/client/hooks/chat/timeline/index.ts +6 -8
- package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
- package/src/client/hooks/chat/timeline/send.ts +16 -29
- package/src/client/hooks/chat/timeline/session-load.ts +48 -106
- package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
- package/src/client/hooks/editor/use-code-window.ts +118 -0
- package/src/client/hooks/ui/input-required-alert.ts +43 -0
- package/src/client/hooks/ui/notification-sound.ts +65 -0
- package/src/client/hooks/ui/theme.ts +22 -6
- package/src/client/tailwind.css +38 -12
- package/src/server/index.ts +4 -3
- package/src/server/lib/lifecycle/bun.ts +18 -0
- package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
- package/src/server/lib/lifecycle/launch-mode.ts +40 -0
- package/src/server/lib/lifecycle/restart.test.ts +21 -0
- package/src/server/lib/lifecycle/restart.ts +111 -0
- package/src/server/lib/omp/rpc/manager.ts +14 -3
- package/src/server/lib/omp/rpc/session-commands.ts +15 -1
- package/src/server/lib/omp/rpc/session-registry.ts +18 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
- package/src/server/lib/updates/apply.ts +8 -5
- package/src/server/lib/updates/install.ts +7 -4
- package/src/server/routes/health/probe.ts +2 -1
- package/src/shared/lib/chat/ask-questions.test.ts +169 -0
- package/src/shared/lib/chat/ask-questions.ts +264 -0
- package/src/shared/lib/code/lazy-window.test.ts +161 -0
- package/src/shared/lib/code/lazy-window.ts +156 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
- package/src/shared/lib/code/syntax-highlight.ts +14 -1
- package/src/shared/lib/code/windowed-highlight.ts +63 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -6
- package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
- package/src/shared/types/omp/agent.ts +2 -2
- package/src/shared/types/workspace.ts +4 -0
- package/dist/client/static/css/index.51f68210a5.css +0 -2
- package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
- package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
- package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
- package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
- package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
- package/dist/client/static/js/async/rf.d626afc367.js +0 -2
- package/dist/client/static/js/async/t.cda4891526.js +0 -1
- package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
- package/dist/client/static/js/index.47d3b04d5f.js +0 -33
- package/dist/client/static/js/index.47d3b04d5f.js.LICENSE.txt +0 -4
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
- /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.8515cbc6f7.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ompchamber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
|
|
5
7
|
"type": "module",
|
|
6
8
|
"engines": {
|
|
7
9
|
"bun": ">=1.4.0"
|
package/src/cli/lib/args.js
CHANGED
|
@@ -189,7 +189,7 @@ COMMANDS:
|
|
|
189
189
|
serve Start the OMPChamber web server (default when no command is given)
|
|
190
190
|
update Update OMPChamber to the latest GitHub release
|
|
191
191
|
stop Stop running OMPChamber instances (every one, or --port <port>)
|
|
192
|
-
restart Stop and
|
|
192
|
+
restart Stop and start again (every CLI-started instance, or --port <port>)
|
|
193
193
|
status Show running instances (every one, or --port <port>)
|
|
194
194
|
logs Print or follow server logs (every instance, or --port <port>)
|
|
195
195
|
|
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
// Scoping matches `status` and `stop`: without `--port` every live instance is
|
|
4
4
|
// restarted on its own recorded port/host/mode, and `--port <port>` restricts
|
|
5
5
|
// the restart to that one. With nothing running, the command is simply `serve`.
|
|
6
|
+
//
|
|
7
|
+
// Instances the CLI did not start are left alone, exactly as `ompchamber update`
|
|
8
|
+
// leaves them: `bun run dev` and `bun run start` own their own process, so
|
|
9
|
+
// replacing it would break a loop OMPChamber does not manage. `ompchamber stop`
|
|
10
|
+
// is still the deliberate way to end one.
|
|
6
11
|
|
|
7
12
|
import { findLiveInstance, listLiveInstances, stopInstance } from '@/cli/lib/runtime.js';
|
|
8
13
|
import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
|
|
9
14
|
import { log, isJson, isQuiet } from '@/cli/lib/output.js';
|
|
10
15
|
import { run as runServe } from '@/cli/lib/commands/serve.js';
|
|
16
|
+
import { isAutoRestartable, skipRestartNote } from '@/server/lib/lifecycle/restart';
|
|
11
17
|
|
|
12
18
|
function explicitPort(options) {
|
|
13
19
|
const raw = options?.port;
|
|
@@ -28,6 +34,11 @@ export async function run(options, ctx) {
|
|
|
28
34
|
if (targets.length === 0) return runServe(options, ctx);
|
|
29
35
|
|
|
30
36
|
for (const live of targets) {
|
|
37
|
+
if (!isAutoRestartable(live.launchMode)) {
|
|
38
|
+
if (!json && !quiet) log(`Skipping OMPChamber on port ${live.port}: ${skipRestartNote(live.launchMode)}.`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
if (!json && !quiet) log(`Stopping OMPChamber on port ${live.port} (pid ${live.pid})...`);
|
|
32
43
|
await stopInstance(live, { timeoutMs: STOP_TIMEOUT_MS });
|
|
33
44
|
|
|
@@ -2,19 +2,23 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The installation mechanics live in the shared update engine so the CLI and
|
|
4
4
|
// the console's "Update" button do the same thing; this command owns the
|
|
5
|
-
// terminal presentation and restarts
|
|
6
|
-
// build
|
|
5
|
+
// terminal presentation and restarts the instances still serving the previous
|
|
6
|
+
// build (the ones the CLI started — a server running from source is left to its
|
|
7
|
+
// own launcher).
|
|
7
8
|
|
|
8
9
|
import { color, configure, error, log, ok, warn, printJson, isJson, isQuiet } from '@/cli/lib/output.js';
|
|
9
10
|
import { listLiveInstances, findLiveInstance, stopInstance } from '@/cli/lib/runtime.js';
|
|
10
11
|
import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
|
|
11
12
|
import { run as runServe } from '@/cli/lib/commands/serve.js';
|
|
13
|
+
import { isAutoRestartable, skipRestartNote } from '@/server/lib/lifecycle/restart';
|
|
12
14
|
import { resolveInstallContext, resolveOmpChamberVersion, updateOmpChamber } from '@/server/lib/updates/install';
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Live instances to restart after an update: every one with `--all`, otherwise
|
|
16
18
|
* the first. Their recorded port/host/mode is reused, so a server started on a
|
|
17
|
-
* non-default port comes back on that same port.
|
|
19
|
+
* non-default port comes back on that same port. Instances started outside the
|
|
20
|
+
* CLI (`bun run dev`, `bun run start`, a supervisor) are reported and left
|
|
21
|
+
* running — their own launcher is what applies the new files.
|
|
18
22
|
*/
|
|
19
23
|
async function liveInstances(options) {
|
|
20
24
|
if (options?.all) return await listLiveInstances();
|
|
@@ -24,7 +28,14 @@ async function liveInstances(options) {
|
|
|
24
28
|
|
|
25
29
|
async function restartInstances(options, ctx) {
|
|
26
30
|
const restarted = [];
|
|
31
|
+
const skipped = [];
|
|
27
32
|
for (const entry of await liveInstances(options)) {
|
|
33
|
+
if (!isAutoRestartable(entry.launchMode)) {
|
|
34
|
+
skipped.push({ port: entry.port, pid: entry.pid, launchMode: entry.launchMode });
|
|
35
|
+
log(`Skipping OMPChamber on port ${entry.port}: ${skipRestartNote(entry.launchMode)}.`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
log(`Restarting OMPChamber on port ${entry.port} (pid ${entry.pid})...`);
|
|
29
40
|
await stopInstance(entry, { timeoutMs: STOP_TIMEOUT_MS });
|
|
30
41
|
await runServe(
|
|
@@ -41,7 +52,7 @@ async function restartInstances(options, ctx) {
|
|
|
41
52
|
);
|
|
42
53
|
restarted.push(entry.port);
|
|
43
54
|
}
|
|
44
|
-
return restarted;
|
|
55
|
+
return { restarted, skipped };
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
async function runCheck(pkgRoot, json) {
|
|
@@ -91,8 +102,10 @@ export async function run(options, ctx) {
|
|
|
91
102
|
// Restarting prints through the serve command; silence it so stdout stays
|
|
92
103
|
// one JSON document.
|
|
93
104
|
if (result.success && result.updated && shouldRestart) configure({ json: false, quiet: true });
|
|
94
|
-
const restarted = result.success && result.updated && shouldRestart
|
|
95
|
-
|
|
105
|
+
const { restarted, skipped } = result.success && result.updated && shouldRestart
|
|
106
|
+
? await restartInstances(options, ctx)
|
|
107
|
+
: { restarted: [], skipped: [] };
|
|
108
|
+
printJson({ ...result, restarted, skipped });
|
|
96
109
|
if (!result.success) process.exitCode = 1;
|
|
97
110
|
return;
|
|
98
111
|
}
|
|
@@ -111,7 +124,8 @@ export async function run(options, ctx) {
|
|
|
111
124
|
return;
|
|
112
125
|
}
|
|
113
126
|
|
|
114
|
-
|
|
127
|
+
const { restarted, skipped } = await restartInstances(options, ctx);
|
|
128
|
+
if (restarted.length === 0 && skipped.length === 0) {
|
|
115
129
|
log('No running instance to restart.');
|
|
116
130
|
}
|
|
117
131
|
}
|
package/src/cli/lib/runtime.js
CHANGED
|
@@ -13,6 +13,7 @@ import fs from 'node:fs';
|
|
|
13
13
|
import { ensureDataDirs, getLogFilePath } from '@/cli/lib/paths.js';
|
|
14
14
|
import { killChildTree, STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
|
|
15
15
|
import { getProcessState, isProcessAlive } from '@/server/lib/lifecycle/identity';
|
|
16
|
+
import { launchModeArg } from '@/server/lib/lifecycle/launch-mode';
|
|
16
17
|
import { listInstanceRecords, readInstanceRecord, removeInstanceRecord } from '@/server/lib/lifecycle/instance';
|
|
17
18
|
import { fetchHealth, probeHost } from '@/server/lib/lifecycle/probe';
|
|
18
19
|
import { joinPath, homeDir } from '@/cli/lib/path-utils.js';
|
|
@@ -134,7 +135,10 @@ export function resolveBunBin() {
|
|
|
134
135
|
* the SSR shell either way. `--ompchamber-server` is an identity marker: it
|
|
135
136
|
* makes this process recognizable in `ps` output, which is how a starting
|
|
136
137
|
* server decides whether a busy port belongs to OMPChamber before signalling
|
|
137
|
-
* anything.
|
|
138
|
+
* anything. The launch mode rides along as argv (`--launch-mode=`), never as an
|
|
139
|
+
* environment variable: env is inherited by descendants, so a `bun run dev`
|
|
140
|
+
* started from an OMPChamber shell would otherwise be labelled `daemon` and
|
|
141
|
+
* replaced by the next update.
|
|
138
142
|
*/
|
|
139
143
|
export function buildServeInvocation({ pkgRoot, mode, port, host, launchMode = 'daemon' }) {
|
|
140
144
|
const entry = joinPath(pkgRoot, 'src', 'server', 'index.ts');
|
|
@@ -146,13 +150,12 @@ export function buildServeInvocation({ pkgRoot, mode, port, host, launchMode = '
|
|
|
146
150
|
}
|
|
147
151
|
return {
|
|
148
152
|
file: resolveBunBin(),
|
|
149
|
-
args: [entry, '--ompchamber-server'],
|
|
153
|
+
args: [entry, '--ompchamber-server', launchModeArg(launchMode)],
|
|
150
154
|
env: {
|
|
151
155
|
...Bun.env,
|
|
152
156
|
NODE_ENV: mode === 'prod' ? 'production' : 'development',
|
|
153
157
|
PORT: String(port),
|
|
154
158
|
HOST: host,
|
|
155
|
-
OMPCHAMBER_LAUNCH_MODE: launchMode,
|
|
156
159
|
},
|
|
157
160
|
};
|
|
158
161
|
}
|
|
@@ -14,13 +14,16 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { TargetedMouseEvent } from 'preact';
|
|
17
|
-
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
|
|
17
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
18
18
|
import { renderMarkdown } from '@/shared/lib/markdown/marked';
|
|
19
19
|
import { hydrateMathBlocks, MATH_PENDING_CLASS, preloadKatex } from '@/shared/lib/markdown/katex';
|
|
20
20
|
import { sanitizeHtml } from '@/shared/lib/markdown/sanitize';
|
|
21
21
|
import { hydrateMermaidBlocks } from '@/shared/lib/markdown/mermaid';
|
|
22
22
|
import { copyToClipboard } from '@/client/hooks/ui/clipboard';
|
|
23
23
|
import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
|
|
24
|
+
import { DiagramViewer } from '@/client/components/common/diagram-viewer';
|
|
25
|
+
import { readDiagramBlock, type DiagramSnapshot } from '@/client/components/common/diagram-viewer/read-block';
|
|
26
|
+
|
|
24
27
|
|
|
25
28
|
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
26
29
|
|
|
@@ -31,6 +34,9 @@ interface MarkdownRendererProps {
|
|
|
31
34
|
|
|
32
35
|
export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
|
|
33
36
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
/** Block the open viewer was read from, so a theme re-render can refresh it. */
|
|
38
|
+
const diagramBlockRef = useRef<HTMLElement | null>(null);
|
|
39
|
+
const [diagram, setDiagram] = useState<DiagramSnapshot | null>(null);
|
|
34
40
|
|
|
35
41
|
const syntaxReady = useSyntaxReady();
|
|
36
42
|
|
|
@@ -83,13 +89,13 @@ export function MarkdownRenderer({ content, className = '' }: MarkdownRendererPr
|
|
|
83
89
|
if (!container) return;
|
|
84
90
|
|
|
85
91
|
let timer: number | undefined;
|
|
86
|
-
const hydrate = () => {
|
|
92
|
+
const hydrate = async () => {
|
|
87
93
|
const current = containerRef.current;
|
|
88
94
|
if (!current || !current.isConnected) return;
|
|
89
|
-
|
|
95
|
+
await hydrateMermaidBlocks(current);
|
|
90
96
|
};
|
|
91
97
|
|
|
92
|
-
hydrate();
|
|
98
|
+
void hydrate();
|
|
93
99
|
// React can rewrite the container's innerHTML with an identical html
|
|
94
100
|
// string (timeline re-renders) without this effect re-running, which
|
|
95
101
|
// would strand freshly-inserted pending blocks. Re-hydrate on any
|
|
@@ -99,45 +105,81 @@ export function MarkdownRenderer({ content, className = '' }: MarkdownRendererPr
|
|
|
99
105
|
const current = containerRef.current;
|
|
100
106
|
if (current?.isConnected) void hydrateMermaidBlocks(current, { cachedOnly: true });
|
|
101
107
|
window.clearTimeout(timer);
|
|
102
|
-
timer = window.setTimeout(hydrate, 120);
|
|
108
|
+
timer = window.setTimeout(() => void hydrate(), 120);
|
|
103
109
|
});
|
|
104
110
|
observer.observe(container, { childList: true });
|
|
105
111
|
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
// Diagrams bake the palette into their SVG, so every theme write must
|
|
113
|
+
// re-render them. The trigger is the `data-theme` attribute itself rather
|
|
114
|
+
// than the `omp:theme-changed` event: the navbar toggle and the settings
|
|
115
|
+
// modal are both writers, and observing the attribute catches all of them
|
|
116
|
+
// the moment the document changes. Vacuously cheap for a same-palette write
|
|
117
|
+
// (blocks already matching the new theme key are skipped).
|
|
118
|
+
const rehydrateForTheme = async () => {
|
|
119
|
+
await hydrate();
|
|
120
|
+
const block = diagramBlockRef.current;
|
|
121
|
+
// A timeline re-render can replace the block under an open viewer; keep
|
|
122
|
+
// the last snapshot then rather than closing the viewer on the user.
|
|
123
|
+
if (!block?.isConnected) return;
|
|
124
|
+
const snapshot = readDiagramBlock(block);
|
|
125
|
+
if (snapshot) setDiagram(snapshot);
|
|
126
|
+
};
|
|
127
|
+
const themeObserver = new MutationObserver(() => void rehydrateForTheme());
|
|
128
|
+
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
129
|
+
|
|
108
130
|
return () => {
|
|
109
131
|
window.clearTimeout(timer);
|
|
110
132
|
observer.disconnect();
|
|
111
|
-
|
|
133
|
+
themeObserver.disconnect();
|
|
112
134
|
};
|
|
113
135
|
}, [hasMermaid, html]);
|
|
114
136
|
|
|
115
137
|
const handleContainerClick = useCallback((e: TargetedMouseEvent<HTMLDivElement>) => {
|
|
116
138
|
const target = (e.target as HTMLElement).closest<HTMLButtonElement>('button.code-copy-float');
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
139
|
+
if (target) {
|
|
140
|
+
const text = target.dataset.copy ?? '';
|
|
141
|
+
if (!text) return;
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
void copyToClipboard(text).then((ok) => {
|
|
145
|
+
if (!ok) return;
|
|
146
|
+
target.setAttribute('data-copied', 'true');
|
|
147
|
+
target.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
|
148
|
+
setTimeout(() => {
|
|
149
|
+
target.removeAttribute('data-copied');
|
|
150
|
+
target.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
|
151
|
+
}, 2000);
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A rendered diagram is a door into the zoom/pan viewer; pending, failed,
|
|
157
|
+
// and still-streaming blocks stay inert.
|
|
158
|
+
const block = (e.target as HTMLElement).closest<HTMLElement>('.mermaid-block[data-mermaid-state="done"]');
|
|
159
|
+
if (!block) return;
|
|
160
|
+
const snapshot = readDiagramBlock(block);
|
|
161
|
+
if (!snapshot) return;
|
|
120
162
|
e.preventDefault();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
target.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
|
129
|
-
}, 2000);
|
|
130
|
-
});
|
|
163
|
+
diagramBlockRef.current = block;
|
|
164
|
+
setDiagram(snapshot);
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
const closeDiagram = useCallback(() => {
|
|
168
|
+
diagramBlockRef.current = null;
|
|
169
|
+
setDiagram(null);
|
|
131
170
|
}, []);
|
|
132
171
|
|
|
133
172
|
if (!content) return null;
|
|
134
173
|
|
|
135
174
|
return (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
175
|
+
<>
|
|
176
|
+
<div
|
|
177
|
+
ref={containerRef}
|
|
178
|
+
className={`prose-content text-[13px] text-ink leading-relaxed select-text ${className}`}
|
|
179
|
+
onClick={handleContainerClick}
|
|
180
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
181
|
+
/>
|
|
182
|
+
{diagram && <DiagramViewer {...diagram} onClose={closeDiagram} />}
|
|
183
|
+
</>
|
|
142
184
|
);
|
|
143
185
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
continuesTyping,
|
|
10
|
+
createHistory,
|
|
11
|
+
mergeTyping,
|
|
12
|
+
pushEdit,
|
|
13
|
+
pushRecord,
|
|
14
|
+
stepHistory,
|
|
15
|
+
HISTORY_TIME_GAP,
|
|
16
|
+
type HistoryRecord,
|
|
17
|
+
} from '@/client/components/common/code-editor/history';
|
|
18
|
+
|
|
19
|
+
const NOW = 1_700_000_000_000;
|
|
20
|
+
|
|
21
|
+
function record(value: string, caret: number, timestamp = NOW): HistoryRecord {
|
|
22
|
+
return { value, selectionStart: caret, selectionEnd: caret, timestamp };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('pushRecord', () => {
|
|
26
|
+
test('appends and moves the current position', () => {
|
|
27
|
+
const history = createHistory();
|
|
28
|
+
pushRecord(history, record('a', 1));
|
|
29
|
+
pushRecord(history, record('ab', 2));
|
|
30
|
+
|
|
31
|
+
expect(history.stack.map((entry) => entry.value)).toEqual(['a', 'ab']);
|
|
32
|
+
expect(history.offset).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('drops the redo entries that follow the current position', () => {
|
|
36
|
+
const history = createHistory();
|
|
37
|
+
pushRecord(history, record('a', 1));
|
|
38
|
+
pushRecord(history, record('ab', 2));
|
|
39
|
+
history.offset = 0;
|
|
40
|
+
|
|
41
|
+
pushRecord(history, record('ax', 2));
|
|
42
|
+
|
|
43
|
+
expect(history.stack.map((entry) => entry.value)).toEqual(['a', 'ax']);
|
|
44
|
+
expect(history.offset).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('pushEdit', () => {
|
|
49
|
+
test('caps the stack and keeps the position on the newest entry', () => {
|
|
50
|
+
const history = createHistory();
|
|
51
|
+
for (let i = 0; i < 120; i++) pushEdit(history, record(`v${i}`, 0, NOW + i));
|
|
52
|
+
|
|
53
|
+
expect(history.stack.length).toBe(100);
|
|
54
|
+
expect(history.stack[history.offset].value).toBe('v119');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('continuesTyping', () => {
|
|
59
|
+
const previous = record('const value = 1', 15);
|
|
60
|
+
|
|
61
|
+
test('merges a keystroke that keeps extending the same word', () => {
|
|
62
|
+
expect(continuesTyping(previous, 'const value = 12', 16, NOW + 100)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('starts a new entry once the burst window has passed', () => {
|
|
66
|
+
expect(continuesTyping(previous, 'const value = 12', 16, NOW + HISTORY_TIME_GAP + 1)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('does not merge a different word or a fresh word', () => {
|
|
70
|
+
expect(continuesTyping(record('const value', 11), 'const other', 11, NOW + 100)).toBe(false);
|
|
71
|
+
expect(continuesTyping(undefined, 'const value = 12', 16, NOW + 100)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('mergeTyping', () => {
|
|
76
|
+
test('rewrites the current entry in place', () => {
|
|
77
|
+
const history = createHistory();
|
|
78
|
+
pushRecord(history, record('a', 1));
|
|
79
|
+
mergeTyping(history, 'ab', 2, 2, NOW + 50);
|
|
80
|
+
|
|
81
|
+
expect(history.stack).toHaveLength(1);
|
|
82
|
+
expect(history.stack[0].value).toBe('ab');
|
|
83
|
+
expect(history.stack[0].selectionStart).toBe(2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('invalidates the redo entries after the current position', () => {
|
|
87
|
+
const history = createHistory();
|
|
88
|
+
pushRecord(history, record('a', 1));
|
|
89
|
+
pushRecord(history, record('ab', 2));
|
|
90
|
+
history.offset = 0;
|
|
91
|
+
|
|
92
|
+
mergeTyping(history, 'ax', 2, 2, NOW + 50);
|
|
93
|
+
|
|
94
|
+
expect(history.stack.map((entry) => entry.value)).toEqual(['ax']);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('stepHistory', () => {
|
|
99
|
+
test('walks back and forward through the stack', () => {
|
|
100
|
+
const history = createHistory();
|
|
101
|
+
pushRecord(history, record('a', 1));
|
|
102
|
+
pushRecord(history, record('ab', 2));
|
|
103
|
+
|
|
104
|
+
expect(stepHistory(history, 'undo')?.value).toBe('a');
|
|
105
|
+
expect(history.offset).toBe(0);
|
|
106
|
+
expect(stepHistory(history, 'redo')?.value).toBe('ab');
|
|
107
|
+
expect(history.offset).toBe(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('stops at both ends without moving', () => {
|
|
111
|
+
const history = createHistory();
|
|
112
|
+
pushRecord(history, record('a', 1));
|
|
113
|
+
|
|
114
|
+
expect(stepHistory(history, 'redo')).toBeNull();
|
|
115
|
+
expect(stepHistory(history, 'undo')).toBeNull();
|
|
116
|
+
expect(history.offset).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Undo history for the code editor.
|
|
8
|
+
*
|
|
9
|
+
* The editor cannot lean on the browser's own undo: it renders a transparent
|
|
10
|
+
* `<textarea>` over a highlighted `<pre>`, so a native undo would desync the two
|
|
11
|
+
* layers. Every entry therefore stores the whole buffer plus the caret, and this
|
|
12
|
+
* module is the bookkeeping behind ⌘Z / ⇧⌘Z — push, merge a typing burst into
|
|
13
|
+
* the word being typed, cap the stack, and step through it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface HistoryRecord {
|
|
17
|
+
value: string;
|
|
18
|
+
selectionStart: number;
|
|
19
|
+
selectionEnd: number;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface History {
|
|
24
|
+
stack: HistoryRecord[];
|
|
25
|
+
offset: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const HISTORY_LIMIT = 100;
|
|
29
|
+
/** Typing within this window of the previous edit merges into that entry. */
|
|
30
|
+
export const HISTORY_TIME_GAP = 3000;
|
|
31
|
+
/** The word the caret sits at the end of, which is what a burst keeps extending. */
|
|
32
|
+
const WORD_TAIL = /[^a-z0-9]([a-z0-9]+)$/i;
|
|
33
|
+
|
|
34
|
+
export function createHistory(): History {
|
|
35
|
+
return { stack: [], offset: -1 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Append `record`, dropping the redo entries that follow the current position. */
|
|
39
|
+
export function pushRecord(history: History, record: HistoryRecord): void {
|
|
40
|
+
if (history.stack.length && history.offset > -1) {
|
|
41
|
+
history.stack = history.stack.slice(0, history.offset + 1);
|
|
42
|
+
}
|
|
43
|
+
history.stack.push(record);
|
|
44
|
+
history.offset = history.stack.length - 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Push an edit and cap the stack, leaving `offset` on the same entry. */
|
|
48
|
+
export function pushEdit(history: History, record: HistoryRecord): void {
|
|
49
|
+
pushRecord(history, record);
|
|
50
|
+
if (history.stack.length <= HISTORY_LIMIT) return;
|
|
51
|
+
const extras = history.stack.length - HISTORY_LIMIT;
|
|
52
|
+
history.stack = history.stack.slice(extras);
|
|
53
|
+
history.offset = Math.max(history.offset - extras, 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The tail word of the line `position` sits on. */
|
|
57
|
+
function tailWord(value: string, position: number): string | undefined {
|
|
58
|
+
return value.substring(0, position).split('\n').pop()?.match(WORD_TAIL)?.[1];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether a change keeps typing the word the previous entry ended with — the
|
|
63
|
+
* test that folds a run of keystrokes into one undoable edit.
|
|
64
|
+
*/
|
|
65
|
+
export function continuesTyping(
|
|
66
|
+
last: HistoryRecord | undefined,
|
|
67
|
+
next: string,
|
|
68
|
+
selectionStart: number,
|
|
69
|
+
timestamp: number,
|
|
70
|
+
): boolean {
|
|
71
|
+
if (!last || timestamp - last.timestamp >= HISTORY_TIME_GAP) return false;
|
|
72
|
+
const previous = tailWord(last.value, last.selectionStart);
|
|
73
|
+
const current = tailWord(next, selectionStart);
|
|
74
|
+
return Boolean(previous && current?.startsWith(previous));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Fold a continuation into the current entry, which stays the current position. */
|
|
78
|
+
export function mergeTyping(
|
|
79
|
+
history: History,
|
|
80
|
+
next: string,
|
|
81
|
+
selectionStart: number,
|
|
82
|
+
selectionEnd: number,
|
|
83
|
+
timestamp: number,
|
|
84
|
+
): void {
|
|
85
|
+
// A merge is an edit like any other, so it drops the redo entries after it —
|
|
86
|
+
// otherwise typing straight after an undo would leave a stale redo target.
|
|
87
|
+
history.stack = history.stack.slice(0, history.offset + 1);
|
|
88
|
+
history.stack[history.offset] = { value: next, selectionStart, selectionEnd, timestamp };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Step one entry back (undo) or forward (redo) and return the buffer to
|
|
93
|
+
* restore, or null at either end of the stack.
|
|
94
|
+
*/
|
|
95
|
+
export function stepHistory(history: History, direction: 'undo' | 'redo'): HistoryRecord | null {
|
|
96
|
+
const index = direction === 'undo' ? history.offset - 1 : history.offset + 1;
|
|
97
|
+
const record = history.stack[index];
|
|
98
|
+
if (!record) return null;
|
|
99
|
+
history.offset = direction === 'undo' ? Math.max(index, 0) : Math.min(index, history.stack.length - 1);
|
|
100
|
+
return record;
|
|
101
|
+
}
|