anentrypoint-design 0.0.174 → 0.0.176

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.174",
3
+ "version": "0.0.176",
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",
@@ -105,10 +105,50 @@ function CodeNode(p) {
105
105
  );
106
106
  }
107
107
 
108
+ // Freddie-flavored agent parts: collapsible tool-call card, tool-result, and
109
+ // transient thinking indicator. Each renders as a `chat-bubble` variant so the
110
+ // surrounding ChatMessage chrome (avatar/meta/reactions) stays consistent.
111
+ function ToolCallNode(p) {
112
+ const status = p.status || (p.error ? 'error' : (p.result != null ? 'done' : 'running'));
113
+ const argsText = typeof p.args === 'string' ? p.args : JSON.stringify(p.args || {}, null, 2);
114
+ const resultText = p.result == null ? '' : (typeof p.result === 'string' ? p.result : JSON.stringify(p.result, null, 2));
115
+ // Default-open while running or on error so the user sees live progress / failure detail;
116
+ // collapse on success unless the caller explicitly overrides with open:true.
117
+ const defaultOpen = p.open != null ? !!p.open : (status === 'running' || status === 'error');
118
+ const iconName = status === 'running' ? 'refresh' : (status === 'error' ? 'warn' : 'check');
119
+ return h('details', { class: 'chat-bubble chat-tool tool-' + status, open: defaultOpen },
120
+ h('summary', { class: 'chat-tool-head' },
121
+ h('span', { class: 'chat-tool-icon', 'aria-hidden': 'true' }, Icon(iconName, { size: 14 })),
122
+ h('span', { class: 'chat-tool-name' }, p.name || 'tool'),
123
+ p.label ? h('span', { class: 'chat-tool-label' }, p.label) : null,
124
+ h('span', { class: 'chat-tool-status' }, status)
125
+ ),
126
+ h('div', { class: 'chat-tool-body' },
127
+ h('div', { class: 'chat-tool-section' },
128
+ h('div', { class: 'chat-tool-section-label' }, 'args'),
129
+ h('pre', { class: 'chat-tool-pre' }, h('code', {}, argsText))),
130
+ resultText ? h('div', { class: 'chat-tool-section' },
131
+ h('div', { class: 'chat-tool-section-label' }, p.error ? 'error' : 'result'),
132
+ h('pre', { class: 'chat-tool-pre' + (p.error ? ' is-error' : '') }, h('code', {}, resultText))) : null
133
+ )
134
+ );
135
+ }
136
+
137
+ function ThinkingNode(p) {
138
+ return h('div', { class: 'chat-bubble chat-thinking', role: 'status', 'aria-live': 'polite' },
139
+ h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
140
+ h('span', { class: 'chat-thinking-text' }, p.text || 'thinking…')
141
+ );
142
+ }
143
+
108
144
  const PART_RENDERERS = {
109
145
  text: (p) => h('div', { class: 'chat-bubble' }, ...renderInline(p.text || '')),
110
146
  md: (p) => MdNode(p),
111
147
  code: (p) => CodeNode(p),
148
+ tool: (p) => ToolCallNode(p),
149
+ tool_call: (p) => ToolCallNode(p),
150
+ tool_result: (p) => ToolCallNode({ ...p, name: p.name || 'tool_result', result: p.text != null ? p.text : p.result }),
151
+ thinking: (p) => ThinkingNode(p),
112
152
  image: (p) => h('a', { class: 'chat-image', href: p.href || p.src, target: '_blank', rel: 'noopener', 'aria-label': p.alt || `embedded image: ${p.src}` },
113
153
  h('img', { src: p.src, alt: p.alt || `embedded image from ${p.src}`, loading: 'lazy' }),
114
154
  p.caption ? h('span', { class: 'cap' }, p.caption) : null),
@@ -146,9 +186,20 @@ function renderPart(p, key) {
146
186
 
147
187
  export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name }) {
148
188
  _stats.messages += 1;
149
- // Support legacy 'who' prop, prefer 'role' with mapping: 'user' <-> 'you', 'assistant' <-> 'them'
150
- const resolvedWho = role ? (role === 'user' ? 'you' : role === 'assistant' ? 'them' : role) : who;
151
- const cls = 'chat-msg ' + resolvedWho + (aicat && resolvedWho === 'them' ? ' aicat' : '');
189
+ // Support legacy 'who' prop, prefer 'role' with mapping:
190
+ // 'user' -> 'you' (right-aligned, accent bubble)
191
+ // 'assistant' -> 'them' (left-aligned, paper bubble)
192
+ // 'system' -> 'system' (centered, italic muted)
193
+ // 'tool' -> 'tool' (centered, collapsible card chrome)
194
+ // 'thinking' -> 'thinking' (centered, transient typing dots)
195
+ const resolvedWho = role
196
+ ? (role === 'user' ? 'you'
197
+ : role === 'assistant' ? 'them'
198
+ : (role === 'system' || role === 'tool' || role === 'thinking') ? role
199
+ : role)
200
+ : who;
201
+ const isCentered = resolvedWho === 'system' || resolvedWho === 'tool' || resolvedWho === 'thinking';
202
+ const cls = 'chat-msg ' + resolvedWho + (aicat && resolvedWho === 'them' ? ' aicat' : '') + (isCentered ? ' centered' : '');
152
203
  const fallbackAvatar = avatar != null
153
204
  ? avatar
154
205
  : (resolvedWho === 'you' ? 'u' : (name ? String(name).trim().charAt(0).toUpperCase() || '?' : '?'));
@@ -171,10 +222,14 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
171
222
  if (tickNode) metaItems.push(tickNode);
172
223
  const meta = metaItems.length ? h('div', { class: 'chat-meta' }, ...metaItems) : null;
173
224
  const stack = h('div', { class: 'chat-stack' }, ...bodyNodes, reactionRow, meta);
225
+ // Centered roles (system/tool/thinking) skip the avatar column entirely so
226
+ // the bubble owns the full row — the chrome reads as out-of-band signal,
227
+ // not a participant turn.
228
+ if (isCentered) return h('div', { key, class: cls }, stack);
174
229
  return h('div', { key, class: cls }, resolvedWho === 'you' ? stack : av, resolvedWho === 'you' ? av : stack);
175
230
  }
