@moxxy/plugin-channel-web 0.27.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/LICENSE +21 -0
- package/dist/channel.d.ts +152 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +595 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +171 -0
- package/dist/index.js.map +1 -0
- package/dist/projector.d.ts +17 -0
- package/dist/projector.d.ts.map +1 -0
- package/dist/projector.js +96 -0
- package/dist/projector.js.map +1 -0
- package/dist/protocol.d.ts +145 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +33 -0
- package/dist/protocol.js.map +1 -0
- package/dist/public/app.js +54 -0
- package/dist/public/index.html +178 -0
- package/dist/tunnel-settings.d.ts +17 -0
- package/dist/tunnel-settings.d.ts.map +1 -0
- package/dist/tunnel-settings.js +42 -0
- package/dist/tunnel-settings.js.map +1 -0
- package/package.json +71 -0
- package/src/channel.test.ts +514 -0
- package/src/channel.ts +669 -0
- package/src/discovery.test.ts +40 -0
- package/src/frontend/chat.test.ts +47 -0
- package/src/frontend/chat.tsx +138 -0
- package/src/frontend/index.html +178 -0
- package/src/frontend/main.tsx +87 -0
- package/src/frontend/render-diff.test.ts +86 -0
- package/src/frontend/render-diff.tsx +66 -0
- package/src/frontend/render.test.ts +166 -0
- package/src/frontend/render.tsx +274 -0
- package/src/frontend/socket.ts +212 -0
- package/src/frontend/url-safety.test.ts +0 -0
- package/src/frontend/url-safety.ts +33 -0
- package/src/frontend/view-store.test.ts +104 -0
- package/src/frontend/view-store.ts +120 -0
- package/src/index.ts +212 -0
- package/src/projector.test.ts +172 -0
- package/src/projector.ts +95 -0
- package/src/protocol.test.ts +59 -0
- package/src/protocol.ts +105 -0
- package/src/tunnel-settings.test.ts +64 -0
- package/src/tunnel-settings.ts +57 -0
- package/src/tunnel-tools.test.ts +120 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { ServiceRegistry } from '@moxxy/sdk';
|
|
3
|
+
import { webChannelPlugin } from './index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The discovery-loadable default export resolves the tunnel registry + the
|
|
7
|
+
* shared viewSurface/webControls refs + the configured default tunnel from the
|
|
8
|
+
* service registry in onInit (a lazy `tunnels` object keeps the tools + boot
|
|
9
|
+
* hook present), instead of the host `{ getTunnel, publishSurface, … }` closure.
|
|
10
|
+
*/
|
|
11
|
+
describe('webChannelPlugin (discovery-loadable)', () => {
|
|
12
|
+
it('exposes the web channel + tunnel tools + an onInit hook', () => {
|
|
13
|
+
expect(webChannelPlugin.channels?.map((c) => c.name)).toContain('web');
|
|
14
|
+
const tools = webChannelPlugin.tools?.map((t) => t.name) ?? [];
|
|
15
|
+
expect(tools).toContain('web_set_tunnel');
|
|
16
|
+
expect(tools).toContain('web_tunnel_status');
|
|
17
|
+
expect(typeof webChannelPlugin.hooks?.onInit).toBe('function');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('onInit resolves tunnelProviders + viewSurface + webControls', () => {
|
|
21
|
+
const tp = { getActive: () => null, list: () => [], setActive: () => {} };
|
|
22
|
+
const get = vi.fn((name: string) => {
|
|
23
|
+
switch (name) {
|
|
24
|
+
case 'tunnelProviders':
|
|
25
|
+
return tp;
|
|
26
|
+
case 'viewSurface':
|
|
27
|
+
return { current: null };
|
|
28
|
+
case 'webControls':
|
|
29
|
+
return { current: null };
|
|
30
|
+
default:
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
const services = { get, register: () => {}, require: () => undefined, has: () => true } as unknown as ServiceRegistry;
|
|
35
|
+
webChannelPlugin.hooks!.onInit!({ services } as never);
|
|
36
|
+
expect(get).toHaveBeenCalledWith('tunnelProviders');
|
|
37
|
+
expect(get).toHaveBeenCalledWith('viewSurface');
|
|
38
|
+
expect(get).toHaveBeenCalledWith('webControls');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createElement } from 'react';
|
|
3
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
4
|
+
import { ChatPanel } from './chat.js';
|
|
5
|
+
import type { TranscriptMessage } from './socket.js';
|
|
6
|
+
|
|
7
|
+
const noop = (): void => undefined;
|
|
8
|
+
const render = (messages: TranscriptMessage[], status: { text: string; error: boolean } | null = null): string =>
|
|
9
|
+
renderToStaticMarkup(createElement(ChatPanel, { messages, status, onSend: noop, onClose: noop }));
|
|
10
|
+
|
|
11
|
+
describe('ChatPanel', () => {
|
|
12
|
+
it('shows a hint + the input when empty', () => {
|
|
13
|
+
const html = render([]);
|
|
14
|
+
expect(html).toContain('Chat with the agent');
|
|
15
|
+
expect(html).toMatch(/add a price filter/);
|
|
16
|
+
expect(html).toContain('placeholder="Ask for changes');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders user and assistant messages distinctly', () => {
|
|
20
|
+
const html = render([
|
|
21
|
+
{ role: 'user', text: 'add a price filter' },
|
|
22
|
+
{ role: 'assistant', text: 'Done — filtered by price.' },
|
|
23
|
+
]);
|
|
24
|
+
expect(html).toContain('add a price filter');
|
|
25
|
+
expect(html).toContain('Done — filtered by price.');
|
|
26
|
+
expect(html).toContain('chat-msg user');
|
|
27
|
+
expect(html).toContain('chat-msg assistant');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('shows working / error status', () => {
|
|
31
|
+
expect(render([], { text: 'working…', error: false })).toContain('working');
|
|
32
|
+
const err = render([], { text: 'boom', error: true });
|
|
33
|
+
expect(err).toContain('chat-status err');
|
|
34
|
+
expect(err).toContain('boom');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('is an accessible modal dialog with a live message region', () => {
|
|
38
|
+
// Focus trap / Escape / focus restoration are effect-driven and need a DOM
|
|
39
|
+
// harness (this package's vitest env is `node`); the static markup still
|
|
40
|
+
// asserts the ARIA contract a screen reader relies on.
|
|
41
|
+
const html = render([{ role: 'assistant', text: 'hi' }]);
|
|
42
|
+
expect(html).toContain('role="dialog"');
|
|
43
|
+
expect(html).toContain('aria-modal="true"');
|
|
44
|
+
expect(html).toContain('aria-live="polite"');
|
|
45
|
+
expect(html).toMatch(/role="log"/);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
|
2
|
+
import type { TranscriptMessage } from './socket';
|
|
3
|
+
import { FileDiffView } from './render-diff';
|
|
4
|
+
|
|
5
|
+
/** Whether the user has asked the OS to reduce motion (SSR-safe). */
|
|
6
|
+
function prefersReducedMotion(): boolean {
|
|
7
|
+
return (
|
|
8
|
+
typeof window !== 'undefined' &&
|
|
9
|
+
typeof window.matchMedia === 'function' &&
|
|
10
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Focusable descendants of a container, in DOM order, for the focus trap. */
|
|
15
|
+
function focusableWithin(root: HTMLElement | null): HTMLElement[] {
|
|
16
|
+
if (!root) return [];
|
|
17
|
+
const sel = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
18
|
+
return Array.from(root.querySelectorAll<HTMLElement>(sel)).filter(
|
|
19
|
+
(el) => el.offsetParent !== null || el === document.activeElement,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* On-demand chat panel (opened from the floating button) for refining the
|
|
25
|
+
* generated view. Messages flow over the same WS as `prompt` frames → an agent
|
|
26
|
+
* turn → the agent re-runs present_view and the view updates live.
|
|
27
|
+
*
|
|
28
|
+
* Implemented as an accessible modal dialog: focus moves into the input on open
|
|
29
|
+
* and is restored to the opener (the FAB) on close, Tab is trapped inside the
|
|
30
|
+
* panel, Escape dismisses it, and the message/status region is an aria-live
|
|
31
|
+
* region so screen-reader users hear the agent's replies.
|
|
32
|
+
*/
|
|
33
|
+
export function ChatPanel(props: {
|
|
34
|
+
messages: ReadonlyArray<TranscriptMessage>;
|
|
35
|
+
status: { text: string; error: boolean } | null;
|
|
36
|
+
onSend: (text: string) => void;
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
}): JSX.Element {
|
|
39
|
+
const { messages, status, onSend, onClose } = props;
|
|
40
|
+
const [text, setText] = useState('');
|
|
41
|
+
const endRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
43
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
endRef.current?.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
|
47
|
+
}, [messages, status]);
|
|
48
|
+
|
|
49
|
+
// Focus the input on open; restore focus to the element that had it (the FAB)
|
|
50
|
+
// when the dialog unmounts, so keyboard users aren't dumped at the page top.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const opener = typeof document !== 'undefined' ? (document.activeElement as HTMLElement | null) : null;
|
|
53
|
+
inputRef.current?.focus();
|
|
54
|
+
return () => opener?.focus?.();
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const onKeyDown = useCallback(
|
|
58
|
+
(e: KeyboardEvent<HTMLDivElement>) => {
|
|
59
|
+
if (e.key === 'Escape') {
|
|
60
|
+
e.stopPropagation();
|
|
61
|
+
onClose();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (e.key !== 'Tab') return;
|
|
65
|
+
// Trap Tab within the panel so focus never escapes to the obscured view.
|
|
66
|
+
const focusables = focusableWithin(panelRef.current);
|
|
67
|
+
if (focusables.length === 0) return;
|
|
68
|
+
const first = focusables[0]!;
|
|
69
|
+
const last = focusables[focusables.length - 1]!;
|
|
70
|
+
const active = document.activeElement;
|
|
71
|
+
if (e.shiftKey && active === first) {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
last.focus();
|
|
74
|
+
} else if (!e.shiftKey && active === last) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
first.focus();
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
[onClose],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
className="chat-panel"
|
|
85
|
+
role="dialog"
|
|
86
|
+
aria-modal="true"
|
|
87
|
+
aria-label="Chat with the agent"
|
|
88
|
+
ref={panelRef}
|
|
89
|
+
onKeyDown={onKeyDown}
|
|
90
|
+
>
|
|
91
|
+
<div className="chat-head">
|
|
92
|
+
<span>Chat with the agent</span>
|
|
93
|
+
<button className="chat-x" onClick={onClose} aria-label="Close chat">
|
|
94
|
+
×
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
<div className="chat-msgs" role="log" aria-live="polite" aria-relevant="additions text">
|
|
98
|
+
{messages.length === 0 && (
|
|
99
|
+
<div className="chat-hint">Ask the agent to change this view — e.g. “add a price filter” or “sort by departure time”.</div>
|
|
100
|
+
)}
|
|
101
|
+
{messages.map((m, i) =>
|
|
102
|
+
m.role === 'diff' ? (
|
|
103
|
+
<FileDiffView key={i} display={m.display} />
|
|
104
|
+
) : (
|
|
105
|
+
<div key={i} className={`chat-msg ${m.role}`}>
|
|
106
|
+
{m.text}
|
|
107
|
+
</div>
|
|
108
|
+
),
|
|
109
|
+
)}
|
|
110
|
+
{status && (
|
|
111
|
+
<div className={status.error ? 'chat-status err' : 'chat-status'} role="status">
|
|
112
|
+
{status.error ? `⚠ ${status.text}` : status.text}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
<div ref={endRef} />
|
|
116
|
+
</div>
|
|
117
|
+
<form
|
|
118
|
+
className="chat-input"
|
|
119
|
+
onSubmit={(e) => {
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
const t = text.trim();
|
|
122
|
+
if (!t) return;
|
|
123
|
+
onSend(t);
|
|
124
|
+
setText('');
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<input
|
|
128
|
+
ref={inputRef}
|
|
129
|
+
value={text}
|
|
130
|
+
onChange={(e) => setText(e.target.value)}
|
|
131
|
+
placeholder="Ask for changes…"
|
|
132
|
+
aria-label="Ask the agent for changes"
|
|
133
|
+
/>
|
|
134
|
+
<button type="submit">Send</button>
|
|
135
|
+
</form>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<!-- Defense-in-depth, mirrors the HTTP CSP header in channel.ts serveFile.
|
|
7
|
+
Scripts are 'self' only (no inline); the inline <style> below needs
|
|
8
|
+
style-src 'unsafe-inline'; connect-src permits the surface WebSocket. -->
|
|
9
|
+
<meta
|
|
10
|
+
http-equiv="Content-Security-Policy"
|
|
11
|
+
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' ws: wss:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
|
12
|
+
/>
|
|
13
|
+
<meta name="referrer" content="no-referrer" />
|
|
14
|
+
<title>moxxy</title>
|
|
15
|
+
<!--moxxy:base-->
|
|
16
|
+
<style>
|
|
17
|
+
:root {
|
|
18
|
+
--bg: #0f1115; --panel: #171a21; --panel2: #1e222b; --line: #2a2f3a;
|
|
19
|
+
--fg: #e6e9ef; --muted: #8b93a7; --accent: #5b8cff; --ok: #3fb950;
|
|
20
|
+
--warn: #d29922; --danger: #f85149; --radius: 10px;
|
|
21
|
+
}
|
|
22
|
+
* { box-sizing: border-box; }
|
|
23
|
+
body { margin: 0; background: var(--bg); color: var(--fg);
|
|
24
|
+
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
|
25
|
+
.app { max-width: 760px; margin: 0 auto; min-height: 100vh; display: flex; flex-direction: column; }
|
|
26
|
+
header { display: flex; align-items: center; justify-content: space-between;
|
|
27
|
+
padding: 14px 18px; border-bottom: 1px solid var(--line); font-weight: 600; }
|
|
28
|
+
header .status { font-weight: 400; font-size: 13px; color: var(--muted); }
|
|
29
|
+
header .status.ok { color: var(--ok); }
|
|
30
|
+
header .back { background: var(--panel2); color: var(--fg); border: 1px solid var(--line);
|
|
31
|
+
border-radius: 8px; padding: 4px 10px; font: inherit; font-size: 13px; cursor: pointer; margin-right: 10px; }
|
|
32
|
+
main { flex: 1; padding: 18px; display: flex; flex-direction: column; gap: 18px; }
|
|
33
|
+
.empty { color: var(--muted); text-align: center; padding: 48px 0; }
|
|
34
|
+
footer { padding: 12px 18px; border-top: 1px solid var(--line); }
|
|
35
|
+
footer form { display: flex; gap: 8px; }
|
|
36
|
+
footer input { flex: 1; background: var(--panel2); border: 1px solid var(--line);
|
|
37
|
+
color: var(--fg); border-radius: var(--radius); padding: 10px 12px; font: inherit; }
|
|
38
|
+
footer button { background: var(--accent); color: #fff; border: 0; border-radius: var(--radius);
|
|
39
|
+
padding: 0 16px; font: inherit; font-weight: 600; cursor: pointer; }
|
|
40
|
+
.transcript { display: flex; flex-direction: column; gap: 8px; }
|
|
41
|
+
.msg { padding: 9px 12px; border-radius: var(--radius); max-width: 85%; white-space: pre-wrap; }
|
|
42
|
+
.msg.user { align-self: flex-end; background: var(--accent); color: #fff; }
|
|
43
|
+
.msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--line); }
|
|
44
|
+
.turn-status { color: var(--muted); font-size: 13px; font-style: italic; }
|
|
45
|
+
.turn-status.err { color: var(--danger); font-style: normal; }
|
|
46
|
+
/* view primitives */
|
|
47
|
+
.v-title { font-size: 20px; margin: 0 0 12px; }
|
|
48
|
+
.v-stack { display: flex; flex-direction: column; }
|
|
49
|
+
.v-stack[data-gap="sm"], .v-row[data-gap="sm"] { gap: 6px; }
|
|
50
|
+
.v-stack[data-gap="md"], .v-row[data-gap="md"], .v-grid[data-gap="md"] { gap: 12px; }
|
|
51
|
+
.v-stack[data-gap="lg"], .v-row[data-gap="lg"], .v-grid[data-gap="lg"] { gap: 20px; }
|
|
52
|
+
.v-stack:not([data-gap]) { gap: 10px; }
|
|
53
|
+
.v-row { display: flex; flex-direction: row; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
54
|
+
.v-row[data-align="start"] { align-items: flex-start; }
|
|
55
|
+
.v-row[data-align="end"] { align-items: flex-end; }
|
|
56
|
+
.v-row[data-justify="between"] { justify-content: space-between; }
|
|
57
|
+
.v-row[data-justify="center"] { justify-content: center; }
|
|
58
|
+
.v-row[data-justify="end"] { justify-content: flex-end; }
|
|
59
|
+
.v-grid { display: grid; gap: 12px; }
|
|
60
|
+
.v-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; }
|
|
61
|
+
.v-card[data-accent="success"] { border-left: 3px solid var(--ok); }
|
|
62
|
+
.v-card[data-accent="warn"] { border-left: 3px solid var(--warn); }
|
|
63
|
+
.v-card[data-accent="danger"] { border-left: 3px solid var(--danger); }
|
|
64
|
+
.v-card-title { font-weight: 600; margin-bottom: 8px; }
|
|
65
|
+
.v-divider { border: 0; border-top: 1px solid var(--line); margin: 4px 0; }
|
|
66
|
+
.v-heading { margin: 4px 0; }
|
|
67
|
+
.v-text[data-tone="muted"] { color: var(--muted); }
|
|
68
|
+
.v-text[data-tone="success"] { color: var(--ok); }
|
|
69
|
+
.v-text[data-tone="warn"] { color: var(--warn); }
|
|
70
|
+
.v-text[data-tone="danger"] { color: var(--danger); }
|
|
71
|
+
.v-text[data-weight="bold"] { font-weight: 700; }
|
|
72
|
+
.v-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 13px;
|
|
73
|
+
background: var(--panel2); border: 1px solid var(--line); }
|
|
74
|
+
.v-badge[data-tone="success"] { color: var(--ok); border-color: var(--ok); }
|
|
75
|
+
.v-badge[data-tone="warn"] { color: var(--warn); border-color: var(--warn); }
|
|
76
|
+
.v-badge[data-tone="danger"] { color: var(--danger); border-color: var(--danger); }
|
|
77
|
+
.v-image { max-width: 100%; border-radius: var(--radius); }
|
|
78
|
+
.v-link { color: var(--accent); }
|
|
79
|
+
.v-list { margin: 0; padding-left: 20px; }
|
|
80
|
+
.v-table { border-collapse: collapse; width: 100%; }
|
|
81
|
+
.v-table th, .v-table td { border: 1px solid var(--line); padding: 7px 10px; }
|
|
82
|
+
.v-table th { background: var(--panel2); }
|
|
83
|
+
.v-form { display: flex; flex-direction: column; gap: 12px; }
|
|
84
|
+
.v-field { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 120px; }
|
|
85
|
+
.v-label { font-size: 13px; color: var(--muted); }
|
|
86
|
+
.v-field input, .v-field select { background: var(--panel2); border: 1px solid var(--line);
|
|
87
|
+
color: var(--fg); border-radius: 8px; padding: 8px 10px; font: inherit; }
|
|
88
|
+
.v-check { display: flex; align-items: center; gap: 6px; }
|
|
89
|
+
.v-form-actions { display: flex; gap: 8px; }
|
|
90
|
+
.v-btn { background: var(--panel2); color: var(--fg); border: 1px solid var(--line);
|
|
91
|
+
border-radius: 8px; padding: 8px 14px; font: inherit; cursor: pointer; }
|
|
92
|
+
.v-btn[data-variant="primary"] { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
93
|
+
.v-btn[data-variant="danger"] { background: var(--danger); color: #fff; border-color: var(--danger); }
|
|
94
|
+
.v-unknown { color: var(--warn); font-size: 13px; }
|
|
95
|
+
/* loading states */
|
|
96
|
+
.v-spinner { display: flex; align-items: center; gap: 10px; color: var(--muted); padding: 8px 0; }
|
|
97
|
+
.v-spin { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--line);
|
|
98
|
+
border-top-color: var(--accent); animation: v-rot 0.8s linear infinite; }
|
|
99
|
+
@keyframes v-rot { to { transform: rotate(360deg); } }
|
|
100
|
+
.v-skeleton { display: flex; flex-direction: column; gap: 10px; }
|
|
101
|
+
.v-skel-row { height: 16px; border-radius: 6px;
|
|
102
|
+
background: linear-gradient(90deg, var(--panel) 25%, var(--panel2) 37%, var(--panel) 63%);
|
|
103
|
+
background-size: 400% 100%; animation: v-shimmer 1.4s ease infinite; }
|
|
104
|
+
.v-skel-row:nth-child(odd) { width: 90%; }
|
|
105
|
+
.v-skel-row:nth-child(3n) { width: 70%; }
|
|
106
|
+
@keyframes v-shimmer { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }
|
|
107
|
+
/* floating chat */
|
|
108
|
+
.chat-fab { position: fixed; right: 20px; bottom: 20px; width: 52px; height: 52px;
|
|
109
|
+
border-radius: 50%; border: 0; background: var(--accent); color: #fff; font-size: 22px;
|
|
110
|
+
cursor: pointer; box-shadow: 0 6px 20px rgba(0,0,0,0.45); display: flex; align-items: center;
|
|
111
|
+
justify-content: center; z-index: 50; }
|
|
112
|
+
.chat-fab:hover { filter: brightness(1.08); }
|
|
113
|
+
.chat-dot { position: absolute; top: 10px; right: 10px; width: 10px; height: 10px;
|
|
114
|
+
border-radius: 50%; background: var(--danger); border: 2px solid var(--bg); }
|
|
115
|
+
.chat-panel { position: fixed; right: 20px; bottom: 20px; width: min(380px, calc(100vw - 40px));
|
|
116
|
+
height: min(520px, calc(100vh - 40px)); background: var(--panel); border: 1px solid var(--line);
|
|
117
|
+
border-radius: 14px; box-shadow: 0 10px 40px rgba(0,0,0,0.5); display: flex; flex-direction: column;
|
|
118
|
+
overflow: hidden; z-index: 50; }
|
|
119
|
+
.chat-head { display: flex; align-items: center; justify-content: space-between;
|
|
120
|
+
padding: 12px 14px; border-bottom: 1px solid var(--line); font-weight: 600; }
|
|
121
|
+
.chat-x { background: none; border: 0; color: var(--muted); font-size: 22px; line-height: 1;
|
|
122
|
+
cursor: pointer; padding: 0 4px; }
|
|
123
|
+
.chat-x:hover { color: var(--fg); }
|
|
124
|
+
.chat-msgs { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; }
|
|
125
|
+
.chat-hint { color: var(--muted); font-size: 13px; }
|
|
126
|
+
.chat-msg { padding: 8px 11px; border-radius: 12px; max-width: 88%; white-space: pre-wrap;
|
|
127
|
+
word-break: break-word; font-size: 14px; }
|
|
128
|
+
.chat-msg.user { align-self: flex-end; background: var(--accent); color: #fff; }
|
|
129
|
+
.chat-msg.assistant { align-self: flex-start; background: var(--panel2); border: 1px solid var(--line); }
|
|
130
|
+
.chat-status { color: var(--muted); font-size: 12px; font-style: italic; }
|
|
131
|
+
.chat-status.err { color: var(--danger); font-style: normal; }
|
|
132
|
+
.chat-input { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); }
|
|
133
|
+
.chat-input input { flex: 1; background: var(--panel2); border: 1px solid var(--line); color: var(--fg);
|
|
134
|
+
border-radius: 10px; padding: 9px 11px; font: inherit; }
|
|
135
|
+
.chat-input button { background: var(--accent); color: #fff; border: 0; border-radius: 10px;
|
|
136
|
+
padding: 0 14px; font: inherit; font-weight: 600; cursor: pointer; }
|
|
137
|
+
/* file diff */
|
|
138
|
+
.v-diff { align-self: stretch; border: 1px solid var(--line); border-radius: var(--radius);
|
|
139
|
+
overflow: hidden; background: var(--panel); font-size: 12.5px; }
|
|
140
|
+
.v-diff-head { display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
141
|
+
padding: 6px 10px; background: var(--panel2); border-bottom: 1px solid var(--line); }
|
|
142
|
+
.v-diff-path { font-weight: 600; word-break: break-all; }
|
|
143
|
+
.v-diff-sum { color: var(--ok); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
144
|
+
white-space: nowrap; }
|
|
145
|
+
.v-diff-body { overflow-x: auto; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
146
|
+
line-height: 1.45; }
|
|
147
|
+
.v-diff-row { display: flex; white-space: pre; }
|
|
148
|
+
.v-diff-no { flex: 0 0 auto; width: 3.5em; padding: 0 8px; text-align: right; color: var(--muted);
|
|
149
|
+
user-select: none; border-right: 1px solid var(--line); }
|
|
150
|
+
.v-diff-mark { flex: 0 0 auto; width: 1.5em; text-align: center; user-select: none; color: var(--muted); }
|
|
151
|
+
.v-diff-text { flex: 1 1 auto; padding-right: 10px; }
|
|
152
|
+
.v-diff-ctx { color: var(--muted); }
|
|
153
|
+
.v-diff-add { background: rgba(63, 185, 80, 0.16); }
|
|
154
|
+
.v-diff-add .v-diff-mark, .v-diff-add .v-diff-text { color: var(--ok); }
|
|
155
|
+
.v-diff-del { background: rgba(248, 81, 73, 0.16); }
|
|
156
|
+
.v-diff-del .v-diff-mark, .v-diff-del .v-diff-text { color: var(--danger); }
|
|
157
|
+
.v-diff-gap { color: var(--muted); opacity: 0.6; }
|
|
158
|
+
.v-diff-gap .v-diff-mark { color: var(--muted); }
|
|
159
|
+
.v-diff-trunc { padding: 4px 10px; color: var(--warn); font-size: 12px;
|
|
160
|
+
border-top: 1px solid var(--line); background: var(--panel2); }
|
|
161
|
+
/* Screen-reader-only: present in the a11y tree (live-region announcements)
|
|
162
|
+
but visually removed. Standard clip pattern, no layout impact. */
|
|
163
|
+
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0;
|
|
164
|
+
margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
165
|
+
/* Respect a user's reduced-motion preference: stop the infinite spinner
|
|
166
|
+
and skeleton shimmer (vestibular safety) and neutralize transitions. */
|
|
167
|
+
@media (prefers-reduced-motion: reduce) {
|
|
168
|
+
.v-spin { animation: none; }
|
|
169
|
+
.v-skel-row { animation: none; background: var(--panel2); }
|
|
170
|
+
* { transition: none !important; scroll-behavior: auto !important; }
|
|
171
|
+
}
|
|
172
|
+
</style>
|
|
173
|
+
</head>
|
|
174
|
+
<body>
|
|
175
|
+
<div id="root"></div>
|
|
176
|
+
<script src="app.js"></script>
|
|
177
|
+
</body>
|
|
178
|
+
</html>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { stripTokenFromUrl, useViewSocket } from './socket';
|
|
4
|
+
import { renderNode } from './render';
|
|
5
|
+
import { ChatPanel } from './chat';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The web surface is a RENDERING surface for the agent-built app — not a chat.
|
|
9
|
+
* A floating button opens a chat panel ON DEMAND so the user can ask the agent
|
|
10
|
+
* to refine the view ("add a price filter", "make the cards bigger"); the agent
|
|
11
|
+
* re-runs present_view and the view updates live.
|
|
12
|
+
*/
|
|
13
|
+
function App(): JSX.Element {
|
|
14
|
+
const { connected, view, canGoBack, messages, status, dispatch, navigate, goBack, sendPrompt } = useViewSocket();
|
|
15
|
+
const [chatOpen, setChatOpen] = useState(false);
|
|
16
|
+
const [seen, setSeen] = useState(0);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (chatOpen) setSeen(messages.length);
|
|
19
|
+
}, [chatOpen, messages.length]);
|
|
20
|
+
const unread = !chatOpen && messages.length > seen;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="app">
|
|
24
|
+
<header>
|
|
25
|
+
<span>
|
|
26
|
+
{canGoBack && (
|
|
27
|
+
<button className="back" onClick={goBack} aria-label="Back">
|
|
28
|
+
‹ Back
|
|
29
|
+
</button>
|
|
30
|
+
)}
|
|
31
|
+
moxxy
|
|
32
|
+
</span>
|
|
33
|
+
<span
|
|
34
|
+
className={connected ? 'status ok' : 'status'}
|
|
35
|
+
role="status"
|
|
36
|
+
aria-live="polite"
|
|
37
|
+
>
|
|
38
|
+
{connected ? '● live' : '○ connecting…'}
|
|
39
|
+
</span>
|
|
40
|
+
</header>
|
|
41
|
+
<main>
|
|
42
|
+
{view ? (
|
|
43
|
+
<div className="view">{renderNode(view.doc.root, { dispatch, navigate })}</div>
|
|
44
|
+
) : (
|
|
45
|
+
<div className="empty">{status ? status.text : 'No view yet — ask the agent to build you an app.'}</div>
|
|
46
|
+
)}
|
|
47
|
+
{view && status && (
|
|
48
|
+
<div
|
|
49
|
+
className={status.error ? 'turn-status err' : 'turn-status'}
|
|
50
|
+
role="status"
|
|
51
|
+
aria-live="polite"
|
|
52
|
+
>
|
|
53
|
+
{status.error ? `⚠ ${status.text}` : status.text}
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
</main>
|
|
57
|
+
{chatOpen ? (
|
|
58
|
+
<ChatPanel messages={messages} status={status} onSend={sendPrompt} onClose={() => setChatOpen(false)} />
|
|
59
|
+
) : (
|
|
60
|
+
<button
|
|
61
|
+
className="chat-fab"
|
|
62
|
+
onClick={() => setChatOpen(true)}
|
|
63
|
+
aria-label={unread ? 'Chat with the agent — new message' : 'Chat with the agent'}
|
|
64
|
+
>
|
|
65
|
+
<span aria-hidden>💬</span>
|
|
66
|
+
{/* The dot is a non-text colour cue; the unread state itself is also
|
|
67
|
+
carried in aria-label above (colour is never the sole signal) and
|
|
68
|
+
announced via the off-screen live region below. */}
|
|
69
|
+
{unread && <span className="chat-dot" aria-hidden="true" />}
|
|
70
|
+
</button>
|
|
71
|
+
)}
|
|
72
|
+
{/* Off-screen live region: announces that the agent replied while the chat
|
|
73
|
+
panel is closed, so a screen-reader user isn't left silent (the red FAB
|
|
74
|
+
dot is purely visual). */}
|
|
75
|
+
<div className="visually-hidden" role="status" aria-live="polite">
|
|
76
|
+
{unread ? 'The agent sent a new message. Open chat to read it.' : ''}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Drop the bearer token from the visible URL after socket.ts has captured it
|
|
83
|
+
// (socket is imported above, so its module-level capture has already run).
|
|
84
|
+
stripTokenFromUrl();
|
|
85
|
+
|
|
86
|
+
const root = document.getElementById('root');
|
|
87
|
+
if (root) createRoot(root).render(<App />);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createElement } from 'react';
|
|
3
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
4
|
+
import type { FileDiffDisplay } from '@moxxy/sdk';
|
|
5
|
+
import { FileDiffView } from './render-diff.js';
|
|
6
|
+
|
|
7
|
+
const render = (display: FileDiffDisplay): string => renderToStaticMarkup(createElement(FileDiffView, { display }));
|
|
8
|
+
|
|
9
|
+
const baseDisplay: FileDiffDisplay = {
|
|
10
|
+
kind: 'file-diff',
|
|
11
|
+
path: 'src/foo.ts',
|
|
12
|
+
mode: 'update',
|
|
13
|
+
added: 2,
|
|
14
|
+
removed: 1,
|
|
15
|
+
hunks: [
|
|
16
|
+
{
|
|
17
|
+
oldStart: 1,
|
|
18
|
+
oldLines: 2,
|
|
19
|
+
newStart: 1,
|
|
20
|
+
newLines: 3,
|
|
21
|
+
lines: [
|
|
22
|
+
{ kind: 'context', text: 'keep me', oldNo: 1, newNo: 1 },
|
|
23
|
+
{ kind: 'del', text: 'old line', oldNo: 2 },
|
|
24
|
+
{ kind: 'add', text: 'new line', newNo: 2 },
|
|
25
|
+
{ kind: 'add', text: 'extra line', newNo: 3 },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('FileDiffView', () => {
|
|
32
|
+
it('renders an Update header with the path and a +/- summary', () => {
|
|
33
|
+
const html = render(baseDisplay);
|
|
34
|
+
expect(html).toContain('Update · src/foo.ts');
|
|
35
|
+
expect(html).toContain('+2 −1');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('uses Create for new files', () => {
|
|
39
|
+
const html = render({ ...baseDisplay, mode: 'create' });
|
|
40
|
+
expect(html).toContain('Create · src/foo.ts');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('applies add/del backgrounds and dim context classes with markers', () => {
|
|
44
|
+
const html = render(baseDisplay);
|
|
45
|
+
expect(html).toContain('v-diff-add');
|
|
46
|
+
expect(html).toContain('v-diff-del');
|
|
47
|
+
expect(html).toContain('v-diff-ctx');
|
|
48
|
+
expect(html).toContain('new line');
|
|
49
|
+
expect(html).toContain('old line');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('shows gutter numbers (new for add/context, old for deletions)', () => {
|
|
53
|
+
const html = render(baseDisplay);
|
|
54
|
+
// del shows old number 2; first add shows new number 2; context shows 1.
|
|
55
|
+
expect(html).toMatch(/v-diff-no[^>]*>1</);
|
|
56
|
+
expect(html).toMatch(/v-diff-no[^>]*>2</);
|
|
57
|
+
expect(html).toMatch(/v-diff-no[^>]*>3</);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('inserts a ⋯ gap row between non-contiguous hunks', () => {
|
|
61
|
+
const twoHunks: FileDiffDisplay = {
|
|
62
|
+
...baseDisplay,
|
|
63
|
+
hunks: [
|
|
64
|
+
baseDisplay.hunks[0]!,
|
|
65
|
+
{ oldStart: 40, oldLines: 1, newStart: 41, newLines: 1, lines: [{ kind: 'context', text: 'far away', oldNo: 40, newNo: 41 }] },
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
const html = render(twoHunks);
|
|
69
|
+
expect(html).toContain('v-diff-gap');
|
|
70
|
+
expect(html).toContain('⋯');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('renders a truncation notice when truncated', () => {
|
|
74
|
+
const html = render({ ...baseDisplay, truncated: true });
|
|
75
|
+
expect(html).toContain('diff truncated');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('keeps the compact "+X −Y" count glyph (not the prose fileDiffSummary form)', () => {
|
|
79
|
+
// The dedup pulls rowsOf/gutter/verb from @moxxy/sdk/tool-display but
|
|
80
|
+
// deliberately keeps the local plusMinus: the shared fileDiffSummary
|
|
81
|
+
// renders "Added N lines, removed M line", which this header must NOT use.
|
|
82
|
+
const html = render(baseDisplay);
|
|
83
|
+
expect(html).toContain('+2 −1');
|
|
84
|
+
expect(html).not.toContain('Added 2 lines');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { FileDiffDisplay, DiffLine } from '@moxxy/sdk';
|
|
2
|
+
import { toDiffRows, diffGutterNo, fileDiffVerb } from '@moxxy/sdk/tool-display';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renders a {@link FileDiffDisplay} as a classic diff: a line-number gutter,
|
|
6
|
+
* +/- markers, green background for added lines, red for removed, dim context,
|
|
7
|
+
* and a `⋯` separator between non-contiguous hunks.
|
|
8
|
+
*
|
|
9
|
+
* Row-flattening, the gutter number, and the header verb come from
|
|
10
|
+
* `@moxxy/sdk/tool-display` (the dependency-free subpath — only type-erased
|
|
11
|
+
* imports plus those tiny pure helpers reach the browser bundle). The `+X −Y`
|
|
12
|
+
* count summary stays local: the shared `fileDiffSummary` renders human prose
|
|
13
|
+
* ("Added N lines, removed M line"), not this compact glyph form.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** "+10 −1" style summary of the change counts. */
|
|
17
|
+
function plusMinus(display: FileDiffDisplay): string {
|
|
18
|
+
return `+${display.added} −${display.removed}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Gutter number for a line — new number, or old for deletions. */
|
|
22
|
+
function gutter(line: DiffLine): string {
|
|
23
|
+
const n = diffGutterNo(line);
|
|
24
|
+
return n === undefined ? '' : String(n);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MARKER: Record<DiffLine['kind'], string> = { context: ' ', add: '+', del: '-' };
|
|
28
|
+
const ROW_CLASS: Record<DiffLine['kind'], string> = {
|
|
29
|
+
context: 'v-diff-ctx',
|
|
30
|
+
add: 'v-diff-add',
|
|
31
|
+
del: 'v-diff-del',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function FileDiffView(props: { display: FileDiffDisplay }): JSX.Element {
|
|
35
|
+
const { display } = props;
|
|
36
|
+
const verb = fileDiffVerb(display);
|
|
37
|
+
const rows = toDiffRows(display);
|
|
38
|
+
return (
|
|
39
|
+
<div className="v-diff">
|
|
40
|
+
<div className="v-diff-head">
|
|
41
|
+
<span className="v-diff-path">
|
|
42
|
+
{verb} · {display.path}
|
|
43
|
+
</span>
|
|
44
|
+
<span className="v-diff-sum">{plusMinus(display)}</span>
|
|
45
|
+
</div>
|
|
46
|
+
<div className="v-diff-body">
|
|
47
|
+
{rows.map((row, i) =>
|
|
48
|
+
row.kind === 'gap' ? (
|
|
49
|
+
<div key={i} className="v-diff-row v-diff-gap">
|
|
50
|
+
<span className="v-diff-no" />
|
|
51
|
+
<span className="v-diff-mark">⋯</span>
|
|
52
|
+
<span className="v-diff-text" />
|
|
53
|
+
</div>
|
|
54
|
+
) : (
|
|
55
|
+
<div key={i} className={`v-diff-row ${ROW_CLASS[row.kind]}`}>
|
|
56
|
+
<span className="v-diff-no">{gutter(row)}</span>
|
|
57
|
+
<span className="v-diff-mark">{MARKER[row.kind]}</span>
|
|
58
|
+
<span className="v-diff-text">{row.text}</span>
|
|
59
|
+
</div>
|
|
60
|
+
),
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
{display.truncated && <div className="v-diff-trunc">diff truncated</div>}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|