anentrypoint-design 0.0.385 → 0.0.387

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/247420.css +33 -0
  2. package/dist/247420.js +53 -51
  3. package/package.json +1 -1
  4. package/src/components/agent-chat/controls.js +143 -0
  5. package/src/components/agent-chat/empty-state.js +63 -0
  6. package/src/components/agent-chat/message-rows.js +141 -0
  7. package/src/components/agent-chat/surface.js +206 -0
  8. package/src/components/agent-chat/thread-behaviour.js +50 -0
  9. package/src/components/agent-chat.js +6 -546
  10. package/src/components/chat/composer-affordances.js +109 -0
  11. package/src/components/chat/composer.js +256 -0
  12. package/src/components/chat/threads.js +105 -0
  13. package/src/components/chat-message-parts/agent-nodes.js +103 -0
  14. package/src/components/chat-message-parts/inline.js +106 -0
  15. package/src/components/chat-message-parts/prose-nodes.js +133 -0
  16. package/src/components/chat-message-parts/renderers.js +89 -0
  17. package/src/components/chat-message-parts.js +12 -408
  18. package/src/components/chat-minimap/minimap.js +167 -0
  19. package/src/components/chat-minimap/paint.js +73 -0
  20. package/src/components/chat-minimap/preview.js +41 -0
  21. package/src/components/chat-minimap.js +7 -263
  22. package/src/components/chat.js +20 -628
  23. package/src/components/community/chrome.js +63 -0
  24. package/src/components/community/navigation.js +167 -0
  25. package/src/components/community/presence.js +106 -0
  26. package/src/components/community/shell.js +17 -0
  27. package/src/components/community/views.js +131 -0
  28. package/src/components/community.js +18 -447
  29. package/src/components/files/chrome.js +140 -0
  30. package/src/components/files/entries.js +156 -0
  31. package/src/components/files/grid-controls.js +63 -0
  32. package/src/components/files/grid.js +177 -0
  33. package/src/components/files/types.js +69 -0
  34. package/src/components/files-modals/dialogs.js +118 -0
  35. package/src/components/files-modals/modal-shell.js +153 -0
  36. package/src/components/files-modals/preview-bodies.js +129 -0
  37. package/src/components/files-modals/preview-containers.js +96 -0
  38. package/src/components/files-modals.js +14 -475
  39. package/src/components/files.js +15 -564
  40. package/src/components/freddie/pages-config.js +3 -94
  41. package/src/components/freddie/pages-models.js +97 -0
  42. package/src/components/freddie.js +2 -1
  43. package/src/components/interaction-primitives/mobile.js +25 -0
  44. package/src/components/interaction-primitives/pointer.js +214 -0
  45. package/src/components/interaction-primitives/reorderable.js +47 -0
  46. package/src/components/interaction-primitives/shortcuts.js +119 -0
  47. package/src/components/interaction-primitives.js +16 -388
  48. package/src/components/sessions/conversation-list.js +230 -0
  49. package/src/components/sessions/dashboard.js +202 -0
  50. package/src/components/sessions/detail-bits.js +49 -0
  51. package/src/components/sessions/format.js +42 -0
  52. package/src/components/sessions/session-card.js +92 -0
  53. package/src/components/sessions.js +16 -579
  54. package/src/components/voice/audio-cue.js +39 -0
  55. package/src/components/voice/capture.js +90 -0
  56. package/src/components/voice/playback.js +75 -0
  57. package/src/components/voice/settings-modal.js +104 -0
  58. package/src/components/voice.js +16 -293
  59. package/src/css/app-shell/base.css +23 -0
  60. package/src/css/app-shell/states-interactions.css +10 -0
  61. package/src/kits/os/freddie/chat-protocol.js +32 -0
  62. package/src/kits/os/freddie/chat-transport.js +178 -0
  63. package/src/kits/os/freddie/pages-chat.js +10 -180
  64. package/src/kits/os/shell-chrome.js +163 -0
  65. package/src/kits/os/shell-geometry.js +59 -0
  66. package/src/kits/os/shell.js +178 -335
  67. package/src/page-html/client-script.js +151 -0
  68. package/src/page-html/head-tags.js +59 -0
  69. package/src/page-html/markdown.js +68 -0
  70. package/src/page-html/page-styles.js +44 -0
  71. package/src/page-html.js +14 -291
@@ -30,6 +30,15 @@ button, input, select, textarea { font: inherit; }
30
30
  border-radius: var(--r-pill);
31
31
  font-weight: 600;
32
32
  font-size: var(--fs-sm);
33
+ /* The skip link is the first thing a keyboard or switch user reaches, so it
34
+ is the last control that should be hard to hit. Its padding alone rendered
35
+ it 39px tall -- measured live under coarse-pointer emulation on every kit.
36
+ Floored unconditionally rather than under a pointer:coarse query, because
37
+ switch and head-pointer users are not detected as coarse pointers and need
38
+ the larger target just as much. */
39
+ min-height: 44px;
40
+ display: inline-flex;
41
+ align-items: center;
33
42
  }
