anentrypoint-design 0.0.375 → 0.0.377

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.375",
3
+ "version": "0.0.377",
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",
@@ -13,7 +13,8 @@
13
13
  // voiceParticipants, micMuted, voiceDeafened,
14
14
  // audioQueueItems, audioQueueCurrentId, audioQueuePaused,
15
15
  // showAuthModal, settingsOpen, voiceSettingsOpen, replyTarget,
16
- // mobileMenuOpen // drives the .ca-rail off-canvas drawer on narrow shells
16
+ // mobileMenuOpen, // drives the .ca-rail off-canvas drawer on narrow shells
17
+ // canManage // gates the rail's "+ create channel" affordance
17
18
  // }
18
19
  // adapter.subscribe(cb) -> unsubscribe // cb fires when any snapshot field changes
19
20
  // adapter.actions = {
@@ -22,7 +23,8 @@
22
23
  // channelContext(id, x, y), serverContext(id, x, y), switchServer(id),
23
24
  // goHome(), openServers(), memberMenu(id, name, x, y),
24
25
  // replaySegment(id), skipSegment(), pauseQueue(), resumeQueue(),
25
- // setInput(v), startReply(msg), cancelReply(), deleteMessage(id)
26
+ // setInput(v), startReply(msg), cancelReply(), deleteMessage(id),
27
+ // createChannel() // optional; when present + canManage, rail shows a "+" next to "rooms"
26
28
  // }
27
29
  // adapter.helpers = { avatarColor(id), initial(name), formatTime(ts) }
28
30
  //
