anentrypoint-design 0.0.458 → 0.0.459
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.459",
|
|
4
4
|
"description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/247420.js",
|
|
@@ -89,6 +89,35 @@ export function ToolCallNode(p) {
|
|
|
89
89
|
);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// Approval-request card for the freddie wire protocol's approval.request event
|
|
93
|
+
// (plugins/gui/gui-agent): a gated tool call pauses mid-turn until the user
|
|
94
|
+
// resolves it here. p.onResolve({approved, always?}) sends the decision back
|
|
95
|
+
// over the same channel; once resolved the card renders the settled state.
|
|
96
|
+
export function ApprovalNode(p) {
|
|
97
|
+
const status = p.status || 'pending';
|
|
98
|
+
const argsText = typeof p.args === 'string' ? p.args : JSON.stringify(p.args || {}, null, 2);
|
|
99
|
+
const iconName = status === 'pending' ? 'warn' : (status === 'approved' ? 'check' : 'warn');
|
|
100
|
+
const decide = (decision) => (e) => { e.preventDefault(); if (p.onResolve) p.onResolve(decision); };
|
|
101
|
+
return h('div', { class: 'chat-bubble chat-tool chat-approval tool-' + (status === 'pending' ? 'running' : status) },
|
|
102
|
+
h('div', { class: 'chat-tool-head' },
|
|
103
|
+
h('span', { class: 'chat-tool-icon', 'aria-hidden': 'true' }, Icon(iconName, { size: 14 })),
|
|
104
|
+
h('span', { class: 'chat-tool-name' }, 'approval: ' + (p.name || 'tool')),
|
|
105
|
+
h('span', { class: 'chat-tool-status' }, status)
|
|
106
|
+
),
|
|
107
|
+
h('div', { class: 'chat-tool-body' },
|
|
108
|
+
h('div', { class: 'chat-tool-section' },
|
|
109
|
+
h('div', { class: 'chat-tool-section-label' }, h('span', {}, 'args')),
|
|
110
|
+
h('pre', { class: 'chat-tool-pre' }, h('code', {}, argsText))),
|
|
111
|
+
status === 'pending'
|
|
112
|
+
? h('div', { class: 'chat-approval-actions' },
|
|
113
|
+
h('button', { type: 'button', class: 'chat-code-copy chat-approval-btn', onclick: decide({ approved: true }) }, 'approve'),
|
|
114
|
+
h('button', { type: 'button', class: 'chat-code-copy chat-approval-btn', onclick: decide({ approved: true, always: true }) }, 'always'),
|
|
115
|
+
h('button', { type: 'button', class: 'chat-code-copy chat-approval-btn', onclick: decide({ approved: false }) }, 'reject'))
|
|
116
|
+
: h('div', { class: 'chat-approval-note' }, status === 'approved' ? (p.always ? 'approved (always, this turn)' : 'approved') : 'rejected')
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
92
121
|
export function ThinkingNode(p) {
|
|
93
122
|
if (p.settled) {
|
|
94
123
|
return h('details', { class: 'chat-bubble chat-thinking-settled' },
|
|
@@ -7,7 +7,7 @@ import { Icon } from '../shell.js';
|
|
|
7
7
|
import { fmtFileSize } from '../files.js';
|
|
8
8
|
import { safeUrl, renderInline, fileIconName } from './inline.js';
|
|
9
9
|
import { MdNode, CodeNode } from './prose-nodes.js';
|
|
10
|
-
import { ToolCallNode, ThinkingNode } from './agent-nodes.js';
|
|
10
|
+
import { ToolCallNode, ThinkingNode, ApprovalNode } from './agent-nodes.js';
|
|
11
11
|
|
|
12
12
|
const h = webjsx.createElement;
|
|
13
13
|
|
|
@@ -35,6 +35,7 @@ export const PART_RENDERERS = {
|
|
|
35
35
|
tool_call: (p) => ToolCallNode(p),
|
|
36
36
|
tool_result: (p) => ToolCallNode({ ...p, name: p.name || 'tool_result', result: p.text != null ? p.text : p.result }),
|
|
37
37
|
thinking: (p) => ThinkingNode(p),
|
|
38
|
+
approval: (p) => ApprovalNode(p),
|
|
38
39
|
image: (p) => {
|
|
39
40
|
// Guard both the wrapping link and the img src against unsafe schemes
|
|
40
41
|
// (e.g. a data:text/html src) so an embedded-image part from untrusted
|
|
@@ -1,5 +1,19 @@
|
|
|
1
|
-
// Freddie conversational pages: the
|
|
2
|
-
//
|
|
1
|
+
// Freddie conversational pages: the `chat` agent-workspace page (WebSocket
|
|
2
|
+
// over the freddie wire protocol — replay + live turn events + prompt/steer/
|
|
3
|
+
// cancel/approve, no more 120s POST ceiling) and the `voice` backend probe.
|
|
4
|
+
//
|
|
5
|
+
// Transport: WS /api/agent/stream?sessionId=<id> (plugins/gui/gui-agent).
|
|
6
|
+
// The page owns the session id (generated client-side so the socket can
|
|
7
|
+
// subscribe BEFORE the first prompt) and rebuilds its transcript from the
|
|
8
|
+
// server's wire log on (re)connect, so a refresh mid-turn loses nothing.
|
|
9
|
+
// The legacy POST /api/chat path remains only for the offline outbox flush.
|
|
10
|
+
//
|
|
11
|
+
// Event -> transcript mapping (freddie wire envelope {v,event,sessionId,ts,data}):
|
|
12
|
+
// message.append(user|assistant) -> thread messages
|
|
13
|
+
// tool.start / tool.end -> interleaved tool cards (running -> done/error)
|
|
14
|
+
// approval.request / .resolved -> ApprovalNode cards (approve/always/reject)
|
|
15
|
+
// steer.append -> user message (mid-turn injection)
|
|
16
|
+
// session.error -> error pinned to the live assistant turn
|
|
3
17
|
|
|
4
18
|
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
5
19
|
import { makePage, api, loadingState, emptyState } from './runtime.js';
|
|
@@ -9,20 +23,73 @@ import { formatTime } from '../../locale.js';
|
|
|
9
23
|
import { queueMessage, watchReconnect, isOnline } from '../../idb-outbox.js';
|
|
10
24
|
import { AgentChat } from '../agent-chat.js';
|
|
11
25
|
import { section, noteAlert } from './shared.js';
|
|
12
|
-
import { parseSseStream, toolProgressPart, partsFromMessages } from './sse.js';
|
|
13
26
|
|
|
14
27
|
const h = webjsx.createElement;
|
|
15
28
|
|
|
29
|
+
function newSessionId() {
|
|
30
|
+
return (crypto.randomUUID ? crypto.randomUUID() : 's' + Date.now().toString(36) + Math.random().toString(16).slice(2));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Apply one wire envelope to a messages array (shared by replay rebuild and
|
|
34
|
+
// the live stream). `sendApprove` is only needed for live approval cards.
|
|
35
|
+
function applyEnvelope(msgs, env, sendApprove) {
|
|
36
|
+
const { event, data } = env;
|
|
37
|
+
const ts = new Date(env.ts).getTime();
|
|
38
|
+
const lastAssistant = () => { for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].role === 'assistant') return msgs[i]; return null; };
|
|
39
|
+
// The page echoes its own sends into the thread optimistically; the server
|
|
40
|
+
// then emits the SAME user/steer text as an authoritative event. The local
|
|
41
|
+
// echo is followed by the placeholder assistant bubble, so scan back past
|
|
42
|
+
// it (and any parts) for a matching user message before appending a dupe.
|
|
43
|
+
const isDupUser = (text) => {
|
|
44
|
+
for (let i = msgs.length - 1; i >= 0 && i >= msgs.length - 3; i--) {
|
|
45
|
+
if (msgs[i].role === 'user' && msgs[i].content === (text || '')) return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
};
|
|
49
|
+
if (event === 'message.append') {
|
|
50
|
+
if (data.role === 'user') { if (!isDupUser(data.content)) msgs.push({ id: 'u' + msgs.length + env.ts, role: 'user', content: data.content || '', time: formatTime(ts) }); }
|
|
51
|
+
else if (data.role === 'assistant') {
|
|
52
|
+
const last = msgs[msgs.length - 1];
|
|
53
|
+
// A new assistant turn starts after a user message; consecutive
|
|
54
|
+
// assistant appends within one turn update the SAME bubble.
|
|
55
|
+
if (last && last.role === 'assistant' && last._live) { if (data.content) last.content = data.content; }
|
|
56
|
+
else msgs.push({ id: 'a' + msgs.length + env.ts, role: 'assistant', content: data.content || '', parts: [], time: formatTime(ts), _live: true });
|
|
57
|
+
}
|
|
58
|
+
} else if (event === 'steer.append') {
|
|
59
|
+
if (!isDupUser(data.text)) msgs.push({ id: 'u' + msgs.length + env.ts, role: 'user', content: data.text || '', time: formatTime(ts) });
|
|
60
|
+
} else if (event === 'assistant.delta') {
|
|
61
|
+
// Progressive text mid-turn: accumulate into the live bubble; the
|
|
62
|
+
// settled message.append at turn end overwrites with the authoritative
|
|
63
|
+
// full content, so a dropped delta never corrupts the transcript.
|
|
64
|
+
const a = lastAssistant(); if (a && a._live) a.content = (a.content || '') + (data.text || '');
|
|
65
|
+
} else if (event === 'tool.start') {
|
|
66
|
+
const a = lastAssistant(); if (a) (a.parts || (a.parts = [])).push({ kind: 'tool', name: data.name || 'tool', args: data.args || {}, status: 'running', _tcid: data.toolCallId });
|
|
67
|
+
} else if (event === 'tool.end') {
|
|
68
|
+
const a = lastAssistant(); if (a) {
|
|
69
|
+
const p = (a.parts || []).find(p => p.kind === 'tool' && p._tcid === data.toolCallId);
|
|
70
|
+
if (p) {
|
|
71
|
+
p.status = data.denied ? 'error' : 'done';
|
|
72
|
+
p.result = data.denied ? 'denied by user' : (typeof data.result === 'string' ? data.result : JSON.stringify(data.result ?? '', null, 2));
|
|
73
|
+
if (data.denied) p.error = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} else if (event === 'approval.request') {
|
|
77
|
+
const a = lastAssistant(); if (a) (a.parts || (a.parts = [])).push({ kind: 'approval', id: data.id, name: data.name, args: data.args || {}, status: 'pending', onResolve: sendApprove ? (d) => sendApprove(data.id, d) : null });
|
|
78
|
+
} else if (event === 'approval.resolved') {
|
|
79
|
+
const a = lastAssistant(); if (a) {
|
|
80
|
+
const p = (a.parts || []).find(p => p.kind === 'approval' && p.id === data.id);
|
|
81
|
+
if (p) { p.status = data.approved ? 'approved' : 'rejected'; p.always = !!data.always; }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
16
86
|
export const chat = makePage((ctx) => {
|
|
17
|
-
Object.assign(ctx.state, { loading: false, messages: [], draft: '', busy: false, error: null,
|
|
87
|
+
Object.assign(ctx.state, { loading: false, messages: [], draft: '', busy: false, error: null, sessionId: null, ws: null, conn: 'closed' });
|
|
18
88
|
|
|
19
89
|
// Offline outbox: a prompt sent while genuinely offline queues to
|
|
20
|
-
// IndexedDB and auto-flushes on the real 'online' event
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// only gets a reply once connectivity actually returns. Reconnect-flush
|
|
24
|
-
// still goes through the single-shot JSON path (no live UI to stream
|
|
25
|
-
// into for a message sent while this page may not even be mounted).
|
|
90
|
+
// IndexedDB and auto-flushes on the real 'online' event via the legacy
|
|
91
|
+
// single-shot POST path (no live UI to stream into for a message sent
|
|
92
|
+
// while this page may not even be mounted).
|
|
26
93
|
async function sendQueuedToServer(body) {
|
|
27
94
|
const r = await api('/api/chat', { method: 'POST', body });
|
|
28
95
|
const reply = r.result || r.content || r.message || (r.messages && r.messages.at(-1)?.content) || JSON.stringify(r);
|
|
@@ -31,97 +98,130 @@ export const chat = makePage((ctx) => {
|
|
|
31
98
|
}
|
|
32
99
|
watchReconnect('chat', sendQueuedToServer);
|
|
33
100
|
|
|
101
|
+
const s = () => ctx.state;
|
|
102
|
+
const cur = () => s().messages[s().messages.length - 1];
|
|
103
|
+
|
|
104
|
+
function sendFrame(obj) {
|
|
105
|
+
const ws = s().ws;
|
|
106
|
+
if (!ws) return false;
|
|
107
|
+
if (ws.readyState === 1) { ws.send(JSON.stringify(obj)); return true; }
|
|
108
|
+
if (ws.readyState === 0) { ws.addEventListener('open', () => ws.send(JSON.stringify(obj)), { once: true }); return true; }
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ensureWs() {
|
|
113
|
+
const st = s();
|
|
114
|
+
if (!st.sessionId) st.sessionId = newSessionId();
|
|
115
|
+
if (st.ws && (st.ws.readyState === 1 || st.ws.readyState === 0)) return st.ws;
|
|
116
|
+
try {
|
|
117
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
118
|
+
const ws = new WebSocket(proto + '://' + location.host + '/api/agent/stream?sessionId=' + encodeURIComponent(st.sessionId));
|
|
119
|
+
st.ws = ws;
|
|
120
|
+
ws.onopen = () => { st.conn = 'open'; ctx.rerender(); };
|
|
121
|
+
ws.onmessage = (e) => {
|
|
122
|
+
let f; try { f = JSON.parse(e.data); } catch { return; }
|
|
123
|
+
if (f.type === 'replay') {
|
|
124
|
+
// Rebuild from the server's wire log only when the local
|
|
125
|
+
// thread is empty (first mount / refresh), never clobber a
|
|
126
|
+
// live thread with a reconnect's replay.
|
|
127
|
+
if (!st.messages.length && f.events && f.events.length) {
|
|
128
|
+
const msgs = [];
|
|
129
|
+
for (const env of f.events) applyEnvelope(msgs, env, null);
|
|
130
|
+
// Settled replay: turns are complete, drop the _live marker.
|
|
131
|
+
for (const m of msgs) delete m._live;
|
|
132
|
+
st.messages = msgs;
|
|
133
|
+
}
|
|
134
|
+
ctx.rerender();
|
|
135
|
+
} else if (f.type === 'event') {
|
|
136
|
+
applyEnvelope(st.messages, f, (id, d) => sendFrame({ type: 'approve', id, approved: d.approved, always: !!d.always }));
|
|
137
|
+
ctx.rerender();
|
|
138
|
+
} else if (f.type === 'prompt.done') {
|
|
139
|
+
const c = cur();
|
|
140
|
+
if (c && c.role === 'assistant') {
|
|
141
|
+
delete c._live;
|
|
142
|
+
if (f.error && !c.error) c.error = f.error;
|
|
143
|
+
}
|
|
144
|
+
ctx.set({ busy: false });
|
|
145
|
+
} else if (f.type === 'error') {
|
|
146
|
+
const c = cur();
|
|
147
|
+
if (c && c.role === 'assistant') c.error = f.error;
|
|
148
|
+
ctx.set({ busy: false });
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
ws.onclose = () => {
|
|
152
|
+
st.conn = 'closed';
|
|
153
|
+
if (st.busy) {
|
|
154
|
+
const c = cur();
|
|
155
|
+
if (c && c.role === 'assistant') { delete c._live; c.incomplete = true; }
|
|
156
|
+
ctx.set({ busy: false });
|
|
157
|
+
}
|
|
158
|
+
ctx.rerender();
|
|
159
|
+
};
|
|
160
|
+
ws.onerror = () => { st.conn = 'closed'; };
|
|
161
|
+
return ws;
|
|
162
|
+
} catch { return null; }
|
|
163
|
+
}
|
|
164
|
+
|
|
34
165
|
async function send(text) {
|
|
35
|
-
const t = (typeof text === 'string' ? text :
|
|
36
|
-
if (!t
|
|
166
|
+
const t = (typeof text === 'string' ? text : s().draft || '').trim();
|
|
167
|
+
if (!t) return;
|
|
168
|
+
|
|
169
|
+
// Mid-turn send = STEER (kimi's steering): injected at the next step
|
|
170
|
+
// boundary instead of starting a parallel turn.
|
|
171
|
+
if (s().busy) {
|
|
172
|
+
if (sendFrame({ type: 'steer', text: t })) {
|
|
173
|
+
s().messages = [...s().messages, { id: 'u' + Date.now(), role: 'user', content: t, time: formatTime(Date.now()) }];
|
|
174
|
+
ctx.set({ draft: '' });
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
37
179
|
const userMsg = { id: 'u' + Date.now(), role: 'user', content: t, time: formatTime(Date.now()) };
|
|
38
|
-
const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: formatTime(Date.now()), parts: [] };
|
|
39
|
-
|
|
180
|
+
const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: formatTime(Date.now()), parts: [], _live: true };
|
|
181
|
+
s().messages = [...s().messages, userMsg, curMsg];
|
|
40
182
|
ctx.set({ draft: '', busy: true, error: null });
|
|
41
183
|
|
|
42
184
|
if (!isOnline()) {
|
|
43
185
|
await queueMessage('chat', { prompt: t });
|
|
44
|
-
|
|
45
|
-
|
|
186
|
+
s().messages = s().messages.slice(0, -1);
|
|
187
|
+
s().messages.push({ id: curMsg.id, role: 'assistant', content: '(offline -- queued, will send when connection returns)', time: formatTime(Date.now()) });
|
|
46
188
|
ctx.set({ busy: false });
|
|
47
189
|
return;
|
|
48
190
|
}
|
|
49
191
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
const res = await fetch('/api/chat', {
|
|
55
|
-
method: 'POST',
|
|
56
|
-
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
57
|
-
body: JSON.stringify({ prompt: t, sessionId: ctx.state.sessionId || undefined }),
|
|
58
|
-
signal: ctrl.signal,
|
|
59
|
-
});
|
|
60
|
-
if (!res.ok || !res.body) {
|
|
61
|
-
const txt = await res.text().catch(() => '');
|
|
62
|
-
throw new Error(txt || ('HTTP ' + res.status));
|
|
63
|
-
}
|
|
64
|
-
let finalMessages = null;
|
|
65
|
-
for await (const { event, data } of parseSseStream(res)) {
|
|
66
|
-
if (ctrl.signal.aborted) break;
|
|
67
|
-
if (event === 'start') {
|
|
68
|
-
if (data && data.sessionId) ctx.state.sessionId = data.sessionId;
|
|
69
|
-
} else if (event === 'tool_progress') {
|
|
70
|
-
cur.parts.push(toolProgressPart(data || {}));
|
|
71
|
-
ctx.rerender();
|
|
72
|
-
} else if (event === 'message') {
|
|
73
|
-
// Buffered per-message events land right before `done` -- accumulate
|
|
74
|
-
// rather than rerender per-message; the final rebuild below is O(1)
|
|
75
|
-
// extra work and avoids a flurry of rerenders in the same tick.
|
|
76
|
-
(finalMessages || (finalMessages = [])).push(data);
|
|
77
|
-
} else if (event === 'error') {
|
|
78
|
-
cur.error = String((data && data.error) || 'stream error');
|
|
79
|
-
ctx.rerender();
|
|
80
|
-
} else if (event === 'done') {
|
|
81
|
-
if (finalMessages && finalMessages.length) {
|
|
82
|
-
cur.parts = partsFromMessages(finalMessages);
|
|
83
|
-
// Prefer the settled assistant text as plain content when the
|
|
84
|
-
// rebuilt parts carry exactly one md part (the common no-tool-call
|
|
85
|
-
// case) -- keeps AgentChat's md-vs-content dedup path simple.
|
|
86
|
-
cur.content = '';
|
|
87
|
-
} else if (data && data.result) {
|
|
88
|
-
cur.content = String(data.result);
|
|
89
|
-
}
|
|
90
|
-
ctx.rerender();
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
} catch (e) {
|
|
94
|
-
if (e && e.name === 'AbortError') {
|
|
95
|
-
cur.stopped = true;
|
|
96
|
-
} else {
|
|
97
|
-
cur.error = String(e && e.message || e);
|
|
98
|
-
}
|
|
99
|
-
} finally {
|
|
100
|
-
ctx.state.abort = null;
|
|
192
|
+
if (!ensureWs() || !sendFrame({ type: 'prompt', text: t })) {
|
|
193
|
+
curMsg.error = 'agent workspace connection unavailable';
|
|
194
|
+
delete curMsg._live;
|
|
101
195
|
ctx.set({ busy: false });
|
|
102
196
|
}
|
|
103
197
|
}
|
|
104
198
|
|
|
105
199
|
function stop() {
|
|
106
|
-
|
|
200
|
+
sendFrame({ type: 'cancel' });
|
|
201
|
+
const c = cur();
|
|
202
|
+
if (c && c.role === 'assistant') c.stopped = true;
|
|
203
|
+
ctx.rerender();
|
|
107
204
|
}
|
|
108
205
|
|
|
109
206
|
return () => {
|
|
110
|
-
const
|
|
207
|
+
const st = s();
|
|
111
208
|
return h('div', { class: 'fd-chat' },
|
|
112
209
|
AgentChat({
|
|
113
|
-
messages:
|
|
114
|
-
busy:
|
|
115
|
-
draft:
|
|
116
|
-
status:
|
|
210
|
+
messages: st.messages,
|
|
211
|
+
busy: st.busy,
|
|
212
|
+
draft: st.draft,
|
|
213
|
+
status: st.busy ? 'streaming…' : (st.conn === 'open' ? 'ready' : 'connecting…'),
|
|
117
214
|
agentName: 'freddie',
|
|
118
|
-
placeholder:
|
|
215
|
+
placeholder: st.busy ? 'steer the turn… (or stop)' : 'message…',
|
|
119
216
|
showMinimap: true,
|
|
120
|
-
banners:
|
|
121
|
-
onInput: (v) => {
|
|
217
|
+
banners: st.error ? [noteAlert({ kind: 'error', msg: st.error })] : [],
|
|
218
|
+
onInput: (v) => { st.draft = v; },
|
|
122
219
|
onSend: send,
|
|
123
220
|
onStop: stop,
|
|
124
|
-
onNewChat: () =>
|
|
221
|
+
onNewChat: () => {
|
|
222
|
+
try { st.ws && st.ws.close(); } catch { /* already closed */ }
|
|
223
|
+
ctx.set({ messages: [], draft: '', error: null, sessionId: null, ws: null, conn: 'closed' });
|
|
224
|
+
},
|
|
125
225
|
}));
|
|
126
226
|
};
|
|
127
227
|
});
|
package/src/kits/os/shell.js
CHANGED
|
@@ -26,18 +26,44 @@ export function createDesktopShell({ root = document.body, wm, registry, brand =
|
|
|
26
26
|
const { drawer, drawerClose, drawerGrid } = buildDrawer();
|
|
27
27
|
const taskbar = buildTaskbar();
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
29
|
+
// The registry is not frozen at shell creation — hosts can register and
|
|
30
|
+
// unregister apps later (thebird's per-instance user-* apps come and go on
|
|
31
|
+
// instance switch and fs edits). refreshApps() re-syncs the three launcher
|
|
32
|
+
// surfaces (apps menu / side rail / drawer grid) surgically: entries for
|
|
33
|
+
// newly registered apps are appended, entries whose app was unregistered
|
|
34
|
+
// are removed, and already-rendered entries keep their nodes (listeners,
|
|
35
|
+
// focus, and any host-side regrouping of the menu intact). The initial
|
|
36
|
+
// render is the same call. Buttons are tagged data-app-id so removal can
|
|
37
|
+
// find them again.
|
|
38
|
+
const renderedAppIds = new Set();
|
|
39
|
+
function refreshApps() {
|
|
40
|
+
const apps = typeof registry.list === 'function' ? registry.list() : [...registry.values()];
|
|
41
|
+
const live = new Set(apps.map(a => a.id));
|
|
42
|
+
for (const id of renderedAppIds) {
|
|
43
|
+
if (live.has(id)) continue;
|
|
44
|
+
for (const container of [appsMenu, sideRail, drawerGrid]) {
|
|
45
|
+
const stale = container.querySelector('[data-app-id="' + id + '"]');
|
|
46
|
+
if (stale) stale.remove();
|
|
47
|
+
}
|
|
48
|
+
renderedAppIds.delete(id);
|
|
49
|
+
}
|
|
50
|
+
for (const app of apps) {
|
|
51
|
+
if (renderedAppIds.has(app.id)) continue;
|
|
52
|
+
const { menuBtn, railBtn, tile } = buildAppEntries(app, {
|
|
53
|
+
onMenuClick: () => { closeMenu(); openApp(app.id); },
|
|
54
|
+
onRailClick: () => openApp(app.id),
|
|
55
|
+
onTileClick: () => { closeDrawer(); openApp(app.id); },
|
|
56
|
+
});
|
|
57
|
+
menuBtn.dataset.appId = app.id;
|
|
58
|
+
railBtn.dataset.appId = app.id;
|
|
59
|
+
tile.dataset.appId = app.id;
|
|
60
|
+
appsMenu.appendChild(menuBtn);
|
|
61
|
+
sideRail.appendChild(railBtn);
|
|
62
|
+
drawerGrid.appendChild(tile);
|
|
63
|
+
renderedAppIds.add(app.id);
|
|
64
|
+
}
|
|
40
65
|
}
|
|
66
|
+
refreshApps();
|
|
41
67
|
|
|
42
68
|
osRoot.append(menubar, appsMenu, taskbar);
|
|
43
69
|
document.body.append(sideRail, drawer);
|
|
@@ -193,7 +219,11 @@ export function createDesktopShell({ root = document.body, wm, registry, brand =
|
|
|
193
219
|
refreshTaskbar();
|
|
194
220
|
const finish = (r) => {
|
|
195
221
|
if (isAsync && typeof win.setBody === 'function') win.setBody(r.node);
|
|
196
|
-
|
|
222
|
+
// Keep the FULL factory result on _app (only id is overridden with
|
|
223
|
+
// the registry's appId): hosts persist/restore per-window view
|
|
224
|
+
// state through getViewState/restoreViewState hooks on the factory
|
|
225
|
+
// result — a lossy {id, dispose} wrap silently dropped them.
|
|
226
|
+
win._app = { ...r, id: appId };
|
|
197
227
|
refreshTaskbar();
|
|
198
228
|
return win;
|
|
199
229
|
};
|
|
@@ -210,7 +240,7 @@ export function createDesktopShell({ root = document.body, wm, registry, brand =
|
|
|
210
240
|
|
|
211
241
|
const api = {
|
|
212
242
|
wm, registry, openApp, setContext, refreshTaskbar, setActiveInstance,
|
|
213
|
-
openDrawer, closeDrawer, openMenu, closeMenu,
|
|
243
|
+
openDrawer, closeDrawer, openMenu, closeMenu, refreshApps,
|
|
214
244
|
get activeInstanceId() { return activeInstanceId; },
|
|
215
245
|
elements: { osRoot, menubar, taskbar, appsMenu, sideRail, drawer, instSwitch, homeBtn, appsBtn },
|
|
216
246
|
dispose() { clearInterval(clockTimer); clearInterval(taskTimer); window.removeEventListener('resize', onViewportResize); osRoot.remove(); sideRail.remove(); drawer.remove(); },
|