Feat v0.5.3 chat polish + integration testing
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 21s
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-frontend (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 21s
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-frontend (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
Conversation search (db.query search_like, chat-core /search endpoint, sidebar search UI), message pagination polish (scroll preservation, loading spinner), workflow-chat integration package, and multi-user E2E test infrastructure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
"title": "Chat Core",
|
||||
"type": "library",
|
||||
"tier": "starlark",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Core chat library — conversations, messages, participants, read cursors. Usable by other packages via lib.require().",
|
||||
"author": "switchboard",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
{"method": "POST", "path": "/participants/*"},
|
||||
{"method": "DELETE", "path": "/participants/*"},
|
||||
{"method": "POST", "path": "/read/*"},
|
||||
{"method": "GET", "path": "/search"},
|
||||
{"method": "GET", "path": "/unread"}
|
||||
],
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Chat Core — Starlark Backend (v0.1.0)
|
||||
# Chat Core — Starlark Backend (v0.2.0)
|
||||
#
|
||||
# Library package providing conversations, messages, participants,
|
||||
# and read cursors. Consumable via lib.require("chat-core").
|
||||
@@ -309,6 +309,10 @@ def on_request(req):
|
||||
if method == "POST" and path == "/conversations":
|
||||
return _handle_create_conversation(req, user_id)
|
||||
|
||||
# GET /search?q=term — search conversations and messages
|
||||
if method == "GET" and path == "/search":
|
||||
return _handle_search(req, user_id)
|
||||
|
||||
# GET /unread — unread counts
|
||||
if method == "GET" and path == "/unread":
|
||||
return _handle_unread(user_id)
|
||||
@@ -652,6 +656,59 @@ def _handle_mark_read(cid, req, user_id):
|
||||
return _resp(200, {"ok": True})
|
||||
|
||||
|
||||
def _handle_search(req, user_id):
|
||||
"""Search across conversation titles and message content."""
|
||||
q = _str(req.get("query", {}).get("q", ""))
|
||||
if len(q) < 2:
|
||||
return _resp(400, {"error": "query must be at least 2 characters"})
|
||||
|
||||
pattern = "%" + q + "%"
|
||||
|
||||
# Get user's conversation IDs
|
||||
my_parts = db.query("participants", filters={"participant_id": user_id}, limit=500)
|
||||
if not my_parts:
|
||||
return _resp(200, {"conversations": [], "messages": []})
|
||||
|
||||
cid_set = {}
|
||||
for p in my_parts:
|
||||
cid_set[_str(p.get("conversation_id", ""))] = True
|
||||
|
||||
# Search conversations by title
|
||||
matching_convs = db.query("conversations", search_like={"title": pattern}, limit=50)
|
||||
conv_results = []
|
||||
for conv in (matching_convs or []):
|
||||
cid = _str(conv.get("id", ""))
|
||||
if cid in cid_set:
|
||||
conv_results.append({
|
||||
"id": cid,
|
||||
"title": conv.get("title", ""),
|
||||
"type": conv.get("type", ""),
|
||||
"updated_at": conv.get("updated_at", ""),
|
||||
"created_at": conv.get("created_at", ""),
|
||||
})
|
||||
|
||||
# Search messages in user's conversations
|
||||
msg_results = []
|
||||
for cid in cid_set:
|
||||
if not cid:
|
||||
continue
|
||||
msgs = db.query("messages", filters={"conversation_id": cid}, search_like={"content": pattern}, order="-created_at", limit=5)
|
||||
for m in (msgs or []):
|
||||
msg_results.append({
|
||||
"id": m.get("id", ""),
|
||||
"conversation_id": cid,
|
||||
"participant_id": m.get("participant_id", ""),
|
||||
"content": _str(m.get("content", "")),
|
||||
"content_type": _str(m.get("content_type", "")),
|
||||
"created_at": _str(m.get("created_at", "")),
|
||||
})
|
||||
|
||||
# Sort messages by created_at descending, limit to 50
|
||||
msg_results = sorted(msg_results, key=lambda x: x.get("created_at", ""), reverse=True)[:50]
|
||||
|
||||
return _resp(200, {"conversations": conv_results, "messages": msg_results})
|
||||
|
||||
|
||||
def _handle_unread(user_id):
|
||||
"""Get unread counts for all user's conversations."""
|
||||
my_parts = db.query("participants", filters={"participant_id": user_id}, limit=500)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ═══════════════════════════════════════════
|
||||
Chat Surface — Styles (v0.1.0)
|
||||
Chat Surface — Styles (v0.2.0)
|
||||
Uses variables.css theme tokens:
|
||||
--bg, --bg-surface, --bg-raised, --bg-hover, --bg-secondary
|
||||
--text, --text-2, --text-3
|
||||
@@ -151,6 +151,78 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar Search ────────────────────── */
|
||||
|
||||
.chat-sidebar__search {
|
||||
position: relative;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.chat-sidebar__search-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 28px 6px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-sidebar__search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-sidebar__search-clear {
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-sidebar__search-clear:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-sidebar__search-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-sidebar__search-section {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.chat-sidebar__search-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-sidebar__item--search-msg .chat-sidebar__item-preview {
|
||||
font-size: 13px;
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Message Thread ─────────────────────── */
|
||||
|
||||
.chat-thread {
|
||||
@@ -181,6 +253,12 @@
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.chat-thread__loading-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.chat-thread__load-more {
|
||||
align-self: center;
|
||||
background: none;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"route": "/s/chat",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"icon": "\ud83d\udcac",
|
||||
"description": "Chat surface — conversations, messaging, typing indicators, read receipts.",
|
||||
"author": "switchboard",
|
||||
|
||||
41
packages/workflow-chat/README.md
Normal file
41
packages/workflow-chat/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Workflow Chat Integration
|
||||
|
||||
Creates scoped chat conversations when workflow stages require team collaboration.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the `workflow-chat` and `chat-core` packages.
|
||||
2. In your workflow stage with `audience: team`, set `stage_config`:
|
||||
|
||||
```json
|
||||
{
|
||||
"on_advance": {
|
||||
"package_id": "workflow-chat",
|
||||
"entry_point": "on_advance"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. When the workflow advances to that stage, ensure `stage_data` includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Bug Triage Discussion",
|
||||
"team_members": [
|
||||
{"id": "user-1", "display_name": "Alice"},
|
||||
{"id": "user-2", "display_name": "Bob"}
|
||||
],
|
||||
"creator_id": "user-1",
|
||||
"creator_display_name": "Alice"
|
||||
}
|
||||
```
|
||||
|
||||
The hook will:
|
||||
- Create a group conversation titled `"Bug Triage Discussion [abcd1234]"` (suffixed with instance ID)
|
||||
- Add all team members as participants
|
||||
- Send a system message linking to the workflow instance
|
||||
- Enrich `stage_data` with the `conversation_id`
|
||||
|
||||
## Idempotency
|
||||
|
||||
If `stage_data` already contains a `conversation_id`, the hook returns `None` (no-op).
|
||||
15
packages/workflow-chat/manifest.json
Normal file
15
packages/workflow-chat/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "workflow-chat",
|
||||
"title": "Workflow Chat",
|
||||
"type": "library",
|
||||
"tier": "starlark",
|
||||
"version": "0.1.0",
|
||||
"description": "Creates scoped chat conversations when workflow stages require team collaboration. Wire into stage_config as an on_advance hook.",
|
||||
"author": "switchboard",
|
||||
|
||||
"permissions": ["db.write", "realtime.publish"],
|
||||
|
||||
"depends": ["chat-core"],
|
||||
|
||||
"exports": ["on_advance"]
|
||||
}
|
||||
88
packages/workflow-chat/script.star
Normal file
88
packages/workflow-chat/script.star
Normal file
@@ -0,0 +1,88 @@
|
||||
# Workflow Chat — Starlark Backend (v0.1.0)
|
||||
#
|
||||
# Library package that creates scoped chat conversations when
|
||||
# workflow stages require team collaboration.
|
||||
#
|
||||
# Usage: wire into stage_config as an on_advance hook:
|
||||
# {"on_advance": {"package_id": "workflow-chat", "entry_point": "on_advance"}}
|
||||
#
|
||||
# Expected stage_data keys:
|
||||
# title — conversation title (optional, defaults to "Workflow Discussion")
|
||||
# team_members — list of {id, display_name} dicts
|
||||
# creator_id — user ID of the workflow initiator
|
||||
# creator_display_name — display name of the workflow initiator
|
||||
#
|
||||
# Modules: db, json, realtime (via chat-core dependency)
|
||||
|
||||
chat = lib.require("chat-core")
|
||||
|
||||
|
||||
def _str(v):
|
||||
if v == None:
|
||||
return ""
|
||||
return str(v)
|
||||
|
||||
|
||||
def on_advance(ctx):
|
||||
"""Hook called when a workflow advances to a team-audience stage.
|
||||
|
||||
Creates a group conversation with all team members and sends
|
||||
a system message linking back to the workflow instance.
|
||||
|
||||
Args:
|
||||
ctx: dict with {instance_id, previous_stage, current_stage, stage_data}
|
||||
|
||||
Returns:
|
||||
dict with {stage_data} containing enriched data with conversation_id,
|
||||
or None if no team members are present.
|
||||
"""
|
||||
data = ctx.get("stage_data", {})
|
||||
if type(data) == "string":
|
||||
data = json.decode(data) if data else {}
|
||||
|
||||
instance_id = _str(ctx.get("instance_id", ""))
|
||||
members = data.get("team_members", [])
|
||||
creator_id = _str(data.get("creator_id", ""))
|
||||
creator_name = _str(data.get("creator_display_name", ""))
|
||||
title = _str(data.get("title", "")) or "Workflow Discussion"
|
||||
|
||||
# Skip if no team members to add
|
||||
if not members:
|
||||
return None
|
||||
|
||||
# Check if conversation already exists for this instance (idempotency)
|
||||
existing_cid = _str(data.get("conversation_id", ""))
|
||||
if existing_cid:
|
||||
return None
|
||||
|
||||
# Build participants list
|
||||
participants = []
|
||||
for m in members:
|
||||
mid = _str(m.get("id", ""))
|
||||
if mid and mid != creator_id:
|
||||
participants.append({
|
||||
"id": mid,
|
||||
"display_name": _str(m.get("display_name", "")),
|
||||
})
|
||||
|
||||
# Create conversation scoped to this workflow instance
|
||||
conv = chat.create(
|
||||
title=title + " [" + instance_id[:8] + "]",
|
||||
type="group",
|
||||
participants=participants,
|
||||
creator_id=creator_id,
|
||||
creator_display_name=creator_name,
|
||||
)
|
||||
|
||||
cid = _str(conv.get("id", ""))
|
||||
|
||||
# Send system message linking to the workflow
|
||||
chat.send(cid, creator_id, "Conversation created for workflow instance " + instance_id, "system")
|
||||
|
||||
# Return enriched stage_data with conversation_id
|
||||
enriched = {}
|
||||
for k in data:
|
||||
enriched[k] = data[k]
|
||||
enriched["conversation_id"] = cid
|
||||
|
||||
return {"stage_data": enriched}
|
||||
Reference in New Issue
Block a user