personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

chat: add /app/chat messenger app (lode 3c)

New apps/chat/ Convey app that renders the chat stream as a
messenger-style transcript. Opts out of the universal bar via
app_bar: false (field added in 3b) so its own composer row fills
the space.

Routes (apps/chat/routes.py):
- GET /app/chat/ → redirects to today's day
- GET /app/chat/<YYYYMMDD> → renders day's transcript
- GET /app/chat/api/stats/<month> → month-picker counts

Partial apps/chat/_chat_event.html renders every chat-stream kind
server-side: owner_message + sol_message bubbles, notes exposed
via title, and talent_spawned/finished/errored as clickable cards
whose data-talent-use-id drives window.openTalentView from 3b.
Anchor ids use #event-<idx> (0-based line index in the day's
JSONL) so search results can jump deterministically without a new
endpoint — the existing /app/search/api/search?stream=chat already
returns (day, idx).

workspace.html handles: client-side time separators >20 min apart
via Intl.DateTimeFormat (user's local TZ), bubble author-side
decoration, today-only live chat subscription, today-only
composer row, past-day composer replaced by a "new messages go to
today" redirect link, and search input wired to /app/search.

Identity labels come from config via the same 3-line fallback used
by think/chat_formatter.py (identity.preferred → identity.name →
"Owner"; agent.name → "Sol") — no hardcoded names.

apps/chat/tests/test_routes.py covers: root redirect, empty-state,
rendered event kinds, anchor stability, invalid day → 404, composer
visibility today vs past.

CSS additions for bubbles, transcript, search, composer, and
talent cards live in convey/static/app.css to reuse the shared
theme variables.

make ci green (3749 tests). make test-app APP=chat green (8 tests).
make review fails on the pinchtab/browser harness in this
environment ("pinchtab failed to start" / screenshot 503s) —
pre-existing infra issue, not a regression from this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+997
+39
apps/chat/_chat_event.html
··· 1 + {% if ev.kind == "owner_message" %} 2 + <article class="chat-bubble chat-bubble--owner" aria-label="{{ owner_name }}: {{ ev.text }}"> 3 + <span class="chat-bubble-author">{{ owner_name }}</span> 4 + <span class="chat-bubble-text">{{ ev.text }}</span> 5 + </article> 6 + {% elif ev.kind == "sol_message" %} 7 + <article class="chat-bubble chat-bubble--sol" 8 + {% if ev.notes %}title="{{ ev.notes }}"{% endif %} 9 + aria-label="{{ agent_name }}: {{ ev.text }}"> 10 + <span class="chat-bubble-author">{{ agent_name }}</span> 11 + <span class="chat-bubble-text">{{ ev.text }}</span> 12 + </article> 13 + {% elif ev.kind == "talent_spawned" %} 14 + <button type="button" 15 + class="chat-talent-card chat-talent-card--spawned" 16 + data-talent-use-id="{{ ev.use_id }}" 17 + data-talent-status="active"> 18 + <span class="chat-talent-card-label">{{ ev.name }} started</span> 19 + {% if ev.task %}<span class="chat-talent-card-task">{{ ev.task }}</span>{% endif %} 20 + </button> 21 + {% elif ev.kind == "talent_finished" %} 22 + <button type="button" 23 + class="chat-talent-card chat-talent-card--finished" 24 + data-talent-use-id="{{ ev.use_id }}" 25 + data-talent-status="completed"> 26 + <span class="chat-talent-card-label">{{ ev.name }} finished</span> 27 + {% if ev.summary %}<span class="chat-talent-card-summary">{{ ev.summary }}</span>{% endif %} 28 + </button> 29 + {% elif ev.kind == "talent_errored" %} 30 + <button type="button" 31 + class="chat-talent-card chat-talent-card--errored" 32 + data-talent-use-id="{{ ev.use_id }}" 33 + data-talent-status="errored"> 34 + <span class="chat-talent-card-label">{{ ev.name }} errored</span> 35 + {% if ev.reason %}<span class="chat-talent-card-reason">{{ ev.reason }}</span>{% endif %} 36 + </button> 37 + {% elif ev.kind == "chat_error" %} 38 + <div class="chat-error-block">chat had trouble — try again</div> 39 + {% endif %}
+7
apps/chat/app.json
··· 1 + { 2 + "icon": "💬", 3 + "label": "Chat", 4 + "date_nav": true, 5 + "allow_future_dates": false, 6 + "app_bar": false 7 + }
+79
apps/chat/routes.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import calendar 7 + from datetime import date 8 + from typing import Any 9 + 10 + from flask import Blueprint, abort, jsonify, redirect, render_template, url_for 11 + 12 + from convey.chat_stream import read_chat_events 13 + from convey.utils import DATE_RE 14 + from think.utils import get_config 15 + 16 + chat_bp = Blueprint( 17 + "app:chat", 18 + __name__, 19 + url_prefix="/app/chat", 20 + ) 21 + 22 + 23 + @chat_bp.route("/") 24 + def index() -> Any: 25 + today = date.today().strftime("%Y%m%d") 26 + return redirect(url_for("app:chat.day", day=today)) 27 + 28 + 29 + @chat_bp.route("/<day>") 30 + def day(day: str) -> str: 31 + if not DATE_RE.fullmatch(day): 32 + abort(404) 33 + 34 + today_day = date.today().strftime("%Y%m%d") 35 + owner_name, agent_name = _resolve_identity() 36 + 37 + return render_template( 38 + "app.html", 39 + app="chat", 40 + events=read_chat_events(day), 41 + day=day, 42 + today_day=today_day, 43 + owner_name=owner_name, 44 + agent_name=agent_name, 45 + ) 46 + 47 + 48 + @chat_bp.route("/api/stats/<month>") 49 + def stats(month: str) -> Any: 50 + if len(month) != 6 or not month.isdigit(): 51 + return jsonify({"error": "Invalid month format, expected YYYYMM"}), 400 52 + 53 + try: 54 + return jsonify(_month_chat_counts(month)) 55 + except ValueError: 56 + return jsonify({"error": "Invalid month format, expected YYYYMM"}), 400 57 + 58 + 59 + def _month_chat_counts(month: str) -> dict[str, int]: 60 + year = int(month[:4]) 61 + month_num = int(month[4:6]) 62 + _, days_in_month = calendar.monthrange(year, month_num) 63 + stats: dict[str, int] = {} 64 + 65 + for day_num in range(1, days_in_month + 1): 66 + day = f"{month}{day_num:02d}" 67 + count = len(read_chat_events(day)) 68 + if count: 69 + stats[day] = count 70 + 71 + return stats 72 + 73 + 74 + def _resolve_identity() -> tuple[str, str]: 75 + config = get_config() 76 + identity = config.get("identity", {}) 77 + owner_name = str(identity.get("preferred") or identity.get("name") or "").strip() 78 + agent_name = str(config.get("agent", {}).get("name") or "").strip() 79 + return owner_name or "Owner", agent_name or "Sol"
+254
apps/chat/tests/test_routes.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import os 7 + import shutil 8 + import subprocess 9 + from dataclasses import dataclass 10 + from datetime import date, datetime 11 + from pathlib import Path 12 + from typing import Any 13 + 14 + import pytest 15 + 16 + from convey import create_app 17 + from convey.chat_stream import append_chat_event 18 + 19 + 20 + def _ms(year: int, month: int, day: int, hour: int, minute: int) -> int: 21 + return int(datetime(year, month, day, hour, minute).timestamp() * 1000) 22 + 23 + 24 + @dataclass 25 + class ChatTestEnv: 26 + client: Any 27 + journal: Any 28 + 29 + 30 + @pytest.fixture 31 + def journal_copy(tmp_path, monkeypatch): 32 + src = Path("tests/fixtures/journal").resolve() 33 + dst = tmp_path / "journal" 34 + copytree_tracked(src, dst) 35 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(dst.resolve())) 36 + return dst 37 + 38 + 39 + def _make_env(journal, monkeypatch) -> ChatTestEnv: 40 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 41 + app = create_app(str(journal)) 42 + app.config["TESTING"] = True 43 + client = app.test_client() 44 + with client.session_transaction() as session: 45 + session["logged_in"] = True 46 + session.permanent = True 47 + return ChatTestEnv(client=client, journal=journal) 48 + 49 + 50 + def _set_today(monkeypatch, day: str) -> None: 51 + import apps.chat.routes as chat_routes 52 + 53 + class FixedDate(date): 54 + @classmethod 55 + def today(cls) -> date: 56 + return cls(int(day[:4]), int(day[4:6]), int(day[6:8])) 57 + 58 + monkeypatch.setattr(chat_routes, "date", FixedDate) 59 + 60 + 61 + def copytree_tracked(src: Path, dst: Path) -> None: 62 + result = subprocess.run( 63 + ["git", "ls-files", "."], 64 + cwd=str(src), 65 + capture_output=True, 66 + text=True, 67 + check=True, 68 + ) 69 + for rel in result.stdout.splitlines(): 70 + if not rel: 71 + continue 72 + src_file = src / rel 73 + dst_file = dst / rel 74 + dst_file.parent.mkdir(parents=True, exist_ok=True) 75 + if src_file.is_symlink(): 76 + os.symlink(os.readlink(src_file), dst_file) 77 + else: 78 + shutil.copy2(src_file, dst_file) 79 + 80 + 81 + def test_chat_index_redirects_to_today(journal_copy, monkeypatch): 82 + today = "20990101" 83 + _set_today(monkeypatch, today) 84 + env = _make_env(journal_copy, monkeypatch) 85 + 86 + response = env.client.get("/app/chat/") 87 + 88 + assert response.status_code == 302 89 + assert response.headers["Location"].endswith(f"/app/chat/{today}") 90 + 91 + 92 + def test_chat_day_renders_empty_state_for_today(journal_copy, monkeypatch): 93 + today = "20990101" 94 + _set_today(monkeypatch, today) 95 + env = _make_env(journal_copy, monkeypatch) 96 + 97 + response = env.client.get(f"/app/chat/{today}") 98 + html = response.get_data(as_text=True) 99 + 100 + assert response.status_code == 200 101 + assert "no chat yet on this day" in html 102 + assert 'id="chatComposerForm"' in html 103 + 104 + 105 + def test_chat_day_renders_all_event_kinds(journal_copy, monkeypatch): 106 + day = "20990102" 107 + _set_today(monkeypatch, "20990103") 108 + env = _make_env(journal_copy, monkeypatch) 109 + append_chat_event( 110 + "owner_message", 111 + ts=_ms(2099, 1, 2, 9, 0), 112 + text="owner hello", 113 + app="chat", 114 + path=f"/app/chat/{day}", 115 + facet="work", 116 + ) 117 + append_chat_event( 118 + "sol_message", 119 + ts=_ms(2099, 1, 2, 9, 1), 120 + use_id="use-1", 121 + text="sol reply", 122 + notes="full note", 123 + requested_exec=False, 124 + requested_task=None, 125 + ) 126 + append_chat_event( 127 + "talent_spawned", 128 + ts=_ms(2099, 1, 2, 9, 2), 129 + use_id="use-2", 130 + name="search", 131 + task="find updates", 132 + started_at=_ms(2099, 1, 2, 9, 2), 133 + ) 134 + append_chat_event( 135 + "talent_finished", 136 + ts=_ms(2099, 1, 2, 9, 3), 137 + use_id="use-2", 138 + name="search", 139 + summary="done", 140 + ) 141 + append_chat_event( 142 + "talent_errored", 143 + ts=_ms(2099, 1, 2, 9, 4), 144 + use_id="use-3", 145 + name="exec", 146 + reason="bad args", 147 + ) 148 + append_chat_event( 149 + "chat_error", 150 + ts=_ms(2026, 4, 20, 9, 5), 151 + reason="network", 152 + use_id="use-4", 153 + ) 154 + 155 + response = env.client.get(f"/app/chat/{day}") 156 + html = response.get_data(as_text=True) 157 + 158 + assert response.status_code == 200 159 + assert "owner hello" in html 160 + assert "sol reply" in html 161 + assert 'title="full note"' in html 162 + assert 'data-talent-use-id="use-2"' in html 163 + assert 'data-talent-use-id="use-3"' in html 164 + assert "chat had trouble" in html 165 + 166 + 167 + def test_chat_event_anchor_ids_are_stable(journal_copy, monkeypatch): 168 + day = "20990102" 169 + _set_today(monkeypatch, "20990103") 170 + env = _make_env(journal_copy, monkeypatch) 171 + append_chat_event( 172 + "owner_message", 173 + ts=_ms(2099, 1, 2, 10, 0), 174 + text="first", 175 + app="chat", 176 + path=f"/app/chat/{day}", 177 + facet="work", 178 + ) 179 + append_chat_event( 180 + "sol_message", 181 + ts=_ms(2099, 1, 2, 10, 1), 182 + use_id="use-5", 183 + text="second", 184 + notes="", 185 + requested_exec=False, 186 + requested_task=None, 187 + ) 188 + 189 + first = env.client.get(f"/app/chat/{day}").get_data(as_text=True) 190 + second = env.client.get(f"/app/chat/{day}").get_data(as_text=True) 191 + 192 + assert first.count('id="event-0"') == 1 193 + assert first.count('id="event-1"') == 1 194 + assert second.count('id="event-0"') == 1 195 + assert second.count('id="event-1"') == 1 196 + 197 + 198 + def test_chat_time_separator_is_inserted_client_side(journal_copy, monkeypatch): 199 + day = "20990102" 200 + _set_today(monkeypatch, "20990103") 201 + env = _make_env(journal_copy, monkeypatch) 202 + append_chat_event( 203 + "owner_message", 204 + ts=_ms(2099, 1, 2, 8, 0), 205 + text="early", 206 + app="chat", 207 + path=f"/app/chat/{day}", 208 + facet="work", 209 + ) 210 + append_chat_event( 211 + "sol_message", 212 + ts=_ms(2099, 1, 2, 8, 25), 213 + use_id="use-6", 214 + text="later", 215 + notes="", 216 + requested_exec=False, 217 + requested_task=None, 218 + ) 219 + 220 + html = env.client.get(f"/app/chat/{day}").get_data(as_text=True) 221 + 222 + assert "early" in html 223 + assert "later" in html 224 + assert "insertTimeSeparators(transcript);" in html 225 + 226 + 227 + def test_chat_invalid_days_return_404(journal_copy, monkeypatch): 228 + _set_today(monkeypatch, "20990101") 229 + env = _make_env(journal_copy, monkeypatch) 230 + 231 + assert env.client.get("/app/chat/abcd1234").status_code == 404 232 + assert env.client.get("/app/chat/20260101extra").status_code == 404 233 + 234 + 235 + def test_past_day_hides_composer(journal_copy, monkeypatch): 236 + today = "20990102" 237 + past_day = "20990101" 238 + _set_today(monkeypatch, today) 239 + env = _make_env(journal_copy, monkeypatch) 240 + 241 + html = env.client.get(f"/app/chat/{past_day}").get_data(as_text=True) 242 + 243 + assert 'id="chatComposerForm"' not in html 244 + assert "past-day view" in html 245 + 246 + 247 + def test_today_shows_composer(journal_copy, monkeypatch): 248 + today = "20990102" 249 + _set_today(monkeypatch, today) 250 + env = _make_env(journal_copy, monkeypatch) 251 + 252 + html = env.client.get(f"/app/chat/{today}").get_data(as_text=True) 253 + 254 + assert 'id="chatComposerForm"' in html
+376
apps/chat/workspace.html
··· 1 + <section class="chat-app" data-day="{{ day }}" data-today="{{ today_day }}"> 2 + <header class="chat-app-search"> 3 + <form id="chatSearchForm" class="chat-search-form" role="search"> 4 + <label class="visually-hidden" for="chatSearchInput">search chat</label> 5 + <input id="chatSearchInput" type="search" placeholder="search chat history" autocomplete="off"> 6 + </form> 7 + <div id="chatSearchResults" class="chat-search-results" hidden></div> 8 + </header> 9 + 10 + <ol id="chatTranscript" class="chat-transcript" data-owner-name="{{ owner_name }}" data-agent-name="{{ agent_name }}"> 11 + {% if not events %} 12 + <li class="chat-empty">no chat yet on this day</li> 13 + {% endif %} 14 + {% for ev in events %} 15 + <li 16 + id="event-{{ loop.index0 }}" 17 + class="chat-event {% if ev.kind == 'owner_message' %}chat-event--owner{% elif ev.kind == 'sol_message' %}chat-event--sol{% elif ev.kind == 'chat_error' %}chat-event--error{% else %}chat-event--talent{% endif %}" 18 + data-kind="{{ ev.kind }}" 19 + data-ts="{{ ev.ts }}" 20 + {% if ev.kind == "sol_message" and ev.notes %}data-notes="{{ ev.notes }}"{% endif %} 21 + > 22 + {% include "chat/_chat_event.html" %} 23 + </li> 24 + {% endfor %} 25 + </ol> 26 + 27 + {% if day == today_day %} 28 + <form id="chatComposerForm" class="chat-composer"> 29 + <textarea id="chatComposerInput" rows="1" placeholder="{{ chat_bar_placeholder }}" aria-label="chat input"></textarea> 30 + <button id="chatComposerSend" type="submit" aria-label="send">Send</button> 31 + </form> 32 + {% else %} 33 + <p class="chat-composer-past">past-day view — new messages go to <a href="{{ url_for('app:chat.day', day=today_day) }}">today</a></p> 34 + {% endif %} 35 + </section> 36 + 37 + <script> 38 + (function () { 39 + const root = document.querySelector('.chat-app'); 40 + if (!root) return; 41 + 42 + const day = root.dataset.day; 43 + const todayDay = root.dataset.today; 44 + const isToday = day === todayDay; 45 + const transcript = document.getElementById('chatTranscript'); 46 + const ownerName = transcript.dataset.ownerName; 47 + const agentName = transcript.dataset.agentName; 48 + const searchInput = document.getElementById('chatSearchInput'); 49 + const searchResults = document.getElementById('chatSearchResults'); 50 + const timeFormatter = new Intl.DateTimeFormat([], { 51 + hour: 'numeric', 52 + minute: '2-digit' 53 + }); 54 + let searchTimer = null; 55 + 56 + insertTimeSeparators(transcript); 57 + decorateBubbles(transcript); 58 + 59 + if (isToday && window.appEvents) { 60 + const off = window.appEvents.listen('chat', (msg) => { 61 + appendEventFromLive(msg, transcript); 62 + }); 63 + window.addEventListener('beforeunload', off, { once: true }); 64 + } 65 + 66 + transcript.addEventListener('click', (event) => { 67 + const target = event.target.closest('[data-talent-use-id]'); 68 + if (!target) return; 69 + event.preventDefault(); 70 + window.openTalentView?.(target.dataset.talentUseId, { 71 + live: target.dataset.talentStatus === 'active' 72 + }); 73 + }); 74 + 75 + const form = document.getElementById('chatComposerForm'); 76 + if (form) { 77 + const input = document.getElementById('chatComposerInput'); 78 + resizeComposer(input); 79 + input.addEventListener('input', () => resizeComposer(input)); 80 + form.addEventListener('submit', (event) => { 81 + event.preventDefault(); 82 + const text = (input.value || '').trim(); 83 + if (!text) return; 84 + postChat(text, form); 85 + }); 86 + } 87 + 88 + wireSearch(); 89 + 90 + if (location.hash && location.hash.startsWith('#event-')) { 91 + document.getElementById(location.hash.slice(1))?.scrollIntoView({ block: 'center' }); 92 + } else { 93 + transcript.scrollTop = transcript.scrollHeight; 94 + } 95 + 96 + function insertTimeSeparators(list) { 97 + Array.from(list.querySelectorAll('.chat-time-sep')).forEach((node) => node.remove()); 98 + const items = Array.from(list.children).filter((node) => node.classList.contains('chat-event')); 99 + let previousTs = null; 100 + 101 + items.forEach((item) => { 102 + const ts = Number(item.dataset.ts || 0); 103 + if (previousTs && ts && ts - previousTs > 20 * 60 * 1000) { 104 + const separator = document.createElement('li'); 105 + separator.className = 'chat-time-sep'; 106 + separator.textContent = timeFormatter.format(new Date(ts)); 107 + list.insertBefore(separator, item); 108 + } 109 + if (ts) previousTs = ts; 110 + }); 111 + } 112 + 113 + function decorateBubbles(list) { 114 + const items = Array.from(list.children).filter((node) => node.classList.contains('chat-event')); 115 + items.forEach((item) => { 116 + item.classList.remove( 117 + 'chat-event--owner', 118 + 'chat-event--sol', 119 + 'chat-event--talent', 120 + 'chat-event--error' 121 + ); 122 + 123 + const kind = item.dataset.kind || ''; 124 + if (kind === 'owner_message') { 125 + item.classList.add('chat-event--owner'); 126 + } else if (kind === 'sol_message') { 127 + item.classList.add('chat-event--sol'); 128 + const bubble = item.querySelector('.chat-bubble--sol'); 129 + if (bubble && item.dataset.notes) { 130 + bubble.title = item.dataset.notes; 131 + } 132 + } else if (kind === 'chat_error') { 133 + item.classList.add('chat-event--error'); 134 + } else { 135 + item.classList.add('chat-event--talent'); 136 + } 137 + }); 138 + } 139 + 140 + function appendEventFromLive(msg, list) { 141 + const kind = String(msg.kind || msg.event || '').trim(); 142 + if (!kind) return; 143 + 144 + const ts = Number(msg.ts || Date.now()); 145 + if (formatDay(ts) !== day) return; 146 + 147 + const item = renderEventItem({ 148 + kind: kind, 149 + ts: ts, 150 + text: msg.text || '', 151 + notes: msg.notes || '', 152 + use_id: msg.use_id || '', 153 + name: msg.name || '', 154 + task: msg.task || '', 155 + summary: msg.summary || '', 156 + reason: msg.reason || '' 157 + }); 158 + if (!item) return; 159 + 160 + list.querySelector('.chat-empty')?.remove(); 161 + list.appendChild(item); 162 + decorateBubbles(list); 163 + insertTimeSeparators(list); 164 + list.scrollTop = list.scrollHeight; 165 + } 166 + 167 + function renderEventItem(event) { 168 + if (![ 169 + 'owner_message', 170 + 'sol_message', 171 + 'talent_spawned', 172 + 'talent_finished', 173 + 'talent_errored', 174 + 'chat_error' 175 + ].includes(event.kind)) { 176 + return null; 177 + } 178 + 179 + const item = document.createElement('li'); 180 + item.id = 'event-' + transcript.querySelectorAll('.chat-event').length; 181 + item.className = 'chat-event'; 182 + item.dataset.kind = event.kind; 183 + item.dataset.ts = String(event.ts); 184 + if (event.kind === 'sol_message' && event.notes) { 185 + item.dataset.notes = event.notes; 186 + } 187 + item.appendChild(renderEventBody(event)); 188 + return item; 189 + } 190 + 191 + function renderEventBody(event) { 192 + if (event.kind === 'owner_message') { 193 + return buildBubble(ownerName, event.text, 'owner', ''); 194 + } 195 + if (event.kind === 'sol_message') { 196 + return buildBubble(agentName, event.text, 'sol', event.notes || ''); 197 + } 198 + if (event.kind === 'talent_spawned') { 199 + return buildTalentCard(event.name + ' started', event.task || '', event.use_id, 'active', 'chat-talent-card--spawned'); 200 + } 201 + if (event.kind === 'talent_finished') { 202 + return buildTalentCard(event.name + ' finished', event.summary || '', event.use_id, 'completed', 'chat-talent-card--finished'); 203 + } 204 + if (event.kind === 'talent_errored') { 205 + return buildTalentCard(event.name + ' errored', event.reason || '', event.use_id, 'errored', 'chat-talent-card--errored'); 206 + } 207 + 208 + const block = document.createElement('div'); 209 + block.className = 'chat-error-block'; 210 + block.textContent = 'chat had trouble — try again'; 211 + return block; 212 + } 213 + 214 + function buildBubble(author, text, side, notes) { 215 + const bubble = document.createElement('article'); 216 + bubble.className = 'chat-bubble chat-bubble--' + side; 217 + bubble.setAttribute('aria-label', author + ': ' + text); 218 + if (notes) bubble.title = notes; 219 + 220 + const authorNode = document.createElement('span'); 221 + authorNode.className = 'chat-bubble-author'; 222 + authorNode.textContent = author; 223 + 224 + const textNode = document.createElement('span'); 225 + textNode.className = 'chat-bubble-text'; 226 + textNode.textContent = text; 227 + 228 + bubble.appendChild(authorNode); 229 + bubble.appendChild(textNode); 230 + return bubble; 231 + } 232 + 233 + function buildTalentCard(label, detail, useId, status, variantClass) { 234 + const card = document.createElement('button'); 235 + card.type = 'button'; 236 + card.className = 'chat-talent-card ' + variantClass; 237 + card.dataset.talentUseId = useId; 238 + card.dataset.talentStatus = status; 239 + 240 + const labelNode = document.createElement('span'); 241 + labelNode.className = 'chat-talent-card-label'; 242 + labelNode.textContent = label; 243 + card.appendChild(labelNode); 244 + 245 + if (detail) { 246 + const detailNode = document.createElement('span'); 247 + detailNode.className = 'chat-talent-card-detail'; 248 + detailNode.textContent = detail; 249 + card.appendChild(detailNode); 250 + } 251 + 252 + return card; 253 + } 254 + 255 + async function postChat(text, formNode) { 256 + const button = formNode.querySelector('button[type=submit]'); 257 + const input = formNode.querySelector('textarea'); 258 + button.disabled = true; 259 + input.disabled = true; 260 + try { 261 + const response = await fetch('/api/chat', { 262 + method: 'POST', 263 + headers: { 'Content-Type': 'application/json' }, 264 + body: JSON.stringify({ 265 + message: text, 266 + app: 'chat', 267 + path: window.location.pathname, 268 + facet: window.selectedFacet || '' 269 + }) 270 + }); 271 + if (!response.ok) throw new Error('chat post failed'); 272 + input.value = ''; 273 + resizeComposer(input); 274 + } finally { 275 + button.disabled = false; 276 + input.disabled = false; 277 + input.focus(); 278 + } 279 + } 280 + 281 + function wireSearch() { 282 + if (!searchInput || !searchResults) return; 283 + searchInput.addEventListener('input', () => { 284 + const query = searchInput.value.trim(); 285 + window.clearTimeout(searchTimer); 286 + if (!query) { 287 + searchResults.hidden = true; 288 + searchResults.innerHTML = ''; 289 + return; 290 + } 291 + 292 + searchTimer = window.setTimeout(() => { 293 + runSearch(query); 294 + }, 150); 295 + }); 296 + } 297 + 298 + async function runSearch(query) { 299 + searchResults.hidden = false; 300 + searchResults.innerHTML = '<p class="chat-search-status">searching…</p>'; 301 + 302 + try { 303 + const params = new URLSearchParams({ 304 + q: query, 305 + stream: 'chat', 306 + day_from: '00000000', 307 + day_to: '99999999' 308 + }); 309 + const response = await fetch('/app/search/api/search?' + params.toString()); 310 + if (!response.ok) throw new Error('search failed'); 311 + const payload = await response.json(); 312 + renderSearchResults(payload); 313 + } catch (_error) { 314 + searchResults.innerHTML = '<p class="chat-search-status">search failed</p>'; 315 + } 316 + } 317 + 318 + function renderSearchResults(payload) { 319 + const rows = []; 320 + (payload.days || []).forEach((group) => { 321 + (group.results || []).forEach((row) => { 322 + rows.push({ 323 + day: row.day || group.day, 324 + date: group.date || row.day, 325 + idx: row.idx, 326 + text: row.text || '' 327 + }); 328 + }); 329 + }); 330 + 331 + searchResults.innerHTML = ''; 332 + if (!rows.length) { 333 + searchResults.innerHTML = '<p class="chat-search-status">no matches</p>'; 334 + return; 335 + } 336 + 337 + const list = document.createElement('ul'); 338 + list.className = 'chat-search-result-list'; 339 + 340 + rows.forEach((row) => { 341 + const item = document.createElement('li'); 342 + const link = document.createElement('a'); 343 + const meta = document.createElement('span'); 344 + const text = document.createElement('span'); 345 + 346 + link.href = '/app/chat/' + row.day + '#event-' + row.idx; 347 + link.className = 'chat-search-result-link'; 348 + 349 + meta.className = 'chat-search-result-day'; 350 + meta.textContent = row.date; 351 + 352 + text.className = 'chat-search-result-text'; 353 + text.innerHTML = row.text; 354 + 355 + link.appendChild(meta); 356 + link.appendChild(text); 357 + item.appendChild(link); 358 + list.appendChild(item); 359 + }); 360 + 361 + searchResults.appendChild(list); 362 + } 363 + 364 + function resizeComposer(input) { 365 + input.style.height = 'auto'; 366 + input.style.height = Math.min(120, input.scrollHeight) + 'px'; 367 + } 368 + 369 + function formatDay(ts) { 370 + const value = new Date(ts); 371 + return value.getFullYear() + 372 + String(value.getMonth() + 1).padStart(2, '0') + 373 + String(value.getDate()).padStart(2, '0'); 374 + } 375 + })(); 376 + </script>
+242
convey/static/app.css
··· 2299 2299 outline-offset: 2px; 2300 2300 } 2301 2301 2302 + /* chat app */ 2303 + .chat-app { 2304 + height: calc(100dvh - var(--facet-bar-height) - 16px); 2305 + max-width: 1100px; 2306 + margin: 0 auto; 2307 + padding: 0 16px 24px; 2308 + display: flex; 2309 + flex-direction: column; 2310 + gap: 12px; 2311 + } 2312 + 2313 + body.has-date-nav .chat-app { 2314 + height: calc(100dvh - var(--facet-bar-height) - var(--date-nav-height) - 16px); 2315 + } 2316 + 2317 + .chat-app-search { 2318 + position: sticky; 2319 + top: 0; 2320 + z-index: 1; 2321 + padding-top: 4px; 2322 + background: white; 2323 + } 2324 + 2325 + .chat-search-form input { 2326 + width: 100%; 2327 + padding: 10px 12px; 2328 + border: 1px solid var(--facet-border, #e5e0db); 2329 + border-radius: 12px; 2330 + font: inherit; 2331 + background: #fffdf9; 2332 + } 2333 + 2334 + .chat-search-results { 2335 + margin-top: 8px; 2336 + padding: 8px 12px; 2337 + border: 1px solid var(--facet-border, #e5e0db); 2338 + border-radius: 12px; 2339 + background: white; 2340 + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); 2341 + max-height: 240px; 2342 + overflow: auto; 2343 + } 2344 + 2345 + .chat-search-result-list { 2346 + list-style: none; 2347 + margin: 0; 2348 + padding: 0; 2349 + display: flex; 2350 + flex-direction: column; 2351 + gap: 8px; 2352 + } 2353 + 2354 + .chat-search-result-link { 2355 + display: flex; 2356 + flex-direction: column; 2357 + gap: 2px; 2358 + text-decoration: none; 2359 + color: inherit; 2360 + } 2361 + 2362 + .chat-search-result-day, 2363 + .chat-search-status, 2364 + .chat-composer-past, 2365 + .chat-empty, 2366 + .chat-time-sep { 2367 + color: #6b7280; 2368 + font-size: 12px; 2369 + } 2370 + 2371 + .chat-search-result-text { 2372 + font-size: 13px; 2373 + line-height: 1.4; 2374 + } 2375 + 2376 + .chat-transcript { 2377 + list-style: none; 2378 + margin: 0; 2379 + padding: 4px 0 16px; 2380 + flex: 1 1 auto; 2381 + overflow: auto; 2382 + display: flex; 2383 + flex-direction: column; 2384 + gap: 12px; 2385 + } 2386 + 2387 + .chat-event { 2388 + display: flex; 2389 + } 2390 + 2391 + .chat-event--owner { 2392 + justify-content: flex-end; 2393 + } 2394 + 2395 + .chat-event--sol { 2396 + justify-content: flex-start; 2397 + } 2398 + 2399 + .chat-event--talent, 2400 + .chat-event--error { 2401 + justify-content: center; 2402 + } 2403 + 2404 + .chat-bubble { 2405 + max-width: 70%; 2406 + padding: 10px 12px; 2407 + border-radius: 16px; 2408 + display: flex; 2409 + flex-direction: column; 2410 + gap: 4px; 2411 + box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06); 2412 + } 2413 + 2414 + .chat-bubble--owner { 2415 + background: var(--facet-color, #b06a1a); 2416 + color: white; 2417 + border-bottom-right-radius: 6px; 2418 + } 2419 + 2420 + .chat-bubble--sol { 2421 + background: #f6efe7; 2422 + color: #1f2937; 2423 + border-bottom-left-radius: 6px; 2424 + } 2425 + 2426 + .chat-bubble-author { 2427 + font-size: 11px; 2428 + opacity: 0.75; 2429 + } 2430 + 2431 + .chat-bubble-text { 2432 + white-space: pre-wrap; 2433 + line-height: 1.5; 2434 + } 2435 + 2436 + .chat-time-sep, 2437 + .chat-empty { 2438 + text-align: center; 2439 + } 2440 + 2441 + .chat-talent-card { 2442 + max-width: 70%; 2443 + padding: 10px 12px; 2444 + border-radius: 14px; 2445 + border: 1px solid var(--facet-border, #e5e0db); 2446 + background: white; 2447 + display: flex; 2448 + flex-direction: column; 2449 + gap: 4px; 2450 + text-align: left; 2451 + font: inherit; 2452 + cursor: pointer; 2453 + } 2454 + 2455 + .chat-talent-card--spawned { 2456 + border-color: rgba(176, 106, 26, 0.22); 2457 + background: rgba(176, 106, 26, 0.08); 2458 + } 2459 + 2460 + .chat-talent-card--finished { 2461 + border-color: rgba(15, 23, 42, 0.08); 2462 + background: rgba(15, 23, 42, 0.03); 2463 + } 2464 + 2465 + .chat-talent-card--errored, 2466 + .chat-error-block { 2467 + border-color: rgba(180, 35, 24, 0.2); 2468 + background: rgba(180, 35, 24, 0.08); 2469 + color: #b42318; 2470 + } 2471 + 2472 + .chat-talent-card-label { 2473 + font-size: 13px; 2474 + font-weight: 600; 2475 + } 2476 + 2477 + .chat-talent-card-task, 2478 + .chat-talent-card-summary, 2479 + .chat-talent-card-reason, 2480 + .chat-talent-card-detail { 2481 + font-size: 12px; 2482 + line-height: 1.4; 2483 + } 2484 + 2485 + .chat-error-block { 2486 + max-width: 70%; 2487 + padding: 10px 12px; 2488 + border: 1px solid rgba(180, 35, 24, 0.2); 2489 + border-radius: 14px; 2490 + } 2491 + 2492 + .chat-composer { 2493 + position: sticky; 2494 + bottom: 0; 2495 + display: flex; 2496 + gap: 8px; 2497 + padding-top: 8px; 2498 + background: linear-gradient(to top, white 70%, rgba(255, 255, 255, 0)); 2499 + } 2500 + 2501 + .chat-composer textarea { 2502 + flex: 1; 2503 + resize: none; 2504 + max-height: 120px; 2505 + padding: 10px 12px; 2506 + border: 1px solid var(--facet-border, #e5e0db); 2507 + border-radius: 12px; 2508 + font: inherit; 2509 + line-height: 1.4; 2510 + background: #fffdf9; 2511 + } 2512 + 2513 + .chat-composer button { 2514 + padding: 10px 14px; 2515 + border: 1px solid var(--facet-border, #e5e0db); 2516 + border-radius: 12px; 2517 + background: white; 2518 + font: inherit; 2519 + cursor: pointer; 2520 + } 2521 + 2522 + .chat-composer button:disabled, 2523 + .chat-composer textarea:disabled { 2524 + opacity: 0.6; 2525 + cursor: not-allowed; 2526 + } 2527 + 2528 + @media (max-width: 768px) { 2529 + .chat-app { 2530 + padding: 0 12px 16px; 2531 + } 2532 + 2533 + .chat-bubble, 2534 + .chat-talent-card, 2535 + .chat-error-block { 2536 + max-width: 88%; 2537 + } 2538 + 2539 + .chat-composer { 2540 + flex-direction: column; 2541 + } 2542 + } 2543 + 2302 2544 /* Visually hidden — accessible to screen readers */ 2303 2545 .visually-hidden { 2304 2546 position: absolute;