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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Composer side-affordances that live in the DOM rather than the vnode tree:
|
|
2
|
+
// the queued transient note strip, the detected-attachment badge, the
|
|
3
|
+
// coarse-pointer probe that decides Enter-to-send vs Enter-for-newline, and
|
|
4
|
+
// the ticking m:ss streaming counter.
|
|
5
|
+
|
|
6
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
7
|
+
const h = webjsx.createElement;
|
|
8
|
+
|
|
9
|
+
// Transient, non-blocking composer note (aria-live polite): e.g. a pasted image
|
|
10
|
+
// when no onPasteFiles handler is wired. Pure-DOM, auto-clears.
|
|
11
|
+
export function flashComposerNote(composerEl, text) {
|
|
12
|
+
if (!composerEl) return;
|
|
13
|
+
let note = composerEl.querySelector('.chat-composer-note');
|
|
14
|
+
if (!note) {
|
|
15
|
+
note = document.createElement('div');
|
|
16
|
+
note.className = 'chat-composer-note';
|
|
17
|
+
note.setAttribute('role', 'status');
|
|
18
|
+
note.setAttribute('aria-live', 'polite');
|
|
19
|
+
composerEl.appendChild(note);
|
|
20
|
+
}
|
|
21
|
+
// A single shared node means a second call before the first note's timeout
|
|
22
|
+
// fires used to silently overwrite it (lost message, not just an early
|
|
23
|
+
// dismiss). Queue instead: show immediately if idle, otherwise append and
|
|
24
|
+
// let the display loop drain the queue in order.
|
|
25
|
+
note._dsNoteQueue = note._dsNoteQueue || [];
|
|
26
|
+
note._dsNoteQueue.push(text);
|
|
27
|
+
if (note._dsNoteTimer) return; // already draining the queue
|
|
28
|
+
const showNext = () => {
|
|
29
|
+
const next = note._dsNoteQueue.shift();
|
|
30
|
+
if (next === undefined) { note.remove(); note._dsNoteTimer = null; return; }
|
|
31
|
+
note.textContent = next;
|
|
32
|
+
note._dsNoteTimer = setTimeout(showNext, 2600);
|
|
33
|
+
};
|
|
34
|
+
showNext();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Cached once per session: coarse-pointer (touch/no-hover) devices get a
|
|
38
|
+
// newline on Enter instead of send (mirrors the one-time-cache pattern
|
|
39
|
+
// editor-primitives.js uses for its own pointer/matchMedia checks).
|
|
40
|
+
let _coarsePointerCache = null;
|
|
41
|
+
export function isCoarsePointer() {
|
|
42
|
+
if (_coarsePointerCache != null) return _coarsePointerCache;
|
|
43
|
+
_coarsePointerCache = !!(typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
|
|
44
|
+
return _coarsePointerCache;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// m:ss elapsed-time formatter for the streaming counter.
|
|
48
|
+
function fmtElapsedMs(ms) {
|
|
49
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
50
|
+
const m = Math.floor(total / 60);
|
|
51
|
+
const s = total % 60;
|
|
52
|
+
return m + ':' + String(s).padStart(2, '0');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A small ticking `m:ss` counter, keyed off the streamingSince timestamp so a
|
|
56
|
+
// parent re-render does not reset the interval — the ref only (re)starts the
|
|
57
|
+
// interval when the timestamp actually changes, and clears it when streaming
|
|
58
|
+
// goes false or the node unmounts.
|
|
59
|
+
export function ChatComposerElapsed({ streamingSince }) {
|
|
60
|
+
return h('span', {
|
|
61
|
+
class: 'chat-composer-elapsed', role: 'status', 'aria-live': 'off',
|
|
62
|
+
ref: (el) => {
|
|
63
|
+
if (!el) return;
|
|
64
|
+
if (el._dsElapsedTimer && el._dsElapsedSince === streamingSince) return; // already ticking for this timestamp
|
|
65
|
+
if (el._dsElapsedTimer) clearInterval(el._dsElapsedTimer);
|
|
66
|
+
el._dsElapsedSince = streamingSince;
|
|
67
|
+
const tick = () => { el.textContent = fmtElapsedMs(Date.now() - streamingSince); };
|
|
68
|
+
tick();
|
|
69
|
+
el._dsElapsedTimer = setInterval(tick, 1000);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// detectAttachment(text) -> {type,label,id} runs on every input change; the
|
|
75
|
+
// badge above the textarea shows/clears based on its result, and clears
|
|
76
|
+
// outright when the textarea empties (mirrors the dismissible-badge pattern
|
|
77
|
+
// via a DOM-owned dismissed flag so a re-render with the same detection
|
|
78
|
+
// doesn't resurrect a badge the user just dismissed).
|
|
79
|
+
export function updateDetectedBadge(composerEl, text, detectAttachment) {
|
|
80
|
+
if (!composerEl) return;
|
|
81
|
+
let badge = composerEl.querySelector('.chat-composer-detected-badge');
|
|
82
|
+
const detected = (text && detectAttachment) ? detectAttachment(text) : null;
|
|
83
|
+
if (!detected) {
|
|
84
|
+
if (badge) badge.remove();
|
|
85
|
+
composerEl._dsDetectedId = null;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (composerEl._dsDismissedId === detected.id) return; // user dismissed this exact detection
|
|
89
|
+
if (composerEl._dsDetectedId === detected.id && badge) return; // unchanged
|
|
90
|
+
composerEl._dsDetectedId = detected.id;
|
|
91
|
+
if (!badge) {
|
|
92
|
+
badge = document.createElement('div');
|
|
93
|
+
badge.className = 'chat-composer-detected-badge';
|
|
94
|
+
badge.setAttribute('role', 'status');
|
|
95
|
+
composerEl.insertBefore(badge, composerEl.firstChild);
|
|
96
|
+
}
|
|
97
|
+
badge.textContent = '';
|
|
98
|
+
const label = document.createElement('span');
|
|
99
|
+
label.className = 'chat-composer-detected-label';
|
|
100
|
+
label.textContent = detected.label;
|
|
101
|
+
badge.appendChild(label);
|
|
102
|
+
const dismiss = document.createElement('button');
|
|
103
|
+
dismiss.type = 'button';
|
|
104
|
+
dismiss.className = 'chat-composer-detected-dismiss';
|
|
105
|
+
dismiss.setAttribute('aria-label', 'dismiss ' + detected.label);
|
|
106
|
+
dismiss.textContent = 'x';
|
|
107
|
+
dismiss.onclick = (e) => { e.preventDefault(); composerEl._dsDismissedId = detected.id; badge.remove(); };
|
|
108
|
+
badge.appendChild(dismiss);
|
|
109
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// ChatComposer — the message input: controlled textarea with caret-safe value
|
|
2
|
+
// sync and auto-grow, the inline `:emoji` and `@file` autocomplete triggers,
|
|
3
|
+
// paste/drop file routing, the optional context line, and the send/stop
|
|
4
|
+
// toolbar. DOM-side affordances (notes, detected badge, elapsed counter,
|
|
5
|
+
// pointer probe) live in ./composer-affordances.js.
|
|
6
|
+
|
|
7
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
8
|
+
import { Icon } from '../shell.js';
|
|
9
|
+
import { EmojiPicker, CommandPalette } from '../overlay-primitives.js';
|
|
10
|
+
import { extractAtQuery, filterFileEntries, buildAtInsertText } from '../../file-mention.js';
|
|
11
|
+
import { flashComposerNote, isCoarsePointer, ChatComposerElapsed, updateDetectedBadge } from './composer-affordances.js';
|
|
12
|
+
|
|
13
|
+
const h = webjsx.createElement;
|
|
14
|
+
|
|
15
|
+
// Matches a trailing `:keyword` at the end of the composer draft (optionally
|
|
16
|
+
// preceded by whitespace/start-of-string) so typing `:smile` opens an inline
|
|
17
|
+
// filtered EmojiPicker without requiring the toolbar button or Ctrl+;.
|
|
18
|
+
const EMOJI_TRIGGER_RE = /(?:^|\s)(:([a-zA-Z0-9_+-]{0,24}))$/;
|
|
19
|
+
|
|
20
|
+
export function ChatComposer({ value, onInput, onSend, onEmoji, onCancel, busy, placeholder = 'message…', disabled, disabledReason, label, context, onPasteFiles, onDropFiles, streamingSince, detectAttachment, mentionFiles }) {
|
|
21
|
+
// Keep a handle to the live textarea so send() reads the actual DOM value
|
|
22
|
+
// (not the possibly-lagging `value` prop) and so we can sync the DOM value
|
|
23
|
+
// only when it genuinely differs — re-applying `value` on every parent
|
|
24
|
+
// re-render otherwise resets the caret and drops fast keystrokes.
|
|
25
|
+
let taEl = null;
|
|
26
|
+
const send = () => {
|
|
27
|
+
const v = ((taEl && taEl.value) || value || '').trim();
|
|
28
|
+
if (!v || disabled) return;
|
|
29
|
+
if (onSend) onSend(v);
|
|
30
|
+
};
|
|
31
|
+
const triggerMatch = EMOJI_TRIGGER_RE.exec(value || '');
|
|
32
|
+
// `@`-file-mention autocomplete: host supplies mentionFiles (a flat list of
|
|
33
|
+
// {path,isDir} entries, or a plain string[] of paths — filterFileEntries
|
|
34
|
+
// normalizes both), we own detection/filtering/insertion. Caret-position-
|
|
35
|
+
// aware (extractAtQuery needs the text BEFORE the caret, not the whole
|
|
36
|
+
// draft) so an "@" earlier in an already-sent-past part of the text doesn't
|
|
37
|
+
// re-trigger after the cursor has moved on.
|
|
38
|
+
const caretPos = taEl ? taEl.selectionStart : (value || '').length;
|
|
39
|
+
const atQuery = mentionFiles ? extractAtQuery((value || '').slice(0, caretPos)) : null;
|
|
40
|
+
// taEl is only assigned by taRef during DOM diffing, which happens AFTER
|
|
41
|
+
// this render function returns — so on first paint of a trigger it is
|
|
42
|
+
// still null here. Fall back to the live DOM textarea from the previous
|
|
43
|
+
// paint (same composer, content patched in place) so the picker anchors
|
|
44
|
+
// near the input instead of the viewport origin.
|
|
45
|
+
const anchorEl = taEl || (typeof document !== 'undefined' ? document.querySelector('.chat-composer textarea') : null);
|
|
46
|
+
const insertEmoji = (ch) => {
|
|
47
|
+
const v = (taEl && taEl.value) || value || '';
|
|
48
|
+
const m = EMOJI_TRIGGER_RE.exec(v);
|
|
49
|
+
const next = m ? (v.slice(0, m.index) + (m[0].startsWith(':') ? '' : v[m.index]) + ch + ' ') : (v + ch);
|
|
50
|
+
if (onInput) onInput(next);
|
|
51
|
+
if (taEl) {
|
|
52
|
+
// Programmatic .value= (like the draft-restore path) discards the
|
|
53
|
+
// native undo stack the same way — surface that once, matching the
|
|
54
|
+
// existing one-time draft-restore note pattern.
|
|
55
|
+
try {
|
|
56
|
+
if (!sessionStorage.getItem('ds.composer.undoNoteShown')) {
|
|
57
|
+
sessionStorage.setItem('ds.composer.undoNoteShown', '1');
|
|
58
|
+
flashComposerNote(taEl.closest('.chat-composer'), 'inserted — undo history does not include this insert');
|
|
59
|
+
}
|
|
60
|
+
} catch { /* swallow: sessionStorage unavailable (private mode etc) — skip the note */ }
|
|
61
|
+
taEl.value = next;
|
|
62
|
+
taEl.focus();
|
|
63
|
+
taEl.selectionStart = taEl.selectionEnd = next.length;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
let autoGrowScheduled = false;
|
|
67
|
+
const autoGrow = (e) => {
|
|
68
|
+
const ta = e.target;
|
|
69
|
+
if (onInput) onInput(ta.value);
|
|
70
|
+
if (detectAttachment) updateDetectedBadge(ta.closest('.chat-composer'), ta.value, detectAttachment);
|
|
71
|
+
// Debounce scrollHeight read with rAF to prevent sync reflow thrashing
|
|
72
|
+
if (!autoGrowScheduled) {
|
|
73
|
+
autoGrowScheduled = true;
|
|
74
|
+
requestAnimationFrame(() => {
|
|
75
|
+
ta.style.height = 'auto';
|
|
76
|
+
// Respect the CSS max-height cap (120px in short-landscape via
|
|
77
|
+
// app-shell.css) instead of a hardcoded 200px.
|
|
78
|
+
const cap = parseFloat(getComputedStyle(ta).maxHeight) || 200;
|
|
79
|
+
ta.style.height = Math.min(ta.scrollHeight, cap) + 'px';
|
|
80
|
+
autoGrowScheduled = false;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const taRef = (el) => {
|
|
85
|
+
if (!el) return;
|
|
86
|
+
taEl = el;
|
|
87
|
+
// Sync the controlled value into the DOM only when it actually differs,
|
|
88
|
+
// so a re-render mid-type does not clobber the caret or pending input.
|
|
89
|
+
const next = value || '';
|
|
90
|
+
if (el.value !== next) el.value = next;
|
|
91
|
+
el.style.height = 'auto';
|
|
92
|
+
const cap = parseFloat(getComputedStyle(el).maxHeight) || 200;
|
|
93
|
+
el.style.height = Math.min(el.scrollHeight, cap) + 'px';
|
|
94
|
+
if (detectAttachment) updateDetectedBadge(el.closest('.chat-composer'), next, detectAttachment);
|
|
95
|
+
};
|
|
96
|
+
// Optional context line shown above the textarea: agent / model / cwd at the
|
|
97
|
+
// point of typing (the way Claude-Desktop surfaces the active target inline).
|
|
98
|
+
// `context` is { bits:[...], onClick? }. Bits may be plain strings (inert
|
|
99
|
+
// text) or { text, onClick, title } objects — a bit with its own onClick
|
|
100
|
+
// renders as an inline button (.chat-composer-context-bit) so e.g. the cwd
|
|
101
|
+
// segment routes to the cwd editor WITHOUT making the whole line one giant
|
|
102
|
+
// click target. Legacy whole-line context.onClick is honored only when no
|
|
103
|
+
// bit carries its own handler. All children are keyed VElements.
|
|
104
|
+
// Normalize FIRST (a bit may carry `text` or `label`), then drop empties -
|
|
105
|
+
// separators must only ever sit between bits that actually render. An
|
|
106
|
+
// object bit whose text resolved empty used to leave a dangling trailing
|
|
107
|
+
// middot AND an invisible zero-width button.
|
|
108
|
+
const ctxBits = ((context && context.bits) ? context.bits : [])
|
|
109
|
+
.map((b) => {
|
|
110
|
+
if (b == null) return null;
|
|
111
|
+
if (typeof b === 'object') {
|
|
112
|
+
const text = b.text || b.label || '';
|
|
113
|
+
return text ? { text, onClick: b.onClick, title: b.title } : null;
|
|
114
|
+
}
|
|
115
|
+
const text = String(b);
|
|
116
|
+
return text ? { text } : null;
|
|
117
|
+
})
|
|
118
|
+
.filter(Boolean);
|
|
119
|
+
const hasBitClicks = ctxBits.some((b) => b.onClick);
|
|
120
|
+
let contextLine = null;
|
|
121
|
+
if (ctxBits.length && hasBitClicks) {
|
|
122
|
+
const kids = [];
|
|
123
|
+
ctxBits.forEach((b, i) => {
|
|
124
|
+
if (i) kids.push(h('span', { key: 'csep' + i, class: 'chat-composer-context-sep', 'aria-hidden': 'true' }, ' · '));
|
|
125
|
+
if (b.onClick) kids.push(h('button', {
|
|
126
|
+
key: 'cbit' + i, type: 'button', class: 'chat-composer-context-bit',
|
|
127
|
+
title: b.title || null, 'aria-label': b.title || b.text,
|
|
128
|
+
onclick: (e) => { e.preventDefault(); b.onClick(e); },
|
|
129
|
+
}, b.text));
|
|
130
|
+
else kids.push(h('span', { key: 'cbit' + i, class: 'chat-composer-context-text' }, b.text));
|
|
131
|
+
});
|
|
132
|
+
contextLine = h('div', { class: 'chat-composer-context', role: 'group', 'aria-label': 'active session: ' + ctxBits.map((b) => b.text).join(', ') }, ...kids);
|
|
133
|
+
} else if (ctxBits.length) {
|
|
134
|
+
const joined = ctxBits.map((b) => b.text).join(' · ');
|
|
135
|
+
contextLine = h(context.onClick ? 'button' : 'div', {
|
|
136
|
+
class: 'chat-composer-context', type: context.onClick ? 'button' : null,
|
|
137
|
+
'aria-label': context.onClick ? ('change target: ' + joined) : null,
|
|
138
|
+
onclick: context.onClick ? (e) => { e.preventDefault(); context.onClick(e); } : null,
|
|
139
|
+
}, joined);
|
|
140
|
+
}
|
|
141
|
+
const hasDraft = !!(value && value.trim());
|
|
142
|
+
// Clamp the picker anchor to the visual viewport: with the on-screen
|
|
143
|
+
// keyboard open the composer sits near the visual-viewport bottom, and on
|
|
144
|
+
// narrow screens the picker width can overflow the right edge.
|
|
145
|
+
const anchorRect = (anchorEl && anchorEl.getBoundingClientRect) ? anchorEl.getBoundingClientRect() : null;
|
|
146
|
+
const vvWidth = (typeof window !== 'undefined')
|
|
147
|
+
? ((window.visualViewport && window.visualViewport.width) || window.innerWidth)
|
|
148
|
+
: 0;
|
|
149
|
+
const triggerPicker = triggerMatch ? EmojiPicker({
|
|
150
|
+
open: true,
|
|
151
|
+
anchorX: anchorRect ? Math.max(0, Math.min(anchorRect.left, vvWidth - 280)) : 0,
|
|
152
|
+
anchorY: anchorRect ? Math.max(8, anchorRect.top - 8) : 0,
|
|
153
|
+
query: triggerMatch[2] || '',
|
|
154
|
+
onSelect: (ch) => insertEmoji(ch),
|
|
155
|
+
onClose: () => { if (taEl) { const v = taEl.value.replace(EMOJI_TRIGGER_RE, (full, tail) => full.slice(0, full.length - tail.length)); if (onInput) onInput(v); taEl.value = v; taEl.focus(); } },
|
|
156
|
+
}) : null;
|
|
157
|
+
// insertMention: replace the in-progress @token (atQuery.start..caretPos)
|
|
158
|
+
// with the built mention text, mirroring insertEmoji's DOM-authoritative
|
|
159
|
+
// read/write-back so the native undo stack isn't clobbered any more than
|
|
160
|
+
// the existing emoji path already accepts.
|
|
161
|
+
const insertMention = (entry) => {
|
|
162
|
+
const v = (taEl && taEl.value) || value || '';
|
|
163
|
+
if (!atQuery) return;
|
|
164
|
+
// buildAtInsertText returns {text, cursorOffset} — cursorOffset is
|
|
165
|
+
// relative to the start of the inserted text, matching the emoji
|
|
166
|
+
// path's absolute-caret style once added to atQuery.start.
|
|
167
|
+
const { text: insertText, cursorOffset } = buildAtInsertText(entry.path, entry.isDir);
|
|
168
|
+
const next = v.slice(0, atQuery.start) + insertText + v.slice(caretPos);
|
|
169
|
+
if (onInput) onInput(next);
|
|
170
|
+
if (taEl) {
|
|
171
|
+
taEl.value = next;
|
|
172
|
+
taEl.focus();
|
|
173
|
+
const pos = atQuery.start + cursorOffset;
|
|
174
|
+
taEl.selectionStart = taEl.selectionEnd = pos;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const mentionEntries = atQuery ? filterFileEntries(mentionFiles, atQuery.query) : [];
|
|
178
|
+
const mentionPicker = atQuery ? CommandPalette({
|
|
179
|
+
open: true,
|
|
180
|
+
items: mentionEntries.map((e) => ({ label: e.path, group: null, icon: e.isDir ? '📁' : null, _entry: e })),
|
|
181
|
+
onSelect: (it) => insertMention(it._entry),
|
|
182
|
+
onClose: () => { if (taEl) { const v = taEl.value.slice(0, atQuery.start) + taEl.value.slice(caretPos); if (onInput) onInput(v); taEl.value = v; taEl.focus(); taEl.selectionStart = taEl.selectionEnd = atQuery.start; } },
|
|
183
|
+
}) : null;
|
|
184
|
+
return h('div', {
|
|
185
|
+
class: 'chat-composer' + (hasDraft ? ' has-draft' : '') + (disabled ? ' is-disabled' : ''),
|
|
186
|
+
// A drop on the composer must NEVER navigate the browser away from the
|
|
187
|
+
// live session: preventDefault on both dragover and drop, route files to
|
|
188
|
+
// the optional onDropFiles handler, ring via .dragover.
|
|
189
|
+
ondragover: (e) => { e.preventDefault(); e.currentTarget.classList.add('dragover'); },
|
|
190
|
+
ondragleave: (e) => { e.currentTarget.classList.remove('dragover'); },
|
|
191
|
+
ondrop: (e) => {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
e.currentTarget.classList.remove('dragover');
|
|
194
|
+
const files = e.dataTransfer && e.dataTransfer.files;
|
|
195
|
+
if (files && files.length) {
|
|
196
|
+
if (onDropFiles) onDropFiles(files);
|
|
197
|
+
else flashComposerNote(e.currentTarget, 'dropped files are not supported here yet');
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
contextLine,
|
|
202
|
+
triggerPicker,
|
|
203
|
+
mentionPicker,
|
|
204
|
+
h('textarea', { ref: taRef, placeholder, rows: 1,
|
|
205
|
+
'aria-label': label || (disabled && disabledReason ? 'message input — ' + disabledReason : 'message input'),
|
|
206
|
+
disabled: !!disabled, 'aria-disabled': disabled ? 'true' : null,
|
|
207
|
+
oninput: autoGrow,
|
|
208
|
+
onpaste: (e) => {
|
|
209
|
+
const cd = e.clipboardData;
|
|
210
|
+
// If the clipboard contains files, always route them — even when
|
|
211
|
+
// text is also present (some apps attach a filename as text).
|
|
212
|
+
if (cd && cd.files && cd.files.length) {
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
if (onPasteFiles) onPasteFiles(cd.files);
|
|
215
|
+
else flashComposerNote(e.currentTarget.closest('.chat-composer'), 'images are not supported yet');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// Large plain-text pastes (e.g. a whole file/log dropped into the
|
|
219
|
+
// composer) get no feedback otherwise — the textarea just grows to
|
|
220
|
+
// its max-height cap with no signal of how much landed. Note the
|
|
221
|
+
// character count; this does not change any truncation behavior.
|
|
222
|
+
const text = cd && cd.getData ? cd.getData('text/plain') : '';
|
|
223
|
+
if (text && text.length > 2000) {
|
|
224
|
+
flashComposerNote(e.currentTarget.closest('.chat-composer'), 'pasted ' + text.length + ' characters');
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
onkeydown: (e) => {
|
|
228
|
+
// Escape blurs the textarea when idle; stops generation when busy.
|
|
229
|
+
if (e.key === 'Escape') {
|
|
230
|
+
if (!busy) { e.currentTarget.blur(); return; }
|
|
231
|
+
if (onCancel) { e.preventDefault(); onCancel(e); return; }
|
|
232
|
+
}
|
|
233
|
+
// IME guard: the Enter that commits a CJK composition must never
|
|
234
|
+
// send (isComposing; keyCode 229 covers older engines).
|
|
235
|
+
// Coarse-pointer (touch) devices get a newline on Enter instead of
|
|
236
|
+
// send — there is no keyboard shortcut discoverability benefit on
|
|
237
|
+
// touch, and Enter-to-send is a frequent accidental-send source on
|
|
238
|
+
// phones/tablets where "Tap Send to send" is the predictable model.
|
|
239
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229 && !isCoarsePointer()) { e.preventDefault(); send(); }
|
|
240
|
+
if (e.key === ';' && e.ctrlKey) { e.preventDefault(); onEmoji && onEmoji(e); }
|
|
241
|
+
} }),
|
|
242
|
+
// Enter-to-send affordance (Claude-Desktop style): a muted hint visible
|
|
243
|
+
// at rest so it's discoverable without focusing the composer first;
|
|
244
|
+
// hidden under 420px (CSS) to save rows. Middot is kept product typography.
|
|
245
|
+
h('div', { class: 'chat-composer-hint' }, isCoarsePointer() ? 'Tap Send to send' : 'Enter to send · Shift+Enter for a new line'),
|
|
246
|
+
(busy && streamingSince) ? ChatComposerElapsed({ streamingSince }) : null,
|
|
247
|
+
h('div', { class: 'chat-composer-toolbar' },
|
|
248
|
+
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,
|
|
249
|
+
busy && onCancel
|
|
250
|
+
? h('button', { type: 'button', class: 'send cancel', onclick: (e) => { e.preventDefault(); onCancel(e); }, 'aria-label': 'stop generating', title: 'stop generating (Esc)' }, Icon('square'))
|
|
251
|
+
: h('button', { type: 'button', class: 'send', disabled: disabled || !(value && value.trim()), onclick: send,
|
|
252
|
+
'aria-label': disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message',
|
|
253
|
+
title: disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message (Enter)' }, Icon('arrow-up'))
|
|
254
|
+
)
|
|
255
|
+
);
|
|
256
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Thread containers: Chat (the standard header + log + composer surface),
|
|
2
|
+
// AICat / AICatPortrait (the ascii-mascot variant), and ChatSuggestions (the
|
|
3
|
+
// blank-thread composer-priming CTA).
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { t } from '../../i18n.js';
|
|
7
|
+
import { ChatMessage } from './message.js';
|
|
8
|
+
import { makeThreadAutoScroll } from './thread-scroll.js';
|
|
9
|
+
import { ensureCachesInit } from './stats.js';
|
|
10
|
+
|
|
11
|
+
const h = webjsx.createElement;
|
|
12
|
+
|
|
13
|
+
// ChatSuggestions — centered blank-thread heading + subtext + a wrapped row
|
|
14
|
+
// of prompt chips that fill the composer textarea on click and auto-dismiss
|
|
15
|
+
// on first send. Ported from docstudio's empty-state composer-priming CTA
|
|
16
|
+
// (distinct from a generic list EmptyState: this exists to seed a first
|
|
17
|
+
// message, not to describe an empty list). `onPick(prompt, item)` is the
|
|
18
|
+
// caller's single hook — the component does not touch the composer DOM
|
|
19
|
+
// itself, so the host decides how "fill the composer" actually happens.
|
|
20
|
+
// A rapid double-click on the same chip (or a click racing the first send)
|
|
21
|
+
// is guarded by a one-shot `_picked` flag: only the first click of any kind
|
|
22
|
+
// dispatches onPick, so the composer is never filled twice and the chips
|
|
23
|
+
// never reappear having already been "used".
|
|
24
|
+
export function ChatSuggestions({ heading = 'What can I help with?', subtext = '', suggestions = [] } = {}) {
|
|
25
|
+
let picked = false;
|
|
26
|
+
return h('div', { class: 'chat-suggestions', role: 'group', 'aria-label': heading },
|
|
27
|
+
h('h2', { class: 'chat-suggestions-heading' }, heading),
|
|
28
|
+
subtext ? h('p', { class: 'chat-suggestions-subtext' }, subtext) : null,
|
|
29
|
+
h('div', { class: 'chat-suggestions-list' },
|
|
30
|
+
...suggestions.map((s, i) => h('button', {
|
|
31
|
+
key: s.id || i, type: 'button', class: 'chat-suggestions-chip',
|
|
32
|
+
onclick: () => { if (picked) return; picked = true; s.onPick ? s.onPick(s) : null; }
|
|
33
|
+
}, s.label))
|
|
34
|
+
)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Chat({ title = 'chat', sub, messages = [], composer, header, suggestions, onSuggestionClick } = {}) {
|
|
39
|
+
// Warm markdown/Prism caches once so library loading parallelizes.
|
|
40
|
+
ensureCachesInit();
|
|
41
|
+
const threadRef = makeThreadAutoScroll(() => messages.length);
|
|
42
|
+
const msgCount = messages.length;
|
|
43
|
+
return h('div', { class: 'chat' },
|
|
44
|
+
header || h('div', { class: 'chat-head', role: 'banner' },
|
|
45
|
+
h('h2', { class: 'ds-chat-title' }, title),
|
|
46
|
+
sub ? h('span', { class: 'sub', 'aria-label': `subtitle: ${sub}` }, ' · ' + sub) : null,
|
|
47
|
+
h('span', { class: 'spread' }),
|
|
48
|
+
msgCount > 0
|
|
49
|
+
? h('span', { class: 'sub', 'aria-live': 'polite' }, msgCount + (msgCount === 1 ? ' message' : ' messages'))
|
|
50
|
+
: null
|
|
51
|
+
),
|
|
52
|
+
h('div', { class: 'chat-thread', ref: threadRef, role: 'log', 'aria-label': 'chat messages', 'aria-live': 'polite', 'aria-relevant': 'additions' },
|
|
53
|
+
messages.length === 0
|
|
54
|
+
? h('div', { key: '_empty', class: 'chat-empty', role: 'status' },
|
|
55
|
+
h('p', { class: 'chat-empty-title' }, t('chat.startConversation', 'start a conversation')),
|
|
56
|
+
h('p', { class: 'chat-empty-sub' }, sub || t('chat.emptySub', 'Send a message to start the conversation')),
|
|
57
|
+
(suggestions && suggestions.length)
|
|
58
|
+
? h('div', { class: 'chat-empty-suggestions' },
|
|
59
|
+
...suggestions.map((s, i) => h('button', { key: 'sug' + i, type: 'button', class: 'chat-empty-suggestion',
|
|
60
|
+
onclick: () => { if (onSuggestionClick) onSuggestionClick(typeof s === 'string' ? s : (s.prompt || s.text || '')); } },
|
|
61
|
+
typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
|
|
62
|
+
: null)
|
|
63
|
+
: null,
|
|
64
|
+
...messages.map((m, i) => ChatMessage({ ...m, key: m.key != null ? m.key : i }))
|
|
65
|
+
),
|
|
66
|
+
composer || null
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const AICAT_FACE = ` /\\_/\\\n( o.o )\n > ^ <`;
|
|
71
|
+
|
|
72
|
+
export function AICatPortrait({ name = 'aicat', status = 'idle', face } = {}) {
|
|
73
|
+
return h('div', { class: 'aicat-portrait' },
|
|
74
|
+
h('pre', { class: 'aicat-face', 'aria-label': `${name} portrait` }, face || AICAT_FACE),
|
|
75
|
+
h('div', { class: 'aicat-meta' },
|
|
76
|
+
h('span', { class: 'name' }, name),
|
|
77
|
+
h('span', { class: 'status', 'aria-label': `status: ${status}` }, h('span', { class: 'dot ds-dot ds-dot-on', 'aria-hidden': 'true' }), ' ', status)
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function AICat({ name = 'aicat', messages = [], thinking, composer, status = 'online · purring' } = {}) {
|
|
83
|
+
ensureCachesInit();
|
|
84
|
+
const annotated = messages.map((m) =>
|
|
85
|
+
m.who === 'them' ? { ...m, aicat: true, avatar: m.avatar || '=^.^=' } : m);
|
|
86
|
+
const all = thinking
|
|
87
|
+
? [...annotated, { who: 'them', aicat: true, avatar: '=^.^=', typing: true, key: '_thinking' }]
|
|
88
|
+
: annotated;
|
|
89
|
+
const threadRef = makeThreadAutoScroll(() => all.length);
|
|
90
|
+
return h('div', { class: 'chat' },
|
|
91
|
+
h('div', { class: 'chat-head', role: 'banner' },
|
|
92
|
+
h('span', { class: 'dot', 'aria-hidden': 'true' }),
|
|
93
|
+
h('h2', { class: 'ds-chat-title' }, name),
|
|
94
|
+
h('span', { class: 'sub', 'aria-label': `status: ${status}` }, ' · ' + status),
|
|
95
|
+
h('span', { class: 'spread' }),
|
|
96
|
+
messages.length > 0
|
|
97
|
+
? h('span', { class: 'sub', 'aria-live': 'polite' }, messages.length + (messages.length === 1 ? ' turn' : ' turns'))
|
|
98
|
+
: null
|
|
99
|
+
),
|
|
100
|
+
h('div', { class: 'chat-thread', ref: threadRef, role: 'log', 'aria-label': 'conversation turns', 'aria-live': 'polite', 'aria-relevant': 'additions' },
|
|
101
|
+
...all.map((m, i) => ChatMessage({ ...m, key: m.key != null ? m.key : i }))
|
|
102
|
+
),
|
|
103
|
+
composer || null
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Agent-turn bubbles: the collapsible tool-call card (with per-section copy,
|
|
2
|
+
// stringify caching, and unified-diff detection that routes a patch-shaped
|
|
3
|
+
// result through GitDiffView) and the transient/settled thinking indicator.
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { Icon } from '../shell.js';
|
|
7
|
+
import { t } from '../../i18n.js';
|
|
8
|
+
import { GitDiffView } from '../git-status.js';
|
|
9
|
+
import { copyToClipboardWithFeedback } from './inline.js';
|
|
10
|
+
|
|
11
|
+
const h = webjsx.createElement;
|
|
12
|
+
|
|
13
|
+
// A tool result reads as a unified diff when it has at least one `@@ ... @@`
|
|
14
|
+
// hunk header and a +/- line — cheap enough to check on every render (no
|
|
15
|
+
// caching) since it only runs once per settled tool card, not per rAF tick.
|
|
16
|
+
function looksLikeUnifiedDiff(text) {
|
|
17
|
+
if (!text || text.indexOf('@@') === -1) return false;
|
|
18
|
+
return /^@@ .* @@/m.test(text) && /^[+-]/m.test(text);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Pull a filename out of a unified diff's `+++ b/path` (or `--- a/path`)
|
|
22
|
+
// header line, for the GitDiffView head label — best-effort, no filename is
|
|
23
|
+
// fine (GitDiffView renders headerless).
|
|
24
|
+
function filenameFromDiff(text) {
|
|
25
|
+
const m = /^\+\+\+ b?\/?(.+)$/m.exec(text) || /^--- a?\/?(.+)$/m.exec(text);
|
|
26
|
+
return m ? m[1].trim() : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Freddie-flavored agent parts: collapsible tool-call card, tool-result, and
|
|
30
|
+
// transient thinking indicator. Each renders as a `chat-bubble` variant so the
|
|
31
|
+
// surrounding ChatMessage chrome (avatar/meta/reactions) stays consistent.
|
|
32
|
+
export function ToolCallNode(p) {
|
|
33
|
+
const status = p.status || (p.error ? 'error' : (p.result != null ? 'done' : 'running'));
|
|
34
|
+
// Args/result are re-stringified on every rAF re-render while any part of
|
|
35
|
+
// the turn is streaming, even for collapsed cards whose own args/result
|
|
36
|
+
// haven't changed since the last frame. Cache by identity on the part
|
|
37
|
+
// object itself so an unchanged args/result skips the stringify.
|
|
38
|
+
if (p._argsCache !== p.args) {
|
|
39
|
+
p._argsTextCache = typeof p.args === 'string' ? p.args : JSON.stringify(p.args || {}, null, 2);
|
|
40
|
+
p._argsCache = p.args;
|
|
41
|
+
}
|
|
42
|
+
const argsText = p._argsTextCache;
|
|
43
|
+
if (p._resultCache !== p.result) {
|
|
44
|
+
p._resultTextCache = p.result == null ? '' : (typeof p.result === 'string' ? p.result : JSON.stringify(p.result, null, 2));
|
|
45
|
+
p._resultCache = p.result;
|
|
46
|
+
}
|
|
47
|
+
const resultText = p._resultTextCache;
|
|
48
|
+
const hasArgs = p.args != null && argsText !== '{}' && argsText.trim() !== '';
|
|
49
|
+
// Default-open while running or on error so the user sees live progress / failure detail;
|
|
50
|
+
// collapse on success unless the caller explicitly overrides with open:true.
|
|
51
|
+
const defaultOpen = p.open != null ? !!p.open : (status === 'running' || status === 'error');
|
|
52
|
+
const iconName = status === 'running' ? 'refresh' : (status === 'error' ? 'warn' : 'check');
|
|
53
|
+
const copyText = (txt) => (e) => copyToClipboardWithFeedback(txt, e.currentTarget);
|
|
54
|
+
const sectionLabel = (text, txt) => h('div', { class: 'chat-tool-section-label' },
|
|
55
|
+
h('span', {}, text),
|
|
56
|
+
h('button', { type: 'button', class: 'chat-code-copy chat-tool-copy', 'aria-label': 'copy ' + text, onclick: copyText(txt) }, 'copy'));
|
|
57
|
+
return h('details', { class: 'chat-bubble chat-tool tool-' + status, open: defaultOpen },
|
|
58
|
+
h('summary', { class: 'chat-tool-head' },
|
|
59
|
+
h('span', { class: 'chat-tool-icon', 'aria-hidden': 'true' }, Icon(iconName, { size: 14 })),
|
|
60
|
+
h('span', { class: 'chat-tool-name' }, p.name || 'tool'),
|
|
61
|
+
p.label ? h('span', { class: 'chat-tool-label' }, p.label) : null,
|
|
62
|
+
h('span', { class: 'chat-tool-status' }, status)
|
|
63
|
+
),
|
|
64
|
+
h('div', { class: 'chat-tool-body' },
|
|
65
|
+
...[
|
|
66
|
+
hasArgs ? h('div', { class: 'chat-tool-section' },
|
|
67
|
+
sectionLabel('args', argsText),
|
|
68
|
+
h('pre', { class: 'chat-tool-pre' }, h('code', {}, argsText))) : null,
|
|
69
|
+
resultText
|
|
70
|
+
? (!p.error && looksLikeUnifiedDiff(resultText)
|
|
71
|
+
// A patch-shaped tool result (edit/write/diff tools) renders
|
|
72
|
+
// through the same split unified-diff view git-status.js's
|
|
73
|
+
// GitDiffView already owns, instead of a raw JSON/text dump —
|
|
74
|
+
// colored +/- hunks read far better than escaped plaintext.
|
|
75
|
+
? h('div', { class: 'chat-tool-section' },
|
|
76
|
+
sectionLabel('result', resultText),
|
|
77
|
+
GitDiffView({ diff: resultText, filename: filenameFromDiff(resultText) }))
|
|
78
|
+
: h('div', { class: 'chat-tool-section' },
|
|
79
|
+
sectionLabel(p.error ? 'error' : 'result', resultText),
|
|
80
|
+
h('pre', { class: 'chat-tool-pre' + (p.error ? ' is-error' : '') }, h('code', {}, resultText))))
|
|
81
|
+
// A finished tool with no output would otherwise render no result
|
|
82
|
+
// section, reading identically to a still-running tool. Show an
|
|
83
|
+
// explicit placeholder so "done, empty" is distinguishable.
|
|
84
|
+
: (status === 'done' ? h('div', { class: 'chat-tool-section' },
|
|
85
|
+
h('div', { class: 'chat-tool-section-label' }, 'result'),
|
|
86
|
+
h('pre', { class: 'chat-tool-pre chat-tool-empty' }, h('code', {}, '(no output)'))) : null)
|
|
87
|
+
].filter(Boolean)
|
|
88
|
+
)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function ThinkingNode(p) {
|
|
93
|
+
if (p.settled) {
|
|
94
|
+
return h('details', { class: 'chat-bubble chat-thinking-settled' },
|
|
95
|
+
h('summary', {}, t('chat.viewThinking', 'View thinking')),
|
|
96
|
+
h('div', { class: 'chat-thinking-body' }, p.text)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return h('div', { class: 'chat-bubble chat-thinking', role: 'status', 'aria-live': 'polite' },
|
|
100
|
+
h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
|
|
101
|
+
h('span', { class: 'chat-thinking-text' }, p.text || t('chat.thinking', 'thinking…'))
|
|
102
|
+
);
|
|
103
|
+
}
|