@@ -65,11 +67,12 @@ export function mountCommunityApp(root, adapter = {}) {
65
67
  const voice = channels.filter(c => c.type === 'voice' || c.type === 'threaded');
66
68
  const cur = s.currentChannel || {};
67
69
  const servers = s.servers || [];
70
+ if (text.length || !servers.length) {
71
+ out.push(groupHeader('rooms', s));
72
+ }
68
73
  if (text.length) {
69
- out.push(h('div', { class: 'group' }, 'rooms'));
70
74
  for (const c of text) out.push(railPill(c, cur, false, s));
71
75
  } else if (!servers.length) {
72
- out.push(h('div', { class: 'group' }, 'rooms'));
73
76
  out.push(h('div', { class: 'rail-empty', role: 'status' }, 'no rooms yet'));
74
77
  }
75
78
  if (voice.length) {
@@ -84,6 +87,15 @@ export function mountCommunityApp(root, adapter = {}) {
84
87
  return h('div', {}, ...out);
85
88
  };
86
89
 
90
+ const groupHeader = (label, s) => h('div', { class: 'group group-header' },
91
+ h('span', {}, label),
92
+ (s.canManage && A.createChannel)
93
+ ? h('button', {
94
+ type: 'button', class: 'group-add-btn', 'aria-label': 'create channel', title: 'Create channel',
95
+ onclick: (e) => { e.preventDefault(); e.stopPropagation(); A.createChannel(); },
96
+ }, Icon('plus', { size: 13 }))
97
+ : null);
98
+
87
99
  const railPill = (c, cur, isVoice, s) => {
88
100
  const active = cur.id === c.id;
89
101
  const inVoice = isVoice && s.voiceConnected && s.voiceChannelName === c.name;
@@ -17,9 +17,9 @@ import { fmtTime, fmtAgo } from './sessions.js';
17
17
  import { ModelsConfig } from './models-config.js';
18
18
  import { SkillsConfig } from './skills-config.js';
19
19
  import { PluginsConfig } from './plugins-config.js';
20
- import { createVirtualizer, measureRef } from '../virtual-scroll.js';
21
20
  import { GitStatusPanel, GitDiffView } from './git-status.js';
22
21
  import { WorktreeSwitcher } from './worktree-switcher.js';
22
+ import { AgentChat } from './agent-chat.js';
23
23
 
24
24
  const h = webjsx.createElement;
25
25
 
@@ -46,10 +46,6 @@ const TRUNC_PROMPT = 50; // batch prompt cells
46
46
  const truncSpan = (s, n) => { const t = trunc(s, n); return h('span', { title: t.title }, t.text); };
47
47
  // Cap a raw JSON dump for an inline table cell without losing the data via tooltip.
48
48
  const truncJson = (v, n = TRUNC_TITLE) => truncSpan(JSON.stringify(v), n);
49
- // Autoscroll a thread only when the user is already near the bottom, so
50
- // scrolling up to read history is not yanked back down on the next render.
51
- const stickyScroll = (el) => { if (!el) return; const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80; if (nearBottom) el.scrollTop = el.scrollHeight; };
52
-
53
49
  // ---- home ------------------------------------------------------------------
54
50
 
55
51
  export const home = makePage((ctx) => {
@@ -109,98 +105,196 @@ export const home = makePage((ctx) => {
109
105
 
110
106
  // ---- chat ------------------------------------------------------------------
111
107
 
112
- // Below this thread length, mount every message directly -- virtualization's
113
- // scroll-listener + spacer-div overhead only pays for itself once the DOM
114
- // node count it would otherwise mount is large enough to matter.
115
- const VIRTUALIZE_THRESHOLD = 60;
108
+ // Parse a fetch Response body as a Server-Sent-Events frame stream. There is
109
+ // no EventSource-over-POST in browsers (EventSource only does GET, no custom
110
+ // headers/body), so a POST-based SSE consumer has to manually decode the
111
+ // ReadableStream and split on blank-line-terminated `event: X\ndata: Y\n\n`
112
+ // frames. No existing SSE-parsing utility exists elsewhere in this SDK
113
+ // (checked idb-outbox.js and grepped src/ for `text/event-stream`) -- this is
114
+ // the first, generic enough (event name + JSON.parse'd data) to reuse for any
115
+ // future SSE endpoint, not freddie-chat-specific in shape.
116
+ async function* parseSseStream(response) {
117
+ const reader = response.body.getReader();
118
+ const decoder = new TextDecoder();
119
+ let buf = '';
120
+ try {
121
+ for (;;) {
122
+ const { done, value } = await reader.read();
123
+ if (done) break;
124
+ buf += decoder.decode(value, { stream: true });
125
+ // Frames are separated by a blank line; a frame may itself contain
126
+ // multiple `field: value` lines (event/data/id/retry) but this
127
+ // server only ever emits one `event:` + one `data:` line per frame.
128
+ let sep;
129
+ while ((sep = buf.indexOf('\n\n')) !== -1) {
130
+ const frame = buf.slice(0, sep);
131
+ buf = buf.slice(sep + 2);
132
+ let event = 'message', dataLines = [];
133
+ for (const line of frame.split('\n')) {
134
+ if (line.startsWith('event:')) event = line.slice(6).trim();
135
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
136
+ }
137
+ if (!dataLines.length) continue;
138
+ let data;
139
+ try { data = JSON.parse(dataLines.join('\n')); } catch { data = dataLines.join('\n'); }
140
+ yield { event, data };
141
+ }
142
+ }
143
+ } finally {
144
+ try { reader.releaseLock(); } catch { /* stream already closed/errored */ }
145
+ }
146
+ }
116
147
 
117
- export const chat = makePage((ctx) => {
118
- Object.assign(ctx.state, { loading: false, messages: [], draft: '', sending: false });
119
- const virtualizer = createVirtualizer();
120
- let threadEl = null;
121
- let range = { startIndex: 0, endIndex: 0, topSpacerPx: 0, bottomSpacerPx: 0 };
122
- function recomputeRange() {
123
- if (!threadEl) return;
124
- virtualizer.setCount(ctx.state.messages.length);
125
- range = virtualizer.computeRange(threadEl.scrollTop, threadEl.clientHeight);
148
+ // Map a freddie tool_progress SSE payload ({name, args, partial}) to an
149
+ // AgentChat tool part. There is no matching id to correlate a later
150
+ // "done" state (freddie's stream has no discrete tool-start/tool-end pair --
151
+ // tool_progress fires zero-or-more times per call while it runs, and the
152
+ // authoritative role:'tool' result only arrives batched in the final
153
+ // `message`/`done` events) so every progress part renders as 'running'; the
154
+ // turn-settle pass below promotes matching parts to 'done'/'error' once the
155
+ // real tool_call_id/content pairs are known.
156
+ function toolProgressPart(payload) {
157
+ return { kind: 'tool', name: payload.name || 'tool', args: payload.args || {}, status: 'running' };
158
+ }
159
+
160
+ // After `done`, freddie's persisted message list is the source of truth:
161
+ // walk it and rebuild the assistant turn's parts as interleaved
162
+ // text/tool/tool_result, replacing the provisional tool_progress-only parts
163
+ // accumulated during streaming. assistant messages with tool_calls become
164
+ // running tool parts (by call id); role:'tool' messages settle the matching
165
+ // part to done/error by tool_call_id.
166
+ function partsFromMessages(assistantAndToolMessages) {
167
+ const parts = [];
168
+ const byId = new Map();
169
+ for (const m of assistantAndToolMessages) {
170
+ if (m.role === 'assistant') {
171
+ if (m.content) parts.push({ kind: 'md', text: m.content });
172
+ for (const tc of (m.tool_calls || [])) {
173
+ const part = { kind: 'tool', _id: tc.id, name: tc.name || tc.function?.name || 'tool', args: tc.arguments || tc.function?.arguments || {}, status: 'running' };
174
+ parts.push(part);
175
+ if (tc.id) byId.set(tc.id, part);
176
+ }
177
+ } else if (m.role === 'tool') {
178
+ const target = m.tool_call_id ? byId.get(m.tool_call_id) : null;
179
+ let content = m.content;
180
+ let isError = false;
181
+ try { const parsed = JSON.parse(content); if (parsed && parsed.error) { isError = true; } } catch { /* not JSON, leave as-is */ }
182
+ if (target) { target.result = content; target.status = isError ? 'error' : 'done'; target.error = isError || undefined; }
183
+ else parts.push({ kind: 'tool_result', name: 'result', result: content, error: isError || undefined, status: isError ? 'error' : 'done' });
184
+ }
126
185
  }
186
+ return parts;
187
+ }
188
+
189
+ export const chat = makePage((ctx) => {
190
+ Object.assign(ctx.state, { loading: false, messages: [], draft: '', busy: false, error: null, abort: null });
191
+
127
192
  // Offline outbox: a prompt sent while genuinely offline queues to
128
193
  // IndexedDB and auto-flushes on the real 'online' event, rather than
129
194
  // surfacing a hard error the user can't act on. True offline LLM
130
195
  // response generation is impossible by definition -- a queued message
131
- // only gets a reply once connectivity actually returns.
132
- async function sendToServer(body) {
196
+ // only gets a reply once connectivity actually returns. Reconnect-flush
197
+ // still goes through the single-shot JSON path (no live UI to stream
198
+ // into for a message sent while this page may not even be mounted).
199
+ async function sendQueuedToServer(body) {
133
200
  const r = await api('/api/chat', { method: 'POST', body });
134
201
  const reply = r.result || r.content || r.message || (r.messages && r.messages.at(-1)?.content) || JSON.stringify(r);
135
- ctx.state.messages.push({ role: 'assistant', text: String(reply), time: formatTime(Date.now()) });
202
+ ctx.state.messages.push({ id: 'a' + Date.now(), role: 'assistant', content: String(reply), time: formatTime(Date.now()) });
203
+ ctx.rerender();
136
204
  }
137
- watchReconnect('chat', sendToServer);
205
+ watchReconnect('chat', sendQueuedToServer);
206
+
138
207
  async function send(text) {
139
- const t = (text || ctx.state.draft || '').trim();
140
- if (!t || ctx.state.sending) return;
141
- ctx.state.messages.push({ role: 'user', text: t, time: formatTime(Date.now()) });
142
- ctx.set({ draft: '', sending: true });
208
+ const t = (typeof text === 'string' ? text : ctx.state.draft || '').trim();
209
+ if (!t || ctx.state.busy) return;
210
+ const userMsg = { id: 'u' + Date.now(), role: 'user', content: t, time: formatTime(Date.now()) };
211
+ const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: formatTime(Date.now()), parts: [] };
212
+ ctx.state.messages = [...ctx.state.messages, userMsg, curMsg];
213
+ ctx.set({ draft: '', busy: true, error: null });
214
+
143
215
  if (!isOnline()) {
144
216
  await queueMessage('chat', { prompt: t });
145
- ctx.state.messages.push({ role: 'assistant', text: '(offline -- queued, will send when connection returns)', time: formatTime(Date.now()) });
146
- ctx.set({ sending: false });
217
+ ctx.state.messages = ctx.state.messages.slice(0, -1);
218
+ ctx.state.messages.push({ id: curMsg.id, role: 'assistant', content: '(offline -- queued, will send when connection returns)', time: formatTime(Date.now()) });
219
+ ctx.set({ busy: false });
147
220
  return;
148
221
  }
222
+
223
+ const ctrl = new AbortController();
224
+ ctx.state.abort = ctrl;
225
+ const cur = ctx.state.messages[ctx.state.messages.length - 1];
149
226
  try {
150
- await sendToServer({ prompt: t });
227
+ const res = await fetch('/api/chat', {
228
+ method: 'POST',
229
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
230
+ body: JSON.stringify({ prompt: t, sessionId: ctx.state.sessionId || undefined }),
231
+ signal: ctrl.signal,
232
+ });
233
+ if (!res.ok || !res.body) {
234
+ const txt = await res.text().catch(() => '');
235
+ throw new Error(txt || ('HTTP ' + res.status));
236
+ }
237
+ let finalMessages = null;
238
+ for await (const { event, data } of parseSseStream(res)) {
239
+ if (ctrl.signal.aborted) break;
240
+ if (event === 'start') {
241
+ if (data && data.sessionId) ctx.state.sessionId = data.sessionId;
242
+ } else if (event === 'tool_progress') {
243
+ cur.parts.push(toolProgressPart(data || {}));
244
+ ctx.rerender();
245
+ } else if (event === 'message') {
246
+ // Buffered per-message events land right before `done` -- accumulate
247
+ // rather than rerender per-message; the final rebuild below is O(1)
248
+ // extra work and avoids a flurry of rerenders in the same tick.
249
+ (finalMessages || (finalMessages = [])).push(data);
250
+ } else if (event === 'error') {
251
+ cur.error = String((data && data.error) || 'stream error');
252
+ ctx.rerender();
253
+ } else if (event === 'done') {
254
+ if (finalMessages && finalMessages.length) {
255
+ cur.parts = partsFromMessages(finalMessages);
256
+ // Prefer the settled assistant text as plain content when the
257
+ // rebuilt parts carry exactly one md part (the common no-tool-call
258
+ // case) -- keeps AgentChat's md-vs-content dedup path simple.
259
+ cur.content = '';
260
+ } else if (data && data.result) {
261
+ cur.content = String(data.result);
262
+ }
263
+ ctx.rerender();
264
+ }
265
+ }
151
266
  } catch (e) {
152
- ctx.state.messages.push({ role: 'assistant', text: 'Error: ' + String(e.message || e), time: formatTime(Date.now()) });
153
- }
154
- ctx.set({ sending: false });
155
- }
156
- // Combines the existing bottom-pin auto-scroll ref (stickyScroll) with the
157
- // virtualizer's range recompute on every scroll tick. Both run off the
158
- // SAME element -- one ref callback wiring both keeps a single listener
159
- // registration instead of two refs fighting over the same node.
160
- function threadRef(el) {
161
- if (!el) { threadEl = null; return; }
162
- threadEl = el;
163
- stickyScroll(el);
164
- if (!el.__vsScrollWired) {
165
- el.__vsScrollWired = true;
166
- el.addEventListener('scroll', () => { recomputeRange(); ctx.rerender(); }, { passive: true });
267
+ if (e && e.name === 'AbortError') {
268
+ cur.stopped = true;
269
+ } else {
270
+ cur.error = String(e && e.message || e);
271
+ }
272
+ } finally {
273
+ ctx.state.abort = null;
274
+ ctx.set({ busy: false });
167
275
  }
168
- recomputeRange();
276
+ }
277
+
278
+ function stop() {
279
+ if (ctx.state.abort) { try { ctx.state.abort.abort(); } catch { /* already settled */ } }
169
280
  }
170
281
 
171
282
  return () => {
172
283
  const s = ctx.state;
173
- const virtualized = s.messages.length > VIRTUALIZE_THRESHOLD;
174
- let threadChildren;
175
- if (!s.messages.length) {
176
- threadChildren = [emptyState('send a prompt to start', Icon('forum'))];
177
- } else if (!virtualized) {
178
- threadChildren = s.messages.map((m, i) => ChatMessage({ ...m, key: i }));
179
- } else {
180
- recomputeRange();
181
- const { startIndex, endIndex, topSpacerPx, bottomSpacerPx } = range;
182
- threadChildren = [
183
- h('div', { key: '_top_spacer', style: `height:${topSpacerPx}px` }),
184
- ...s.messages.slice(startIndex, endIndex).map((m, i) => {
185
- const realIndex = startIndex + i;
186
- return h('div', { key: 'vm' + realIndex, ref: measureRef(virtualizer, realIndex) }, ChatMessage({ ...m, key: realIndex }));
187
- }),
188
- h('div', { key: '_bottom_spacer', style: `height:${bottomSpacerPx}px` }),
189
- ];
190
- }
191
284
  return h('div', { class: 'fd-chat' },
192
- PageHeader({ eyebrow: 'freddie', title: 'chat', lede: 'one-shot agent turns · POST /api/chat' }),
193
- liveRegion(s.sending ? 'waiting for assistant reply' : ''),
194
- h('div', { class: 'chat-thread fd-chat-thread', role: 'log', 'aria-label': 'chat messages',
195
- ref: threadRef },
196
- ...threadChildren,
197
- s.sending ? ChatMessage({ role: 'assistant', typing: true, key: '_typing' }) : null),
198
- ChatComposer({
199
- value: s.draft,
200
- placeholder: s.sending ? 'waiting for reply…' : 'message…',
201
- disabled: s.sending,
285
+ AgentChat({
286
+ messages: s.messages,
287
+ busy: s.busy,
288
+ draft: s.draft,
289
+ status: s.busy ? 'streaming…' : 'ready',
290
+ agentName: 'freddie',
291
+ placeholder: s.busy ? 'waiting for reply…' : 'message…',
292
+ showMinimap: true,
293
+ banners: s.error ? [noteAlert({ kind: 'error', msg: s.error })] : [],
202
294
  onInput: (v) => { s.draft = v; },
203
295
  onSend: send,
296
+ onStop: stop,
297
+ onNewChat: () => ctx.set({ messages: [], draft: '', error: null, sessionId: null }),
204
298
  }));
205
299
  };
206
300
  });
@@ -176,6 +176,7 @@ export const ICON_PATHS = {
176
176
  'arrow-down': '<path d="M12 5v14M5 12l7 7 7-7"/>',
177
177
  'arrow-right': '<path d="M5 12h14M12 5l7 7-7 7"/>',
178
178
  x: '<path d="M18 6 6 18M6 6l12 12"/>',
179
+ plus: '<path d="M12 5v14M5 12h14"/>',
179
180
  play: '<path d="M6 4v16l14-8z"/>',
180
181
  pause: '<path d="M8 5v14M16 5v14"/>',
181
182
  refresh: '<path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/>',