acrylblends 1.0.10 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/index.d.ts +60 -2
- package/dist/index.js +1992 -33
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/api/summary.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +1 -1
- package/docs/framework/guides/production.md +20 -0
- package/docs/framework/workshop/README.md +174 -285
- package/docs/framework/workshop/facilitator.md +81 -90
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +44 -7
- package/lib/cordis/door.js +214 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.d.ts +3 -2
- package/lib/cordis/state.js +371 -7
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +20 -1
- package/lib/door/page.js +75 -14
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/src/host/channel.ts +124 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +58 -23
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +3 -0
- package/plugins/acryl-door/src/index.ts +166 -29
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +11 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +49 -7
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +46 -2
- package/plugins/acryl-door/src/page/indicator.ts +14 -6
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-state/plugin.json +4 -3
- package/plugins/acryl-state/src/client-source.ts +244 -0
- package/plugins/acryl-state/src/service.ts +33 -6
- package/plugins/acryl-state/src/socket.ts +96 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +343 -81
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +187 -61
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +26 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +12 -3
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/AGENTS.md +4 -2
- package/templates/agent/blend.yaml +4 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +150 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +73 -21
- package/templates/agent/template.json +2 -0
- package/templates/agent/test/app.test.ts +83 -5
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/main.ts +1 -6
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/main.ts +1 -6
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/main.ts +1 -6
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- package/shared/SKILL.md +0 -14
|
@@ -1,13 +1,69 @@
|
|
|
1
1
|
// The panel in the page: a button at the corner opens a side panel (a shadow root, so the app's CSS cannot touch it and it cannot touch the app's). It asks the app's
|
|
2
|
-
// agent through POST
|
|
3
|
-
|
|
2
|
+
// agent through POST <prefix>/ask, shows the answer as it arrives, and reloads the page after a run that installed or removed a plugin. `panel.js` calls
|
|
3
|
+
// `start` with the prefix the app is served under (the root of its own server, or `/app` when the ACRYL frame hosts it).
|
|
4
|
+
import { describePageStep, describeTool } from './describe.ts'
|
|
5
|
+
import { createPickGeneration } from './driver-pick.ts'
|
|
6
|
+
import { FRAME_MODE_EVENT, frameViewOf, panelVariant, panelVisible } from './frame.ts'
|
|
4
7
|
import { renderMarkdown } from './markdown.ts'
|
|
5
8
|
|
|
6
|
-
|
|
9
|
+
let PREFIX = '/api/agent-panel'
|
|
10
|
+
/** Where the composer's draft is kept across a page reload or a remount. */
|
|
11
|
+
const DRAFT_KEY = 'agent-panel-draft'
|
|
12
|
+
/** Set when a visitor closed the Assistant, so it stays closed on the next page. */
|
|
13
|
+
const ASSISTANT_CLOSED_KEY = 'agent-panel-assistant-closed'
|
|
14
|
+
/** What the Assistant tells a visitor first: what it can do, and that it cannot change the app. */
|
|
15
|
+
export const ASSISTANT_NOTE =
|
|
16
|
+
'The assistant can use the app for you: open pages, filter, fill in forms. It cannot change the app.'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Why a submit does not send: nothing typed, or a run still going. The panel says it in its log instead of returning silently - a Send that does nothing and
|
|
20
|
+
* says nothing reads as a broken button, and one was observed doing exactly that in the frame gate (empty log, enabled Send, no run).
|
|
21
|
+
*/
|
|
22
|
+
export type SendRefusal = 'empty' | 'busy'
|
|
23
|
+
export function sendRefusal(message: string, following: boolean): SendRefusal | undefined {
|
|
24
|
+
if (message.trim() === '') return 'empty'
|
|
25
|
+
if (following) return 'busy'
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether the page may be replaced to show a change a run made. Asked at the moment of reloading, not earlier: text in the composer or the focus in it
|
|
31
|
+
* belongs to the person, and a run still going must not lose its page either.
|
|
32
|
+
*/
|
|
33
|
+
export function reloadDecision(state: {
|
|
34
|
+
readonly value: string
|
|
35
|
+
readonly focused: boolean
|
|
36
|
+
readonly following: boolean
|
|
37
|
+
}): boolean {
|
|
38
|
+
return state.value.trim() === '' && !state.focused && !state.following
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where the last panel-initiated reload is recorded, for the page after it and for a gate looking at a failure. */
|
|
42
|
+
export const RELOAD_MARKER = 'agent-panel-reload'
|
|
43
|
+
|
|
44
|
+
/** Replace the page, saying why first: a warning the page's own error report and the frame gate both see, and a marker the reloaded page keeps. */
|
|
45
|
+
function reloadPage(reason: string): void {
|
|
46
|
+
try {
|
|
47
|
+
sessionStorage.setItem(RELOAD_MARKER, JSON.stringify({ reason, at: Date.now() }))
|
|
48
|
+
} catch {
|
|
49
|
+
// No storage: the warning below still names it.
|
|
50
|
+
}
|
|
51
|
+
console.warn(`agent-panel: reloading the page (${reason})`)
|
|
52
|
+
location.reload()
|
|
53
|
+
}
|
|
7
54
|
|
|
8
55
|
interface Session {
|
|
9
56
|
token: string
|
|
10
|
-
|
|
57
|
+
/** The production Assistant: the app's own agent drives the page for a visitor. */
|
|
58
|
+
assistant?: boolean
|
|
59
|
+
/** The app's production environment names the Assistant (ACRYL_ASSISTANT=on); the frame's Preview shows it even though the frame host runs in development. */
|
|
60
|
+
assistantInProduction?: boolean
|
|
61
|
+
drivers: Array<{
|
|
62
|
+
name: string
|
|
63
|
+
kind: 'chat' | 'terminal'
|
|
64
|
+
/** A terminal driver says where its view module is and which prefix its endpoints live under (the driver's own plugin serves both). */
|
|
65
|
+
view?: { module: string; prefix: string }
|
|
66
|
+
}>
|
|
11
67
|
}
|
|
12
68
|
interface PanelEvent {
|
|
13
69
|
event: string
|
|
@@ -42,70 +98,90 @@ interface RunSource {
|
|
|
42
98
|
readonly events: AsyncIterable<PanelEvent>
|
|
43
99
|
}
|
|
44
100
|
|
|
101
|
+
/** The door's id for this page, when the door is in it: sent with a question so the agent's calls (which name no window) drive this page and not another open one. */
|
|
102
|
+
function doorPage(): { page?: string } {
|
|
103
|
+
const published = (window as unknown as Record<symbol, { windowId?: unknown } | undefined>)[
|
|
104
|
+
Symbol.for('acryl.door.page')
|
|
105
|
+
]
|
|
106
|
+
return typeof published?.windowId === 'string' ? { page: published.windowId } : {}
|
|
107
|
+
}
|
|
108
|
+
|
|
45
109
|
/** A WebSocket is not one of the browser's six connections per host, so the panel's Stop and a run still work when two windows stream their own state. */
|
|
46
110
|
let socketBroken = false
|
|
47
111
|
let activeSocket: WebSocket | undefined
|
|
48
112
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
113
|
+
/** The server did not accept the token: it is not the one this page was given (the app restarted since, which `node --watch` does at every change of a file). */
|
|
114
|
+
class Refused extends Error {}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Open the run socket and send the token and the first action. Resolves once the server has accepted the token (its `accepted` frame), with the socket and its events: the
|
|
118
|
+
* frames of the run as panel events, until the socket closes. One set of handlers serves the socket for its whole life, so a frame is never handed to a handler that was
|
|
119
|
+
* replaced. Rejects with `Refused` when the token is not accepted, and with another error when the page cannot upgrade (a proxy, a server without `ws`): the caller falls back
|
|
120
|
+
* to the stream.
|
|
121
|
+
*/
|
|
122
|
+
function openSocket(
|
|
123
|
+
token: string,
|
|
124
|
+
action: Record<string, unknown>,
|
|
125
|
+
): Promise<{ socket: WebSocket; events: AsyncGenerator<PanelEvent> }> {
|
|
51
126
|
return new Promise((resolve, reject) => {
|
|
52
127
|
const socket = new WebSocket(
|
|
53
128
|
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${PREFIX}/run/ws`,
|
|
54
129
|
)
|
|
130
|
+
const queue: PanelEvent[] = []
|
|
131
|
+
let wake: (() => void) | undefined
|
|
132
|
+
let closed = false
|
|
133
|
+
let accepted = false
|
|
55
134
|
const timer = setTimeout(() => {
|
|
56
135
|
socket.close()
|
|
57
|
-
reject(new Error('the socket
|
|
136
|
+
reject(new Error('the socket was not accepted in time'))
|
|
58
137
|
}, 4000)
|
|
59
138
|
socket.onopen = () => {
|
|
60
|
-
clearTimeout(timer)
|
|
61
139
|
socket.send(JSON.stringify({ t: 'hello', token }))
|
|
62
140
|
socket.send(JSON.stringify(action))
|
|
63
|
-
resolve(socket)
|
|
64
141
|
}
|
|
65
|
-
socket.
|
|
66
|
-
|
|
67
|
-
|
|
142
|
+
socket.onmessage = (message) => {
|
|
143
|
+
try {
|
|
144
|
+
const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
|
|
145
|
+
if (frame.e === 'accepted' && !accepted) {
|
|
146
|
+
accepted = true
|
|
147
|
+
clearTimeout(timer)
|
|
148
|
+
resolve({ socket, events: read() })
|
|
149
|
+
} else if (typeof frame.e === 'string')
|
|
150
|
+
queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
|
|
151
|
+
} catch {
|
|
152
|
+
// Not a frame of ours.
|
|
153
|
+
}
|
|
154
|
+
wake?.()
|
|
68
155
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
|
|
80
|
-
if (typeof frame.e === 'string')
|
|
81
|
-
queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
|
|
82
|
-
} catch {
|
|
83
|
-
// Not a frame of ours.
|
|
156
|
+
socket.onclose = (event) => {
|
|
157
|
+
clearTimeout(timer)
|
|
158
|
+
closed = true
|
|
159
|
+
if (!accepted)
|
|
160
|
+
reject(
|
|
161
|
+
event.code === 4401
|
|
162
|
+
? new Refused('the token was not accepted')
|
|
163
|
+
: new Error('the socket closed before it was accepted'),
|
|
164
|
+
)
|
|
165
|
+
wake?.()
|
|
84
166
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
closed = event
|
|
89
|
-
wake?.()
|
|
90
|
-
}
|
|
91
|
-
for (;;) {
|
|
92
|
-
const next = queue.shift()
|
|
93
|
-
if (next !== undefined) {
|
|
94
|
-
yield next
|
|
95
|
-
continue
|
|
167
|
+
socket.onerror = () => {
|
|
168
|
+
clearTimeout(timer)
|
|
169
|
+
if (!accepted) reject(new Error('the server refused the upgrade'))
|
|
96
170
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
171
|
+
async function* read(): AsyncGenerator<PanelEvent> {
|
|
172
|
+
for (;;) {
|
|
173
|
+
const next = queue.shift()
|
|
174
|
+
if (next !== undefined) {
|
|
175
|
+
yield next
|
|
176
|
+
continue
|
|
102
177
|
}
|
|
103
|
-
|
|
178
|
+
if (closed) return
|
|
179
|
+
await new Promise<void>((done) => {
|
|
180
|
+
wake = done
|
|
181
|
+
})
|
|
182
|
+
}
|
|
104
183
|
}
|
|
105
|
-
|
|
106
|
-
wake = resolve
|
|
107
|
-
})
|
|
108
|
-
}
|
|
184
|
+
})
|
|
109
185
|
}
|
|
110
186
|
|
|
111
187
|
const css = `
|
|
@@ -140,33 +216,86 @@ textarea { width: 100%; min-height: 64px; resize: vertical; padding: 8px; border
|
|
|
140
216
|
.row button, header button { padding: 6px 12px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer }
|
|
141
217
|
.row button.send { background: #1f2937; color: #fff; border-color: #1f2937 }
|
|
142
218
|
.row button:disabled { opacity: 0.45; cursor: not-allowed }
|
|
219
|
+
.notice, .steps, .tab { display: none }
|
|
220
|
+
/* The Assistant (production): docked on the right in the app's dark surface, one tab, a note, the conversation and one line to ask. */
|
|
221
|
+
:host([data-assistant]) section { width: min(330px, 100vw); background: #18181b; color: #e4e4e7; border-left: 1px solid #27272a; box-shadow: none }
|
|
222
|
+
:host([data-assistant]) header { padding: 0 12px; border-bottom: 1px solid #27272a; min-height: 38px }
|
|
223
|
+
:host([data-assistant]) header select, :host([data-assistant]) header strong { display: none }
|
|
224
|
+
:host([data-assistant]) .tab { display: block; flex: 1; align-self: stretch }
|
|
225
|
+
:host([data-assistant]) .tab span { display: inline-block; height: 100%; padding: 10px 6px 8px; font-size: 12.5px; font-weight: 600; color: #fafafa; border-bottom: 2px solid #5468ff }
|
|
226
|
+
:host([data-assistant]) header button.close { border: 0; background: none; color: #a1a1aa; font-size: 18px; padding: 2px 6px }
|
|
227
|
+
:host([data-assistant]) .notice { display: block; margin: 8px 10px 0; padding: 7px 9px; border-radius: 6px; background: #15291e; color: #a7f3c0; font-size: 12px; line-height: 1.5 }
|
|
228
|
+
:host([data-assistant]) #log { padding: 10px; gap: 10px }
|
|
229
|
+
:host([data-assistant]) .msg { background: #232326; color: #e4e4e7; border-radius: 8px; padding: 9px 11px; line-height: 1.45; max-width: 92% }
|
|
230
|
+
:host([data-assistant]) .me { background: #23264f; color: #eef0ff; align-self: flex-end }
|
|
231
|
+
:host([data-assistant]) .err { background: #3b1d1d; color: #fecaca }
|
|
232
|
+
:host([data-assistant]) .tool, :host([data-assistant]) .working { background: none; color: #a1a1aa; padding: 0 2px }
|
|
233
|
+
:host([data-assistant]) .steps { display: block; margin-top: 6px; font: 11.5px ui-monospace, monospace; color: #a1a1aa; white-space: normal }
|
|
234
|
+
:host([data-assistant]) .steps:empty { display: none }
|
|
235
|
+
:host([data-assistant]) .md code { background: #2e2e33; color: #e4e4e7 }
|
|
236
|
+
:host([data-assistant]) form { border-top: 0; padding: 10px }
|
|
237
|
+
:host([data-assistant]) textarea { min-height: 0; height: 38px; resize: none; background: #18181b; color: #e4e4e7; border: 1px solid #3f3f46; border-radius: 8px; padding: 9px 11px; font-size: 13px }
|
|
238
|
+
:host([data-assistant]) textarea:focus { outline: none; border-color: #5468ff }
|
|
239
|
+
:host([data-assistant]) .row { display: none }
|
|
240
|
+
:host([data-assistant][data-following]) .row { display: flex; justify-content: flex-end }
|
|
241
|
+
:host([data-assistant]) .row .send { display: none }
|
|
242
|
+
:host([data-assistant]) .row button { background: none; color: #e4e4e7; border-color: #3f3f46 }
|
|
243
|
+
:host([data-assistant]) button.open { background: #18181b; color: #fafafa; border: 1px solid #3f3f46 }
|
|
143
244
|
`
|
|
144
245
|
|
|
145
|
-
|
|
246
|
+
/** Start the panel against the app served at `prefix` (`/api/agent-panel` at the root, `/app/api/agent-panel` in the frame). */
|
|
247
|
+
export async function start(prefix: string = '/api/agent-panel'): Promise<void> {
|
|
248
|
+
PREFIX = prefix
|
|
249
|
+
// A panel that is mounted again replaces the previous one: one live host, and the readiness mark then belongs to the instance being wired now. A
|
|
250
|
+
// duplicated or reused host once let a submit reach a form that no live instance was listening to (the frame gate's empty-log failure, 2026-11-02).
|
|
251
|
+
// The previous instance's run socket goes with it, so its watch ends instead of writing into a detached log.
|
|
252
|
+
activeSocket?.close()
|
|
253
|
+
activeSocket = undefined
|
|
254
|
+
document.getElementById('agent-panel')?.remove()
|
|
146
255
|
const reply = await fetch(`${PREFIX}/session`)
|
|
147
256
|
if (!reply.ok) return
|
|
148
|
-
|
|
257
|
+
let session = (await reply.json()) as Session
|
|
149
258
|
|
|
259
|
+
// The frame's Build is the builder's screen, and its Preview shows the app as production would: the panel takes the Assistant's variant only where
|
|
260
|
+
// production has it, and it is hidden everywhere the canvas should be the app alone. A direct page (no frame) is unchanged.
|
|
261
|
+
const frame = frameViewOf()
|
|
262
|
+
const assistant = panelVariant(session, frame)
|
|
150
263
|
const host = document.createElement('div')
|
|
151
264
|
host.id = 'agent-panel'
|
|
265
|
+
if (assistant) host.dataset.assistant = ''
|
|
152
266
|
const shadow = host.attachShadow({ mode: 'open' })
|
|
153
267
|
shadow.innerHTML = `<style>${css}</style>
|
|
154
|
-
<button class="open" type="button"
|
|
155
|
-
<section aria-label="Agent panel">
|
|
156
|
-
<header><strong>Agent</strong><select aria-label="Agent">${session.drivers
|
|
268
|
+
<button class="open" type="button">${assistant ? 'Assistant' : 'Ask the agent'}</button>
|
|
269
|
+
<section aria-label="${assistant ? 'Assistant' : 'Agent panel'}">
|
|
270
|
+
<header><strong>Agent</strong><div class="tab"><span>Assistant</span></div><select aria-label="Agent">${session.drivers
|
|
157
271
|
.map((d) => `<option>${d.name}</option>`)
|
|
158
272
|
.join(
|
|
159
273
|
'',
|
|
160
274
|
)}</select><button class="termstop" type="button" hidden>Stop terminal</button><button class="close" type="button" aria-label="Close">×</button></header>
|
|
275
|
+
<p class="notice">${ASSISTANT_NOTE}</p>
|
|
161
276
|
<div id="log" role="log"></div>
|
|
162
277
|
<div id="term" hidden></div>
|
|
163
|
-
<form><textarea aria-label="Message" placeholder="What should the app do?"></textarea>
|
|
278
|
+
<form><textarea aria-label="Message" placeholder="${assistant ? 'Ask the assistant…' : 'What should the app do?'}"${assistant ? ' rows="1"' : ''}></textarea>
|
|
164
279
|
<div class="row"><button class="send" type="submit">Send</button><button class="stop" type="button" disabled>Stop</button></div></form>
|
|
165
280
|
</section>`
|
|
166
281
|
const $ = <T extends Element>(selector: string): T => shadow.querySelector<T>(selector)!
|
|
167
282
|
const panel = $<HTMLElement>('section')
|
|
168
283
|
const log = $<HTMLElement>('#log')
|
|
169
284
|
const input = $<HTMLTextAreaElement>('textarea')
|
|
285
|
+
// The composer survives the page being replaced (a run that changes files reloads the page, and a mount replaces this panel): the draft is kept
|
|
286
|
+
// beside the page, restored into this textarea before the form is wired, and cleared when a submit sends it.
|
|
287
|
+
try {
|
|
288
|
+
input.value = sessionStorage.getItem(DRAFT_KEY) ?? ''
|
|
289
|
+
} catch {
|
|
290
|
+
// No storage: the draft lives as long as this document.
|
|
291
|
+
}
|
|
292
|
+
input.addEventListener('input', () => {
|
|
293
|
+
try {
|
|
294
|
+
sessionStorage.setItem(DRAFT_KEY, input.value)
|
|
295
|
+
} catch {
|
|
296
|
+
// No storage: the draft lives as long as this document.
|
|
297
|
+
}
|
|
298
|
+
})
|
|
170
299
|
const send = $<HTMLButtonElement>('.send')
|
|
171
300
|
const stop = $<HTMLButtonElement>('.stop')
|
|
172
301
|
const driver = $<HTMLSelectElement>('select')
|
|
@@ -175,34 +304,52 @@ async function start(): Promise<void> {
|
|
|
175
304
|
termStop.addEventListener('click', () => void terminal?.stop())
|
|
176
305
|
const form = $<HTMLFormElement>('form')
|
|
177
306
|
document.body.append(host)
|
|
307
|
+
const applyFrame = (): void => {
|
|
308
|
+
host.style.display = panelVisible(frame, assistant) ? '' : 'none'
|
|
309
|
+
}
|
|
310
|
+
window.addEventListener(FRAME_MODE_EVENT, applyFrame)
|
|
311
|
+
applyFrame()
|
|
178
312
|
const kindOf = (name: string): 'chat' | 'terminal' =>
|
|
179
313
|
session.drivers.find((d) => d.name === name)?.kind ?? 'chat'
|
|
180
314
|
let terminal: { detach(): void; stop(): Promise<void> } | undefined
|
|
315
|
+
// A pick can land while an earlier pick's terminal is still loading (the page also restores a remembered terminal at
|
|
316
|
+
// start). Only the latest pick may apply its result; a stale terminal must never replace the form the person chose.
|
|
317
|
+
const picks = createPickGeneration()
|
|
181
318
|
// A terminal driver swaps the chat for the driver's own terminal (loaded from the plugin that provides it, only when asked for).
|
|
182
319
|
const choose = async (): Promise<void> => {
|
|
320
|
+
const generation = picks.begin()
|
|
321
|
+
const name = driver.value
|
|
183
322
|
terminal?.detach()
|
|
184
323
|
terminal = undefined
|
|
185
|
-
const isTerminal = kindOf(
|
|
324
|
+
const isTerminal = kindOf(name) === 'terminal'
|
|
186
325
|
log.hidden = isTerminal
|
|
187
326
|
form.hidden = isTerminal
|
|
188
327
|
term.hidden = !isTerminal
|
|
189
328
|
termStop.hidden = !isTerminal
|
|
190
329
|
if (!isTerminal) return
|
|
330
|
+
const selected = session.drivers.find((entry) => entry.name === name)
|
|
331
|
+
if (selected?.view === undefined) {
|
|
332
|
+
line('This terminal driver does not provide its view.', 'err')
|
|
333
|
+
return
|
|
334
|
+
}
|
|
191
335
|
try {
|
|
192
|
-
const url =
|
|
336
|
+
const url = selected.view.module
|
|
193
337
|
const { openTerminal } = (await import(url)) as {
|
|
194
338
|
openTerminal(options: {
|
|
195
339
|
container: HTMLElement
|
|
196
340
|
token: string
|
|
197
341
|
driver: string
|
|
342
|
+
prefix: string
|
|
198
343
|
status(text: string): void
|
|
199
344
|
onChange(change: { extension: string; action: string }): void
|
|
200
345
|
}): Promise<{ detach(): void; stop(): Promise<void> }>
|
|
201
346
|
}
|
|
202
|
-
|
|
347
|
+
await refreshSession()
|
|
348
|
+
const opened = await openTerminal({
|
|
203
349
|
container: term,
|
|
204
350
|
token: session.token,
|
|
205
|
-
driver:
|
|
351
|
+
driver: name,
|
|
352
|
+
prefix: selected.view.prefix,
|
|
206
353
|
status: (text) => line(text, 'tool'),
|
|
207
354
|
onChange: (change) => {
|
|
208
355
|
line(`plugin ${change.extension}: ${change.action}`, 'tool')
|
|
@@ -210,7 +357,13 @@ async function start(): Promise<void> {
|
|
|
210
357
|
setTimeout(() => location.reload(), 900)
|
|
211
358
|
},
|
|
212
359
|
})
|
|
360
|
+
if (!picks.isCurrent(generation)) {
|
|
361
|
+
opened.detach()
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
terminal = opened
|
|
213
365
|
} catch (error) {
|
|
366
|
+
if (!picks.isCurrent(generation)) return
|
|
214
367
|
line(
|
|
215
368
|
`The terminal could not open: ${error instanceof Error ? error.message : String(error)}`,
|
|
216
369
|
'err',
|
|
@@ -231,9 +384,10 @@ async function start(): Promise<void> {
|
|
|
231
384
|
root.style.getPropertyValue(property),
|
|
232
385
|
root.style.getPropertyPriority(property),
|
|
233
386
|
])
|
|
387
|
+
// The panel's own width (the Assistant's is narrower than the builder's), so the page meets its edge with no gap.
|
|
234
388
|
root.style.setProperty(
|
|
235
389
|
'margin-right',
|
|
236
|
-
`${String(Math.
|
|
390
|
+
`${String(Math.ceil(panel.getBoundingClientRect().width))}px`,
|
|
237
391
|
'important',
|
|
238
392
|
)
|
|
239
393
|
} else {
|
|
@@ -245,14 +399,39 @@ async function start(): Promise<void> {
|
|
|
245
399
|
saved.clear()
|
|
246
400
|
}
|
|
247
401
|
}
|
|
248
|
-
const show = (open: boolean): void => {
|
|
402
|
+
const show = (open: boolean, focus = true): void => {
|
|
249
403
|
const was = panel.classList.contains('shown')
|
|
250
404
|
panel.classList.toggle('shown', open)
|
|
405
|
+
$<HTMLButtonElement>('button.open').hidden = open && assistant
|
|
251
406
|
if (open !== was) dock(open)
|
|
252
|
-
if (open) input.focus()
|
|
407
|
+
if (open && focus) input.focus()
|
|
408
|
+
// Other corner buttons of the page (the feedback button) stand clear of the panel.
|
|
409
|
+
window.dispatchEvent(new Event('agent-panel:layout'))
|
|
410
|
+
}
|
|
411
|
+
const remember = (closed: boolean): void => {
|
|
412
|
+
try {
|
|
413
|
+
if (closed) sessionStorage.setItem(ASSISTANT_CLOSED_KEY, '1')
|
|
414
|
+
else sessionStorage.removeItem(ASSISTANT_CLOSED_KEY)
|
|
415
|
+
} catch {
|
|
416
|
+
// No storage: the Assistant opens again on the next page.
|
|
417
|
+
}
|
|
253
418
|
}
|
|
254
|
-
$('button.open').addEventListener('click', () =>
|
|
255
|
-
|
|
419
|
+
$('button.open').addEventListener('click', () => {
|
|
420
|
+
show(true)
|
|
421
|
+
if (assistant) remember(false)
|
|
422
|
+
})
|
|
423
|
+
$('button.close').addEventListener('click', () => {
|
|
424
|
+
show(false)
|
|
425
|
+
if (assistant) remember(true)
|
|
426
|
+
})
|
|
427
|
+
// Enter asks (Shift+Enter is a new line): the Assistant has one line to ask in and no Send button.
|
|
428
|
+
if (assistant)
|
|
429
|
+
input.addEventListener('keydown', (event) => {
|
|
430
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
|
431
|
+
event.preventDefault()
|
|
432
|
+
form.requestSubmit()
|
|
433
|
+
}
|
|
434
|
+
})
|
|
256
435
|
const line = (text: string, kind = ''): HTMLElement => {
|
|
257
436
|
const el = document.createElement('div')
|
|
258
437
|
el.className = `msg ${kind}`
|
|
@@ -265,26 +444,41 @@ async function start(): Promise<void> {
|
|
|
265
444
|
// A run belongs to the app, not to this page: it goes on when the page reloads (the door reloads it after a plugin change, an agent may move it to another page) and the page that
|
|
266
445
|
// loads next follows it again. Only Stop cancels it.
|
|
267
446
|
const headers = { 'content-type': 'application/json', 'x-agent-panel-token': session.token }
|
|
447
|
+
/**
|
|
448
|
+
* The token belongs to one start of the app: `node --watch` restarts it at every change of a file, and a page left open across that restart holds a token the new server
|
|
449
|
+
* does not know. Ask for the session again; the caller tries once more with what it gets.
|
|
450
|
+
*/
|
|
451
|
+
const refreshSession = async (): Promise<void> => {
|
|
452
|
+
const reply = await fetch(`${PREFIX}/session`)
|
|
453
|
+
if (!reply.ok) return
|
|
454
|
+
session = (await reply.json()) as Session
|
|
455
|
+
headers['x-agent-panel-token'] = session.token
|
|
456
|
+
}
|
|
268
457
|
let following = false
|
|
269
|
-
|
|
458
|
+
const stopRun = (): void => {
|
|
270
459
|
// On the run's socket when there is one: a request would wait behind the browser's six connections per host.
|
|
271
460
|
if (activeSocket?.readyState === WebSocket.OPEN)
|
|
272
461
|
activeSocket.send(JSON.stringify({ t: 'stop' }))
|
|
273
462
|
else void fetch(`${PREFIX}/stop`, { method: 'POST', headers })
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
463
|
+
}
|
|
464
|
+
stop.addEventListener('click', stopRun)
|
|
465
|
+
// The door's "Agent is driving" bar has a Stop too (the same page): it ends this run.
|
|
466
|
+
window.addEventListener('acryl:stop-run', stopRun)
|
|
467
|
+
const REFUSED = Symbol('refused')
|
|
468
|
+
/** A run over the socket when the server upgrades, else over the stream, with one console warning saying why. `REFUSED` when the server does not know the token. */
|
|
469
|
+
const tryOpenRun = async (
|
|
470
|
+
action: { t: 'ask'; message: string; driver: string; page?: string } | { t: 'follow' },
|
|
471
|
+
): Promise<RunSource | { error: string } | undefined | typeof REFUSED> => {
|
|
279
472
|
if (!socketBroken && typeof WebSocket !== 'undefined') {
|
|
280
473
|
try {
|
|
281
|
-
const socket = await openSocket(session.token, action)
|
|
474
|
+
const { socket, events: frames } = await openSocket(session.token, action)
|
|
282
475
|
activeSocket = socket
|
|
283
476
|
socket.addEventListener('close', () => {
|
|
284
477
|
if (activeSocket === socket) activeSocket = undefined
|
|
285
478
|
})
|
|
286
|
-
return { events:
|
|
479
|
+
return { events: frames }
|
|
287
480
|
} catch (cause) {
|
|
481
|
+
if (cause instanceof Refused) return REFUSED
|
|
288
482
|
socketBroken = true
|
|
289
483
|
console.warn(
|
|
290
484
|
`agent-panel: the run could not open a WebSocket (${cause instanceof Error ? cause.message : String(cause)}); using server-sent events, which count against the browser's six connections per host`,
|
|
@@ -295,8 +489,9 @@ async function start(): Promise<void> {
|
|
|
295
489
|
const response = await fetch(`${PREFIX}/ask`, {
|
|
296
490
|
method: 'POST',
|
|
297
491
|
headers,
|
|
298
|
-
body: JSON.stringify({ message: action.message, driver: action.driver }),
|
|
492
|
+
body: JSON.stringify({ message: action.message, driver: action.driver, page: action.page }),
|
|
299
493
|
})
|
|
494
|
+
if (response.status === 403) return REFUSED
|
|
300
495
|
if (!response.ok)
|
|
301
496
|
return {
|
|
302
497
|
error:
|
|
@@ -306,10 +501,23 @@ async function start(): Promise<void> {
|
|
|
306
501
|
return { events: events(response) }
|
|
307
502
|
}
|
|
308
503
|
const latest = await fetch(`${PREFIX}/run`, { headers })
|
|
504
|
+
if (latest.status === 403) return REFUSED
|
|
309
505
|
return latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream')
|
|
310
506
|
? { events: events(latest) }
|
|
311
507
|
: undefined
|
|
312
508
|
}
|
|
509
|
+
/** `tryOpenRun`, and when the token was refused, a new session and one more try without the person seeing anything; a second refusal is shown. */
|
|
510
|
+
const openRun = async (
|
|
511
|
+
action: { t: 'ask'; message: string; driver: string; page?: string } | { t: 'follow' },
|
|
512
|
+
): Promise<RunSource | { error: string } | undefined> => {
|
|
513
|
+
const first = await tryOpenRun(action)
|
|
514
|
+
if (first !== REFUSED) return first
|
|
515
|
+
await refreshSession()
|
|
516
|
+
const second = await tryOpenRun(action)
|
|
517
|
+
return second === REFUSED
|
|
518
|
+
? { error: 'the panel was not accepted (reload the page and try again)' }
|
|
519
|
+
: second
|
|
520
|
+
}
|
|
313
521
|
/**
|
|
314
522
|
* Show one run, as its events come, until it ends. `own` is true for a run this page just started (its question is on screen); a run found on load is shown from its question, and
|
|
315
523
|
* a run that had already ended before the page loaded is only shown, never reloads the page.
|
|
@@ -319,6 +527,10 @@ async function start(): Promise<void> {
|
|
|
319
527
|
let changed = false
|
|
320
528
|
let answer: HTMLElement | undefined
|
|
321
529
|
let answered = ''
|
|
530
|
+
// The Assistant's steps on the page, shown under its answer in one line, as the person reads what it did.
|
|
531
|
+
const steps: string[] = []
|
|
532
|
+
const stepsLine = document.createElement('div')
|
|
533
|
+
stepsLine.className = 'steps'
|
|
322
534
|
// A coding agent started headless says little until it is done, so the panel says it is working, and for how long (from when the run began, not from when this page loaded).
|
|
323
535
|
let started = Date.now()
|
|
324
536
|
let working: HTMLElement | undefined
|
|
@@ -334,6 +546,7 @@ async function start(): Promise<void> {
|
|
|
334
546
|
const begin = (): void => {
|
|
335
547
|
if (working !== undefined) return
|
|
336
548
|
following = true
|
|
549
|
+
host.dataset.following = '1'
|
|
337
550
|
send.disabled = true
|
|
338
551
|
stop.disabled = false
|
|
339
552
|
working = line('', 'working')
|
|
@@ -362,8 +575,17 @@ async function start(): Promise<void> {
|
|
|
362
575
|
answer ??= line('', 'md')
|
|
363
576
|
answered += String(data.text ?? '')
|
|
364
577
|
answer.replaceChildren(renderMarkdown(answered, document))
|
|
578
|
+
if (assistant) {
|
|
579
|
+
stepsLine.textContent = steps.join(' · ')
|
|
580
|
+
answer.append(stepsLine)
|
|
581
|
+
}
|
|
365
582
|
log.scrollTop = log.scrollHeight
|
|
366
583
|
} else if (event === 'tool') {
|
|
584
|
+
if (assistant) {
|
|
585
|
+
const step = describePageStep(data)
|
|
586
|
+
if (step !== undefined) steps.push(step)
|
|
587
|
+
continue
|
|
588
|
+
}
|
|
367
589
|
const described = describeTool(data)
|
|
368
590
|
if (described !== undefined) line(described, 'tool')
|
|
369
591
|
} else if (event === 'change') {
|
|
@@ -382,25 +604,52 @@ async function start(): Promise<void> {
|
|
|
382
604
|
if (working !== undefined) {
|
|
383
605
|
working.remove()
|
|
384
606
|
following = false
|
|
607
|
+
delete host.dataset.following
|
|
385
608
|
send.disabled = false
|
|
386
609
|
stop.disabled = true
|
|
387
610
|
}
|
|
388
611
|
}
|
|
389
|
-
// A plugin was installed or removed while this page watched: the new feature is served by the running app, so the page only needs to be loaded again
|
|
390
|
-
//
|
|
391
|
-
|
|
612
|
+
// A plugin was installed or removed while this page watched: the new feature is served by the running app, so the page only needs to be loaded again.
|
|
613
|
+
// The panel lives inside the app's own page (it cannot refresh the frame's canvas from here), so the page reload is the app's refresh - but the decision
|
|
614
|
+
// to reload is made at the moment of reloading, never on a check made a second earlier: a person (or the frame gate) can start typing in between, and a
|
|
615
|
+
// reload then replaces the page under their text (the frame gate's empty-composer dump, 2026-11-02).
|
|
616
|
+
if (live && changed) {
|
|
392
617
|
line('Reloading the page to show it.', 'tool')
|
|
393
|
-
setTimeout(() =>
|
|
618
|
+
setTimeout(() => {
|
|
619
|
+
if (
|
|
620
|
+
reloadDecision({
|
|
621
|
+
value: input.value,
|
|
622
|
+
focused: host.shadowRoot?.activeElement === input,
|
|
623
|
+
following,
|
|
624
|
+
})
|
|
625
|
+
)
|
|
626
|
+
reloadPage('a plugin changed')
|
|
627
|
+
}, 900)
|
|
394
628
|
}
|
|
395
629
|
}
|
|
396
630
|
form.addEventListener('submit', async (submitted) => {
|
|
397
631
|
submitted.preventDefault()
|
|
632
|
+
const refusal = sendRefusal(input.value, following)
|
|
633
|
+
if (refusal !== undefined) {
|
|
634
|
+
line(
|
|
635
|
+
refusal === 'empty' ? 'Type a message first.' : 'A run is already going; stop it first.',
|
|
636
|
+
'err',
|
|
637
|
+
)
|
|
638
|
+
return
|
|
639
|
+
}
|
|
398
640
|
const message = input.value.trim()
|
|
399
|
-
if (message === '' || following) return
|
|
400
641
|
input.value = ''
|
|
642
|
+
try {
|
|
643
|
+
sessionStorage.removeItem(DRAFT_KEY)
|
|
644
|
+
} catch {
|
|
645
|
+
// No storage.
|
|
646
|
+
}
|
|
647
|
+
// The message is sent: the composer is empty and the person is not typing, so it gives the focus back. A run that installs a plugin reloads the page
|
|
648
|
+
// afterwards, and that reload must not be refused for a focus nobody is using.
|
|
649
|
+
input.blur()
|
|
401
650
|
line(message, 'me')
|
|
402
651
|
try {
|
|
403
|
-
const run = await openRun({ t: 'ask', message, driver: driver.value })
|
|
652
|
+
const run = await openRun({ t: 'ask', message, driver: driver.value, ...doorPage() })
|
|
404
653
|
if (run === undefined) return
|
|
405
654
|
if ('error' in run) {
|
|
406
655
|
line(run.error, 'err')
|
|
@@ -411,7 +660,22 @@ async function start(): Promise<void> {
|
|
|
411
660
|
line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
|
|
412
661
|
}
|
|
413
662
|
})
|
|
414
|
-
// The
|
|
663
|
+
// The Assistant is open beside the page unless the visitor closed it.
|
|
664
|
+
if (assistant) {
|
|
665
|
+
let closed = false
|
|
666
|
+
try {
|
|
667
|
+
closed = sessionStorage.getItem(ASSISTANT_CLOSED_KEY) === '1'
|
|
668
|
+
} catch {
|
|
669
|
+
// No storage: open.
|
|
670
|
+
}
|
|
671
|
+
show(!closed, false)
|
|
672
|
+
}
|
|
673
|
+
// The form is wired: a script (the frame gate included) waits for this instead of guessing that the panel can be submitted, and a panel that never got
|
|
674
|
+
// this far says so by the mark's absence - together with the visible guards above, no submit can vanish without a trace.
|
|
675
|
+
host.dataset.ready = '1'
|
|
676
|
+
// The latest run, if there is one: shown again after a reload (its question, its events), and followed if it is still going. The server records a run
|
|
677
|
+
// before it answers the ask that started it (startRun sets the current run, then follow answers), so a page that asks after that answer always finds
|
|
678
|
+
// it - no client poll is needed. A run the app server itself restarted away (a file change the run made) no longer exists for that server to show.
|
|
415
679
|
try {
|
|
416
680
|
const latest = await openRun({ t: 'follow' })
|
|
417
681
|
if (latest !== undefined && !('error' in latest)) void watch(latest, false)
|
|
@@ -432,5 +696,3 @@ async function start(): Promise<void> {
|
|
|
432
696
|
// No storage: nothing to show again.
|
|
433
697
|
}
|
|
434
698
|
}
|
|
435
|
-
|
|
436
|
-
void start()
|
|
@@ -16,8 +16,8 @@ export type ChatEvent =
|
|
|
16
16
|
export interface ChatDriver {
|
|
17
17
|
readonly kind: 'chat'
|
|
18
18
|
readonly name: string
|
|
19
|
-
/** Runs one message. Aborting `signal` stops the run. */
|
|
20
|
-
start(options: { message: string; signal: AbortSignal }): AsyncIterable<ChatEvent>
|
|
19
|
+
/** Runs one message. Aborting `signal` stops the run. `page` is the door's id of the page whose panel asked, when it sent one: a driver that uses the page drives that one. */
|
|
20
|
+
start(options: { message: string; signal: AbortSignal; page?: string }): AsyncIterable<ChatEvent>
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** A live pseudo-terminal session. */
|
|
@@ -34,6 +34,11 @@ export interface TerminalSession {
|
|
|
34
34
|
export interface TerminalDriver {
|
|
35
35
|
readonly kind: 'terminal'
|
|
36
36
|
readonly name: string
|
|
37
|
+
/**
|
|
38
|
+
* Where the panel loads the terminal view from: the browser module that draws it, and the URL prefix the view's endpoints live under. The driver
|
|
39
|
+
* plugin serves both, so the view follows wherever the app is mounted (the root of its own server, or the prefix the ACRYL frame serves it at).
|
|
40
|
+
*/
|
|
41
|
+
readonly view: { readonly module: string; readonly prefix: string }
|
|
37
42
|
start(options: {
|
|
38
43
|
cwd: string
|
|
39
44
|
env: NodeJS.ProcessEnv
|