@chalky/chalky 0.0.0-stage → 0.1.4
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/README.md +45 -2
- package/app/SYSTEM.md +31 -0
- package/app/extensions/appdata/README.md +10 -0
- package/app/extensions/appdata/guide.md +4 -0
- package/app/extensions/appdata/index.ts +47 -0
- package/app/extensions/boards/browser.js +357 -0
- package/app/extensions/boards/guide.md +4 -0
- package/app/extensions/boards/index.ts +98 -0
- package/app/extensions/commands/browser.js +13 -0
- package/app/extensions/commands/guide.md +3 -0
- package/app/extensions/commands/index.ts +96 -0
- package/app/extensions/doodle/README.md +15 -0
- package/app/extensions/doodle/browser.js +398 -0
- package/app/extensions/doodle/guide.md +6 -0
- package/app/extensions/doodle/index.ts +54 -0
- package/app/extensions/files/browser.js +96 -0
- package/app/extensions/files/guide.md +7 -0
- package/app/extensions/files/index.ts +73 -0
- package/app/extensions/frames/README.md +37 -0
- package/app/extensions/frames/browser.js +166 -0
- package/app/extensions/frames/guide.md +7 -0
- package/app/extensions/frames/index.ts +85 -0
- package/app/extensions/login/browser.js +363 -0
- package/app/extensions/login/index.ts +214 -0
- package/app/extensions/mcp/browser.js +64 -0
- package/app/extensions/mcp/guide.md +5 -0
- package/app/extensions/mcp/index.ts +64 -0
- package/app/extensions/model/index.ts +20 -0
- package/app/extensions/page/index.ts +96 -0
- package/app/extensions/page/page.html +615 -0
- package/app/extensions/point/README.md +22 -0
- package/app/extensions/point/browser.js +247 -0
- package/app/extensions/point/guide.md +6 -0
- package/app/extensions/point/index.ts +112 -0
- package/app/extensions/prompt/index.ts +19 -0
- package/app/extensions/reload/index.ts +21 -0
- package/app/extensions/screen/browser.js +76 -0
- package/app/extensions/screen/index.ts +80 -0
- package/app/extensions/steps/index.ts +17 -0
- package/app/extensions/swarm/README.md +47 -0
- package/app/extensions/swarm/guide.md +11 -0
- package/app/extensions/swarm/index.ts +304 -0
- package/app/extensions/ui/README.md +69 -0
- package/app/extensions/ui/agents.js +45 -0
- package/app/extensions/ui/browser.js +364 -0
- package/app/extensions/ui/components/App.js +28 -0
- package/app/extensions/ui/components/Button.js +28 -0
- package/app/extensions/ui/components/Card.js +23 -0
- package/app/extensions/ui/components/Column.js +17 -0
- package/app/extensions/ui/components/Markdown.js +37 -0
- package/app/extensions/ui/components/Row.js +17 -0
- package/app/extensions/ui/components/Table.js +31 -0
- package/app/extensions/ui/components/Text.js +19 -0
- package/app/extensions/ui/components/TextField.js +30 -0
- package/app/extensions/ui/guide.md +7 -0
- package/app/extensions/ui/index.ts +291 -0
- package/app/extensions/ui/skills/agent-apps/SKILL.md +91 -0
- package/app/extensions/ui/skills/ui-check/SKILL.md +71 -0
- package/app/extensions/ui/skills/ui-check/scripts/check.mjs +306 -0
- package/app/extensions/ui/skills/ui-design/SKILL.md +123 -0
- package/app/extensions/ui/voice.js +114 -0
- package/app/extensions/ui_history/browser.js +97 -0
- package/app/extensions/ui_history/index.ts +109 -0
- package/app/extensions/versions/browser.js +58 -0
- package/app/extensions/versions/index.ts +168 -0
- package/app/extensions/viewer/README.md +12 -0
- package/app/extensions/viewer/browser.js +322 -0
- package/app/extensions/viewer/guide.md +5 -0
- package/app/extensions/viewer/index.ts +209 -0
- package/app/extensions/voice/browser.js +71 -0
- package/app/extensions/voice/index.ts +37 -0
- package/app/mcp.json +3 -0
- package/app/settings.json +3 -0
- package/app/skills/plugin-map/SKILL.md +49 -0
- package/app/skills/write-skill/SKILL.md +57 -0
- package/app/ui/apps/.keep +0 -0
- package/bin/chalky.mjs +11 -0
- package/node_modules/xlsx/CHANGELOG.md +361 -0
- package/node_modules/xlsx/LICENSE +201 -0
- package/node_modules/xlsx/README.md +41 -0
- package/node_modules/xlsx/bin/xlsx.njs +310 -0
- package/node_modules/xlsx/bower.json +22 -0
- package/node_modules/xlsx/dist/LICENSE +201 -0
- package/node_modules/xlsx/dist/cpexcel.d.ts +39 -0
- package/node_modules/xlsx/dist/cpexcel.full.mjs +1502 -0
- package/node_modules/xlsx/dist/cpexcel.js +1506 -0
- package/node_modules/xlsx/dist/shim.min.js +2 -0
- package/node_modules/xlsx/dist/xlsx.core.min.js +18 -0
- package/node_modules/xlsx/dist/xlsx.core.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.extendscript.js +28259 -0
- package/node_modules/xlsx/dist/xlsx.full.min.js +24 -0
- package/node_modules/xlsx/dist/xlsx.full.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.mini.min.js +10 -0
- package/node_modules/xlsx/dist/xlsx.mini.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.zahl.js +4 -0
- package/node_modules/xlsx/dist/xlsx.zahl.mjs +2 -0
- package/node_modules/xlsx/dist/zahl.d.ts +4 -0
- package/node_modules/xlsx/package.json +183 -0
- package/node_modules/xlsx/types/index.d.ts +1065 -0
- package/node_modules/xlsx/types/tsconfig.json +15 -0
- package/node_modules/xlsx/xlsx.js +28103 -0
- package/node_modules/xlsx/xlsx.mjs +28228 -0
- package/node_modules/xlsx/xlsxworker.js +14 -0
- package/package.json +42 -4
- package/server.ts +281 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!-- The page the user sees, served fresh on every load by the page extension (extensions/page/; edits show on reload).
|
|
3
|
+
Design (pivot 2026-10-14, see the Chalky repo's NOTES.md "Direction"): what the agents build fills the screen; talk is secondary.
|
|
4
|
+
Layout, every part fixed over the full window:
|
|
5
|
+
#top main space: what the agents built (the ui plugin draws its surfaces here). Scrolls.
|
|
6
|
+
#rail left edge: icons that open a panel; clicking the open one closes it.
|
|
7
|
+
#panel a glass panel over the main space: #messages (the conversation) or #versions (history,
|
|
8
|
+
a screenshot of each version, rollback; the user's way to undo the agent's own changes: keep it working).
|
|
9
|
+
/?shot: the page as the versions plugin screenshots it (panels closed, nothing sent but what the page needs).
|
|
10
|
+
#working bottom left: animated dots for each busy agent, and Stop.
|
|
11
|
+
#f bottom centre: the instruction line (#to picks the agent, shown when there are several).
|
|
12
|
+
#status top centre: the tool palette, a row of tools. Browser plugins add a <button> with an icon, or a
|
|
13
|
+
<details> whose <summary> is the icon and whose content pops out below.
|
|
14
|
+
A tool with sub-tools (frames' Implement) is a <span class="tool">: its button, then a
|
|
15
|
+
smaller row (.sub) that hangs directly under it, hidden while none of its buttons shows (pageApi.tool).
|
|
16
|
+
#side right edge: empty; a browser plugin fills it (the files plugin's file panel).
|
|
17
|
+
Look: glass layers on one soft background, tokens in :root (colours, glass, blur, sizes); icons are inline
|
|
18
|
+
SVG from ICONS (data-icon="name").
|
|
19
|
+
Messages: only words are full text (the user's, and an agent's reply); everything else an agent does
|
|
20
|
+
(reasoning, tool calls, results, messages between agents) is a small icon: hover to peek, click to read.
|
|
21
|
+
App agents (status "app": part of something built, e.g. a simulation's members) stay out of the messages,
|
|
22
|
+
the picker and the working rows (one row per app): their app shows their talk.
|
|
23
|
+
Data: one WebSocket /ws. Every server event arrives as JSON {type, agent, ts, ...}; ws.onmessage handles
|
|
24
|
+
"status", "message", "version", errors. Sending: {to, text} is a message, {type: "stop"} stops all.
|
|
25
|
+
Extending without editing this file: an extension's browser.js (extensions/<name>/browser.js; GET /page/scripts lists them);
|
|
26
|
+
each script's default export gets pageApi: on(type, fn), send(obj) (-> the extensions' page handlers),
|
|
27
|
+
slot("top" | "status" | "side"), tool(button) -> the row under that palette tool (for its sub-tools), note(text), load(name), component(name, props) (components from extensions/ui/components),
|
|
28
|
+
command(name, fn, description): "/name rest" typed in the input calls fn(rest) instead of sending (other "/..." text
|
|
29
|
+
is sent); typing "/" lists the commands above the input, narrowing as you type.
|
|
30
|
+
provide(name, obj) / use(name) -> promise of obj: what one browser plugin offers others (scripts load in any
|
|
31
|
+
order, so use waits until it's provided; the screen plugin offers "screen"). -->
|
|
32
|
+
<meta charset="utf-8">
|
|
33
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
34
|
+
<title>Chalky</title>
|
|
35
|
+
<!-- Libraries that components import by name (installed from extensions/ui/package.json). Must come before any module. -->
|
|
36
|
+
<script type="importmap">
|
|
37
|
+
{ "imports": {
|
|
38
|
+
"marked": "/ui/node_modules/marked/lib/marked.esm.js",
|
|
39
|
+
"dompurify": "/ui/node_modules/dompurify/dist/purify.es.mjs"
|
|
40
|
+
} }
|
|
41
|
+
</script>
|
|
42
|
+
<style>
|
|
43
|
+
/* Glass: everything floats on one soft background; panels and bars are clear layers over the work,
|
|
44
|
+
told apart by blur and light, not by borders. Tune the look here. */
|
|
45
|
+
:root {
|
|
46
|
+
--text: #1d1d1f; --soft: rgba(29, 29, 31, 0.72); --muted: rgba(60, 60, 67, 0.55); --accent: #0071e3;
|
|
47
|
+
--danger: #e5484d; --line: rgba(0, 0, 0, 0.06); --hover: rgba(0, 0, 0, 0.045);
|
|
48
|
+
--glass: rgba(255, 255, 255, 0.45); --glass-strong: rgba(255, 255, 255, 0.7); --edge: rgba(255, 255, 255, 0.75);
|
|
49
|
+
--blur: saturate(170%) blur(24px); --shadow: 0 6px 28px rgba(0, 0, 0, 0.05); --radius: 16px;
|
|
50
|
+
--rail: 56px; --panel: 400px; --bar: 96px; --palette: 72px; /* edge columns' width, panel width, bottom room, top room */
|
|
51
|
+
}
|
|
52
|
+
body { margin: 0; height: 100vh; color: var(--text); -webkit-font-smoothing: antialiased;
|
|
53
|
+
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", system-ui, sans-serif;
|
|
54
|
+
background: radial-gradient(1100px 700px at 10% 0%, #edf1fa, transparent 60%),
|
|
55
|
+
radial-gradient(1000px 700px at 100% 100%, #f3effa, transparent 60%), #f7f7f9; }
|
|
56
|
+
[hidden] { display: none !important; } /* hidden wins over any display rule below */
|
|
57
|
+
button { font: inherit; color: inherit; border: 0; background: none; cursor: pointer; padding: 0; }
|
|
58
|
+
svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.5;
|
|
59
|
+
stroke-linecap: round; stroke-linejoin: round; display: block; }
|
|
60
|
+
|
|
61
|
+
/* ---- Main space: the agents' work, under everything else ---- */
|
|
62
|
+
#top { position: fixed; inset: 0; overflow-y: auto; box-sizing: border-box;
|
|
63
|
+
padding: var(--palette) calc(var(--rail) + 28px) var(--bar) calc(var(--rail) + 28px); }
|
|
64
|
+
#top:empty::before { content: "What should we build?"; position: absolute; inset: 0; display: grid; place-items: center;
|
|
65
|
+
font-size: 30px; font-weight: 300; letter-spacing: -0.02em; color: rgba(0, 0, 0, 0.2); }
|
|
66
|
+
|
|
67
|
+
/* ---- Rail: an icon column on the left edge, no background of its own. Tool palette: a row of tools on
|
|
68
|
+
a glass bar, top centre. Side: the right edge, for a plugin's panel (sized by its content). ---- */
|
|
69
|
+
#rail { position: fixed; top: 0; bottom: 0; left: 0; width: var(--rail); z-index: 20; display: flex;
|
|
70
|
+
flex-direction: column; align-items: center; gap: 6px; padding-top: 16px; box-sizing: border-box; }
|
|
71
|
+
#status { position: fixed; top: 14px; left: 50%; transform: translateX(-50%); z-index: 20; display: flex; gap: 4px;
|
|
72
|
+
padding: 4px; border-radius: 16px; background: var(--glass); backdrop-filter: var(--blur);
|
|
73
|
+
-webkit-backdrop-filter: var(--blur); border: 1px solid var(--edge); box-shadow: var(--shadow); }
|
|
74
|
+
#status:empty { display: none; }
|
|
75
|
+
#status > .tool { position: relative; }
|
|
76
|
+
#status .sub { position: absolute; top: calc(100% + 10px); left: 50%; transform: translateX(-50%); display: flex; gap: 4px;
|
|
77
|
+
padding: 3px; border-radius: 14px; background: var(--glass); backdrop-filter: var(--blur);
|
|
78
|
+
-webkit-backdrop-filter: var(--blur); border: 1px solid var(--edge); box-shadow: var(--shadow); }
|
|
79
|
+
#status .sub:not(:has(> :not([hidden]))) { display: none; } /* only hidden buttons (or none): no row */
|
|
80
|
+
#status .tool .badge { top: -4px; right: -4px; } /* on the corner, off the icon */
|
|
81
|
+
#side { position: fixed; top: 0; right: 0; z-index: 20; display: flex; flex-direction: column; align-items: flex-end; }
|
|
82
|
+
#rail button, #status > button, #status > details > summary, #status > .tool > button, #status .sub > button {
|
|
83
|
+
width: 38px; height: 38px; display: grid; place-items: center; border-radius: 12px; color: var(--muted);
|
|
84
|
+
position: relative; list-style: none; cursor: pointer; transition: background 0.15s, color 0.15s; }
|
|
85
|
+
#status > details > summary::-webkit-details-marker { display: none; }
|
|
86
|
+
#rail button:hover, #status > button:hover, #status > details > summary:hover, #status > .tool > button:hover, #status .sub > button:hover { background: var(--hover); color: var(--text); }
|
|
87
|
+
#rail button.on, #status > button.on, #status > details[open] > summary, #status > .tool > button.on, #status .sub > button.on { background: var(--glass-strong); color: var(--text);
|
|
88
|
+
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.06), inset 0 0 0 1px var(--edge); }
|
|
89
|
+
#status > details { position: relative; }
|
|
90
|
+
#status > details > div { position: absolute; left: 50%; top: 50px; transform: translateX(-50%); width: 320px; max-height: 60vh; overflow-y: auto;
|
|
91
|
+
padding: 12px 14px; border-radius: var(--radius); font-size: 13px; color: var(--soft);
|
|
92
|
+
background: var(--glass-strong); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
93
|
+
border: 1px solid var(--edge); box-shadow: var(--shadow); }
|
|
94
|
+
.badge { position: absolute; top: 3px; right: 3px; min-width: 15px; height: 15px; border-radius: 8px; padding: 0 4px;
|
|
95
|
+
box-sizing: border-box; background: var(--accent); color: #fff; font-size: 9.5px; line-height: 15px; text-align: center; }
|
|
96
|
+
.badge:empty { display: none; }
|
|
97
|
+
.tasks .task { padding: 3px 0; } .task.done { color: #16a34a; } .task.blocked { color: var(--danger); }
|
|
98
|
+
|
|
99
|
+
/* ---- Panel: a tint over the main space that fades out to the right, so it blends into the work.
|
|
100
|
+
(No backdrop blur here: blur plus a fading edge renders as stripes.) ---- */
|
|
101
|
+
#panel { position: fixed; top: 0; bottom: 0; left: 0; width: calc(var(--rail) + var(--panel)); z-index: 10;
|
|
102
|
+
transition: opacity 0.25s ease, transform 0.25s ease;
|
|
103
|
+
background: linear-gradient(to right, rgba(247, 247, 249, 0.92) 55%, rgba(247, 247, 249, 0.6) 80%, transparent); }
|
|
104
|
+
body:not([data-panel="messages"]):not([data-panel="versions"]) #panel { opacity: 0; transform: translateX(-24px); pointer-events: none; }
|
|
105
|
+
#panel > section { position: absolute; inset: 0 0 0 var(--rail); display: none; }
|
|
106
|
+
body[data-panel="messages"] #messages, body[data-panel="versions"] #versions { display: flex; }
|
|
107
|
+
|
|
108
|
+
/* Newest at the bottom, just above the instruction line; older ones rise and grow fainter towards the top.
|
|
109
|
+
Pointing at the messages lifts the fade, to read back. */
|
|
110
|
+
#log { flex: 1; overflow-y: auto; padding: 56px 72px var(--bar) 16px; scrollbar-width: none;
|
|
111
|
+
display: flex; flex-direction: column;
|
|
112
|
+
--fade: linear-gradient(transparent, rgba(0, 0, 0, 0.25) 15%, rgba(0, 0, 0, 0.7) 35%, #000 55%);
|
|
113
|
+
mask-image: var(--fade); -webkit-mask-image: var(--fade); }
|
|
114
|
+
#log:hover { --fade: linear-gradient(transparent, #000 56px); }
|
|
115
|
+
#log > :first-child { margin-top: auto; } /* few messages: they sit at the bottom (flex-end would break scrolling) */
|
|
116
|
+
.block { margin: 0 0 22px; animation: rise 0.4s ease-out; }
|
|
117
|
+
@keyframes rise { from { opacity: 0; transform: translateY(14px); } }
|
|
118
|
+
.who { display: flex; align-items: center; gap: 6px; font-size: 11.5px; font-weight: 500; letter-spacing: 0.01em;
|
|
119
|
+
color: var(--c, var(--muted)); }
|
|
120
|
+
.who svg { width: 16px; height: 16px; }
|
|
121
|
+
.who time { margin-left: auto; font-weight: 400; color: var(--muted); opacity: 0; transition: opacity 0.2s; }
|
|
122
|
+
.block:hover .who time { opacity: 1; }
|
|
123
|
+
.text { margin: 3px 0 0 22px; color: var(--soft); white-space: pre-wrap; overflow-wrap: anywhere; letter-spacing: -0.005em; }
|
|
124
|
+
.block:not([data-who="you"]) .text { color: var(--text); }
|
|
125
|
+
.text .to { color: var(--muted); font-size: 12px; margin-right: 4px; }
|
|
126
|
+
.text > ui-markdown, .text > ui-markdown > p:first-child { display: inline; } /* first line next to .to */
|
|
127
|
+
.steps { display: flex; flex-wrap: wrap; gap: 1px; margin: 4px 0 0 19px; }
|
|
128
|
+
.step { color: var(--muted); padding: 3px; border-radius: 6px; opacity: 0.8; }
|
|
129
|
+
.step svg { width: 14px; height: 14px; }
|
|
130
|
+
.step:hover, .step.open { background: var(--hover); color: var(--text); opacity: 1; }
|
|
131
|
+
.step.error { color: var(--danger); }
|
|
132
|
+
.detail { margin: 4px 0 0 22px; padding: 8px 10px; border-radius: 10px; color: var(--soft);
|
|
133
|
+
background: var(--glass); border: 1px solid var(--edge);
|
|
134
|
+
font: 11px/1.45 ui-monospace, "SF Mono", Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere;
|
|
135
|
+
max-height: 260px; overflow-y: auto; }
|
|
136
|
+
.note { margin: 0 0 16px; text-align: center; font-size: 11px; letter-spacing: 0.01em; color: var(--muted); }
|
|
137
|
+
|
|
138
|
+
/* Versions: a scroll wheel of commits (newest at top) and a screenshot of the selected one */
|
|
139
|
+
#versions { flex-direction: column; align-items: center; justify-content: center; gap: 16px; padding: 18px 72px 18px 16px; }
|
|
140
|
+
#frame { width: 280px; display: grid; place-items: center; overflow: hidden; border-radius: 12px; box-shadow: var(--shadow);
|
|
141
|
+
background: #fff; font-size: 11.5px; color: var(--muted); } /* aspect-ratio: the window's, set in openPanel */
|
|
142
|
+
#frame > * { grid-area: 1 / 1; } /* the screenshot covers the "no preview" text */
|
|
143
|
+
#frame img { width: 100%; height: 100%; object-fit: contain; background: #fff; }
|
|
144
|
+
#wheel { width: 100%; user-select: none; cursor: ns-resize; }
|
|
145
|
+
.v { display: flex; align-items: center; gap: 14px; padding: 3px 10px; cursor: pointer; white-space: nowrap; color: var(--soft); }
|
|
146
|
+
.v .dot { border: 1.5px solid var(--muted); border-radius: 50%; flex: none; box-sizing: border-box; }
|
|
147
|
+
.v .t { overflow: hidden; text-overflow: ellipsis; }
|
|
148
|
+
.v.sel { color: var(--text); } .v.sel .dot { background: var(--text); border-color: var(--text); }
|
|
149
|
+
#detail { font-size: 11.5px; color: var(--muted); text-align: center; min-height: 32px; }
|
|
150
|
+
#rollback { padding: 6px 16px; border-radius: 980px; font-size: 13px; color: var(--accent);
|
|
151
|
+
background: var(--glass-strong); border: 1px solid var(--edge); box-shadow: var(--shadow); }
|
|
152
|
+
#rollback:disabled { color: var(--muted); box-shadow: none; cursor: default; }
|
|
153
|
+
|
|
154
|
+
/* ---- Bottom: who is working (left) and the instruction line (centre) ---- */
|
|
155
|
+
#working { position: fixed; left: calc(var(--rail) + 16px); bottom: 26px; z-index: 30; display: flex;
|
|
156
|
+
align-items: center; gap: 10px; font-size: 12px; }
|
|
157
|
+
#busy { display: flex; flex-direction: column; gap: 4px; }
|
|
158
|
+
.busy { display: flex; align-items: center; gap: 8px; max-width: 170px; } /* stays clear of the instruction line */
|
|
159
|
+
.busy b { color: var(--c); font-weight: 500; }
|
|
160
|
+
.busy span:last-child { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
161
|
+
.dots { display: flex; gap: 4px; }
|
|
162
|
+
.dots i { width: 6px; height: 6px; border-radius: 50%; background: var(--c); animation: pulse 1.2s infinite ease-in-out; }
|
|
163
|
+
.dots i:nth-child(2) { animation-delay: 0.15s; } .dots i:nth-child(3) { animation-delay: 0.3s; }
|
|
164
|
+
@keyframes pulse { 0%, 80%, 100% { opacity: 0.25; transform: scale(0.8); } 40% { opacity: 1; transform: scale(1); } }
|
|
165
|
+
#stop { color: var(--muted); } #stop:hover { color: var(--danger); }
|
|
166
|
+
#stop svg { width: 20px; height: 20px; }
|
|
167
|
+
|
|
168
|
+
/* The instruction: one line on the glass, no box; the send button is a clear bead */
|
|
169
|
+
#f { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 30; width: min(720px, 64vw);
|
|
170
|
+
display: flex; align-items: center; gap: 8px; padding: 2px 0 6px; border-bottom: 1px solid rgba(0, 0, 0, 0.16); }
|
|
171
|
+
#f:focus-within { border-bottom-color: rgba(0, 0, 0, 0.4); }
|
|
172
|
+
#to { border: 0; background: none; font: inherit; font-size: 12px; color: var(--muted); }
|
|
173
|
+
#text { flex: 1; border: 0; outline: none; background: none; font: inherit; font-size: 16.5px; color: var(--text); padding: 4px 0; }
|
|
174
|
+
#text::placeholder { color: var(--muted); }
|
|
175
|
+
/* Slash commands: the list above the input while a command's name is typed */
|
|
176
|
+
#cmds { position: absolute; left: 0; right: 0; bottom: calc(100% + 8px); padding: 4px; border-radius: var(--radius);
|
|
177
|
+
background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
178
|
+
border: 1px solid var(--edge); box-shadow: var(--shadow); font-size: 14px; }
|
|
179
|
+
#cmds > div { display: flex; gap: 12px; padding: 6px 10px; border-radius: 10px; cursor: pointer; }
|
|
180
|
+
#cmds > div.on { background: var(--glass-strong); }
|
|
181
|
+
#cmds span { color: var(--muted); }
|
|
182
|
+
#f button { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; color: var(--text);
|
|
183
|
+
background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
184
|
+
border: 1px solid var(--edge); box-shadow: 0 1px 6px rgba(0, 0, 0, 0.06); transition: background 0.15s; }
|
|
185
|
+
#f button:hover { background: var(--glass-strong); }
|
|
186
|
+
#f button svg { width: 15px; height: 15px; stroke-width: 2; }
|
|
187
|
+
</style>
|
|
188
|
+
<body data-panel="messages">
|
|
189
|
+
<div id="top"></div>
|
|
190
|
+
<nav id="rail">
|
|
191
|
+
<button id="show-messages" data-icon="messages" title="Messages"><span class="badge" id="unread"></span></button>
|
|
192
|
+
<button id="show-versions" data-icon="versions" title="Versions"></button>
|
|
193
|
+
</nav>
|
|
194
|
+
<aside id="panel">
|
|
195
|
+
<section id="messages"><div id="log"></div></section>
|
|
196
|
+
<section id="versions">
|
|
197
|
+
<div id="frame"><span>No preview for this version</span><img id="preview" alt="The page at this version"></div>
|
|
198
|
+
<div id="wheel" title="Scroll to pick a version"></div>
|
|
199
|
+
<div id="detail"></div>
|
|
200
|
+
<button id="rollback" type="button">Roll back to this version</button>
|
|
201
|
+
</section>
|
|
202
|
+
</aside>
|
|
203
|
+
<div id="working" hidden><div id="busy"></div><button id="stop" data-icon="stop" title="Stop all agents"></button></div>
|
|
204
|
+
<form id="f">
|
|
205
|
+
<div id="cmds" hidden></div>
|
|
206
|
+
<select id="to" title="Send to" hidden></select>
|
|
207
|
+
<input id="text" placeholder="Tell the agents what to build or change" autocomplete="off">
|
|
208
|
+
<button data-icon="send" title="Send"></button>
|
|
209
|
+
</form>
|
|
210
|
+
<nav id="status"></nav>
|
|
211
|
+
<aside id="side"></aside>
|
|
212
|
+
<script>
|
|
213
|
+
const log = document.getElementById("log"), to = document.getElementById("to");
|
|
214
|
+
const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws");
|
|
215
|
+
const SHOT = new URLSearchParams(location.search).has("shot"); // the versions plugin's screenshot
|
|
216
|
+
const agents = new Set();
|
|
217
|
+
const appOf = {}; // agent -> the app it is part of (from "status"): its talk is the app's, not the messages'
|
|
218
|
+
const joined = new Set(); // agents whose first system message was shown (a later one: memory cleared)
|
|
219
|
+
|
|
220
|
+
// ---- Look: line icons (24×24, drawn with the current colour). Elements with data-icon="name" get one.
|
|
221
|
+
const ICONS = {
|
|
222
|
+
messages: '<path d="M4 5h16v11H9l-5 4z"/>',
|
|
223
|
+
versions: '<circle cx="12" cy="12" r="8"/><path d="M12 8v4l3 2"/>',
|
|
224
|
+
robot: '<rect x="5" y="8" width="14" height="11" rx="3"/><path d="M12 5v3M9.5 13h.01M14.5 13h.01"/><circle cx="12" cy="4" r="1"/>',
|
|
225
|
+
person: '<circle cx="12" cy="8" r="4"/><path d="M4 20c1.5-4 4.5-6 8-6s6.5 2 8 6"/>',
|
|
226
|
+
think: '<path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-4 10.5c.7.7 1 1.5 1 2.5h6c0-1 .3-1.8 1-2.5A6 6 0 0 0 12 3z"/>',
|
|
227
|
+
said: '<path d="M5 7h14M5 12h14M5 17h9"/>',
|
|
228
|
+
tool: '<path d="M14.7 6.3a4 4 0 0 0-5.4 5.1L4 16.7 7.3 20l5.3-5.3a4 4 0 0 0 5.1-5.4l-2.4 2.4-2.6-.7-.7-2.6z"/>',
|
|
229
|
+
result: '<path d="M7 3h7l4 4v14H7z"/><path d="M14 3v4h4"/>',
|
|
230
|
+
ask: '<path d="M21 3 3 10l7 3 3 7z"/><path d="m10 13 4-4"/>',
|
|
231
|
+
inbox: '<rect x="3" y="6" width="18" height="12" rx="2"/><path d="m3 7 9 6 9-6"/>',
|
|
232
|
+
stop: '<circle cx="12" cy="12" r="9"/><rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor"/>',
|
|
233
|
+
send: '<path d="M12 19V5M6 11l6-6 6 6"/>',
|
|
234
|
+
};
|
|
235
|
+
const icon = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`;
|
|
236
|
+
for (const el of document.querySelectorAll("[data-icon]")) el.insertAdjacentHTML("afterbegin", icon(el.dataset.icon));
|
|
237
|
+
|
|
238
|
+
// Same agent name -> same colour, so parallel agents are easy to tell apart.
|
|
239
|
+
// ×137 spreads the hue, so names that differ by one character (agent1, agent2) look clearly different.
|
|
240
|
+
const colour = (a) => `hsl(${([...a].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 997, 7) * 137) % 360} 65% 45%)`;
|
|
241
|
+
|
|
242
|
+
function addAgent(a) {
|
|
243
|
+
if (agents.has(a)) return;
|
|
244
|
+
agents.add(a);
|
|
245
|
+
if (appOf[a]) return; // app agents are reached through their app, not the instruction line
|
|
246
|
+
to.append(new Option(a, a));
|
|
247
|
+
to.hidden = to.options.length < 2;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---- Component runtime: the catalog of reusable UI pieces (/ui/components/<Name>.js), each a custom
|
|
251
|
+
// element <ui-name> with update(props, ui). The page draws replies with it; browser plugins use
|
|
252
|
+
// page.load / page.component (the ui plugin draws agents' surfaces from the same catalog).
|
|
253
|
+
let catalog = null;
|
|
254
|
+
const loading = {}; // name -> promise: true once <ui-name> is defined, false if there is no such component
|
|
255
|
+
const loaded = {}; // name -> version loaded (custom elements can't be redefined: changes need a reload)
|
|
256
|
+
async function fetchCatalog() { // false: the agent didn't answer (e.g. restarting); the catalog stays as it was
|
|
257
|
+
try {
|
|
258
|
+
const r = await fetch("/ui-api/catalog");
|
|
259
|
+
if (r.ok) { catalog = await r.json(); return true; }
|
|
260
|
+
} catch {}
|
|
261
|
+
catalog ??= {};
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
function load(name) {
|
|
265
|
+
return loading[name] ??= (async () => {
|
|
266
|
+
if (!catalog || !(name in catalog)) await fetchCatalog(); // maybe just written
|
|
267
|
+
if (!(name in catalog)) return false;
|
|
268
|
+
const tag = `ui-${name.toLowerCase()}`;
|
|
269
|
+
if (!customElements.get(tag)) customElements.define(tag, (await import(`/ui/components/${name}.js?v=${catalog[name]}`)).default);
|
|
270
|
+
loaded[name] = catalog[name];
|
|
271
|
+
return true;
|
|
272
|
+
})().catch((e) => { delete loading[name]; throw e; }); // a broken file: try again next time
|
|
273
|
+
}
|
|
274
|
+
async function component(name, props = {}) { // a ready element, or null if the component is missing/broken
|
|
275
|
+
try { if (!(await load(name))) return null; } catch (e) { console.warn(`component ${name}:`, e); return null; }
|
|
276
|
+
const el = document.createElement(`ui-${name.toLowerCase()}`);
|
|
277
|
+
el.update(props, { children: [], mount() {}, set() {}, action() {} });
|
|
278
|
+
return el;
|
|
279
|
+
}
|
|
280
|
+
// After an agent saves a version (end of its turn), show what it changed without a manual refresh:
|
|
281
|
+
// an App's files -> reload that App's frame; this page, a browser.js or a component in use -> reload the page
|
|
282
|
+
// (the browser can't redefine a custom element). New components need nothing: they load on first use.
|
|
283
|
+
async function refreshAfter(files) {
|
|
284
|
+
if (!(await fetchCatalog())) return reloadWhenUp(); // can't tell what changed: reload once the agent answers
|
|
285
|
+
for (const [n, p] of Object.entries(loading)) p.then((ok) => { if (!ok) delete loading[n]; }); // maybe created now
|
|
286
|
+
const changed = Object.keys(loaded).filter((n) => catalog[n] !== loaded[n]);
|
|
287
|
+
if (changed.length || files.some((f) => f === "extensions/page/page.html" || f.endsWith("/browser.js"))) return location.reload();
|
|
288
|
+
const apps = files.filter((f) => f.startsWith("ui/apps/")).map((f) => `/ui/apps/${f.split("/")[2]}/`);
|
|
289
|
+
for (const frame of document.querySelectorAll("iframe"))
|
|
290
|
+
if (apps.some((a) => new URL(frame.src, location.href).pathname.startsWith(a))) frame.contentWindow.location.reload();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---- Messages: blocks of consecutive messages from one speaker (an agent, or "you"). A block has a
|
|
294
|
+
// header (robot in the agent's colour, name, time), then words as text and other work as rows of icons.
|
|
295
|
+
let ts = null; // time of the event being shown (set per event)
|
|
296
|
+
let unread = 0; // replies that arrived while the messages panel was closed
|
|
297
|
+
function follow(change) { // make a change to the log; stay at the bottom if the user was there
|
|
298
|
+
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 40;
|
|
299
|
+
change();
|
|
300
|
+
if (atBottom) log.scrollTop = log.scrollHeight;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function block(who) {
|
|
304
|
+
const last = log.lastElementChild;
|
|
305
|
+
if (last?.dataset.who === who) return last;
|
|
306
|
+
const b = document.createElement("div");
|
|
307
|
+
b.className = "block";
|
|
308
|
+
b.dataset.who = who;
|
|
309
|
+
if (who !== "you") b.style.setProperty("--c", colour(who));
|
|
310
|
+
b.innerHTML = `<div class="who">${icon(who === "you" ? "person" : "robot")}<span></span><time></time></div>`;
|
|
311
|
+
b.querySelector("span").textContent = who;
|
|
312
|
+
b.querySelector("time").textContent = new Date((ts || Date.now() / 1000) * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
313
|
+
log.append(b);
|
|
314
|
+
return b;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function text(who, words, toWhom) { // full text: the user's words, or an agent's reply (Markdown)
|
|
318
|
+
const div = document.createElement("div");
|
|
319
|
+
div.className = "text";
|
|
320
|
+
if (toWhom) div.append(Object.assign(document.createElement("span"), { className: "to", textContent: `→ ${toWhom}` }));
|
|
321
|
+
const span = document.createElement("span"); // plain at once; swapped for the Markdown component when ready
|
|
322
|
+
span.textContent = words;
|
|
323
|
+
div.append(span);
|
|
324
|
+
follow(() => block(who).append(div));
|
|
325
|
+
component("Markdown", { text: words }).then((el) => el && follow(() => span.replaceWith(el)));
|
|
326
|
+
if (who !== "you" && document.body.dataset.panel !== "messages")
|
|
327
|
+
document.getElementById("unread").textContent = ++unread;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function step(agent, kind, title, content, error = false) { // one icon in the agent's row; click to read it
|
|
331
|
+
follow(() => {
|
|
332
|
+
const b = block(agent);
|
|
333
|
+
let row = b.lastElementChild;
|
|
334
|
+
if (row.classList.contains("detail")) row = row.previousElementSibling; // the row whose step is open
|
|
335
|
+
if (!row.classList.contains("steps")) b.append((row = Object.assign(document.createElement("div"), { className: "steps" })));
|
|
336
|
+
const btn = document.createElement("button");
|
|
337
|
+
btn.className = "step" + (error ? " error" : "");
|
|
338
|
+
btn.innerHTML = icon(kind);
|
|
339
|
+
btn.title = `${title}\n${content.slice(0, 200)}`;
|
|
340
|
+
btn.onclick = () => { // one open detail per row, right under it
|
|
341
|
+
const wasOpen = btn.classList.contains("open");
|
|
342
|
+
for (const s of row.querySelectorAll(".open")) s.classList.remove("open");
|
|
343
|
+
let detail = row.nextElementSibling;
|
|
344
|
+
if (!detail?.classList.contains("detail")) row.after((detail = Object.assign(document.createElement("pre"), { className: "detail" })));
|
|
345
|
+
detail.hidden = wasOpen;
|
|
346
|
+
if (wasOpen) return;
|
|
347
|
+
btn.classList.add("open");
|
|
348
|
+
detail.textContent = `${title}\n\n${content}`;
|
|
349
|
+
};
|
|
350
|
+
row.append(btn);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function note(words) { // a quiet line: joined, saved a version, errors, ...
|
|
355
|
+
follow(() => log.append(Object.assign(document.createElement("div"), { className: "note", textContent: words })));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---- Working: animated dots for each agent that isn't idle (from "status" events), Stop beside them
|
|
359
|
+
// An app's agents share one row ("board · 3 working"): the app shows what each of them does.
|
|
360
|
+
function renderWorking(list) {
|
|
361
|
+
const busy = document.getElementById("busy"), apps = {};
|
|
362
|
+
busy.innerHTML = "";
|
|
363
|
+
const row = (name, text, title) => {
|
|
364
|
+
const r = document.createElement("div");
|
|
365
|
+
r.className = "busy";
|
|
366
|
+
r.style.setProperty("--c", colour(name));
|
|
367
|
+
r.title = title;
|
|
368
|
+
r.innerHTML = `<span class="dots"><i></i><i></i><i></i></span><b></b><span></span>`;
|
|
369
|
+
r.children[1].textContent = name;
|
|
370
|
+
r.children[2].textContent = text;
|
|
371
|
+
busy.append(r);
|
|
372
|
+
};
|
|
373
|
+
for (const [a, s] of Object.entries(list)) {
|
|
374
|
+
appOf[a] = s.app;
|
|
375
|
+
addAgent(a);
|
|
376
|
+
if (s.state === "idle") continue;
|
|
377
|
+
if (s.app) apps[s.app] = (apps[s.app] || 0) + 1;
|
|
378
|
+
else row(a, (s.doing || s.state) + (s.queued ? ` · ${s.queued} queued` : ""), s.role);
|
|
379
|
+
}
|
|
380
|
+
for (const [app, n] of Object.entries(apps)) row(app, `${n} working`, `agents in the app ${app}`);
|
|
381
|
+
document.getElementById("working").hidden = !busy.children.length;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Browser plugins: each extension's browser.js (GET /page/scripts lists them); each script's
|
|
385
|
+
// default export is called with this API.
|
|
386
|
+
const listeners = {};
|
|
387
|
+
const commands = Object.create(null); // "/name" typed in the input -> {fn(rest of the line), description}
|
|
388
|
+
const offered = {}, wanted = {}; // provide/use: name -> obj; name -> [resolve] until it's provided
|
|
389
|
+
const pageApi = {
|
|
390
|
+
on: (type, fn) => (listeners[type] ??= []).push(fn), // receive server events of this type
|
|
391
|
+
send: (obj) => ws.send(JSON.stringify(obj)), // arrives at the extensions' page handlers
|
|
392
|
+
slot: (id) => document.getElementById(id), // "top": the main space; "status": the tool palette (top); "side": right edge
|
|
393
|
+
note, // note(text): a quiet line in the messages
|
|
394
|
+
load, // load(name): define component <ui-name>; resolves false if there is no such component
|
|
395
|
+
component, // component(name, props): a ready element, or null
|
|
396
|
+
command: (name, fn, description = "") => { commands[name] = { fn, description }; },
|
|
397
|
+
provide: (name, obj) => { offered[name] = obj; for (const ok of wanted[name] || []) ok(obj); delete wanted[name]; },
|
|
398
|
+
use: (name) => name in offered ? Promise.resolve(offered[name]) : new Promise((ok) => (wanted[name] ??= []).push(ok)),
|
|
399
|
+
tool: (button) => { // puts button in the palette with a row under it; returns the row, for its sub-tools
|
|
400
|
+
const sub = Object.assign(document.createElement("div"), { className: "sub" });
|
|
401
|
+
const wrap = Object.assign(document.createElement("span"), { className: "tool" });
|
|
402
|
+
wrap.append(button, sub);
|
|
403
|
+
document.getElementById("status").append(wrap);
|
|
404
|
+
return sub;
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
fetch("/page/scripts").then((r) => r.ok ? r.json() : []).then((urls) => {
|
|
408
|
+
for (const url of urls) import(url).then((m) => m.default(pageApi)).catch((e) => note(`plugin ${url} failed: ${e}`));
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const lastFrom = {}; // agent -> who its current turn's answer goes to
|
|
412
|
+
const asked = {}; // ask_agent / tell tool-call ids
|
|
413
|
+
|
|
414
|
+
ws.onmessage = (e) => {
|
|
415
|
+
const ev = JSON.parse(e.data);
|
|
416
|
+
const a = ev.agent;
|
|
417
|
+
ts = ev.ts || null;
|
|
418
|
+
for (const fn of listeners[ev.type] || []) fn(ev);
|
|
419
|
+
if (ev.type === "status") return renderWorking(ev.agents);
|
|
420
|
+
if (ev.type === "message") {
|
|
421
|
+
addAgent(a);
|
|
422
|
+
if (appOf[a]) return; // an app agent: its app shows its talk
|
|
423
|
+
if (ev.role === "system") {
|
|
424
|
+
const again = joined.has(a);
|
|
425
|
+
joined.add(a);
|
|
426
|
+
return note(again ? `${a} starts fresh (memory cleared)` : `${a} joined`);
|
|
427
|
+
}
|
|
428
|
+
if (ev.role === "user") {
|
|
429
|
+
// a message with pictures is a list of parts: its text, and [image <path>] for each picture
|
|
430
|
+
const content = typeof ev.content === "string" ? ev.content
|
|
431
|
+
: (ev.content || []).map((p) => p.type === "image" ? `[image ${p.file}]` : p.text).join("\n");
|
|
432
|
+
// header: "[<kind> from <name> · <time>] ..."
|
|
433
|
+
const m = /^\[(\w+) from ([^\s\]]+)[^\]]*\][^\n]*\n?/.exec(content);
|
|
434
|
+
const from = m ? m[2] : "user", words = m ? content.slice(m[0].length) : content;
|
|
435
|
+
if (ev.reply_to) lastFrom[a] = ev.reply_to; // set on the message that starts a turn
|
|
436
|
+
// a message the page wrote for the user ("[note on the UI] <their words>" + lines for the agent):
|
|
437
|
+
// show their words; the lines meant for the agent go behind an icon
|
|
438
|
+
const tagged = from === "user" && /^\[([^\]]+)\] ?(.*)/.exec(words);
|
|
439
|
+
if (tagged) {
|
|
440
|
+
text("you", tagged[2] || tagged[1], agents.size > 1 ? a : null);
|
|
441
|
+
if (words.includes("\n")) step("you", "inbox", `${tagged[1]}: what ${a} got`, words);
|
|
442
|
+
} else if (from === "user") text("you", words, agents.size > 1 ? a : null);
|
|
443
|
+
// from another agent: already shown as its "asks"/"tells" step, or as its answer
|
|
444
|
+
else if (!agents.has(from) || from === a) step(a, "inbox", `${a} got a message from ${from}`, words);
|
|
445
|
+
}
|
|
446
|
+
if (ev.role === "assistant") {
|
|
447
|
+
if (ev.reasoning) step(a, "think", `${a} thinking`, ev.reasoning);
|
|
448
|
+
const final = !(ev.tool_calls || []).length; // a final answer is the reply to whoever sent the message
|
|
449
|
+
const replyTo = lastFrom[a] || "user";
|
|
450
|
+
if (ev.content.trim()) {
|
|
451
|
+
if (final) text(a, ev.content.trim(), replyTo === "user" ? null : replyTo);
|
|
452
|
+
else step(a, "said", `${a} said`, ev.content.trim());
|
|
453
|
+
}
|
|
454
|
+
for (const c of ev.tool_calls || []) {
|
|
455
|
+
let args = {};
|
|
456
|
+
try { args = JSON.parse(c.function.arguments); } catch {}
|
|
457
|
+
if (c.function.name === "ask_agent") { asked[c.id] = true; step(a, "ask", `${a} asks ${args.to}`, args.text || ""); }
|
|
458
|
+
else if (c.function.name === "tell") { asked[c.id] = true; step(a, "ask", `${a} tells ${args.to}`, args.text || ""); }
|
|
459
|
+
else step(a, "tool", `${a} → ${c.function.name}`, c.function.arguments);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// an ask_agent result is the other agent's answer, already shown as its reply; a tell result is
|
|
463
|
+
// just "sent" (errors still shown)
|
|
464
|
+
// a result may be a list of parts (view_image): text, and [image <path>] for an image
|
|
465
|
+
const res = typeof ev.content === "string" ? ev.content
|
|
466
|
+
: (ev.content || []).map((p) => p.type === "image" ? `[image ${p.file}]` : p.text).join(" ");
|
|
467
|
+
if (ev.role === "tool" && !(asked[ev.tool_call_id] && !res.startsWith("error:")))
|
|
468
|
+
step(a, "result", `${a} ← result`, res.slice(0, 4000), res.startsWith("error"));
|
|
469
|
+
}
|
|
470
|
+
if (ev.type === "compacted") note(`${a}: history compacted (~${ev.tokens_before} → ~${ev.tokens_after} tokens)`);
|
|
471
|
+
if (ev.type === "error") note(`${a} error: ${ev.text}`);
|
|
472
|
+
if (ev.type === "stopped") note("all agents stopped");
|
|
473
|
+
if (ev.type === "version") {
|
|
474
|
+
note(`${a} saved version ${ev.sha}: ${ev.message}`);
|
|
475
|
+
// A restart (or rollback) follows: the process is going down, so asking it now fails; ws.onclose reloads.
|
|
476
|
+
if (!ev.restart) { loadVersions(); refreshAfter(ev.files || []); }
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
// Reload only once the agent answers: reloading while it is down shows the proxy's error page, which has no
|
|
480
|
+
// script to come back from.
|
|
481
|
+
let reloading = false;
|
|
482
|
+
function reloadWhenUp() {
|
|
483
|
+
if (reloading) return;
|
|
484
|
+
reloading = true;
|
|
485
|
+
const retry = () => fetch(location.pathname).then((r) => r.ok ? location.reload() : setTimeout(retry, 2000),
|
|
486
|
+
() => setTimeout(retry, 2000));
|
|
487
|
+
setTimeout(retry, 2000);
|
|
488
|
+
}
|
|
489
|
+
// The agent restarts itself after editing its plugins (or a rollback): wait until it is back, then reload.
|
|
490
|
+
ws.onclose = () => {
|
|
491
|
+
note("disconnected; reconnecting…");
|
|
492
|
+
reloadWhenUp();
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
document.getElementById("f").onsubmit = (e) => {
|
|
496
|
+
e.preventDefault();
|
|
497
|
+
const input = document.getElementById("text");
|
|
498
|
+
const [, name, rest] = input.value.trim().match(/^\/(\S+)\s*([\s\S]*)$/) || [];
|
|
499
|
+
if (commands[name]) commands[name].fn(rest);
|
|
500
|
+
else if (input.value.trim()) ws.send(JSON.stringify({ to: to.value || "main", text: input.value }));
|
|
501
|
+
input.value = "";
|
|
502
|
+
};
|
|
503
|
+
// Slash commands: while the input is "/" plus part of a name, the matching commands show above it.
|
|
504
|
+
// ↑↓ pick one, Enter or a click runs it, Tab fills it in (to add words after it), Esc closes the list.
|
|
505
|
+
const textInput = document.getElementById("text"), cmds = document.getElementById("cmds");
|
|
506
|
+
let picked = 0;
|
|
507
|
+
const matching = () => {
|
|
508
|
+
const m = textInput.value.match(/^\/(\S*)$/);
|
|
509
|
+
return m ? Object.keys(commands).filter((n) => n.startsWith(m[1])).sort() : [];
|
|
510
|
+
};
|
|
511
|
+
function showCmds() {
|
|
512
|
+
const list = matching();
|
|
513
|
+
cmds.hidden = !list.length;
|
|
514
|
+
picked = Math.max(0, Math.min(picked, list.length - 1));
|
|
515
|
+
cmds.replaceChildren(...list.map((n, i) => {
|
|
516
|
+
const row = document.createElement("div"), name = document.createElement("b"), about = document.createElement("span");
|
|
517
|
+
row.className = i === picked ? "on" : "";
|
|
518
|
+
name.textContent = "/" + n;
|
|
519
|
+
about.textContent = commands[n].description;
|
|
520
|
+
row.append(name, about);
|
|
521
|
+
row.onmousedown = (e) => { e.preventDefault(); runCmd(n); }; // before the input loses focus
|
|
522
|
+
return row;
|
|
523
|
+
}));
|
|
524
|
+
}
|
|
525
|
+
function runCmd(n) {
|
|
526
|
+
textInput.value = "";
|
|
527
|
+
cmds.hidden = true;
|
|
528
|
+
commands[n].fn("");
|
|
529
|
+
}
|
|
530
|
+
textInput.addEventListener("input", () => { picked = 0; showCmds(); });
|
|
531
|
+
textInput.addEventListener("blur", () => { cmds.hidden = true; });
|
|
532
|
+
textInput.addEventListener("keydown", (e) => {
|
|
533
|
+
const list = cmds.hidden ? [] : matching();
|
|
534
|
+
if (!list.length) return;
|
|
535
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") { picked = (picked + (e.key === "ArrowDown" ? 1 : list.length - 1)) % list.length; showCmds(); }
|
|
536
|
+
else if (e.key === "Tab") { textInput.value = `/${list[picked]} `; showCmds(); }
|
|
537
|
+
else if (e.key === "Enter") runCmd(list[picked]);
|
|
538
|
+
else if (e.key === "Escape") cmds.hidden = true;
|
|
539
|
+
else return;
|
|
540
|
+
e.preventDefault();
|
|
541
|
+
});
|
|
542
|
+
document.getElementById("stop").onclick = () => ws.send(JSON.stringify({ type: "stop" }));
|
|
543
|
+
|
|
544
|
+
// ---- Panels: the rail opens one at a time (body[data-panel]); clicking the open one closes it
|
|
545
|
+
function openPanel(name) {
|
|
546
|
+
document.body.dataset.panel = name;
|
|
547
|
+
for (const p of ["messages", "versions"]) document.getElementById(`show-${p}`).classList.toggle("on", p === name);
|
|
548
|
+
if (name === "messages") { unread = 0; document.getElementById("unread").textContent = ""; log.scrollTop = log.scrollHeight; }
|
|
549
|
+
if (name === "versions") { document.getElementById("frame").style.aspectRatio = innerWidth / innerHeight; renderWheel(); }
|
|
550
|
+
}
|
|
551
|
+
for (const p of ["messages", "versions"])
|
|
552
|
+
document.getElementById(`show-${p}`).onclick = () => openPanel(document.body.dataset.panel === p ? "" : p);
|
|
553
|
+
|
|
554
|
+
// ---- Versions panel: a scroll wheel of commits (newest at top), the selected one's screenshot ----
|
|
555
|
+
const wheel = document.getElementById("wheel"), detail = document.getElementById("detail");
|
|
556
|
+
const preview = document.getElementById("preview"), rollbackBtn = document.getElementById("rollback");
|
|
557
|
+
let versions = [], sel = 0;
|
|
558
|
+
preview.onerror = () => { // no shot: an older version, or a new one whose shot is still being taken (a few s)
|
|
559
|
+
preview.hidden = true;
|
|
560
|
+
const v = versions[sel];
|
|
561
|
+
if (v && Date.now() - new Date(v.date) < 30000) setTimeout(() => versions[sel] === v && renderWheel(), 3000);
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
async function loadVersions() {
|
|
565
|
+
try { versions = await (await fetch("/versions")).json(); } catch { return; } // no versions plugin
|
|
566
|
+
sel = Math.max(0, versions.findIndex((v) => v.current));
|
|
567
|
+
renderWheel();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function renderWheel() {
|
|
571
|
+
wheel.innerHTML = "";
|
|
572
|
+
for (let d = -3; d <= 3; d++) { // the selected version in the middle, 3 on each side
|
|
573
|
+
const v = versions[sel + d], row = document.createElement("div"), dist = Math.abs(d);
|
|
574
|
+
row.className = "v" + (d === 0 ? " sel" : "");
|
|
575
|
+
row.style.opacity = v ? 1 - dist * 0.25 : 0;
|
|
576
|
+
row.style.fontSize = 15 - dist * 2 + "px";
|
|
577
|
+
const size = 22 - dist * 4;
|
|
578
|
+
row.innerHTML = `<span class="dot" style="width:${size}px;height:${size}px"></span><span class="t"></span>`;
|
|
579
|
+
if (v) {
|
|
580
|
+
row.lastChild.textContent = (v.current ? "● " : "") + v.message;
|
|
581
|
+
row.onclick = () => select(sel + d);
|
|
582
|
+
} else row.lastChild.textContent = ".";
|
|
583
|
+
wheel.append(row);
|
|
584
|
+
}
|
|
585
|
+
const v = versions[sel];
|
|
586
|
+
if (!v) return;
|
|
587
|
+
detail.textContent = `${v.short} · ${v.author} · ${new Date(v.date).toLocaleString()}` + (v.current ? " · current" : "");
|
|
588
|
+
rollbackBtn.disabled = v.current;
|
|
589
|
+
preview.hidden = !v.shot; // a version made while no page was open has no picture
|
|
590
|
+
if (v.shot) preview.src = `/versions/${v.sha}/shot`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function select(i) {
|
|
594
|
+
sel = Math.min(Math.max(i, 0), versions.length - 1);
|
|
595
|
+
renderWheel();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
wheel.addEventListener("wheel", (e) => { e.preventDefault(); select(sel + Math.sign(e.deltaY)); }, { passive: false });
|
|
599
|
+
|
|
600
|
+
rollbackBtn.onclick = async () => {
|
|
601
|
+
const v = versions[sel];
|
|
602
|
+
if (!confirm(`Roll back to "${v.message}" (${v.short})?\n\nThe agent restarts and busy agents are interrupted. ` +
|
|
603
|
+
`Later changes stay in the history, so you can roll forward again.`)) return;
|
|
604
|
+
note(`rolling back to ${v.short}…`);
|
|
605
|
+
await fetch(`/versions/${v.sha}/rollback`, { method: "POST" });
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
openPanel(SHOT ? "" : document.body.dataset.panel);
|
|
609
|
+
// The window size, for the versions plugin's screenshots (sent on connect and after a resize)
|
|
610
|
+
let resized;
|
|
611
|
+
const sendSize = () => SHOT || ws.send(JSON.stringify({ type: "viewport", width: innerWidth, height: innerHeight }));
|
|
612
|
+
ws.addEventListener("open", sendSize);
|
|
613
|
+
addEventListener("resize", () => { clearTimeout(resized); resized = setTimeout(sendSize, 1000); });
|
|
614
|
+
loadVersions();
|
|
615
|
+
</script>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Notes on the UI (the point extension)
|
|
2
|
+
The user can click anything on the page and write a note there. You get "[note on the UI] <their words>"
|
|
3
|
+
with the target: surface and component (from ui_render), the element they clicked (inside App frames
|
|
4
|
+
too), the part (if the thing has partAt), its text, and the file it comes from. Act on that exact thing.
|
|
5
|
+
- If the text is data (a table row, a score), change the surface's data, not the component's code.
|
|
6
|
+
- "here" means the spot: to add something there, ui_render(..., place={"x", "y"}) with its position, and keep
|
|
7
|
+
it small (as wide as its content). On empty space the spot is all there is to the target.
|
|
8
|
+
- A note with an "area" came with a drawing: the user circled that box. On empty space, build what they ask
|
|
9
|
+
for inside it: ui_render(..., place={"x", "y", "width", "height"}) from the area. On a surface, the drawing
|
|
10
|
+
marks the part of it the note is about: change that part, and leave the surface's place and size alone.
|
|
11
|
+
If the thing drawn on can say what is inside the drawing (partIn), the part lists exactly what was circled
|
|
12
|
+
(cells and values, paragraphs, lines, a picture of the area): the note is about that.
|
|
13
|
+
- A note with "files": the user dropped those files there, from their computer or their file panel (all
|
|
14
|
+
in {work}/drops/). Look at them (read shows you pictures) and do what the note says with them, at
|
|
15
|
+
that spot or on that surface.
|
|
16
|
+
- "[combine on the UI]": the user dragged surface A onto surface B and said how to combine them. Change B
|
|
17
|
+
so it takes A in, as they said: ui_render(surface=B, ..., made_from=<B's made_from plus A>,
|
|
18
|
+
instruction=<their words>, about=<updated>). Read A's data with {"ref": "A", "path": "/..."} props, so it
|
|
19
|
+
stays live; copy only what has to change shape. Never change or delete A: the user deletes it when they
|
|
20
|
+
want (B then gets a copy of what it read, automatically). Keep what B already showed unless they said otherwise.
|
|
21
|
+
- Pointing is only as precise as your names: give elements you build a clear id or data-name.
|
|
22
|
+
- Each note has an id. When you have dealt with it, call note_done(id): its pin fades from the page.
|