34
43
  .skip-link:focus {
35
44
  top: 10px;
@@ -89,6 +98,20 @@ body {
89
98
  }
90
99
  .app * { min-width: 0; }
91
100
 
101
+ /* Scrollbar treatment, applied once at the root rather than per-component.
102
+ chat.css already styled its own thread and code blocks this way, but every
103
+ other scroll container (.app-side-shell, .app-main, .ds-scroll-x,
104
+ .ds-table-wrap, .ds-live-log) fell back to the UA default -- measured live,
105
+ 4 of 4 containers on dashboard and 7 of 7 on gm_inspector were unstyled while
106
+ chat's two were not. A default scrollbar reads as a bright seam against a
107
+ near-black panel in the ink and dark themes, and inconsistently between two
108
+ panels on the same page. Tokens mean the thumb follows the active theme.
109
+ Inheriting from the container also covers scrollers added later. */
110
+ .app, .ds-stage, .app *, .ds-stage * {
111
+ scrollbar-width: thin;
112
+ scrollbar-color: var(--bg-3) transparent;
113
+ }
114
+
92
115
  /* ============================================================
93
116
  Typography
94
117
  ============================================================ */
@@ -395,6 +395,16 @@ hr.rule-dotted { border-top: 1px dotted var(--rule-strong); }
395
395
  padding: var(--space-2) var(--space-1);
396
396
  font: inherit; color: var(--fg);
397
397
  }
398
+ /* Touch floor for the BASE input. Several specific inputs already carried this
399
+ (ds-file-filter-input, ds-modal-input, ds-term-input) but the primitive every
400
+ form actually uses did not, so a plain field measured 34px under a coarse
401
+ pointer -- measured live on the settings and gm_inspector kits. The 16px
402
+ font-size is not cosmetic: below 16px iOS auto-zooms the viewport on focus,
403
+ and max() keeps it from ever shrinking under a reduced typescale. */
404
+ @media (pointer: coarse) {
405
+ .input { min-height: 44px; font-size: max(16px, var(--fs-body)); }
406
+ textarea.input { min-height: 88px; }
407
+ }
398
408
  .input::placeholder { color: var(--fg-3); }