176
231
 
177
- export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, placeholder = 'message…', disabled }) {
232
+ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled }) {
178
233
  // Keep a handle to the live textarea so send() reads the actual DOM value
179
234
  // (not the possibly-lagging `value` prop) and so we can sync the DOM value
180
235
  // only when it genuinely differs — re-applying `value` on every parent
@@ -220,12 +275,14 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
220
275
  onAttach ? h('button', { type: 'button', class: 'composer-btn', onclick: (e) => { e.preventDefault(); onAttach(e); }, 'aria-label': 'attach file', title: 'attach file' }, Icon('paperclip')) : null,
221
276
  onEmoji ? h('button', { type: 'button', class: 'composer-btn', onclick: (e) => { e.preventDefault(); onEmoji(e); }, 'aria-label': 'emoji picker', title: 'emoji picker (Ctrl+;)' }, Icon('smile')) : null,
222
277
  onMenu ? h('button', { type: 'button', class: 'composer-btn', onclick: (e) => { e.preventDefault(); onMenu(e); }, 'aria-label': 'composer menu', title: 'more options' }, Icon('more-horizontal')) : null,
223
- h('button', { type: 'button', class: 'send', disabled: disabled || !(value && value.trim()), onclick: send, 'aria-label': 'send message', title: 'send message (Enter)' }, Icon('arrow-up'))
278
+ busy && onCancel
279
+ ? h('button', { type: 'button', class: 'send cancel', onclick: (e) => { e.preventDefault(); onCancel(e); }, 'aria-label': 'stop generating', title: 'stop generating (Esc)' }, Icon('square'))
280
+ : h('button', { type: 'button', class: 'send', disabled: disabled || !(value && value.trim()), onclick: send, 'aria-label': 'send message', title: 'send message (Enter)' }, Icon('arrow-up'))
224
281
  )
