@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/dist/channel.d.ts +152 -0
  3. package/dist/channel.d.ts.map +1 -0
  4. package/dist/channel.js +595 -0
  5. package/dist/channel.js.map +1 -0
  6. package/dist/index.d.ts +53 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +171 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/projector.d.ts +17 -0
  11. package/dist/projector.d.ts.map +1 -0
  12. package/dist/projector.js +96 -0
  13. package/dist/projector.js.map +1 -0
  14. package/dist/protocol.d.ts +145 -0
  15. package/dist/protocol.d.ts.map +1 -0
  16. package/dist/protocol.js +33 -0
  17. package/dist/protocol.js.map +1 -0
  18. package/dist/public/app.js +54 -0
  19. package/dist/public/index.html +178 -0
  20. package/dist/tunnel-settings.d.ts +17 -0
  21. package/dist/tunnel-settings.d.ts.map +1 -0
  22. package/dist/tunnel-settings.js +42 -0
  23. package/dist/tunnel-settings.js.map +1 -0
  24. package/package.json +71 -0
  25. package/src/channel.test.ts +514 -0
  26. package/src/channel.ts +669 -0
  27. package/src/discovery.test.ts +40 -0
  28. package/src/frontend/chat.test.ts +47 -0
  29. package/src/frontend/chat.tsx +138 -0
  30. package/src/frontend/index.html +178 -0
  31. package/src/frontend/main.tsx +87 -0
  32. package/src/frontend/render-diff.test.ts +86 -0
  33. package/src/frontend/render-diff.tsx +66 -0
  34. package/src/frontend/render.test.ts +166 -0
  35. package/src/frontend/render.tsx +274 -0
  36. package/src/frontend/socket.ts +212 -0
  37. package/src/frontend/url-safety.test.ts +0 -0
  38. package/src/frontend/url-safety.ts +33 -0
  39. package/src/frontend/view-store.test.ts +104 -0
  40. package/src/frontend/view-store.ts +120 -0
  41. package/src/index.ts +212 -0
  42. package/src/projector.test.ts +172 -0
  43. package/src/projector.ts +95 -0
  44. package/src/protocol.test.ts +59 -0
  45. package/src/protocol.ts +105 -0
  46. package/src/tunnel-settings.test.ts +64 -0
  47. package/src/tunnel-settings.ts +57 -0
  48. 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
+ }