399
409
  .input:focus-visible {
400
410
  outline: none;
@@ -0,0 +1,32 @@
1
+ // Wire-format translation for the dashboard chat page: SSE frame parsing and
2
+ // the dashboard-message -> kit-ChatMessage shape conversion.
3
+
4
+ export function parseSseEvents(text) {
5
+ const events = [];
6
+ let curEvent = null, curData = '';
7
+ for (const line of text.split('\n')) {
8
+ if (line.startsWith('event: ')) { curEvent = line.slice(7).trim(); }
9
+ else if (line.startsWith('data: ')) { curData = line.slice(6).trim(); }
10
+ else if (line === '' && curEvent) {
11
+ try { events.push({ event: curEvent, data: JSON.parse(curData) }); } catch { /* swallow: a malformed SSE event is dropped, the stream continues */ }
12
+ curEvent = null; curData = '';
13
+ }
14
+ }
15
+ return events;
16
+ }
17
+
18
+ // Convert the dashboard message shape into the kit ChatMessage shape:
19
+ // { role:'user', content:string } -> { role:'user', text }
20
+ // { role:'assistant', content:string } -> { role:'assistant', parts:[{kind:'md', text}] }
21
+ // { role:'tool', name, argsSummary, content } ->
22
+ // { role:'tool', parts:[{kind:'tool_call', name, label, args, result, status}] }
23
+ // { role:'thinking' } -> { role:'thinking', parts:[{kind:'thinking', text}] }
24
+ export function toKitMessage(m) {
25
+ if (m.role === 'tool') {
26
+ const status = m.status || (m.error ? 'error' : (m.content != null ? 'done' : 'running'));
27
+ return { role: 'tool', parts: [{ kind: 'tool_call', name: m.name || 'tool', label: m.argsSummary || '', args: m.args || m.input || {}, result: m.content, status, error: !!m.error, open: status !== 'done' }] };
28
+ }
29
+ if (m.role === 'thinking') return { role: 'thinking', parts: [{ kind: 'thinking', text: m.content || 'thinking…' }] };
30
+ if (m.role === 'assistant' && m.content) return { role: 'assistant', parts: [{ kind: 'md', text: String(m.content) }] };
31
+ return { role: m.role, text: m.content || '' };
32
+ }
@@ -0,0 +1,178 @@
1
+ // The three send strategies the dashboard chat falls through, and the
2
+ // provider discovery that decides which are viable. All return (or synthesize)
3
+ // the same `{event, data}` list the server SSE path produces, so the render
4
+ // loop that consumes them is identical regardless of which path ran.
5
+
6
+ import { parseSseEvents } from './chat-protocol.js';
7
+
8
+ // Static deploy (no freddie-server, so /api/providers 404s): probe the
9
+ // acptoapi gateway directly and, when it answers, surface it as a real
10
+ // configured provider with its model list. Without this the dashboard
11
+ // tells the user to "run a gateway" even though one is live and chat works.
12
+ export async function loadProviders() {
13
+ let providers = await fetch('/api/providers').then(r => r.json()).catch(() => []);
14
+ if (!Array.isArray(providers)) providers = [];
15
+ if (!providers.some(p => p.configured)) {
16
+ try {
17
+ const cfg = (window.__debug?.instances?.i1?.host?.fs?.readJson?.('/etc/freddie/freddie.json', null)) || {};
18
+ const baseUrl = (cfg?.providers?.openai?.baseUrl || 'http://localhost:4800').replace(/\/+$/, '');
19
+ const ac = new AbortController();
20
+ const t = setTimeout(() => ac.abort(), 4000);
21
+ const r = await fetch(baseUrl + '/v1/models', { signal: ac.signal }).catch(() => null);
22
+ clearTimeout(t);
23
+ if (r && r.ok) {
24
+ const j = await r.json().catch(() => null);
25
+ const models = Array.isArray(j?.data) ? j.data.map(m => m.id) : [];
26
+ providers = [{ id: 'acptoapi', name: 'acptoapi gateway (' + baseUrl + ')', configured: true, models: ['auto', ...models] }, ...providers];
27
+ }
28
+ } catch { /* swallow: probing the local acptoapi gateway is opt-in best-effort, absence just means no providers shown */ }
29
+ }
30
+ return providers;
31
+ }
32
+
33
+ // Static deploy with an in-page agent runtime (e.g. thebird): drive
34
+ // the REAL multi-step agent loop (host tools + acptoapi gateway +
35
+ // tool execution) instead of a single bare completion, so the model
36
+ // emits tool_calls, the loop executes them, feeds results back, and
37
+ // iterates. We synthesize the same {event:'message'} stream the
38
+ // server path produces so the render loop is unchanged. The
39
+ // window global is the opt-in: hosts without it keep single-shot.
40
+ async function runInPageAgent(trimmed, chatState, renderPage) {
41
+ const events = [];
42
+ try {
43
+ let stepN = 0;
44
+ const onUpdate = (snap) => {
45
+ try {
46
+ const msgs = (snap && snap.context && snap.context.messages) || [];
47
+ const toolMsgs = msgs.filter(m => m.role === 'tool');
48
+ const lastAssist = [...msgs].reverse().find(m => m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length);
49
+ const running = lastAssist && lastAssist.tool_calls[0] && (lastAssist.tool_calls[0].function?.name || lastAssist.tool_calls[0].name);
50
+ stepN = toolMsgs.length;
51
+ chatState.progress = running
52
+ ? ('agent: ' + running + ' (step ' + (stepN + 1) + ')…')
53
+ : ('agent thinking' + (stepN ? ' (step ' + stepN + ')' : '') + '…');
54
+ renderPage();
55
+ } catch { /* swallow: progress-indicator update failing must not abort the agent run */ }
56
+ };
57
+ const out = await window.__thebirdRunAgent({ prompt: trimmed, onUpdate });
58
+ const turnMsgs = (out && Array.isArray(out.messages)) ? out.messages : [];
59
+ for (const m of turnMsgs) {
60
+ if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
61
+ const parts = [];
62
+ if (m.content) parts.push({ type: 'text', text: String(m.content) });
63
+ for (const tc of m.tool_calls) {
64
+ const rawArgs = tc.function?.arguments ?? tc.arguments;
65
+ let input = {};
66
+ if (rawArgs && typeof rawArgs === 'object') input = rawArgs;
67
+ else if (typeof rawArgs === 'string') { try { input = JSON.parse(rawArgs || '{}'); } catch { input = {}; } }
68
+ parts.push({ type: 'tool_use', name: tc.function?.name || tc.name, input });
69
+ }
70
+ events.push({ event: 'message', data: { role: 'assistant', content: parts } });
71
+ } else if (m.role === 'tool') {
72
+ events.push({ event: 'message', data: { role: 'tool', content: [{ content: String(m.content ?? '') }] } });
73
+ }
74
+ }
75
+ const finalText = (out && out.result) || (out && out.error ? 'error: ' + out.error : '');
76
+ if (finalText) events.push({ event: 'message', data: { role: 'assistant', content: [{ type: 'text', text: String(finalText) }] } });
77
+ if (!events.length) events.push({ event: 'message', data: { role: 'assistant', content: [{ type: 'text', text: '' }] } });
78
+ return events;
79
+ } catch (e) {
80
+ return [{ event: 'error', data: { error: e?.message || String(e) } }];
81
+ }
82
+ }
83
+
84
+ // Static deploy without an in-page agent runtime: single direct
85
+ // acptoapi /v1/chat/completions call (no tool loop — one shot).
86
+ async function runDirectCompletion(trimmed, chatState) {
87
+ const cfg = (window.__debug?.instances?.i1?.host?.fs?.readJson?.('/etc/freddie/freddie.json', null)) || {};
88
+ const baseUrl = cfg?.providers?.openai?.baseUrl || 'http://localhost:4800';
89
+ try {
90
+ const url = baseUrl.replace(/\/+$/, '') + '/v1/chat/completions';
91
+ const reqBody = { model: chatState.model || cfg?.providers?.openai?.model || 'auto', messages: [{ role: 'user', content: trimmed }] };
92
+ const r2 = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(reqBody), signal: chatState.abort.signal });
93
+ if (!r2.ok) {
94
+ const errText = await r2.text().catch(() => '');
95
+ return [{ event: 'error', data: { error: 'acptoapi ' + r2.status + ': ' + errText.slice(0, 200) } }];
96
+ }
97
+ const j = await r2.json();
98
+ const content = j?.choices?.[0]?.message?.content || '';
99
+ const tool_calls = j?.choices?.[0]?.message?.tool_calls;
100
+ const parts = [];
101
+ if (content) parts.push({ type: 'text', text: content });
102
+ if (Array.isArray(tool_calls)) {
103
+ for (const tc of tool_calls) parts.push({ type: 'tool_use', name: tc.function?.name, input: (() => { try { return JSON.parse(tc.function?.arguments || '{}'); } catch { return {}; } })() });
104
+ }
105
+ return [{ event: 'message', data: { role: 'assistant', content: parts.length ? parts : [{ type: 'text', text: '' }] } }];
106
+ } catch (e) {
107
+ return [{ event: 'error', data: { error: e?.message || String(e) } }];
108
+ }
109
+ }
110
+
111
+ // Try the real server SSE endpoint first; fall back to the in-page agent
112
+ // runtime, then to a single direct gateway completion.
113
+ export async function fetchChatEvents(trimmed, chatState, renderPage) {
114
+ const body = { prompt: trimmed, cwd: chatState.cwd || undefined, skill: chatState.skill || undefined, provider: chatState.provider || undefined, model: chatState.model || undefined, sessionId: chatState.sessionId || undefined };
115
+ let resp;
116
+ try {
117
+ resp = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: chatState.abort.signal });
118
+ } catch (fetchErr) {
119
+ resp = null;
120
+ }
121
+ if (resp && resp.ok) {
122
+ const text = await resp.text();
123
+ return parseSseEvents(text);
124
+ }
125
+ if (typeof window !== 'undefined' && typeof window.__thebirdRunAgent === 'function') {
126
+ return runInPageAgent(trimmed, chatState, renderPage);
127
+ }
128
+ return runDirectCompletion(trimmed, chatState);
129
+ }
130
+
131
+ // Fold the event list into chatState.messages, resolving running tool calls to
132
+ // done/error and accumulating assistant prose. Returns the trailing assistant
133
+ // text the caller appends once the stream is fully drained.
134
+ export function applyChatEvents(events, chatState, syncMessages) {
135
+ let assistantContent = '';
136
+ for (const { event, data } of events) {
137
+ if (event === 'start' && data.sessionId) chatState.sessionId = data.sessionId;
138
+ if (event === 'done' && data.sessionId) chatState.sessionId = data.sessionId;
139
+ if (event === 'message') {
140
+ const role = data.role;
141
+ if (role === 'assistant') {
142
+ const content = Array.isArray(data.content) ? data.content : [{ type: 'text', text: String(data.content || '') }];
143
+ for (const block of content) {
144
+ if (block.type === 'text') assistantContent += block.text;
145
+ if (block.type === 'tool_use') {
146
+ if (assistantContent) { chatState.messages.push({ role: 'assistant', content: assistantContent }); assistantContent = ''; }
147
+ const argsSummary = JSON.stringify(block.input || {}).slice(0, 60);
148
+ chatState.messages.push({ role: 'tool', name: block.name, argsSummary, content: JSON.stringify(block.input || {}, null, 2), status: 'running' });
149
+ syncMessages();
150
+ }
151
+ }
152
+ } else if (role === 'tool') {
153
+ const tc = Array.isArray(data.content) ? data.content[0] : data;
154
+ // Resolve the last running tool call to done with this result.
155
+ for (let i = chatState.messages.length - 1; i >= 0; i--) {
156
+ const m = chatState.messages[i];
157
+ if (m.role === 'tool' && m.status === 'running') {
158
+ m.content = String(tc?.content || tc?.text || JSON.stringify(tc));
159
+ m.status = 'done';
160
+ break;
161
+ }
162
+ }
163
+ syncMessages();
164
+ }
165
+ }
166
+ if (event === 'done' && data.result) { if (!assistantContent) assistantContent = data.result; }
167
+ if (event === 'error') {
168
+ const msg = 'error: ' + (data.error || 'unknown');
169
+ // Mark any running tool as errored; record assistant error.
170
+ for (let i = chatState.messages.length - 1; i >= 0; i--) {
171
+ const m = chatState.messages[i];
172
+ if (m.role === 'tool' && m.status === 'running') { m.status = 'error'; m.error = true; m.content = msg; break; }
173
+ }
174
+ if (!assistantContent) assistantContent = msg;
175
+ }
176
+ }
177
+ return assistantContent;
178
+ }
@@ -3,70 +3,26 @@
3
3
  // share the same bubble / tool-call / empty-state chrome. The bespoke