225
282
  );
226
283
  }
227
284
 
228
- export function Chat({ title = 'chat', sub, messages = [], composer, header } = {}) {
285
+ export function Chat({ title = 'chat', sub, messages = [], composer, header, suggestions, onSuggestionClick } = {}) {
229
286
  // Warm markdown/Prism caches once so library loading parallelizes.
230
287
  ensureCachesInit();
231
288
  const threadRef = makeThreadAutoScroll(() => messages.length);
@@ -243,8 +300,14 @@ export function Chat({ title = 'chat', sub, messages = [], composer, header } =
243
300
  h('div', { class: 'chat-thread', ref: threadRef, role: 'log', 'aria-label': 'chat messages' },
244
301
  messages.length === 0
245
302
  ? h('div', { key: '_empty', class: 'chat-empty', role: 'status' },
246
- h('p', { class: 'chat-empty-title' }, 'no messages yet'),
247
- h('p', { class: 'chat-empty-sub' }, 'start the conversation'))
303
+ h('p', { class: 'chat-empty-title' }, 'start a conversation'),
304
+ h('p', { class: 'chat-empty-sub' }, sub || 'ask anything — i can search, read files, recall context, and call tools'),
305
+ (suggestions && suggestions.length)
306
+ ? h('div', { class: 'chat-empty-suggestions' },
307
+ ...suggestions.map((s, i) => h('button', { key: 'sug' + i, type: 'button', class: 'chat-empty-suggestion',
308
+ onclick: () => { if (onSuggestionClick) onSuggestionClick(typeof s === 'string' ? s : (s.prompt || s.text || '')); } },
309
+ typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
310
+ : null)
248
311
  : null,
249
312
  ...messages.map((m, i) => ChatMessage({ ...m, key: m.key != null ? m.key : i }))
250
313
  ),
@@ -272,6 +272,14 @@ export function SplitPanel({ orientation = 'horizontal', initial = '50%', min =
272
272
  const sizeProp = isH ? 'width' : 'height';
273
273
  const initStyle = typeof initial === 'number' ? initial + 'px' : initial;
274
274
  let rootEl = null;
275
+ // The dragged size is persisted here so a re-render (applyDiff reconciling
276
+ // the pane's style back to the initial value) does NOT reset the user's
277
+ // resize. onResize records it; the pane's ref re-applies it after each diff.
278
+ let draggedSize = null;
279
+ const applySize = (a) => {
280
+ if (!a) return;
281
+ if (draggedSize != null) { a.style[sizeProp] = draggedSize + 'px'; a.style.flex = '0 0 auto'; }
282
+ };
275
283
  const onResize = (delta) => {
276
284
  if (!rootEl) return;
277
285
  const a = rootEl.firstChild;
@@ -280,6 +288,7 @@ export function SplitPanel({ orientation = 'horizontal', initial = '50%', min =
280
288
  const curr = isH ? rect.width : rect.height;
281
289
  const total = isH ? rootEl.getBoundingClientRect().width : rootEl.getBoundingClientRect().height;
282
290
  const next = Math.max(min, Math.min(max === Infinity ? total - min : max, curr + delta));
291
+ draggedSize = next;
283
292
  a.style[sizeProp] = next + 'px';
284
293
  a.style.flex = '0 0 auto';
285
294
  };
@@ -287,7 +296,7 @@ export function SplitPanel({ orientation = 'horizontal', initial = '50%', min =
287
296
  class: 'ds-ep-split ' + (isH ? 'horiz' : 'vert'),
288
297
  ref: (el) => { rootEl = el; }
289
298
  },
290
- h('div', { class: 'ds-ep-split-pane', style: '--split-size:' + initStyle + ';flex:0 0 auto' }, first),
299
+ h('div', { class: 'ds-ep-split-pane', style: '--split-size:' + initStyle + ';flex:0 0 auto', ref: applySize }, first),
291
300
  ResizeHandle({ axis: isH ? 'horizontal' : 'vertical', onResize }),
292
301
  h('div', { class: 'ds-ep-split-pane grow', style: 'flex:1 1 0;min-' + sizeProp + ':0' }, second)
293
302
  );
@@ -89,6 +89,56 @@ export function useDraggable(el, { data, kind, onDragStart, onDragEnd } = {}) {
89
89
  }};
90
90
  }
91
91
 
92
+ // useNumberScrub — pointer-event horizontal drag-to-scrub on a numeric input.
93
+ // Pointer Events (mouse+touch+pen+XR), touch-action:none so a vertical page
94
+ // scroll never steals the gesture. Click-to-edit is preserved: a press that
95
+ // does not cross SCRUB_THRESHOLD leaves the input focusable for typing.
96
+ export function useNumberScrub(el, { getValue, onChange, step = 0.01, threshold = 3 } = {}) {
97
+ if (!el) return { destroy() {} };
98
+ el.style.touchAction = 'none';
99
+ let pid = null, startX = 0, startV = 0, moved = false;
100
+ const onMove = (e) => {
101
+ if (pid == null) return;
102
+ const dx = e.clientX - startX;
103
+ if (!moved) {
104
+ if (Math.abs(dx) < threshold) return;
105
+ moved = true;
106
+ el.setAttribute('data-scrubbing', 'true');
107
+ if (document.activeElement === el) el.blur();
108
+ }
109
+ const v = startV + dx * step;
110
+ if (onChange) onChange(v);
111
+ };
112
+ const onUp = () => {
113
+ if (pid == null) return;
114
+ try { el.releasePointerCapture(pid); } catch {}
115
+ pid = null;
116
+ el.removeAttribute('data-scrubbing');
117
+ window.removeEventListener('pointermove', onMove);
118
+ window.removeEventListener('pointerup', onUp);
119
+ window.removeEventListener('pointercancel', onUp);
120
+ };
121
+ const onDown = (e) => {
122
+ if (e.button != null && e.button !== 0) return;
123
+ // Let a focused input handle caret placement / text selection instead.
124
+ if (document.activeElement === el) return;
125
+ pid = e.pointerId; startX = e.clientX; moved = false;
126
+ const cur = getValue ? getValue() : parseFloat(el.value);
127
+ startV = Number.isFinite(cur) ? cur : 0;
128
+ try { el.setPointerCapture(pid); } catch {}
129
+ window.addEventListener('pointermove', onMove);
130
+ window.addEventListener('pointerup', onUp);
131
+ window.addEventListener('pointercancel', onUp);
132
+ };
133
+ el.addEventListener('pointerdown', onDown);
134
+ return { destroy() {
135
+ el.removeEventListener('pointerdown', onDown);
136
+ window.removeEventListener('pointermove', onMove);
137
+ window.removeEventListener('pointerup', onUp);
138
+ window.removeEventListener('pointercancel', onUp);
139
+ }};
140
+ }
141
+
92
142
  export function useDropTarget(el, { accepts = [], onDrop, onDragOver } = {}) {
93
143
  if (!el) return { destroy() {} };
94
144
  el.setAttribute('data-drop-target', '');
package/src/components.js CHANGED
@@ -58,7 +58,7 @@ export {
58
58
  } from './components/form-primitives.js';
59
59
 
60
60
  export {
61
- useDraggable, useDropTarget, Reorderable,
61
+ useDraggable, useDropTarget, useNumberScrub, Reorderable,
62
62
  useKeyboardShortcut, formatShortcut, ShortcutHint,
63
63
  useKeyboardShortcutHelp, ShortcutHelpDialog
64
64
  } from './components/interaction-primitives.js';
@@ -1,7 +1,13 @@
1
- // Chat page — its own module because of SSE plumbing weight.
1
+ // Chat page — dashboard surface for the agent. Renders via the kit's
2
+ // Chat + ChatComposer primitives so the dashboard tab and the OS chat panel
3
+ // share the same bubble / tool-call / empty-state chrome. The bespoke
4
+ // cwd/skill/provider/model selectors live in a collapsible config strip
5
+ // above the thread (mirror of the OS panel cc-strip pattern).
6
+
2
7
  import * as webjsx from '../../../../vendor/webjsx/index.js';
3
8
  import * as components from '../../../components.js';
4
- import { getRecentPaths, saveRecentPath, skillLabel, renderChatMessages } from './helpers.js';
9
+ import { getRecentPaths, saveRecentPath, skillLabel } from './helpers.js';
10
+ import { Chat, ChatComposer } from '../../../components/chat.js';
5
11
 
6
12
  const h = webjsx.createElement;
7
13
  const { Panel, Receipt, Chip, Icon } = components;
@@ -20,6 +26,22 @@ function parseSseEvents(text) {
20
26
  return events;
21
27
  }
22
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
+
23
45
  export function makeChatPage(ctx) {
24
46
  return async function chat(h0) {
25
47
  const root = ctx.root;
@@ -28,34 +50,44 @@ export function makeChatPage(ctx) {
28
50
  const configuredProviders = providers.filter(p => p.configured);
29
51
 
30
52
  const chatState = window.__fd_chatState = window.__fd_chatState || {
31
- cwd: '', skill: '', provider: '', model: '', messages: [], busy: false, sessionId: null,
53
+ cwd: '', skill: '', provider: '', model: '', messages: [], busy: false, sessionId: null, draft: '', abort: null,
32
54
  };
33
55
  if (!chatState.cwd) chatState.cwd = (getRecentPaths()[0] || '');
34
56
 
35
- const getMsgsContainer = () => root.querySelector('#fd-chat-msgs');
57
+ // Find the chat container in the live DOM (set on the rendered <ds-chat>).
58
+ const getChatHost = () => root.querySelector('ds-chat.fd-dashboard-chat');
59
+ const syncMessages = () => {
60
+ const host = getChatHost();
61
+ if (host) host.messages = chatState.messages.map(toKitMessage);
62
+ };
36
63
 
37
64
  const newSession = () => {
38
65
  if (chatState.busy) return;
39
66
  chatState.messages = [];
40
67
  chatState.sessionId = null;
41
- renderChatMessages(getMsgsContainer(), chatState.messages);
68
+ syncMessages();
69
+ };
70
+
71
+ const cancelInFlight = () => {
72
+ if (chatState.abort) { try { chatState.abort.abort(); } catch {} chatState.abort = null; }
73
+ chatState.busy = false;
74
+ syncMessages();
75
+ renderPage();
42
76
  };
43
77
 
44
- const sendChat = async (ev) => {
45
- ev.preventDefault();
78
+ const sendChat = async (prompt) => {
46
79
  if (chatState.busy) return;
47
- const promptEl = ev.target.elements.prompt;
48
- const prompt = promptEl.value.trim();
49
- if (!prompt) return;
50
- chatState.messages.push({ role: 'user', content: prompt });
51
- promptEl.value = '';
52
- promptEl.style.height = 'auto';
80
+ const trimmed = String(prompt || '').trim();
81
+ if (!trimmed) return;
82
+ chatState.messages.push({ role: 'user', content: trimmed });
53
83
  chatState.busy = true;
84
+ chatState.abort = new AbortController();
54
85
  saveRecentPath(chatState.cwd);
55
- renderChatMessages(getMsgsContainer(), chatState.messages);
86
+ syncMessages();
87
+ renderPage();
56
88
  try {
57
- const body = { prompt, cwd: chatState.cwd || undefined, skill: chatState.skill || undefined, provider: chatState.provider || undefined, model: chatState.model || undefined, sessionId: chatState.sessionId || undefined };
58
- const resp = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
89
+ const body = { prompt: trimmed, cwd: chatState.cwd || undefined, skill: chatState.skill || undefined, provider: chatState.provider || undefined, model: chatState.model || undefined, sessionId: chatState.sessionId || undefined };
90
+ const resp = await fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: chatState.abort.signal });
59
91
  const text = await resp.text();
60
92
  const events = parseSseEvents(text);
61
93
  let assistantContent = '';
@@ -71,63 +103,110 @@ export function makeChatPage(ctx) {
71
103
  if (block.type === 'tool_use') {
72
104
  if (assistantContent) { chatState.messages.push({ role: 'assistant', content: assistantContent }); assistantContent = ''; }
73
105
  const argsSummary = JSON.stringify(block.input || {}).slice(0, 60);
74
- chatState.messages.push({ role: 'tool', name: block.name, argsSummary, content: JSON.stringify(block.input || {}, null, 2) });
106
+ chatState.messages.push({ role: 'tool', name: block.name, argsSummary, content: JSON.stringify(block.input || {}, null, 2), status: 'running' });
107
+ syncMessages();
75
108
  }
76
109
  }
77
110
  } else if (role === 'tool') {
78
111
  const tc = Array.isArray(data.content) ? data.content[0] : data;
79
- chatState.messages.push({ role: 'tool', name: 'result', argsSummary: '', content: String(tc?.content || tc?.text || JSON.stringify(tc)) });
112
+ // Resolve the last running tool call to done with this result.
113
+ for (let i = chatState.messages.length - 1; i >= 0; i--) {
114
+ const m = chatState.messages[i];
115
+ if (m.role === 'tool' && m.status === 'running') {
116
+ m.content = String(tc?.content || tc?.text || JSON.stringify(tc));
117
+ m.status = 'done';
118
+ break;
119
+ }
120
+ }
121
+ syncMessages();
80
122
  }
81
123
  }
82
124
  if (event === 'done' && data.result) { if (!assistantContent) assistantContent = data.result; }
83
- if (event === 'error') assistantContent = 'error: ' + (data.error || 'unknown');
125
+ if (event === 'error') {
126
+ const msg = 'error: ' + (data.error || 'unknown');
127
+ // Mark any running tool as errored; record assistant error.
128
+ for (let i = chatState.messages.length - 1; i >= 0; i--) {
129
+ const m = chatState.messages[i];
130
+ if (m.role === 'tool' && m.status === 'running') { m.status = 'error'; m.error = true; m.content = msg; break; }
131
+ }
132
+ if (!assistantContent) assistantContent = msg;
133
+ }
84
134
  }
85
135
  if (assistantContent) chatState.messages.push({ role: 'assistant', content: assistantContent });
86
136
  if (!events.length) chatState.messages.push({ role: 'assistant', content: '(no response)' });
87
137
  } catch (e) {
88
- chatState.messages.push({ role: 'assistant', content: 'error: ' + e.message });
138
+ if (e.name === 'AbortError') chatState.messages.push({ role: 'assistant', content: '[cancelled]' });
139
+ else chatState.messages.push({ role: 'assistant', content: 'error: ' + e.message });
89
140
  }
141
+ chatState.abort = null;
90
142
  chatState.busy = false;
91
- renderChatMessages(getMsgsContainer(), chatState.messages);
143
+ syncMessages();
144
+ renderPage();
92
145
  };
93
146
 
94
147
  const recentPaths = getRecentPaths();
95
148
  const datalistId = 'fd-cwd-list';
96
149
  const byCat = skills.reduce((a, s) => { const c = s.category || 'other'; (a[c] = a[c] || []).push(s); return a; }, {});
97
150
 
98
- setTimeout(() => renderChatMessages(getMsgsContainer(), chatState.messages), 50);
151
+ const renderPage = () => {
152
+ const host = getChatHost();
153
+ if (host) {
154
+ host.busy = chatState.busy;
155
+ host.placeholder = chatState.busy ? 'agent working…' : 'describe what you want to do in the working directory…';
156
+ }
157
+ // Refresh disabled state on header buttons.
158
+ const newBtn = root.querySelector('.fd-chat-new');
159
+ if (newBtn) newBtn.disabled = !!chatState.busy;
160
+ const cancelBtn = root.querySelector('.fd-chat-cancel');
161
+ if (cancelBtn) cancelBtn.style.display = chatState.busy ? '' : 'none';
162
+ };
163
+
164
+ // After mount, seed messages onto the ds-chat element + wire the send event.
165
+ setTimeout(() => {
166
+ const host = getChatHost();
167
+ if (host && !host._fdBound) {
168
+ host._fdBound = true;
169
+ host.addEventListener('send', (e) => { sendChat(e.detail && e.detail.text); });
170
+ host.messages = chatState.messages.map(toKitMessage);
171
+ host.placeholder = 'describe what you want to do in the working directory…';
172
+ host.sub = chatState.sessionId ? ('session ' + chatState.sessionId.slice(0, 8)) : 'agent';
173
+ }
174
+ renderPage();
175
+ }, 50);
99
176
 
100
177
  const selSkill = h('select', { name: 'skill', onchange: (ev) => { chatState.skill = ev.target.value; } },
101
- h('option', { value: '' }, 'no skill'),
178
+ h('option', { value: '' }, 'no skill'),
102
179
  ...Object.entries(byCat).map(([cat, ss]) =>
103
180
  h('optgroup', { label: cat },
104
181
  ...ss.map(s => h('option', { value: s.name, selected: chatState.skill === s.name ? 'true' : null, title: s.description || s.name }, skillLabel(s)))
105
182
  )));
106
183
 
107
184
  const selProv = h('select', { name: 'provider', onchange: (ev) => { chatState.provider = ev.target.value; } },
108
- h('option', { value: '' }, configuredProviders.length ? 'auto' : 'no providers configured'),
185
+ h('option', { value: '' }, configuredProviders.length ? 'auto' : 'no providers configured'),
109
186
  ...configuredProviders.map(p => h('option', { value: p.name, selected: chatState.provider === p.name ? 'true' : null }, (p.available ? '(on) ' : '(off) ') + p.name)));
110
187
 
111
188
  return [
112
189
  Panel({
113
190
  title: 'chat',
114
- right: h('button', { class: 'btn-primary fd-btn-mini', onclick: (ev) => { ev.preventDefault(); newSession(); }, disabled: chatState.busy ? 'true' : null }, '+ new session'),
191
+ right: h('div', { class: 'fd-chat-actions' },
192
+ h('button', { class: 'btn-secondary fd-btn-mini fd-chat-cancel', style: chatState.busy ? '' : 'display:none', onclick: (ev) => { ev.preventDefault(); cancelInFlight(); } }, 'cancel'),
193
+ h('button', { class: 'btn-primary fd-btn-mini fd-chat-new', onclick: (ev) => { ev.preventDefault(); newSession(); }, disabled: chatState.busy ? 'true' : null }, 'new session')
194
+ ),
115
195
  children: [
116
196
  h('datalist', { id: datalistId }, ...recentPaths.map(p => h('option', { value: p }))),
117
- h('form', { class: 'row-form fd-chat-form', onsubmit: sendChat },
118
- h('div', { class: 'fd-chat-field' },
197
+ h('div', { class: 'fd-chat-config' },
198
+ h('div', { class: 'fd-chat-field fd-chat-field-grow' },
119
199
  h('label', {}, 'working directory'),
120
200
  h('input', { name: 'cwd', type: 'text', placeholder: 'e.g. C:/dev/myproject or /home/user/project', value: chatState.cwd, list: datalistId, oninput: (ev) => { chatState.cwd = ev.target.value; } })),
121
201
  h('div', { class: 'fd-chat-row' },
122
202
  h('div', { class: 'fd-chat-field fd-chat-field-grow' }, h('label', {}, 'skill'), selSkill),
123
203
  h('div', { class: 'fd-chat-field fd-chat-field-grow' }, h('label', {}, 'provider'), selProv),
124
204
  h('div', { class: 'fd-chat-field fd-chat-field-grow' }, h('label', {}, 'model (optional)'),
125
- h('input', { name: 'model', type: 'text', placeholder: configuredProviders.find(p => p.name === chatState.provider)?.defaultModel || 'default', value: chatState.model, oninput: (ev) => { chatState.model = ev.target.value; } }))),
126
- h('div', { class: 'fd-chat-submit' },
127
- h('textarea', { name: 'prompt', placeholder: 'describe what you want to do in the working directory…', rows: 4,
128
- oninput: (ev) => { ev.target.style.height = 'auto'; ev.target.style.height = Math.min(ev.target.scrollHeight, 240) + 'px'; } }),
129
- h('button', { type: 'submit', class: 'btn-primary', disabled: chatState.busy ? 'true' : null }, chatState.busy ? '…' : 'send'))),
130
- h('div', { id: 'fd-chat-msgs', class: 'fd-chatlog' }),
205
+ h('input', { name: 'model', type: 'text', placeholder: configuredProviders.find(p => p.name === chatState.provider)?.defaultModel || 'default', value: chatState.model, oninput: (ev) => { chatState.model = ev.target.value; } })))),
206
+ // Live chat surface — the kit's ds-chat web component handles
207
+ // layout, scroll, empty state, bubble chrome, tool-call cards,
208
+ // composer focus rings, send/cancel button swap, etc.
209
+ h('ds-chat', { class: 'fd-dashboard-chat ds-247420', title: 'chat', placeholder: 'describe what you want to do in the working directory…' }),
131
210
  ],
132
211
  }),
133
212
  configuredProviders.length === 0
@@ -52,3 +52,21 @@
52
52
  .fd-chip-wrap { display: flex; flex-wrap: wrap; gap: var(--space-1, 4px); }
53
53
  .fd-chip-wrap-padded { padding: var(--space-2, 8px) var(--space-1, 4px); }
54
54
  .fd-env-chip { cursor: pointer; }
55
+
56
+ /* dashboard chat tab — config strip above + <ds-chat> thread below
57
+ --------------------------------------------------------------------- */
58
+ .fd-chat-config { display: flex; flex-direction: column; gap: var(--space-2, 8px); padding-bottom: var(--space-3, 12px); border-bottom: 1px solid color-mix(in oklab, var(--fg) 10%, transparent); margin-bottom: var(--space-3, 12px); }
59
+ .fd-chat-config .fd-chat-field { display: flex; flex-direction: column; gap: var(--space-1, 4px); min-width: 120px; }
60
+ .fd-chat-config .fd-chat-field > label { font-family: var(--os-mono, monospace); font-size: 10px; opacity: 0.6; letter-spacing: 0.05em; text-transform: uppercase; }
61
+ .fd-chat-config .fd-chat-field > input,
62
+ .fd-chat-config .fd-chat-field > select { width: 100%; box-sizing: border-box; padding: 6px 8px; background: var(--panel-1, transparent); color: var(--fg, inherit); border: 1px solid color-mix(in oklab, var(--fg) 14%, transparent); border-radius: 6px; font: inherit; font-size: 12px; }
63
+ .fd-chat-config .fd-chat-field > input:focus-visible,
64
+ .fd-chat-config .fd-chat-field > select:focus-visible { outline: 2px solid var(--os-accent, #247420); outline-offset: 0; border-color: color-mix(in oklab, var(--os-accent, #247420) 60%, transparent); }
65
+ .fd-chat-config .fd-chat-row { display: flex; gap: var(--space-2, 8px); flex-wrap: wrap; }
66
+ .fd-chat-config .fd-chat-field-grow { flex: 1 1 140px; min-width: 0; }
67
+
68
+ .app-fd ds-chat.fd-dashboard-chat { flex: 1 1 auto; min-height: 280px; display: flex; flex-direction: column; overflow: hidden; }
69
+
70
+ .fd-chat-actions { display: inline-flex; gap: var(--space-1, 4px); align-items: center; }
71
+ .fd-chat-actions .btn-secondary { background: transparent; color: var(--danger, #c0392b); border: 1px solid color-mix(in oklab, var(--danger, #c0392b) 40%, transparent); cursor: pointer; padding: 2px 8px; border-radius: 4px; font: inherit; font-size: 12px; }
72
+ .fd-chat-actions .btn-secondary:hover { background: color-mix(in oklab, var(--danger, #c0392b) 10%, transparent); }