janux 0.1.0 → 0.2.1
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 +12 -0
- package/src/client/morph.ts +5 -0
- package/src/client/navigate-tool.test.ts +55 -0
- package/src/client/navigate-tool.ts +78 -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 +222 -0
- package/src/client/webmcp.ts +169 -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.1
|
|
3
|
+
"version": "0.2.1",
|
|
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,15 @@ 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';
|
|
19
|
+
export { collectPageLinks, createNavigateTool, type PageLink } from './navigate-tool';
|
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,55 @@
|
|
|
1
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
2
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
3
|
+
import { GLOW_CLASS } from './glow';
|
|
4
|
+
import { collectPageLinks, createNavigateTool } from './navigate-tool';
|
|
5
|
+
|
|
6
|
+
beforeAll(() => GlobalRegistrator.register({ url: 'https://app.test/docs/guide/components' }));
|
|
7
|
+
afterAll(() => GlobalRegistrator.unregister());
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
document.body.innerHTML = `
|
|
11
|
+
<nav>
|
|
12
|
+
<a href="/docs/guide/components">Components</a>
|
|
13
|
+
<a href="/docs/guide/cli-and-deployment">CLI and deployment</a>
|
|
14
|
+
<a href="/docs/guide/cli-and-deployment">CLI (duplicate)</a>
|
|
15
|
+
<a href="https://github.com/aralroca/Janux">GitHub</a>
|
|
16
|
+
</nav>`;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('collectPageLinks', () => {
|
|
20
|
+
it('collects same-origin links, deduped, keeping the first label', () => {
|
|
21
|
+
const links = collectPageLinks();
|
|
22
|
+
|
|
23
|
+
expect(links).toEqual([
|
|
24
|
+
{ path: '/docs/guide/components', label: 'Components' },
|
|
25
|
+
{ path: '/docs/guide/cli-and-deployment', label: 'CLI and deployment' },
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('createNavigateTool', () => {
|
|
31
|
+
it('glows the pressed link, then navigates', async () => {
|
|
32
|
+
const assign = mock((path: string) => path);
|
|
33
|
+
|
|
34
|
+
(location as any).assign = assign;
|
|
35
|
+
const result = createNavigateTool().execute({ path: '/docs/guide/cli-and-deployment' });
|
|
36
|
+
const anchor = document.querySelector('a[href="/docs/guide/cli-and-deployment"]')!;
|
|
37
|
+
|
|
38
|
+
expect(result).toEqual({ navigated: '/docs/guide/cli-and-deployment', label: 'CLI and deployment' });
|
|
39
|
+
expect(anchor.classList.contains(GLOW_CLASS)).toBe(true);
|
|
40
|
+
expect(assign).not.toHaveBeenCalled();
|
|
41
|
+
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, 420));
|
|
43
|
+
expect(assign).toHaveBeenCalledWith('/docs/guide/cli-and-deployment');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects paths not linked on the page and returns the real links', () => {
|
|
47
|
+
const assign = mock((path: string) => path);
|
|
48
|
+
|
|
49
|
+
(location as any).assign = assign;
|
|
50
|
+
const result = createNavigateTool().execute({ path: '/docs/made/up' }) as any;
|
|
51
|
+
|
|
52
|
+
expect(assign).not.toHaveBeenCalled();
|
|
53
|
+
expect(result.links.map((link: any) => link.path)).toContain('/docs/guide/components');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { glowElement, injectGlowStyles } from './glow';
|
|
2
|
+
import type { WebMCPToolDescriptor } from './webmcp';
|
|
3
|
+
|
|
4
|
+
export interface PageLink {
|
|
5
|
+
path: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Bounds the tool payload on link-heavy pages. */
|
|
10
|
+
const MAX_LINKS = 100;
|
|
11
|
+
|
|
12
|
+
function toPageLink(anchor: HTMLAnchorElement): PageLink | undefined {
|
|
13
|
+
const url = new URL(anchor.getAttribute('href') ?? '', location.href);
|
|
14
|
+
|
|
15
|
+
if (url.origin !== location.origin) return undefined;
|
|
16
|
+
|
|
17
|
+
return { path: url.pathname + url.search + url.hash, label: anchor.textContent?.trim() ?? '' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The app's real navigation surface: every same-origin link currently in the
|
|
22
|
+
* DOM — which is exactly what the JSX projected, SSR'd or client-rendered,
|
|
23
|
+
* with zero authoring effort.
|
|
24
|
+
*/
|
|
25
|
+
export function collectPageLinks(): PageLink[] {
|
|
26
|
+
const anchors = [...document.querySelectorAll<HTMLAnchorElement>('a[href]')];
|
|
27
|
+
|
|
28
|
+
return anchors
|
|
29
|
+
.map(toPageLink)
|
|
30
|
+
.filter((link): link is PageLink => link !== undefined)
|
|
31
|
+
.filter((link, index, all) => all.findIndex((other) => other.path === link.path) === index)
|
|
32
|
+
.slice(0, MAX_LINKS);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Long enough to see the glow on the link the agent "pressed", short enough to feel instant. */
|
|
36
|
+
const GLOW_BEFORE_NAV_MS = 350;
|
|
37
|
+
|
|
38
|
+
function anchorFor(path: string): HTMLAnchorElement | undefined {
|
|
39
|
+
const matches = [...document.querySelectorAll<HTMLAnchorElement>('a[href]')].filter(
|
|
40
|
+
(anchor) => toPageLink(anchor)?.path === path,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Sidebars often repeat a link in a hidden mobile drawer — glow the visible one.
|
|
44
|
+
return matches.find((anchor) => anchor.getClientRects().length > 0) ?? matches[0];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function navigateTo(path: string): unknown {
|
|
48
|
+
const links = collectPageLinks();
|
|
49
|
+
const resolved = new URL(path, location.href);
|
|
50
|
+
const wanted = resolved.pathname + resolved.search + resolved.hash;
|
|
51
|
+
const target = links.find((link) => link.path === wanted);
|
|
52
|
+
const anchor = target ? anchorFor(target.path) : undefined;
|
|
53
|
+
|
|
54
|
+
// Models hallucinate paths; handing back the real links makes the retry self-correcting.
|
|
55
|
+
if (!target) return { error: `No link to "${path}" on this page. Current links:`, links };
|
|
56
|
+
if (anchor) {
|
|
57
|
+
injectGlowStyles();
|
|
58
|
+
glowElement(anchor);
|
|
59
|
+
anchor.scrollIntoView({ block: 'nearest' });
|
|
60
|
+
}
|
|
61
|
+
setTimeout(() => location.assign(target.path), anchor ? GLOW_BEFORE_NAV_MS : 0);
|
|
62
|
+
|
|
63
|
+
return { navigated: target.path, label: target.label };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The built-in `navigate` WebMCP tool `installWebMCP` registers on every page. */
|
|
67
|
+
export function createNavigateTool(): WebMCPToolDescriptor {
|
|
68
|
+
return {
|
|
69
|
+
name: 'navigate',
|
|
70
|
+
description: 'Navigate this app to one of the links on the current page, by path.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: { path: { type: 'string', description: 'Target path, e.g. /docs/guide/navigation' } },
|
|
74
|
+
required: ['path'],
|
|
75
|
+
},
|
|
76
|
+
execute: (input) => navigateTo(String((input as { path?: unknown })?.path ?? '')),
|
|
77
|
+
};
|
|
78
|
+
}
|