Feat v0.5.3 chat polish (#33)
All checks were successful
CI/CD / detect-changes (push) Successful in 21s
CI/CD / test-frontend (push) Successful in 4s
CI/CD / test-sqlite (push) Successful in 2m47s
CI/CD / test-go-pg (push) Successful in 2m53s
CI/CD / build-and-deploy (push) Successful in 1m15s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #33.
This commit is contained in:
2026-03-30 17:04:40 +00:00
committed by xcaliber
parent 6931b125a4
commit 8092f00fbe
18 changed files with 1134 additions and 40 deletions

View File

@@ -1,5 +1,5 @@
/**
* Chat — Surface Entry Point (v0.1.0)
* Chat — Surface Entry Point (v0.2.0)
*
* Messaging surface built on chat-core library:
* sw.api.ext('chat-core') — conversation/message CRUD
@@ -85,36 +85,110 @@
// ═══════════════════════════════════════════
function ConversationList({ selected, onSelect, onNew, conversations, unread }) {
var [searchQuery, setSearchQuery] = useState('');
var [searchResults, setSearchResults] = useState(null);
var [searching, setSearching] = useState(false);
function handleSearchInput(e) {
var q = e.target.value;
setSearchQuery(q);
if (q.length < 2) {
setSearchResults(null);
setSearching(false);
return;
}
debounce('search', () => {
setSearching(true);
api.get('/search?q=' + encodeURIComponent(q)).then(res => {
setSearchResults(res || { conversations: [], messages: [] });
setSearching(false);
}).catch(() => { setSearching(false); });
}, 300);
}
function clearSearch() {
setSearchQuery('');
setSearchResults(null);
setSearching(false);
}
function selectFromSearch(cid) {
clearSearch();
onSelect(cid);
}
// Render search results
var showSearch = searchResults !== null;
var sConvs = showSearch ? (searchResults.conversations || []) : [];
var sMsgs = showSearch ? (searchResults.messages || []) : [];
return html`
<div class="chat-sidebar">
<div class="chat-sidebar__header">
<span class="chat-sidebar__title">Conversations</span>
<${Button} size="sm" onClick=${onNew}>New<//>
</div>
<div class="chat-sidebar__list">
${conversations.length === 0 && html`
<div class="chat-sidebar__empty">No conversations yet</div>`}
${conversations.map(c => html`
<div key=${c.id}
class=${'chat-sidebar__item' + (selected === c.id ? ' chat-sidebar__item--active' : '')}
onClick=${() => onSelect(c.id)}>
<div class="chat-sidebar__item-top">
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
</div>
<div class="chat-sidebar__item-bottom">
<span class="chat-sidebar__item-preview">
${c.last_message
? truncate(c.last_message.content_type === 'system'
? '\u2022 ' + c.last_message.content
: c.last_message.content, 60)
: 'No messages yet'}
</span>
${(unread[c.id] || 0) > 0 && html`
<span class="chat-sidebar__badge">${unread[c.id]}</span>`}
</div>
</div>`)}
<div class="chat-sidebar__search">
<input class="chat-sidebar__search-input"
type="text"
value=${searchQuery}
placeholder="Search\u2026"
onInput=${handleSearchInput} />
${searchQuery && html`
<button class="chat-sidebar__search-clear" onClick=${clearSearch}>\u00d7</button>`}
</div>
${showSearch ? html`
<div class="chat-sidebar__search-results">
${searching && html`<div class="chat-sidebar__search-loading"><${Spinner} size="sm" /></div>`}
${!searching && sConvs.length === 0 && sMsgs.length === 0 && html`
<div class="chat-sidebar__empty">No results</div>`}
${sConvs.length > 0 && html`
<div class="chat-sidebar__search-section">Conversations</div>
${sConvs.map(c => html`
<div key=${c.id} class="chat-sidebar__item" onClick=${() => selectFromSearch(c.id)}>
<div class="chat-sidebar__item-top">
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
</div>
</div>`)}`}
${sMsgs.length > 0 && html`
<div class="chat-sidebar__search-section">Messages</div>
${sMsgs.map(m => html`
<div key=${m.id} class="chat-sidebar__item chat-sidebar__item--search-msg" onClick=${() => selectFromSearch(m.conversation_id)}>
<div class="chat-sidebar__item-top">
<span class="chat-sidebar__item-preview">${truncate(m.content, 80)}</span>
</div>
<div class="chat-sidebar__item-bottom">
<span class="chat-sidebar__item-time">${timeAgo(m.created_at)}</span>
</div>
</div>`)}`}
</div>
` : html`
<div class="chat-sidebar__list">
${conversations.length === 0 && html`
<div class="chat-sidebar__empty">No conversations yet</div>`}
${conversations.map(c => html`
<div key=${c.id}
class=${'chat-sidebar__item' + (selected === c.id ? ' chat-sidebar__item--active' : '')}
onClick=${() => onSelect(c.id)}>
<div class="chat-sidebar__item-top">
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
</div>
<div class="chat-sidebar__item-bottom">
<span class="chat-sidebar__item-preview">
${c.last_message
? truncate(c.last_message.content_type === 'system'
? '\u2022 ' + c.last_message.content
: c.last_message.content, 60)
: 'No messages yet'}
</span>
${(unread[c.id] || 0) > 0 && html`
<span class="chat-sidebar__badge">${unread[c.id]}</span>`}
</div>
</div>`)}
</div>
`}
</div>`;
}
@@ -244,9 +318,11 @@
}).catch(() => setLoading(false));
}, [conversationId, partMap]);
// Load older messages
// Load older messages (preserves scroll position)
function loadMore() {
if (!hasMore || !nextCursor || loading) return;
var el = listRef.current;
var prevHeight = el ? el.scrollHeight : 0;
setLoading(true);
api.get('/messages/' + conversationId + '?limit=50&cursor=' + encodeURIComponent(nextCursor)).then(res => {
var data = res || {};
@@ -255,6 +331,10 @@
setHasMore(!!data.has_more);
setNextCursor(data.next_cursor || '');
setLoading(false);
// Restore scroll position after prepending older messages
requestAnimationFrame(() => {
if (el) el.scrollTop = el.scrollHeight - prevHeight;
});
}).catch(() => setLoading(false));
}
@@ -370,8 +450,9 @@
<div class="chat-thread">
<div class="chat-thread__messages" ref=${listRef}>
${loading && messages.length === 0 && html`<div class="chat-thread__loading"><${Spinner} /></div>`}
${hasMore && html`
<button class="chat-thread__load-more" onClick=${loadMore} disabled=${loading}>
${loading && messages.length > 0 && html`<div class="chat-thread__loading-more"><${Spinner} size="sm" /></div>`}
${hasMore && !loading && html`
<button class="chat-thread__load-more" onClick=${loadMore}>
Load older messages
</button>`}
${messages.map(m => html`