janux 0.1.0 → 0.2.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/package.json +5 -2
- package/src/client/boot.test.ts +87 -0
- package/src/client/boot.ts +76 -2
- package/src/client/bridge.ts +55 -6
- package/src/client/glow.ts +74 -0
- package/src/client/index.ts +11 -0
- package/src/client/morph.ts +5 -0
- package/src/client/navigate.test.ts +172 -0
- package/src/client/navigate.ts +180 -0
- package/src/client/prefetch.ts +38 -0
- package/src/client/single-chunk.ts +14 -0
- package/src/client/webmcp.test.ts +201 -0
- package/src/client/webmcp.ts +165 -0
- package/src/define/factories.test.tsx +25 -0
- package/src/define/factories.ts +3 -2
- package/src/define/types.ts +9 -0
- package/src/index.ts +2 -1
- package/src/jsx-runtime.ts +3 -0
- package/src/render/server.test.ts +10 -0
- package/src/render/server.ts +3 -1
- package/src/runtime/effects.ts +7 -6
- package/src/runtime/instance.test.ts +23 -0
- package/src/runtime/instance.ts +8 -6
- package/src/runtime/intents.ts +7 -4
- package/src/state/mutation-gate.ts +32 -9
- package/src/state/reactive-state.test.ts +17 -11
- package/src/state/reactive-state.ts +8 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "janux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The agent-native UI framework core. One component, two faces: a view for humans, typed MCP tools & resources for AI agents.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -42,5 +42,8 @@
|
|
|
42
42
|
],
|
|
43
43
|
"files": [
|
|
44
44
|
"src"
|
|
45
|
-
]
|
|
45
|
+
],
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"diff-dom-streaming": "^0.6.6"
|
|
48
|
+
}
|
|
46
49
|
}
|
package/src/client/boot.test.ts
CHANGED
|
@@ -165,6 +165,93 @@ describe('client boot (resume without hydration)', () => {
|
|
|
165
165
|
expect(clientQuery).toHaveBeenCalledTimes(0);
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
+
it('emits janux:tool-call events around bridge calls (agent activity)', async () => {
|
|
169
|
+
const client = await serveAndBoot();
|
|
170
|
+
const phases: string[] = [];
|
|
171
|
+
const onTool = (event: any) => phases.push(`${event.detail.tool}:${event.detail.phase}`);
|
|
172
|
+
|
|
173
|
+
document.addEventListener('janux:tool-call', onTool);
|
|
174
|
+
await client.call('counter.inc');
|
|
175
|
+
const proposal: any = await client.call('counter.reset');
|
|
176
|
+
|
|
177
|
+
expect(phases).toEqual([
|
|
178
|
+
'counter.inc:start',
|
|
179
|
+
'counter.inc:ok',
|
|
180
|
+
'counter.reset:start',
|
|
181
|
+
'counter.reset:proposal',
|
|
182
|
+
]);
|
|
183
|
+
document.removeEventListener('janux:tool-call', onTool);
|
|
184
|
+
await client.approve(proposal.id);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('glow targets the element carrying the intent marker, falling back to the island', async () => {
|
|
188
|
+
const { html, snapshots } = await renderToString(jsx(counter as any, {}), {
|
|
189
|
+
initialState: { 'ui://counter#default': { n: 5, history: [] } },
|
|
190
|
+
storeDefs: { session },
|
|
191
|
+
});
|
|
192
|
+
const scripts = snapshots
|
|
193
|
+
.map(
|
|
194
|
+
(s) =>
|
|
195
|
+
`<script type="application/janux+state" data-uri="${s.uri}">${JSON.stringify({ state: s.state, sources: s.sources ?? {} })}</script>`,
|
|
196
|
+
)
|
|
197
|
+
.join('');
|
|
198
|
+
|
|
199
|
+
document.body.innerHTML = html + scripts;
|
|
200
|
+
document.getElementById('janux-glow-styles')?.remove();
|
|
201
|
+
const client = boot({ defs: [counter, session], glow: { duration: 10 } });
|
|
202
|
+
const island = document.querySelector('janux-island')!;
|
|
203
|
+
const incButton = document.querySelector('[data-jxa="counter#default:inc"]')!;
|
|
204
|
+
|
|
205
|
+
// counter.inc has a button in the view → the BUTTON glows, not the island
|
|
206
|
+
const pending = client.call('counter.inc');
|
|
207
|
+
|
|
208
|
+
expect(incButton.classList.contains('janux-agent-glow')).toBe(true);
|
|
209
|
+
expect(island.classList.contains('janux-agent-glow')).toBe(false);
|
|
210
|
+
await pending;
|
|
211
|
+
// the intent mutated state → re-render happened; morph must NOT wipe the class
|
|
212
|
+
expect(document.querySelector('[data-jxa="counter#default:inc"]')!.classList.contains('janux-agent-glow')).toBe(true);
|
|
213
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
214
|
+
|
|
215
|
+
// confirm-guarded intents do NOT glow on call — nothing executed yet
|
|
216
|
+
const resetPending = client.call('counter.reset');
|
|
217
|
+
|
|
218
|
+
expect(island.classList.contains('janux-agent-glow')).toBe(false);
|
|
219
|
+
const proposal: any = await resetPending;
|
|
220
|
+
|
|
221
|
+
expect(document.querySelectorAll('.janux-agent-glow')).toHaveLength(0);
|
|
222
|
+
|
|
223
|
+
// the glow happens on APPROVAL, when the action actually runs
|
|
224
|
+
const approvePending = client.approve(proposal.id);
|
|
225
|
+
|
|
226
|
+
expect(island.classList.contains('janux-agent-glow')).toBe(true);
|
|
227
|
+
await approvePending;
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('boot({ glow: true }) injects styles and glows the operated island', async () => {
|
|
231
|
+
const { html, snapshots } = await renderToString(jsx(counter as any, {}), {
|
|
232
|
+
initialState: { 'ui://counter#default': { n: 1, history: [] } },
|
|
233
|
+
storeDefs: { session },
|
|
234
|
+
});
|
|
235
|
+
const scripts = snapshots
|
|
236
|
+
.map(
|
|
237
|
+
(s) =>
|
|
238
|
+
`<script type="application/janux+state" data-uri="${s.uri}">${JSON.stringify({ state: s.state, sources: s.sources ?? {} })}</script>`,
|
|
239
|
+
)
|
|
240
|
+
.join('');
|
|
241
|
+
|
|
242
|
+
document.body.innerHTML = html + scripts;
|
|
243
|
+
document.getElementById('janux-glow-styles')?.remove();
|
|
244
|
+
const client = boot({ defs: [counter, session], glow: { duration: 10 } });
|
|
245
|
+
|
|
246
|
+
expect(document.getElementById('janux-glow-styles')).not.toBeNull();
|
|
247
|
+
const pending = client.call('counter.inc');
|
|
248
|
+
|
|
249
|
+
expect(document.querySelectorAll('.janux-agent-glow')).toHaveLength(1);
|
|
250
|
+
await pending;
|
|
251
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
252
|
+
expect(document.querySelectorAll('.janux-agent-glow')).toHaveLength(0);
|
|
253
|
+
});
|
|
254
|
+
|
|
168
255
|
it('survives a malformed state snapshot (boot regression)', async () => {
|
|
169
256
|
document.body.innerHTML =
|
|
170
257
|
'<script type="application/janux+state" data-uri="ui://broken">{not json</script>';
|
package/src/client/boot.ts
CHANGED
|
@@ -4,15 +4,26 @@ import type { Proposal } from '../runtime/intents';
|
|
|
4
4
|
import { createBridge, type JanuxBridge } from './bridge';
|
|
5
5
|
import { mountIsland, type MountContext } from './mount';
|
|
6
6
|
import { createClientRegistry, registerDef, type IslandLoader } from './registry';
|
|
7
|
+
import { enableAgentGlow, type GlowOptions } from './glow';
|
|
8
|
+
import { mountEagerIslands, performNavigation } from './navigate';
|
|
9
|
+
import { prefetch } from './prefetch';
|
|
10
|
+
import { installWebMCP } from './webmcp';
|
|
7
11
|
|
|
8
12
|
export interface BootOptions {
|
|
9
13
|
islands?: Record<string, IslandLoader>;
|
|
10
14
|
defs?: ComponentDef[];
|
|
11
15
|
ctx?: Record<string, unknown>;
|
|
16
|
+
/** Highlight islands while an agent operates them. `true` or `{ duration }`. */
|
|
17
|
+
glow?: boolean | GlowOptions;
|
|
18
|
+
/** SPA navigation via the Navigation API + streamed DOM diff. Default: true. */
|
|
19
|
+
navigation?: boolean;
|
|
20
|
+
/** Register mounted tools with `document.modelContext` (WebMCP), polyfilled when absent. Default: true. */
|
|
21
|
+
webmcp?: boolean;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
export interface JanuxClient extends JanuxBridge {
|
|
15
25
|
mount(id: string): Promise<unknown>;
|
|
26
|
+
navigate(url: string): Promise<void>;
|
|
16
27
|
proposals: Map<string, Proposal>;
|
|
17
28
|
}
|
|
18
29
|
|
|
@@ -59,8 +70,7 @@ function formInput(form: HTMLFormElement): Record<string, unknown> {
|
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
function trackInflight(mount: MountContext, work: Promise<unknown>): void {
|
|
62
|
-
mount.
|
|
63
|
-
work.catch(reportIntentError).finally(() => mount.inflight.delete(work));
|
|
73
|
+
awaitTracked(mount, work).catch(reportIntentError);
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
function listen(mount: MountContext): void {
|
|
@@ -86,6 +96,59 @@ function reportIntentError(error: unknown): void {
|
|
|
86
96
|
document.dispatchEvent(new CustomEvent('janux:error', { detail: String(error) }));
|
|
87
97
|
}
|
|
88
98
|
|
|
99
|
+
/** Tracks navigation work in `inflight` (settled() waits it) while propagating failures. */
|
|
100
|
+
async function awaitTracked(mount: MountContext, work: Promise<unknown>): Promise<void> {
|
|
101
|
+
mount.inflight.add(work);
|
|
102
|
+
try {
|
|
103
|
+
await work;
|
|
104
|
+
} finally {
|
|
105
|
+
mount.inflight.delete(work);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let nativeClickAt = 0;
|
|
110
|
+
|
|
111
|
+
function shouldIntercept(event: any): boolean {
|
|
112
|
+
if (!event.canIntercept || event.hashChange || event.downloadRequest || event.formData) return false;
|
|
113
|
+
if (new URL(event.destination.url).origin !== location.origin) return false;
|
|
114
|
+
// Prefer the precise source element; fall back to a recent data-native click.
|
|
115
|
+
if (event.sourceElement) return !event.sourceElement.closest?.('[data-native]');
|
|
116
|
+
const wasNative = Date.now() - nativeClickAt < 100;
|
|
117
|
+
|
|
118
|
+
nativeClickAt = 0;
|
|
119
|
+
|
|
120
|
+
return !wasNative;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Navigation API interception (Baseline 2026). Browsers without it keep
|
|
125
|
+
* native MPA links — which already work — so there is no History fallback.
|
|
126
|
+
*/
|
|
127
|
+
function installNavigation(mount: MountContext): void {
|
|
128
|
+
const nav = (window as any).navigation;
|
|
129
|
+
|
|
130
|
+
document.addEventListener(
|
|
131
|
+
'click',
|
|
132
|
+
(event) => {
|
|
133
|
+
if ((event.target as Element | null)?.closest?.('[data-native]')) nativeClickAt = Date.now();
|
|
134
|
+
},
|
|
135
|
+
true,
|
|
136
|
+
);
|
|
137
|
+
nav?.addEventListener('navigate', (event: any) => {
|
|
138
|
+
if (!shouldIntercept(event)) return;
|
|
139
|
+
event.intercept({
|
|
140
|
+
scroll: 'after-transition',
|
|
141
|
+
handler: () =>
|
|
142
|
+
awaitTracked(mount, performNavigation(event.destination.url, mount, { signal: event.signal })),
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
document.addEventListener('mouseover', (event) => {
|
|
146
|
+
const link = (event.target as Element | null)?.closest?.('a[href^="/"]:not([data-native])');
|
|
147
|
+
|
|
148
|
+
if (link) prefetch((link as HTMLAnchorElement).href);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
89
152
|
/**
|
|
90
153
|
* Resumes a server-rendered page: indexes islands and state snapshots,
|
|
91
154
|
* installs delegated listeners, and exposes the gui-agent bridge.
|
|
@@ -111,6 +174,8 @@ export function boot(options: BootOptions = {}): JanuxClient {
|
|
|
111
174
|
(options.defs ?? []).forEach((def) => registerDef(registry, def));
|
|
112
175
|
readSnapshots(mount);
|
|
113
176
|
listen(mount);
|
|
177
|
+
if (options.glow) enableAgentGlow(options.glow === true ? {} : options.glow);
|
|
178
|
+
if (options.navigation !== false) installNavigation(mount);
|
|
114
179
|
const bridge = createBridge(mount, proposals);
|
|
115
180
|
const client: JanuxClient = {
|
|
116
181
|
...bridge,
|
|
@@ -122,9 +187,18 @@ export function boot(options: BootOptions = {}): JanuxClient {
|
|
|
122
187
|
|
|
123
188
|
return mountIsland(id, root, mount);
|
|
124
189
|
},
|
|
190
|
+
async navigate(url: string) {
|
|
191
|
+
const nav = (window as any).navigation;
|
|
192
|
+
|
|
193
|
+
// Through the interceptor when the platform has it; direct SPA otherwise.
|
|
194
|
+
if (nav?.navigate) await nav.navigate(url).finished;
|
|
195
|
+
else await awaitTracked(mount, performNavigation(new URL(url, location.href).href, mount));
|
|
196
|
+
},
|
|
125
197
|
};
|
|
126
198
|
|
|
127
199
|
if (typeof window !== 'undefined') (window as any).janux = client;
|
|
200
|
+
if (options.webmcp !== false) installWebMCP(bridge);
|
|
201
|
+
mountEagerIslands(mount).catch(reportIntentError);
|
|
128
202
|
|
|
129
203
|
return client;
|
|
130
204
|
}
|
package/src/client/bridge.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildManifest, type Manifest } from '../manifest';
|
|
2
|
+
import { resolveGuard } from '../runtime/intents';
|
|
2
3
|
import type { JanuxInstance } from '../runtime/instance';
|
|
3
4
|
import type { Proposal } from '../runtime/intents';
|
|
4
5
|
import { ensureStore, mountIsland, type MountContext } from './mount';
|
|
@@ -39,6 +40,33 @@ function liveInstances(registry: ClientRegistry): JanuxInstance[] {
|
|
|
39
40
|
return [...registry.mounted.values(), ...registry.stores.values()];
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
interface ToolEventExtras {
|
|
44
|
+
guard?: string;
|
|
45
|
+
approval?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolves an intent's guard synchronously from the registered defs (no mount needed). */
|
|
49
|
+
function guardOf(mount: MountContext, component: string, intentName: string): string {
|
|
50
|
+
const def =
|
|
51
|
+
mount.registry.defs.get(component) ?? mount.registry.stores.get(component)?.def;
|
|
52
|
+
const intentDef = def?.intents?.[intentName];
|
|
53
|
+
|
|
54
|
+
return intentDef ? resolveGuard(intentDef, mount.ctx) : 'auto';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Emits `janux:tool-call` DOM events so apps can visualize agent activity (glow, chat lines). */
|
|
58
|
+
function emitToolEvent(
|
|
59
|
+
tool: string,
|
|
60
|
+
input: unknown,
|
|
61
|
+
phase: 'start' | 'ok' | 'proposal' | 'error',
|
|
62
|
+
extras: ToolEventExtras = {},
|
|
63
|
+
): void {
|
|
64
|
+
if (typeof document === 'undefined') return;
|
|
65
|
+
document.dispatchEvent(
|
|
66
|
+
new CustomEvent('janux:tool-call', { detail: { tool, input, phase, ...extras } }),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
42
70
|
/** The gui-agent bridge: `ui.read / ui.call / ui.settled / ui.subscribe` over the mounted tree. */
|
|
43
71
|
export function createBridge(mount: MountContext, proposals: Map<string, Proposal>): JanuxBridge {
|
|
44
72
|
const { registry } = mount;
|
|
@@ -53,12 +81,23 @@ export function createBridge(mount: MountContext, proposals: Map<string, Proposa
|
|
|
53
81
|
|
|
54
82
|
async call(tool, input) {
|
|
55
83
|
const [component = '', intentName = ''] = tool.split('.');
|
|
56
|
-
const
|
|
57
|
-
|
|
84
|
+
const guard = guardOf(mount, component, intentName);
|
|
85
|
+
|
|
86
|
+
emitToolEvent(tool, input, 'start', { guard });
|
|
87
|
+
try {
|
|
88
|
+
const instance = await instanceFor(component, mount);
|
|
89
|
+
const invoke = instance.intents[intentName];
|
|
58
90
|
|
|
59
|
-
|
|
91
|
+
if (!invoke) throw new Error(`Janux: unknown tool "${tool}"`);
|
|
92
|
+
const result: any = await invoke(input, { origin: 'agent' });
|
|
60
93
|
|
|
61
|
-
|
|
94
|
+
emitToolEvent(tool, input, result?.status === 'proposal' ? 'proposal' : 'ok', { guard });
|
|
95
|
+
|
|
96
|
+
return result;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
emitToolEvent(tool, input, 'error', { guard });
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
62
101
|
},
|
|
63
102
|
|
|
64
103
|
async approve(id) {
|
|
@@ -66,8 +105,18 @@ export function createBridge(mount: MountContext, proposals: Map<string, Proposa
|
|
|
66
105
|
|
|
67
106
|
if (!proposal) throw new Error(`Janux: unknown proposal "${id}"`);
|
|
68
107
|
proposals.delete(id);
|
|
69
|
-
|
|
70
|
-
|
|
108
|
+
// The approval IS the execution — this is when activity feedback fires.
|
|
109
|
+
emitToolEvent(proposal.tool, proposal.input, 'start', { guard: 'confirm', approval: true });
|
|
110
|
+
try {
|
|
111
|
+
const result = await proposal.execute();
|
|
112
|
+
|
|
113
|
+
emitToolEvent(proposal.tool, proposal.input, 'ok', { guard: 'confirm', approval: true });
|
|
114
|
+
|
|
115
|
+
return result;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
emitToolEvent(proposal.tool, proposal.input, 'error', { guard: 'confirm', approval: true });
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
71
120
|
},
|
|
72
121
|
|
|
73
122
|
reject(id) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export const GLOW_CLASS = 'janux-agent-glow';
|
|
2
|
+
|
|
3
|
+
export interface GlowOptions {
|
|
4
|
+
/** ms the glow lingers after the call finishes. Default 700. */
|
|
5
|
+
duration?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/* !important: the glow is runtime-owned feedback and must win over inline
|
|
9
|
+
view styles (e.g. a button that sets its own box-shadow). */
|
|
10
|
+
const GLOW_CSS = `
|
|
11
|
+
.${GLOW_CLASS} {
|
|
12
|
+
box-shadow: 0 0 0 3px var(--janux-glow-ring, rgba(124, 58, 237, 0.55)),
|
|
13
|
+
0 0 var(--janux-glow-spread, 34px) 4px var(--janux-glow-halo, rgba(34, 211, 238, 0.35)) !important;
|
|
14
|
+
border-radius: var(--janux-glow-radius, 18px);
|
|
15
|
+
transition: box-shadow 0.25s;
|
|
16
|
+
}`;
|
|
17
|
+
|
|
18
|
+
/** Idempotently installs the default glow styles. Override via the --janux-glow-* CSS vars. */
|
|
19
|
+
export function injectGlowStyles(doc: Document = document): void {
|
|
20
|
+
if (doc.getElementById('janux-glow-styles')) return;
|
|
21
|
+
const style = doc.createElement('style');
|
|
22
|
+
|
|
23
|
+
style.id = 'janux-glow-styles';
|
|
24
|
+
style.textContent = GLOW_CSS;
|
|
25
|
+
doc.head.appendChild(style);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Glows one element now, fading after `duration` ms. */
|
|
29
|
+
export function glowElement(el: Element, duration = 700): void {
|
|
30
|
+
el.classList.add(GLOW_CLASS);
|
|
31
|
+
setTimeout(() => el.classList.remove(GLOW_CLASS), duration);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The element that carries the intent's delegation marker (`on={intents.x}` /
|
|
36
|
+
* `<form intent>`), so the glow points at the exact control the agent "pressed".
|
|
37
|
+
* Falls back to the whole island when the intent has no element in the view.
|
|
38
|
+
*/
|
|
39
|
+
export function glowTargetFor(tool: string, scope: ParentNode = document): Element | undefined {
|
|
40
|
+
const [component = '', intentName = ''] = tool.split('.');
|
|
41
|
+
const island = scope.querySelector(`janux-island[data-jx^="${component}#"]`);
|
|
42
|
+
|
|
43
|
+
if (!island) return undefined;
|
|
44
|
+
const marker = `${island.getAttribute('data-jx')}:${intentName}`;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
island.querySelector(`[data-jxa="${marker}"], [data-jxform="${marker}"]`) ?? island
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Highlights the island an agent is operating (gui-agent style): listens to
|
|
53
|
+
* `janux:tool-call` bridge events and glows the matching island. Returns a
|
|
54
|
+
* disposer. `boot({ glow: true })` wires this for you.
|
|
55
|
+
*/
|
|
56
|
+
export function enableAgentGlow(options: GlowOptions = {}): () => void {
|
|
57
|
+
const duration = options.duration ?? 700;
|
|
58
|
+
const onToolCall = (event: Event): void => {
|
|
59
|
+
const { tool, phase, guard, approval } = (event as CustomEvent).detail ?? {};
|
|
60
|
+
const target = tool ? glowTargetFor(tool) : undefined;
|
|
61
|
+
|
|
62
|
+
if (!target) return;
|
|
63
|
+
// confirm-guarded calls only PROPOSE — nothing executes, nothing glows.
|
|
64
|
+
// The glow fires on approval, when the action actually runs.
|
|
65
|
+
if (guard === 'confirm' && !approval) return;
|
|
66
|
+
if (phase === 'start') target.classList.add(GLOW_CLASS);
|
|
67
|
+
else setTimeout(() => target.classList.remove(GLOW_CLASS), duration);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
injectGlowStyles();
|
|
71
|
+
document.addEventListener('janux:tool-call', onToolCall);
|
|
72
|
+
|
|
73
|
+
return () => document.removeEventListener('janux:tool-call', onToolCall);
|
|
74
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -5,3 +5,14 @@ export { mountIsland, type MountContext } from './mount';
|
|
|
5
5
|
export { morph } from './morph';
|
|
6
6
|
export { toDomNodes } from './dom';
|
|
7
7
|
export { clientApi } from './api-stub';
|
|
8
|
+
export { enableAgentGlow, injectGlowStyles, glowElement, GLOW_CLASS, type GlowOptions } from './glow';
|
|
9
|
+
export { performNavigation, mountEagerIslands } from './navigate';
|
|
10
|
+
export { prefetch } from './prefetch';
|
|
11
|
+
export {
|
|
12
|
+
installWebMCP,
|
|
13
|
+
createModelContextPolyfill,
|
|
14
|
+
type ModelContext,
|
|
15
|
+
type ModelContextPolyfill,
|
|
16
|
+
type WebMCPHandle,
|
|
17
|
+
type WebMCPToolDescriptor,
|
|
18
|
+
} from './webmcp';
|
package/src/client/morph.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
function syncAttrs(from: Element, to: Element): void {
|
|
2
|
+
const runtimeClasses = [...from.classList].filter((name) => name.startsWith('janux-'));
|
|
3
|
+
|
|
2
4
|
[...from.getAttributeNames()]
|
|
3
5
|
.filter((name) => !to.hasAttribute(name))
|
|
4
6
|
.forEach((name) => from.removeAttribute(name));
|
|
@@ -7,6 +9,9 @@ function syncAttrs(from: Element, to: Element): void {
|
|
|
7
9
|
from.setAttribute(name, to.getAttribute(name)!);
|
|
8
10
|
}
|
|
9
11
|
});
|
|
12
|
+
// `janux-*` classes belong to the runtime (e.g. the agent glow) — views
|
|
13
|
+
// never render them, so re-renders must not wipe them.
|
|
14
|
+
runtimeClasses.forEach((name) => from.classList.add(name));
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
function syncValue(from: Element, to: Element): void {
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';
|
|
3
|
+
import { component, intent, store } from '../define/factories';
|
|
4
|
+
import { jsx } from '../jsx-runtime';
|
|
5
|
+
import { int, list, schema, str, enums } from '../schema';
|
|
6
|
+
import { renderToString } from '../render/server';
|
|
7
|
+
import { boot } from './boot';
|
|
8
|
+
|
|
9
|
+
beforeAll(() => GlobalRegistrator.register({ url: 'http://localhost:3000/' }));
|
|
10
|
+
afterAll(() => GlobalRegistrator.unregister());
|
|
11
|
+
|
|
12
|
+
const counterDetach = mock(() => {});
|
|
13
|
+
|
|
14
|
+
const theme = store({
|
|
15
|
+
name: 'theme',
|
|
16
|
+
state: schema({ mode: enums(['dark', 'light']) }),
|
|
17
|
+
intents: { toggle: intent({ run: ({ state }: any) => (state.mode = 'light') }) },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const counter = component({
|
|
21
|
+
name: 'counter',
|
|
22
|
+
state: schema({ n: int() }),
|
|
23
|
+
lifecycle: { detach: counterDetach },
|
|
24
|
+
intents: { inc: intent({ run: ({ state }: any) => (state.n += 1) }) },
|
|
25
|
+
view: ({ state, intents }: any) =>
|
|
26
|
+
jsx('div', { children: [jsx('output', { children: String(state.n) }), jsx('button', { on: intents.inc, children: '+' })] }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const chat = component({
|
|
30
|
+
name: 'chat',
|
|
31
|
+
state: schema({ messages: list({ text: str() }) }),
|
|
32
|
+
intents: {
|
|
33
|
+
add: intent({ input: schema({ text: str() }), run: ({ state, input }: any) => state.messages.push(input) }),
|
|
34
|
+
},
|
|
35
|
+
view: ({ state }: any) => jsx('ul', { children: state.messages.map((m: any, i: number) => jsx('li', { key: String(i), children: m.text })) }),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const editorAttach = mock(() => {});
|
|
39
|
+
const editorDetach = mock(() => {});
|
|
40
|
+
|
|
41
|
+
const editor = component({
|
|
42
|
+
name: 'editor',
|
|
43
|
+
lifecycle: { attach: () => editorAttach(), detach: () => editorDetach() },
|
|
44
|
+
intents: {},
|
|
45
|
+
view: () => jsx('div', { class: 'editor', children: 'ready' }),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function snapshotScripts(snapshots: any[]): string {
|
|
49
|
+
return snapshots
|
|
50
|
+
.map(
|
|
51
|
+
(s) =>
|
|
52
|
+
`<script type="application/janux+state" data-uri="${s.uri}">${JSON.stringify({ state: s.state, sources: s.sources ?? {} })}</script>`,
|
|
53
|
+
)
|
|
54
|
+
.join('');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function pageHtml(title: string, node: unknown): Promise<string> {
|
|
58
|
+
const { html, snapshots } = await renderToString(node, { storeDefs: { theme } });
|
|
59
|
+
|
|
60
|
+
return `<!doctype html><html><head><title>${title}</title></head><body>${html}${snapshotScripts(snapshots)}</body></html>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('SPA navigation (streamed diff)', () => {
|
|
64
|
+
it('applies the three state rules: app stores live, persist islands live, the rest re-resumes', async () => {
|
|
65
|
+
const pageA = await pageHtml('Page A', [
|
|
66
|
+
jsx('h1', { children: 'A' }),
|
|
67
|
+
jsx(counter as any, {}),
|
|
68
|
+
jsx(chat as any, { persist: true }),
|
|
69
|
+
]);
|
|
70
|
+
const pageB = await pageHtml('Page B', [
|
|
71
|
+
jsx('h1', { children: 'B' }),
|
|
72
|
+
jsx(chat as any, { persist: true }),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
document.write(pageA);
|
|
76
|
+
document.close();
|
|
77
|
+
expect(document.querySelector('janux-island[data-jx="chat#default"]')!.hasAttribute('data-jx-persist')).toBe(true);
|
|
78
|
+
|
|
79
|
+
const client = boot({ defs: [counter, chat, theme] });
|
|
80
|
+
|
|
81
|
+
// live state before navigating
|
|
82
|
+
await client.call('theme.toggle');
|
|
83
|
+
await client.call('chat.add', { text: 'hello' });
|
|
84
|
+
await client.call('counter.inc');
|
|
85
|
+
await client.settled();
|
|
86
|
+
counterDetach.mockClear();
|
|
87
|
+
|
|
88
|
+
(globalThis as any).fetch = mock(async () => ({
|
|
89
|
+
ok: true,
|
|
90
|
+
text: async () => pageB,
|
|
91
|
+
}));
|
|
92
|
+
await client.navigate('/b');
|
|
93
|
+
|
|
94
|
+
// swapped content + title (whole-document diff)
|
|
95
|
+
expect(document.querySelector('h1')!.textContent).toBe('B');
|
|
96
|
+
expect(document.title).toBe('Page B');
|
|
97
|
+
|
|
98
|
+
// rule 1: app-scope store survives with live state
|
|
99
|
+
expect(((await client.read('store://theme')) as any).state.mode).toBe('light');
|
|
100
|
+
|
|
101
|
+
// rule 2: persisted island keeps its live instance state
|
|
102
|
+
expect(((await client.read('ui://chat')) as any).state.messages).toEqual([{ text: 'hello' }]);
|
|
103
|
+
expect(document.querySelector('janux-island[data-jx="chat#default"] li')!.textContent).toBe('hello');
|
|
104
|
+
|
|
105
|
+
// rule 3: the counter was disposed (detach ran) and is gone from the page
|
|
106
|
+
expect(counterDetach).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(document.querySelector('janux-island[data-jx="counter#default"]')).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('re-mounts an eager island when its page is revisited after navigating away', async () => {
|
|
111
|
+
const pageEditor = await pageHtml('Editor', [jsx('h1', { children: 'E' }), jsx(editor as any, { eager: true })]);
|
|
112
|
+
const pageDoc = await pageHtml('Doc', [jsx('h1', { children: 'D' })]);
|
|
113
|
+
|
|
114
|
+
document.write(pageEditor);
|
|
115
|
+
document.close();
|
|
116
|
+
editorAttach.mockClear();
|
|
117
|
+
editorDetach.mockClear();
|
|
118
|
+
const client = boot({ defs: [editor] });
|
|
119
|
+
|
|
120
|
+
await client.settled();
|
|
121
|
+
expect(editorAttach).toHaveBeenCalledTimes(1); // first visit mounts
|
|
122
|
+
|
|
123
|
+
// navigate away → the island is gone AND torn down (detach ran)
|
|
124
|
+
(globalThis as any).fetch = mock(async () => ({ ok: true, text: async () => pageDoc }));
|
|
125
|
+
await client.navigate('/doc');
|
|
126
|
+
expect(document.querySelector('janux-island[data-jx="editor#default"]')).toBeNull();
|
|
127
|
+
expect(editorDetach).toHaveBeenCalledTimes(1);
|
|
128
|
+
|
|
129
|
+
// revisit → the eager island mounts again from a clean slate (the playground
|
|
130
|
+
// relies on this attach/detach symmetry to reset Monaco)
|
|
131
|
+
(globalThis as any).fetch = mock(async () => ({ ok: true, text: async () => pageEditor }));
|
|
132
|
+
await client.navigate('/editor');
|
|
133
|
+
expect(document.querySelector('janux-island[data-jx="editor#default"]')).not.toBeNull();
|
|
134
|
+
expect(editorAttach).toHaveBeenCalledTimes(2);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('buffers the response into a single chunk before diffing (deterministic swap)', async () => {
|
|
138
|
+
const pageA = await pageHtml('Page A', jsx('h1', { children: 'A' }));
|
|
139
|
+
const pageB = await pageHtml('Page B', jsx('h1', { children: 'B' }));
|
|
140
|
+
let bodyRead = false;
|
|
141
|
+
|
|
142
|
+
document.write(pageA);
|
|
143
|
+
document.close();
|
|
144
|
+
const client = boot({ defs: [] });
|
|
145
|
+
|
|
146
|
+
(globalThis as any).fetch = mock(async () => ({
|
|
147
|
+
ok: true,
|
|
148
|
+
text: async () => pageB,
|
|
149
|
+
get body() {
|
|
150
|
+
bodyRead = true;
|
|
151
|
+
|
|
152
|
+
return new Response(pageB).body;
|
|
153
|
+
},
|
|
154
|
+
}));
|
|
155
|
+
await client.navigate('/b');
|
|
156
|
+
|
|
157
|
+
expect(document.querySelector('h1')!.textContent).toBe('B');
|
|
158
|
+
expect(bodyRead).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('emits janux:error and hard-navigates when the fetch fails', async () => {
|
|
162
|
+
(globalThis as any).fetch = mock(async () => ({ ok: false, status: 500 }));
|
|
163
|
+
document.write(await pageHtml('Page A', jsx(chat as any, {})));
|
|
164
|
+
document.close();
|
|
165
|
+
const client = boot({ defs: [chat] });
|
|
166
|
+
const errors: string[] = [];
|
|
167
|
+
|
|
168
|
+
document.addEventListener('janux:error', (event: any) => errors.push(event.detail));
|
|
169
|
+
await client.navigate('/broken'); // resolves — the fallback owns the failure
|
|
170
|
+
expect(errors.some((message) => /navigation fetch failed/.test(message))).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
});
|