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.
- package/dist/247420.css +33 -0
- package/dist/247420.js +53 -51
- package/package.json +1 -1
- package/src/components/agent-chat/controls.js +143 -0
- package/src/components/agent-chat/empty-state.js +63 -0
- package/src/components/agent-chat/message-rows.js +141 -0
- package/src/components/agent-chat/surface.js +206 -0
- package/src/components/agent-chat/thread-behaviour.js +50 -0
- package/src/components/agent-chat.js +6 -546
- package/src/components/chat/composer-affordances.js +109 -0
- package/src/components/chat/composer.js +256 -0
- package/src/components/chat/threads.js +105 -0
- package/src/components/chat-message-parts/agent-nodes.js +103 -0
- package/src/components/chat-message-parts/inline.js +106 -0
- package/src/components/chat-message-parts/prose-nodes.js +133 -0
- package/src/components/chat-message-parts/renderers.js +89 -0
- package/src/components/chat-message-parts.js +12 -408
- package/src/components/chat-minimap/minimap.js +167 -0
- package/src/components/chat-minimap/paint.js +73 -0
- package/src/components/chat-minimap/preview.js +41 -0
- package/src/components/chat-minimap.js +7 -263
- package/src/components/chat.js +20 -628
- package/src/components/community/chrome.js +63 -0
- package/src/components/community/navigation.js +167 -0
- package/src/components/community/presence.js +106 -0
- package/src/components/community/shell.js +17 -0
- package/src/components/community/views.js +131 -0
- package/src/components/community.js +18 -447
- package/src/components/files/chrome.js +140 -0
- package/src/components/files/entries.js +156 -0
- package/src/components/files/grid-controls.js +63 -0
- package/src/components/files/grid.js +177 -0
- package/src/components/files/types.js +69 -0
- package/src/components/files-modals/dialogs.js +118 -0
- package/src/components/files-modals/modal-shell.js +153 -0
- package/src/components/files-modals/preview-bodies.js +129 -0
- package/src/components/files-modals/preview-containers.js +96 -0
- package/src/components/files-modals.js +14 -475
- package/src/components/files.js +15 -564
- package/src/components/freddie/pages-config.js +3 -94
- package/src/components/freddie/pages-models.js +97 -0
- package/src/components/freddie.js +2 -1
- package/src/components/interaction-primitives/mobile.js +25 -0
- package/src/components/interaction-primitives/pointer.js +214 -0
- package/src/components/interaction-primitives/reorderable.js +47 -0
- package/src/components/interaction-primitives/shortcuts.js +119 -0
- package/src/components/interaction-primitives.js +16 -388
- package/src/components/sessions/conversation-list.js +230 -0
- package/src/components/sessions/dashboard.js +202 -0
- package/src/components/sessions/detail-bits.js +49 -0
- package/src/components/sessions/format.js +42 -0
- package/src/components/sessions/session-card.js +92 -0
- package/src/components/sessions.js +16 -579
- package/src/components/voice/audio-cue.js +39 -0
- package/src/components/voice/capture.js +90 -0
- package/src/components/voice/playback.js +75 -0
- package/src/components/voice/settings-modal.js +104 -0
- package/src/components/voice.js +16 -293
- package/src/css/app-shell/base.css +23 -0
- package/src/css/app-shell/states-interactions.css +10 -0
- package/src/kits/os/freddie/chat-protocol.js +32 -0
- package/src/kits/os/freddie/chat-transport.js +178 -0
- package/src/kits/os/freddie/pages-chat.js +10 -180
- package/src/kits/os/shell-chrome.js +163 -0
- package/src/kits/os/shell-geometry.js +59 -0
- package/src/kits/os/shell.js +178 -335
- package/src/page-html/client-script.js +151 -0
- package/src/page-html/head-tags.js +59 -0
- package/src/page-html/markdown.js +68 -0
- package/src/page-html/page-styles.js +44 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
111
|
-
|
|
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
|
+
}
|