4
4
  // cwd/skill/provider/model selectors live in a collapsible config strip
5
5
  // above the thread (mirror of the OS panel cc-strip pattern).
6
+ //
7
+ // Wire-format translation lives in ./chat-protocol.js; provider discovery and
8
+ // the three send strategies (server SSE / in-page agent runtime / direct
9
+ // gateway completion) plus the event->state fold live in ./chat-transport.js.
10
+ // This module owns only the page's own state, DOM wiring, and config strip.
6
11
 
7
12
  import * as webjsx from '../../../../vendor/webjsx/index.js';
8
13
  import * as components from '../../../components.js';
9
14
  import { getRecentPaths, saveRecentPath, skillLabel } from '../../../components/freddie/helpers.js';
10
- import { Chat, ChatComposer } from '../../../components/chat.js';
15
+ import { toKitMessage } from './chat-protocol.js';
16
+ import { loadProviders, fetchChatEvents, applyChatEvents } from './chat-transport.js';
11
17
 
12
18
  const h = webjsx.createElement;
13
19
  const { Panel, Receipt, Chip, Icon } = components;
14
20
 
15
- function parseSseEvents(text) {
16
- const events = [];
17
- let curEvent = null, curData = '';
18
- for (const line of text.split('\n')) {
19
- if (line.startsWith('event: ')) { curEvent = line.slice(7).trim(); }
20
- else if (line.startsWith('data: ')) { curData = line.slice(6).trim(); }
21
- else if (line === '' && curEvent) {
22
- try { events.push({ event: curEvent, data: JSON.parse(curData) }); } catch { /* swallow: a malformed SSE event is dropped, the stream continues */ }
23
- curEvent = null; curData = '';
24
- }
25
- }
26
- return events;
27
- }
28
-
29
- // Convert the dashboard message shape into the kit ChatMessage shape:
30
- // { role:'user', content:string } -> { role:'user', text }
31
- // { role:'assistant', content:string } -> { role:'assistant', parts:[{kind:'md', text}] }
32
- // { role:'tool', name, argsSummary, content } ->
33
- // { role:'tool', parts:[{kind:'tool_call', name, label, args, result, status}] }
34
- // { role:'thinking' } -> { role:'thinking', parts:[{kind:'thinking', text}] }
35
- function toKitMessage(m) {
36
- if (m.role === 'tool') {
37
- const status = m.status || (m.error ? 'error' : (m.content != null ? 'done' : 'running'));
38
- return { role: 'tool', parts: [{ kind: 'tool_call', name: m.name || 'tool', label: m.argsSummary || '', args: m.args || m.input || {}, result: m.content, status, error: !!m.error, open: status !== 'done' }] };
39
- }
40
- if (m.role === 'thinking') return { role: 'thinking', parts: [{ kind: 'thinking', text: m.content || 'thinking…' }] };
41
- if (m.role === 'assistant' && m.content) return { role: 'assistant', parts: [{ kind: 'md', text: String(m.content) }] };
42
- return { role: m.role, text: m.content || '' };
43
- }
44
-
45
21
  export function makeChatPage(ctx) {
46
22
  return async function chat(h0) {
47
23
  const root = ctx.root;
48
24
  const skills = [...h0.pi.skills.values()];
49
- let providers = await fetch('/api/providers').then(r => r.json()).catch(() => []);
50
- if (!Array.isArray(providers)) providers = [];
51
- // Static deploy (no freddie-server, so /api/providers 404s): probe the
52
- // acptoapi gateway directly and, when it answers, surface it as a real
53
- // configured provider with its model list. Without this the dashboard
54
- // tells the user to "run a gateway" even though one is live and chat works.
55
- if (!providers.some(p => p.configured)) {
56
- try {
57
- const cfg = (window.__debug?.instances?.i1?.host?.fs?.readJson?.('/etc/freddie/freddie.json', null)) || {};
58
- const baseUrl = (cfg?.providers?.openai?.baseUrl || 'http://localhost:4800').replace(/\/+$/, '');
59
- const ac = new AbortController();
60
- const t = setTimeout(() => ac.abort(), 4000);
61
- const r = await fetch(baseUrl + '/v1/models', { signal: ac.signal }).catch(() => null);
62
- clearTimeout(t);
63
- if (r && r.ok) {
64
- const j = await r.json().catch(() => null);
65
- const models = Array.isArray(j?.data) ? j.data.map(m => m.id) : [];
66
- providers = [{ id: 'acptoapi', name: 'acptoapi gateway (' + baseUrl + ')', configured: true, models: ['auto', ...models] }, ...providers];
67
- }
68
- } catch { /* swallow: probing the local acptoapi gateway is opt-in best-effort, absence just means no providers shown */ }
69
- }
25
+ const providers = await loadProviders();
70
26
  const configuredProviders = providers.filter(p => p.configured);
71
27
 
72
28
  const chatState = window.__fd_chatState = window.__fd_chatState || {
@@ -107,134 +63,8 @@ export function makeChatPage(ctx) {
107
63
  syncMessages();
108
64
  renderPage();
109
65
  try {
110
- const body = { prompt: trimmed, cwd: chatState.cwd || undefined, skill: chatState.skill || undefined, provider: chatState.provider || undefined, model: chatState.model || undefined, sessionId: chatState.sessionId || undefined };
111
- let resp;
112
- try {
113
- resp = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: chatState.abort.signal });
114
- } catch (fetchErr) {
115
- resp = null;
116
- }
117
- let text = '';
118
- let events = [];
119
- if (resp && resp.ok) {
120
- text = await resp.text();
121
- events = parseSseEvents(text);
122
- } else if (typeof window !== 'undefined' && typeof window.__thebirdRunAgent === 'function') {
123
- // Static deploy with an in-page agent runtime (e.g. thebird): drive
124
- // the REAL multi-step agent loop (host tools + acptoapi gateway +
125
- // tool execution) instead of a single bare completion, so the model
126
- // emits tool_calls, the loop executes them, feeds results back, and
127
- // iterates. We synthesize the same {event:'message'} stream the
128
- // server path produces so the render loop below is unchanged. The
129
- // window global is the opt-in: hosts without it keep single-shot.
130
- try {
131
- let stepN = 0;
132
- const onUpdate = (snap) => {
133
- try {
134
- const msgs = (snap && snap.context && snap.context.messages) || [];
135
- const toolMsgs = msgs.filter(m => m.role === 'tool');
136
- const lastAssist = [...msgs].reverse().find(m => m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length);
137
- const running = lastAssist && lastAssist.tool_calls[0] && (lastAssist.tool_calls[0].function?.name || lastAssist.tool_calls[0].name);
138
- stepN = toolMsgs.length;
139
- chatState.progress = running
140
- ? ('agent: ' + running + ' (step ' + (stepN + 1) + ')…')
141
- : ('agent thinking' + (stepN ? ' (step ' + stepN + ')' : '') + '…');
142
- renderPage();
143
- } catch { /* swallow: progress-indicator update failing must not abort the agent run */ }
144
- };
145
- const out = await window.__thebirdRunAgent({ prompt: trimmed, onUpdate });
146
- const turnMsgs = (out && Array.isArray(out.messages)) ? out.messages : [];
147
- for (const m of turnMsgs) {
148
- if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
149
- const parts = [];
150
- if (m.content) parts.push({ type: 'text', text: String(m.content) });
151
- for (const tc of m.tool_calls) {
152
- const rawArgs = tc.function?.arguments ?? tc.arguments;
153
- let input = {};
154
- if (rawArgs && typeof rawArgs === 'object') input = rawArgs;
155
- else if (typeof rawArgs === 'string') { try { input = JSON.parse(rawArgs || '{}'); } catch { input = {}; } }
156
- parts.push({ type: 'tool_use', name: tc.function?.name || tc.name, input });
157
- }
158
- events.push({ event: 'message', data: { role: 'assistant', content: parts } });
159
- } else if (m.role === 'tool') {
160
- events.push({ event: 'message', data: { role: 'tool', content: [{ content: String(m.content ?? '') }] } });
161
- }
162
- }
163
- const finalText = (out && out.result) || (out && out.error ? 'error: ' + out.error : '');
164
- if (finalText) events.push({ event: 'message', data: { role: 'assistant', content: [{ type: 'text', text: String(finalText) }] } });
165
- if (!events.length) events.push({ event: 'message', data: { role: 'assistant', content: [{ type: 'text', text: '' }] } });
166
- } catch (e) {
167
- events = [{ event: 'error', data: { error: e?.message || String(e) } }];
168
- }
169
- } else {
170
- // Static deploy without an in-page agent runtime: single direct
171
- // acptoapi /v1/chat/completions call (no tool loop — one shot).
172
- const cfg = (window.__debug?.instances?.i1?.host?.fs?.readJson?.('/etc/freddie/freddie.json', null)) || {};
173
- const baseUrl = cfg?.providers?.openai?.baseUrl || 'http://localhost:4800';
174
- try {
175
- const url = baseUrl.replace(/\/+$/, '') + '/v1/chat/completions';
176
- const reqBody = { model: chatState.model || cfg?.providers?.openai?.model || 'auto', messages: [{ role: 'user', content: trimmed }] };
177
- const r2 = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(reqBody), signal: chatState.abort.signal });
178
- if (!r2.ok) {
179
- const errText = await r2.text().catch(() => '');
180
- events = [{ event: 'error', data: { error: 'acptoapi ' + r2.status + ': ' + errText.slice(0, 200) } }];
181
- } else {
182
- const j = await r2.json();
183
- const content = j?.choices?.[0]?.message?.content || '';
184
- const tool_calls = j?.choices?.[0]?.message?.tool_calls;
185
- const parts = [];
186
- if (content) parts.push({ type: 'text', text: content });
187
- if (Array.isArray(tool_calls)) {
188
- for (const tc of tool_calls) parts.push({ type: 'tool_use', name: tc.function?.name, input: (() => { try { return JSON.parse(tc.function?.arguments || '{}'); } catch { return {}; } })() });
189
- }
190
- events = [{ event: 'message', data: { role: 'assistant', content: parts.length ? parts : [{ type: 'text', text: '' }] } }];
191
- }
192
- } catch (e) {
193
- events = [{ event: 'error', data: { error: e?.message || String(e) } }];
194
- }
195
- }
196
- let assistantContent = '';
197
- for (const { event, data } of events) {
198
- if (event === 'start' && data.sessionId) chatState.sessionId = data.sessionId;
199
- if (event === 'done' && data.sessionId) chatState.sessionId = data.sessionId;
200
- if (event === 'message') {
201
- const role = data.role;
202
- if (role === 'assistant') {
203
- const content = Array.isArray(data.content) ? data.content : [{ type: 'text', text: String(data.content || '') }];
204
- for (const block of content) {
205
- if (block.type === 'text') assistantContent += block.text;
206
- if (block.type === 'tool_use') {
207
- if (assistantContent) { chatState.messages.push({ role: 'assistant', content: assistantContent }); assistantContent = ''; }
208
- const argsSummary = JSON.stringify(block.input || {}).slice(0, 60);
209
- chatState.messages.push({ role: 'tool', name: block.name, argsSummary, content: JSON.stringify(block.input || {}, null, 2), status: 'running' });
210
- syncMessages();
211
- }
212
- }
213
- } else if (role === 'tool') {
214
- const tc = Array.isArray(data.content) ? data.content[0] : data;
215
- // Resolve the last running tool call to done with this result.
216
- for (let i = chatState.messages.length - 1; i >= 0; i--) {
217
- const m = chatState.messages[i];
218
- if (m.role === 'tool' && m.status === 'running') {
219
- m.content = String(tc?.content || tc?.text || JSON.stringify(tc));
220
- m.status = 'done';
221
- break;
222
- }
223
- }
224
- syncMessages();
225
- }
226
- }
227
- if (event === 'done' && data.result) { if (!assistantContent) assistantContent = data.result; }
228
- if (event === 'error') {
229
- const msg = 'error: ' + (data.error || 'unknown');
230
- // Mark any running tool as errored; record assistant error.
231
- for (let i = chatState.messages.length - 1; i >= 0; i--) {
232
- const m = chatState.messages[i];
233
- if (m.role === 'tool' && m.status === 'running') { m.status = 'error'; m.error = true; m.content = msg; break; }
234
- }
235
- if (!assistantContent) assistantContent = msg;
236
- }
237
- }
66
+ const events = await fetchChatEvents(trimmed, chatState, renderPage);
67
+ const assistantContent = applyChatEvents(events, chatState, syncMessages);
238
68
  if (assistantContent) chatState.messages.push({ role: 'assistant', content: assistantContent });
239
69
  if (!events.length) chatState.messages.push({ role: 'assistant', content: '(no response)' });
240
70
  } catch (e) {
@@ -0,0 +1,163 @@
1
+ // Pure DOM builders for the desktop shell: theme-stylesheet injection, the
2
+ // icon span and button factories, and the four static chrome structures
3
+ // (menubar, apps menu, side rail, apps drawer) the shell wires behaviour onto.
4
+ // Nothing here closes over shell state — every function returns fresh nodes.
5
+
6
+ import { icons } from './icons.js';
7
+
8
+ const THEME_CSS_URL = new URL('./theme.css', import.meta.url).href;
9
+
10
+ export function ensureCss(href) {
11
+ if (document.querySelector('link[data-os-theme]')) return;
12
+ const l = document.createElement('link');
13
+ l.rel = 'stylesheet';
14
+ l.href = href || THEME_CSS_URL;
15
+ l.dataset.osTheme = '1';
16
+ document.head.appendChild(l);
17
+ }
18
+
19
+ export function ic(svg) {
20
+ const s = document.createElement('span');
21
+ s.className = 'ic';
22
+ s.innerHTML = svg;
23
+ return s;
24
+ }
25
+
26
+ export function makeBtn(svg, label, role) {
27
+ const b = document.createElement('button');
28
+ b.className = 'os-btn';
29
+ b.type = 'button';
30
+ if (role) b.dataset.role = role;
31
+ if (svg) b.append(ic(svg));
32
+ if (label) b.append(Object.assign(document.createElement('span'), { textContent: label }));
33
+ // Icon-only buttons (no visible label text) need an accessible name from
34
+ // somewhere; `role` ('home'/'add'/etc) is already a short human-readable
35
+ // word, so reuse it as aria-label rather than leaving the button unnamed.
36
+ if (!label && role) b.setAttribute('aria-label', role);
37
+ return b;
38
+ }
39
+
40
+ // The top menubar: home/brand/apps, an optional new-instance button, the
41
+ // instance switcher, and the tray clock. Returns every node the shell needs
42
+ // to bind listeners to or update later.
43
+ export function buildMenubar({ brand, withNewInstance }) {
44
+ const menubar = document.createElement('div');
45
+ menubar.className = 'os-menubar';
46
+ menubar.setAttribute('role', 'menubar');
47
+ menubar.setAttribute('aria-label', 'Desktop menu bar');
48
+
49
+ const homeBtn = makeBtn(icons.home, '', 'home');
50
+ homeBtn.title = 'apps';
51
+
52
+ const brandEl = document.createElement('span');
53
+ brandEl.className = 'os-brand';
54
+ brandEl.textContent = brand;
55
+
56
+ const appsBtn = makeBtn(icons.apps, 'apps', 'apps');
57
+ appsBtn.setAttribute('aria-haspopup', 'menu');
58
+ appsBtn.setAttribute('aria-expanded', 'false');
59
+ const newInstBtn = withNewInstance ? makeBtn(icons.plus, 'instance', 'add') : null;
60
+
61
+ const instSwitch = document.createElement('div');
62
+ instSwitch.className = 'os-instances';
63
+
64
+ const spacer = document.createElement('div');
65
+ spacer.className = 'os-spacer';
66
+
67
+ const tray = document.createElement('div');
68
+ tray.className = 'os-tray';
69
+ const clock = document.createElement('span');
70
+ clock.className = 'os-clock';
71
+ tray.appendChild(clock);
72
+
73
+ menubar.append(homeBtn, brandEl, appsBtn);
74
+ if (newInstBtn) menubar.append(newInstBtn);
75
+ menubar.append(instSwitch, spacer, tray);
76
+
77
+ return { menubar, homeBtn, appsBtn, newInstBtn, instSwitch, clock };
78
+ }
79
+
80
+ export function buildAppsMenu() {
81
+ const appsMenu = document.createElement('div');
82
+ appsMenu.className = 'os-menu';
83
+ appsMenu.setAttribute('role', 'menu');
84
+ appsMenu.setAttribute('aria-label', 'Apps');
85
+ return appsMenu;
86
+ }
87
+
88
+ export function buildSideRail() {
89
+ const sideRail = document.createElement('div');
90
+ sideRail.className = 'os-side-rail';
91
+ sideRail.setAttribute('role', 'navigation');
92
+ sideRail.setAttribute('aria-label', 'App launcher rail');
93
+ return sideRail;
94
+ }
95
+
96
+ // The full-screen apps drawer. Full-screen overlay that traps the user's
97
+ // attention while open — the dialog role + aria-modal + aria-labelledby give
98
+ // a screen reader the same "you are now in a dialog named X" announcement a
99
+ // sighted user gets from the visual takeover.
100
+ export function buildDrawer() {
101
+ const drawer = document.createElement('div');
102
+ drawer.className = 'os-drawer';
103
+ drawer.setAttribute('aria-hidden', 'true');
104
+ drawer.setAttribute('role', 'dialog');
105
+ drawer.setAttribute('aria-modal', 'true');
106
+ drawer.setAttribute('aria-labelledby', 'os-drawer-title');
107
+ const drawerHeader = document.createElement('div');
108
+ drawerHeader.className = 'os-drawer-head';
109
+ const drawerTitle = document.createElement('span');
110
+ drawerTitle.className = 'os-drawer-title';
111
+ drawerTitle.id = 'os-drawer-title';
112
+ drawerTitle.textContent = 'apps';
113
+ const drawerClose = document.createElement('button');
114
+ drawerClose.className = 'os-drawer-close';
115
+ drawerClose.type = 'button';
116
+ drawerClose.setAttribute('aria-label', 'Close apps drawer');
117
+ drawerClose.append(ic(icons.close));
118
+ drawerHeader.append(drawerTitle, drawerClose);
119
+ const drawerGrid = document.createElement('div');
120
+ drawerGrid.className = 'os-drawer-grid';
121
+ drawer.append(drawerHeader, drawerGrid);
122
+ return { drawer, drawerClose, drawerGrid };
123
+ }
124
+
125
+ // Taskbar contents are rebuilt on a 500ms poll whenever windows open/
126
+ // close/gain focus (refreshTaskbar in the shell); aria-live announces those
127
+ // additions/removals to a screen reader, which otherwise gets no signal
128
+ // that the running-window list changed. "polite" so it never interrupts.
129
+ export function buildTaskbar() {
130
+ const taskbar = document.createElement('div');
131
+ taskbar.className = 'os-taskbar';
132
+ taskbar.setAttribute('role', 'toolbar');
133
+ taskbar.setAttribute('aria-label', 'Open windows');
134
+ taskbar.setAttribute('aria-live', 'polite');
135
+ taskbar.setAttribute('aria-relevant', 'additions removals');
136
+ return taskbar;
137
+ }
138
+
139
+ // Per-app launcher entries: a menu item, a rail button, and a drawer tile,
140
+ // each wired to the caller's own open/close handlers.
141
+ export function buildAppEntries(app, { onMenuClick, onRailClick, onTileClick }) {
142
+ const iconSvg = app.icon || icons[app.id] || '';
143
+
144
+ const menuBtn = makeBtn(iconSvg, app.name);
145
+ menuBtn.setAttribute('role', 'menuitem');
146
+ menuBtn.addEventListener('click', onMenuClick);
147
+
148
+ const railBtn = document.createElement('button');
149
+ railBtn.className = 'os-rail-btn';
150
+ railBtn.type = 'button';
151
+ railBtn.title = app.name;
152
+ railBtn.setAttribute('aria-label', app.name);
153
+ railBtn.append(ic(iconSvg));
154
+ railBtn.addEventListener('click', onRailClick);
155
+
156
+ const tile = document.createElement('button');
157
+ tile.className = 'os-drawer-tile';
158
+ tile.type = 'button';
159
+ tile.append(ic(iconSvg), Object.assign(document.createElement('span'), { className: 'lbl', textContent: app.name }));
160
+ tile.addEventListener('click', onTileClick);
161
+
162
+ return { menuBtn, railBtn, tile };
163
+ }