From a9d115dca31e56f0af6806f957a687e355a7399c Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 21:03:19 +0100 Subject: [PATCH 01/23] =?UTF-8?q?docs:=20implementation=20plan=20for=20cha?= =?UTF-8?q?t=20Phase=202b-1=20=E2=80=94=20threads=20+=20attachments=20+=20?= =?UTF-8?q?shared=20file=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 19 tasks covering attachments column migration, from-path endpoint, thread messages query + GET endpoint, thread recipient resolver, router integration, /help command + intercept, bridge event payload + attachment footer, VfsBrowser refactor, SharedFilePickerDialog shell primitive, chat-attachments-api client, AttachmentsBar + Gallery + Lightbox, hover actions + thread indicator + panel, chat-guide.md, MessagesApp integration, bundle rebuild, Playwright E2E. --- ...-19-chat-phase-2b-1-threads-attachments.md | 2645 +++++++++++++++++ 1 file changed, 2645 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-19-chat-phase-2b-1-threads-attachments.md diff --git a/docs/superpowers/plans/2026-04-19-chat-phase-2b-1-threads-attachments.md b/docs/superpowers/plans/2026-04-19-chat-phase-2b-1-threads-attachments.md new file mode 100644 index 00000000..73a4a9af --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-chat-phase-2b-1-threads-attachments.md @@ -0,0 +1,2645 @@ +# Chat Phase 2b-1 — Threads + Attachments + Shared File Picker + Chat Guide Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Threaded replies (right-side panel, narrow routing, per-thread policy), attachments via a reusable shell file picker (disk + user workspace + agent workspaces), and a canonical `docs/chat-guide.md` with an in-app `/help` surface. + +**Architecture:** Attachments land as a new jsonb column on `chat_messages` plus a new `POST /api/chat/attachments/from-path` for VFS sources. Threads reuse the existing `thread_id` column; a new `tinyagentos/chat/threads.py` handles narrow recipient resolution (parent author + repliers + `@mentions`, with `@all` escalating channel-wide) and the router grows one branch for thread routing. `/help` is a server-side text intercept, bypassing the Phase 2a bare-slash guardrail. Frontend factors the Files-app VFS browser into a shell primitive that both the Files app and a new `SharedFilePickerDialog` consume. + +**Tech Stack:** Python 3.12, FastAPI, pytest + pytest-asyncio, React + TypeScript, Vitest. Spec at `docs/superpowers/specs/2026-04-19-chat-phase-2b-1-threads-attachments-design.md`. + +--- + +## File Structure + +**New backend files:** +- `tinyagentos/chat/threads.py` — thread recipient resolver + context builder +- `tinyagentos/chat/help.py` — `/help [topic]` command handler +- `tests/test_chat_threads.py` +- `tests/test_chat_help.py` +- `tests/test_chat_attachments.py` + +**New frontend files:** +- `desktop/src/shell/VfsBrowser.tsx` — factored from FilesApp (dual consumer) +- `desktop/src/shell/FilePicker.tsx` — `SharedFilePickerDialog` +- `desktop/src/shell/file-picker-api.ts` — `openFilePicker(...)` +- `desktop/src/apps/chat/MessageHoverActions.tsx` — reaction + reply-in-thread + more +- `desktop/src/apps/chat/ThreadIndicator.tsx` — "N replies · Xm ago" chip +- `desktop/src/apps/chat/ThreadPanel.tsx` — right-side thread view +- `desktop/src/apps/chat/AttachmentsBar.tsx` — pre-send thumbnails +- `desktop/src/apps/chat/AttachmentGallery.tsx` — in-message gallery +- `desktop/src/apps/chat/AttachmentLightbox.tsx` — fullscreen viewer +- `desktop/src/lib/chat-attachments-api.ts` — upload + from-path client +- `desktop/src/lib/use-thread-panel.ts` — panel state hook +- Component tests under `__tests__/` + +**New docs:** +- `docs/chat-guide.md` — canonical guide (retroactive P1 + 2a + 2b-1) + +**Modified backend:** +- `tinyagentos/chat/message_store.py` — `attachments` column migration, persist attachments, `get_thread_messages` +- `tinyagentos/agent_chat_router.py` — thread-aware recipient branch, per-thread policy key +- `tinyagentos/routes/chat.py` — `/help` intercept, thread-messages GET, `attachments/from-path` POST, message send accepts `attachments` +- `tinyagentos/bridge_session.py` — event payload includes `thread_id` + `attachments` +- `tinyagentos/scripts/install_{hermes,smolagents,langroid,pocketflow,openai_agents_sdk,openai-agents-sdk}.sh` — bridge `_render_context` appends attachment footer + +**Modified frontend:** +- `desktop/src/apps/FilesApp.tsx` — consume shared `VfsBrowser` +- `desktop/src/apps/MessagesApp.tsx` — integrate hover actions, threads, attachments, "?" icon +- `static/desktop/**` — rebuilt bundle + +--- + +## Task 1: Attachments column migration + message_store + +**Files:** +- Modify: `tinyagentos/chat/message_store.py` +- Test: `tests/test_chat_attachments.py` (new) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_chat_attachments.py`: + +```python +import json +import pytest +from tinyagentos.chat.message_store import ChatMessageStore + + +@pytest.mark.asyncio +async def test_send_message_persists_attachments(tmp_path): + store = ChatMessageStore(tmp_path / "msgs.db") + await store.init() + atts = [ + {"filename": "screenshot.png", "mime_type": "image/png", + "size": 312456, "url": "/api/chat/files/abc-screenshot.png", + "source": "disk"}, + ] + msg = await store.send_message( + channel_id="c1", author_id="user", author_type="user", + content="look", content_type="text", state="complete", + metadata=None, attachments=atts, + ) + assert msg["attachments"] == atts + + +@pytest.mark.asyncio +async def test_send_message_defaults_attachments_to_empty_list(tmp_path): + store = ChatMessageStore(tmp_path / "msgs.db") + await store.init() + msg = await store.send_message( + channel_id="c1", author_id="user", author_type="user", + content="plain", content_type="text", state="complete", + metadata=None, + ) + assert msg["attachments"] == [] + + +@pytest.mark.asyncio +async def test_get_message_round_trips_attachments(tmp_path): + store = ChatMessageStore(tmp_path / "msgs.db") + await store.init() + atts = [{"filename": "r.pdf", "mime_type": "application/pdf", + "size": 500, "url": "/api/chat/files/r.pdf", "source": "workspace"}] + msg = await store.send_message( + channel_id="c1", author_id="user", author_type="user", + content="see", content_type="text", state="complete", + metadata=None, attachments=atts, + ) + roundtripped = await store.get_message(msg["id"]) + assert roundtripped["attachments"] == atts +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. pytest tests/test_chat_attachments.py -v` +Expected: FAIL — `send_message` doesn't accept `attachments`; `attachments` column doesn't exist. + +- [ ] **Step 3: Add the migration + column** + +In `tinyagentos/chat/message_store.py`, in the `init()` method where the table is created, add `attachments TEXT NOT NULL DEFAULT '[]'` to the schema, and add an idempotent `ALTER TABLE` for existing databases. + +Find the `CREATE TABLE IF NOT EXISTS chat_messages (...)` statement. Add `attachments TEXT NOT NULL DEFAULT '[]'` as a new column definition. Then, after the CREATE TABLE statement runs, add the migration: + +```python +# Idempotent migration for databases created before Phase 2b-1. +try: + await self._conn.execute( + "ALTER TABLE chat_messages ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]'" + ) +except Exception: + pass # column already exists +``` + +- [ ] **Step 4: Thread the field through send_message** + +Update the `send_message` signature and body: + +```python +async def send_message( + self, + *, + channel_id: str, + author_id: str, + author_type: str, + content: str, + content_type: str, + state: str, + metadata: dict | None, + thread_id: str | None = None, + attachments: list[dict] | None = None, +) -> dict: + ... +``` + +In the INSERT column list, add `attachments`. In the VALUES binding, add `json.dumps(attachments or [])`. Return the message dict with `attachments` parsed. + +Update `_parse` (the row-to-dict helper) to JSON-decode `attachments`, defaulting to `[]` on parse failure. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `PYTHONPATH=. pytest tests/test_chat_attachments.py -v` +Expected: 3 pass. + +Run: `PYTHONPATH=. pytest tests/test_chat_messages.py -v` (existing suite must stay green). +Expected: all existing tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add tinyagentos/chat/message_store.py tests/test_chat_attachments.py +git commit -m "feat(chat): attachments column on chat_messages + persist/parse round-trip" +``` + +--- + +## Task 2: `POST /api/chat/attachments/from-path` endpoint + ACL + +**Files:** +- Modify: `tinyagentos/routes/chat.py` +- Test: `tests/test_chat_attachments.py` (extend) + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_chat_attachments.py`: + +```python +import os +from httpx import AsyncClient, ASGITransport +from tinyagentos.app import create_app + + +@pytest.mark.asyncio +async def test_from_path_copies_workspace_file_and_returns_record(tmp_path, monkeypatch): + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path)) + # seed a file in the user workspace + ws = tmp_path / "agent-workspaces" / "user" + ws.mkdir(parents=True, exist_ok=True) + (ws / "report.md").write_text("# hi") + + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + r = await client.post( + "/api/chat/attachments/from-path", + json={"path": "/workspaces/user/report.md", "source": "workspace"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["filename"] == "report.md" + assert body["mime_type"] == "text/markdown" + assert body["source"] == "workspace" + assert body["url"].startswith("/api/chat/files/") + # physical file exists + stored_name = body["url"].rsplit("/", 1)[-1] + assert (tmp_path / "chat-files" / stored_name).exists() + + +@pytest.mark.asyncio +async def test_from_path_rejects_traversal(tmp_path, monkeypatch): + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path)) + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + r = await client.post( + "/api/chat/attachments/from-path", + json={"path": "/workspaces/user/../../../etc/passwd", "source": "workspace"}, + ) + assert r.status_code in (400, 403) + + +@pytest.mark.asyncio +async def test_from_path_rejects_oversize(tmp_path, monkeypatch): + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path)) + ws = tmp_path / "agent-workspaces" / "user" + ws.mkdir(parents=True, exist_ok=True) + big = ws / "big.bin" + big.write_bytes(b"0" * (101 * 1024 * 1024)) # 101 MB + + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + r = await client.post( + "/api/chat/attachments/from-path", + json={"path": "/workspaces/user/big.bin", "source": "workspace"}, + ) + assert r.status_code == 413 or r.status_code == 400 + assert "too large" in r.json().get("error", "").lower() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. pytest tests/test_chat_attachments.py -v -k "from_path"` +Expected: 3 FAIL — endpoint missing. + +- [ ] **Step 3: Implement the endpoint** + +In `tinyagentos/routes/chat.py`, add near the upload endpoint: + +```python +import mimetypes +import secrets +import shutil +from pathlib import Path as _Path + +_MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024 # 100 MB + + +def _resolve_workspace_path(data_dir: Path, source: str, slug: str | None, vfs_path: str) -> Path: + """Resolve a VFS path like '/workspaces/user/foo.md' to an on-disk + absolute path under data_dir/agent-workspaces/{slug-or-user}. + Raises ValueError on traversal or bad shape. + """ + if not vfs_path.startswith("/workspaces/"): + raise ValueError("path must start with /workspaces/") + parts = vfs_path.split("/", 3) # ['', 'workspaces', '', 'rest...'] + if len(parts) < 3 or not parts[2]: + raise ValueError("path missing slug") + owner = parts[2] + if source == "agent-workspace": + if not slug or slug != owner: + raise ValueError("slug must match path owner for agent-workspace") + if source == "workspace": + if owner != "user": + raise ValueError("workspace source requires /workspaces/user/...") + rel = parts[3] if len(parts) > 3 else "" + root = (data_dir / "agent-workspaces" / owner).resolve() + target = (root / rel).resolve() + # Traversal check: target must be inside root. + if not str(target).startswith(str(root) + os.sep) and target != root: + raise ValueError("path traversal rejected") + if not target.exists() or target.is_dir(): + raise ValueError("file not found") + return target + + +@router.post("/api/chat/attachments/from-path") +async def attachment_from_path(body: dict, request: Request): + """Server-side reference to a file in a workspace. Copies into + chat-files/ and returns the attachment record.""" + vfs_path = (body or {}).get("path") + source = (body or {}).get("source") + slug = (body or {}).get("slug") + if not vfs_path or source not in ("workspace", "agent-workspace"): + return JSONResponse({"error": "path and source in {workspace,agent-workspace} required"}, status_code=400) + data_dir = Path(getattr(request.app.state, "data_dir", Path(os.environ.get("TAOS_DATA_DIR", "./data")))) + try: + src = _resolve_workspace_path(data_dir, source, slug, vfs_path) + except ValueError as e: + return JSONResponse({"error": str(e)}, status_code=400) + if src.stat().st_size > _MAX_ATTACHMENT_BYTES: + return JSONResponse({"error": "file too large (100 MB max)"}, status_code=413) + chat_files = data_dir / "chat-files" + chat_files.mkdir(parents=True, exist_ok=True) + stored_name = f"{secrets.token_hex(8)}-{src.name}" + dest = chat_files / stored_name + shutil.copy2(src, dest) + mime, _ = mimetypes.guess_type(src.name) + return JSONResponse({ + "filename": src.name, + "mime_type": mime or "application/octet-stream", + "size": src.stat().st_size, + "url": f"/api/chat/files/{stored_name}", + "source": source, + }, status_code=200) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `PYTHONPATH=. pytest tests/test_chat_attachments.py -v` +Expected: 6 pass (3 from Task 1 + 3 new). + +- [ ] **Step 5: Commit** + +```bash +git add tinyagentos/routes/chat.py tests/test_chat_attachments.py +git commit -m "feat(chat): POST /api/chat/attachments/from-path for workspace file refs" +``` + +--- + +## Task 3: `POST /api/chat/messages` accepts attachments[] + +**Files:** +- Modify: `tinyagentos/routes/chat.py` +- Test: `tests/test_chat_attachments.py` (extend) + +- [ ] **Step 1: Write the failing test** + +Append: + +```python +@pytest.mark.asyncio +async def test_send_message_with_attachments_persists(tmp_path, monkeypatch): + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path)) + # seed a file that /api/chat/files/ would serve + (tmp_path / "chat-files").mkdir(parents=True, exist_ok=True) + (tmp_path / "chat-files" / "abc-file.png").write_bytes(b"x") + + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + store = app.state.chat_channels + ch = await store.create_channel( + name="g", type="group", description="", topic="", + members=["user", "tom"], settings={}, created_by="user", + ) + ch_id = ch["id"] if isinstance(ch, dict) else ch + r = await client.post( + "/api/chat/messages", + json={ + "channel_id": ch_id, "author_id": "user", + "author_type": "user", "content": "here", + "content_type": "text", + "attachments": [ + {"filename": "file.png", "mime_type": "image/png", + "size": 1, "url": "/api/chat/files/abc-file.png", + "source": "disk"}, + ], + }, + ) + assert r.status_code in (200, 201) + body = r.json() + assert body["attachments"][0]["filename"] == "file.png" + + +@pytest.mark.asyncio +async def test_send_message_rejects_more_than_10_attachments(tmp_path, monkeypatch): + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path)) + (tmp_path / "chat-files").mkdir(parents=True, exist_ok=True) + (tmp_path / "chat-files" / "f.png").write_bytes(b"x") + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + store = app.state.chat_channels + ch = await store.create_channel( + name="g", type="group", description="", topic="", + members=["user"], settings={}, created_by="user", + ) + ch_id = ch["id"] if isinstance(ch, dict) else ch + atts = [{"filename": "f.png", "mime_type": "image/png", "size": 1, + "url": "/api/chat/files/f.png", "source": "disk"}] * 11 + r = await client.post( + "/api/chat/messages", + json={"channel_id": ch_id, "author_id": "user", + "author_type": "user", "content": "overflow", + "content_type": "text", "attachments": atts}, + ) + assert r.status_code == 400 + assert "10" in r.json().get("error", "") + + +@pytest.mark.asyncio +async def test_send_message_rejects_bad_url_prefix(tmp_path, monkeypatch): + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path)) + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + store = app.state.chat_channels + ch = await store.create_channel( + name="g", type="group", description="", topic="", + members=["user"], settings={}, created_by="user", + ) + ch_id = ch["id"] if isinstance(ch, dict) else ch + r = await client.post( + "/api/chat/messages", + json={"channel_id": ch_id, "author_id": "user", + "author_type": "user", "content": "bad", + "content_type": "text", + "attachments": [ + {"filename": "f", "mime_type": "x", "size": 1, + "url": "https://evil.example/f", "source": "disk"} + ]}, + ) + assert r.status_code == 400 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `PYTHONPATH=. pytest tests/test_chat_attachments.py -v -k "send_message"` +Expected: 3 FAIL. + +- [ ] **Step 3: Implement in routes/chat.py** + +In the POST `/api/chat/messages` handler, before calling `chat_messages.send_message(...)`, extract + validate `attachments`: + +```python +attachments = (body or {}).get("attachments") or [] +if not isinstance(attachments, list): + return JSONResponse({"error": "attachments must be a list"}, status_code=400) +if len(attachments) > 10: + return JSONResponse({"error": "max 10 attachments per message"}, status_code=400) +data_dir = Path(getattr(request.app.state, "data_dir", Path(os.environ.get("TAOS_DATA_DIR", "./data")))) +chat_files = data_dir / "chat-files" +for att in attachments: + if not isinstance(att, dict): + return JSONResponse({"error": "each attachment must be a dict"}, status_code=400) + url = att.get("url", "") + if not url.startswith("/api/chat/files/"): + return JSONResponse({"error": "attachment url must be served from /api/chat/files/"}, status_code=400) + stored_name = url.rsplit("/", 1)[-1] + if not (chat_files / stored_name).exists(): + return JSONResponse({"error": f"attachment file not found: {stored_name}"}, status_code=400) +``` + +Then pass `attachments=attachments` into the `send_message(...)` call. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `PYTHONPATH=. pytest tests/test_chat_attachments.py -v` +Expected: 9 pass (3 from Task 1 + 3 from Task 2 + 3 new). + +- [ ] **Step 5: Commit** + +```bash +git add tinyagentos/routes/chat.py tests/test_chat_attachments.py +git commit -m "feat(chat): POST /api/chat/messages accepts attachments[] with validation" +``` + +--- + +## Task 4: Thread messages query + GET endpoint + +**Files:** +- Modify: `tinyagentos/chat/message_store.py` +- Modify: `tinyagentos/routes/chat.py` +- Test: `tests/test_chat_threads.py` (new) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_chat_threads.py`: + +```python +import pytest +from httpx import AsyncClient, ASGITransport +from tinyagentos.app import create_app +from tinyagentos.chat.message_store import ChatMessageStore + + +@pytest.mark.asyncio +async def test_get_thread_messages_returns_replies_oldest_first(tmp_path): + store = ChatMessageStore(tmp_path / "msgs.db") + await store.init() + parent = await store.send_message( + channel_id="c1", author_id="user", author_type="user", + content="parent", content_type="text", state="complete", metadata=None, + ) + r1 = await store.send_message( + channel_id="c1", author_id="tom", author_type="agent", + content="r1", content_type="text", state="complete", metadata=None, + thread_id=parent["id"], + ) + r2 = await store.send_message( + channel_id="c1", author_id="don", author_type="agent", + content="r2", content_type="text", state="complete", metadata=None, + thread_id=parent["id"], + ) + msgs = await store.get_thread_messages(channel_id="c1", parent_id=parent["id"], limit=20) + assert [m["id"] for m in msgs] == [r1["id"], r2["id"]] + # parent is NOT included + assert all(m["id"] != parent["id"] for m in msgs) + + +@pytest.mark.asyncio +async def test_get_thread_messages_endpoint(tmp_path, monkeypatch): + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path)) + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + store = app.state.chat_channels + ch = await store.create_channel( + name="g", type="group", description="", topic="", + members=["user", "tom"], settings={}, created_by="user", + ) + ch_id = ch["id"] if isinstance(ch, dict) else ch + # post a parent + reply via HTTP + r = await client.post( + "/api/chat/messages", + json={"channel_id": ch_id, "author_id": "user", "author_type": "user", + "content": "parent", "content_type": "text"}, + ) + parent_id = r.json()["id"] + r = await client.post( + "/api/chat/messages", + json={"channel_id": ch_id, "author_id": "user", "author_type": "user", + "content": "reply", "content_type": "text", + "thread_id": parent_id}, + ) + assert r.status_code in (200, 201) + r = await client.get(f"/api/chat/channels/{ch_id}/threads/{parent_id}/messages") + assert r.status_code == 200 + body = r.json() + assert len(body["messages"]) == 1 + assert body["messages"][0]["content"] == "reply" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. pytest tests/test_chat_threads.py -v` +Expected: FAIL — method + endpoint missing. + +- [ ] **Step 3: Add `get_thread_messages` to message_store** + +In `tinyagentos/chat/message_store.py`: + +```python +async def get_thread_messages( + self, channel_id: str, parent_id: str, limit: int = 20, +) -> list[dict]: + """Return messages in a thread (not the parent), oldest first. + + Thread replies are persisted with thread_id = parent_id. + """ + async with self._conn.execute( + "SELECT * FROM chat_messages " + "WHERE channel_id = ? AND thread_id = ? " + "ORDER BY created_at ASC LIMIT ?", + (channel_id, parent_id, limit), + ) as cursor: + rows = await cursor.fetchall() + description = cursor.description + return [_parse(row, description) for row in rows] +``` + +Also update `send_message` to accept `thread_id: str | None = None` (wire into the INSERT — `thread_id` column already exists) if not already done in Task 1. + +- [ ] **Step 4: Add the GET endpoint** + +In `tinyagentos/routes/chat.py`: + +```python +@router.get("/api/chat/channels/{channel_id}/threads/{parent_id}/messages") +async def get_thread_messages_endpoint( + channel_id: str, parent_id: str, request: Request, limit: int = 20, +): + store = request.app.state.chat_messages + msgs = await store.get_thread_messages(channel_id, parent_id, limit=min(limit, 100)) + return JSONResponse({"messages": msgs}) +``` + +Also update the POST `/api/chat/messages` handler to forward `thread_id` from the request body into `chat_messages.send_message(...)`. Find the existing `send_message` call in that handler; add `thread_id=body.get("thread_id")` to its kwargs. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `PYTHONPATH=. pytest tests/test_chat_threads.py -v` +Expected: 2 pass. + +Run: `PYTHONPATH=. pytest tests/test_chat_messages.py tests/test_chat_channels.py -v` — existing tests must stay green. + +- [ ] **Step 6: Commit** + +```bash +git add tinyagentos/chat/message_store.py tinyagentos/routes/chat.py tests/test_chat_threads.py +git commit -m "feat(chat): get_thread_messages + GET /channels/{id}/threads/{parent}/messages" +``` + +--- + +## Task 5: `threads.py` recipient resolver + +**Files:** +- Create: `tinyagentos/chat/threads.py` +- Test: `tests/test_chat_threads.py` (extend) + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_chat_threads.py`: + +```python +from unittest.mock import AsyncMock, MagicMock +from tinyagentos.chat.threads import resolve_thread_recipients + + +def _ch(members, muted=None): + return { + "id": "c1", "type": "group", "members": members, + "settings": {"muted": muted or []}, + } + + +@pytest.mark.asyncio +async def test_narrow_scope_parent_author_and_mentions(): + """parent=tom (agent), mentions @linus: recipients = {tom, linus}.""" + cm = MagicMock() + cm.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "tom", "author_type": "agent", + }) + cm.get_thread_messages = AsyncMock(return_value=[]) + msg = { + "author_id": "user", "author_type": "user", + "content": "@linus thoughts?", "thread_id": "p1", + } + recipients, forced = await resolve_thread_recipients( + msg, _ch(["user", "tom", "don", "linus"]), cm, + ) + assert sorted(recipients) == ["linus", "tom"] + assert forced["linus"] is True + # tom (parent author) is a recipient but not force_respond unless mentioned + assert forced.get("tom") is not True + + +@pytest.mark.asyncio +async def test_narrow_scope_prior_repliers(): + """Thread already has don as a replier → don is a recipient even if not mentioned.""" + cm = MagicMock() + cm.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "user", "author_type": "user", + }) + cm.get_thread_messages = AsyncMock(return_value=[ + {"author_id": "don", "author_type": "agent"}, + ]) + msg = {"author_id": "user", "author_type": "user", + "content": "more thoughts?", "thread_id": "p1"} + recipients, forced = await resolve_thread_recipients( + msg, _ch(["user", "tom", "don"]), cm, + ) + assert "don" in recipients + + +@pytest.mark.asyncio +async def test_at_all_escalates_to_all_channel_agents(): + cm = MagicMock() + cm.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "user", "author_type": "user", + }) + cm.get_thread_messages = AsyncMock(return_value=[]) + msg = {"author_id": "user", "author_type": "user", + "content": "@all weigh in", "thread_id": "p1"} + recipients, forced = await resolve_thread_recipients( + msg, _ch(["user", "tom", "don", "linus"]), cm, + ) + assert sorted(recipients) == ["don", "linus", "tom"] + assert all(forced[s] is True for s in recipients) + + +@pytest.mark.asyncio +async def test_muted_agent_excluded_from_thread(): + cm = MagicMock() + cm.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "tom", "author_type": "agent", + }) + cm.get_thread_messages = AsyncMock(return_value=[]) + msg = {"author_id": "user", "author_type": "user", + "content": "hi", "thread_id": "p1"} + recipients, _ = await resolve_thread_recipients( + msg, _ch(["user", "tom", "don"], muted=["tom"]), cm, + ) + assert "tom" not in recipients + + +@pytest.mark.asyncio +async def test_author_never_recipient(): + """Agent tom replies in a thread → tom is not re-notified.""" + cm = MagicMock() + cm.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "user", "author_type": "user", + }) + cm.get_thread_messages = AsyncMock(return_value=[ + {"author_id": "tom", "author_type": "agent"}, + ]) + msg = {"author_id": "tom", "author_type": "agent", + "content": "follow-up", "thread_id": "p1"} + recipients, _ = await resolve_thread_recipients( + msg, _ch(["user", "tom", "don"]), cm, + ) + assert "tom" not in recipients + + +@pytest.mark.asyncio +async def test_user_parent_author_not_recipient(): + """Parent authored by user → parent-author rule adds nobody (user is not an agent).""" + cm = MagicMock() + cm.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "user", "author_type": "user", + }) + cm.get_thread_messages = AsyncMock(return_value=[]) + msg = {"author_id": "user", "author_type": "user", + "content": "kickoff", "thread_id": "p1"} + recipients, _ = await resolve_thread_recipients( + msg, _ch(["user", "tom", "don"]), cm, + ) + # No agent has opted in yet, no mentions → empty recipients + assert recipients == [] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. pytest tests/test_chat_threads.py -v` +Expected: FAIL — `tinyagentos.chat.threads` module missing. + +- [ ] **Step 3: Implement** + +Create `tinyagentos/chat/threads.py`: + +```python +"""Thread-aware recipient resolution for agent chat routing. + +Narrow-by-default scope: parent-message author (if agent), prior thread +repliers, and explicit @ mentions in the new message. @all inside +a thread escalates to every channel-member agent with force_respond=true. + +Muted agents are excluded. The message author is always excluded +(threads don't re-notify the speaker). +""" +from __future__ import annotations + +from tinyagentos.chat.mentions import parse_mentions + + +async def resolve_thread_recipients( + message: dict, channel: dict, chat_messages, +) -> tuple[list[str], dict[str, bool]]: + """Return (recipients, force_by_slug) for a message in a thread. + + Args: + message: the new message being routed. Must have thread_id, author_id, + author_type, content. + channel: the channel dict including members and settings.muted. + chat_messages: the ChatMessageStore (needs get_message, get_thread_messages). + """ + author = message["author_id"] + thread_id = message.get("thread_id") + if not thread_id: + return [], {} + + members = channel.get("members") or [] + muted = set((channel.get("settings") or {}).get("muted") or []) + candidates_all = [m for m in members if m and m != author and m != "user" and m not in muted] + + mentions = parse_mentions(message.get("content") or "", members) + + # @all escalation — fan out to every agent in channel. + if mentions.all: + return list(candidates_all), {m: True for m in candidates_all} + + recipients: set[str] = set() + forced: dict[str, bool] = {} + + # Parent author (if agent, and not the current author). + parent = await chat_messages.get_message(thread_id) + if parent and parent.get("author_type") == "agent": + parent_author = parent.get("author_id") + if parent_author and parent_author != author and parent_author not in muted: + recipients.add(parent_author) + + # Prior repliers (agents only). + prior = await chat_messages.get_thread_messages( + channel_id=channel["id"], parent_id=thread_id, limit=200, + ) + for m in prior: + if m.get("author_type") == "agent": + aid = m.get("author_id") + if aid and aid != author and aid not in muted: + recipients.add(aid) + + # Explicit mentions (force_respond). + for slug in mentions.explicit: + if slug in candidates_all: + recipients.add(slug) + forced[slug] = True + + return sorted(recipients), forced +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `PYTHONPATH=. pytest tests/test_chat_threads.py -v` +Expected: 8 pass (2 from Task 4 + 6 new). + +- [ ] **Step 5: Commit** + +```bash +git add tinyagentos/chat/threads.py tests/test_chat_threads.py +git commit -m "feat(chat): threads.resolve_thread_recipients for narrow thread routing" +``` + +--- + +## Task 6: agent_chat_router integrates threads + +**Files:** +- Modify: `tinyagentos/agent_chat_router.py` +- Test: `tests/test_agent_chat_router.py` (extend) + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_agent_chat_router.py`: + +```python +@pytest.mark.asyncio +async def test_router_uses_thread_resolver_when_thread_id_present(): + """A message with thread_id set goes through threads.resolve_thread_recipients, + skipping the channel fanout path.""" + bridge = _FakeBridge() + state = _state_for({"name": "tom", "status": "running"}, bridge=bridge) + state.config.agents = [ + {"name": "tom", "status": "running"}, + {"name": "don", "status": "running"}, + ] + from tinyagentos.chat.group_policy import GroupPolicy + state.group_policy = GroupPolicy() + # chat_messages needs get_message + get_thread_messages for the resolver + state.chat_messages.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "tom", "author_type": "agent", + }) + state.chat_messages.get_thread_messages = AsyncMock(return_value=[]) + router = AgentChatRouter(state) + message = { + "id": "m1", "author_id": "user", "author_type": "user", + "content": "thoughts?", "thread_id": "p1", + "metadata": {"hops_since_user": 0}, + } + await router._route(message, _channel(["user", "tom", "don"], "quiet")) + slugs = sorted(c[0] for c in bridge.calls) + # tom is the parent author → recipient; don is not mentioned + not prior replier → skipped. + assert slugs == ["tom"] + + +@pytest.mark.asyncio +async def test_router_thread_policy_key_is_scoped(): + """Policy key used in thread routing should be channel_id:thread:, + so a thread doesn't consume the channel's rate cap or block unrelated + channel messages.""" + bridge = _FakeBridge() + state = _state_for({"name": "tom", "status": "running"}, bridge=bridge) + state.config.agents = [{"name": "tom", "status": "running"}] + from tinyagentos.chat.group_policy import GroupPolicy + state.group_policy = GroupPolicy() + state.chat_messages.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "tom", "author_type": "agent", + }) + state.chat_messages.get_thread_messages = AsyncMock(return_value=[]) + router = AgentChatRouter(state) + # Route a thread message. + msg = {"id": "m1", "author_id": "user", "author_type": "user", + "content": "go", "thread_id": "p1", + "metadata": {"hops_since_user": 0}} + await router._route(msg, _channel(["user", "tom"], "quiet")) + # The policy should have recorded a send keyed "c1:thread:p1" — check by trying to + # route a channel-scope message next; it should NOT be rate-limited by the thread send. + msg2 = {"id": "m2", "author_id": "user", "author_type": "user", + "content": "channel msg", "metadata": {"hops_since_user": 0}} + await router._route(msg2, _channel(["user", "tom"], "lively")) + # Expect 2 bridge calls total (one per message), since policy keys are independent. + assert len(bridge.calls) == 2 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. pytest tests/test_agent_chat_router.py -v -k "thread"` +Expected: FAIL. + +- [ ] **Step 3: Integrate threads into `_route_inner`** + +At the top of `_route_inner`, after the `content_type == "system"` guard, add a branch: + +```python +thread_id = message.get("thread_id") +if thread_id: + from tinyagentos.chat.threads import resolve_thread_recipients + recipients, force_by_slug = await resolve_thread_recipients( + message, channel, self._state.chat_messages, + ) + if not recipients: + return + # Thread policy key scopes hops/cooldown/rate-cap per thread. + policy_key = f"{channel['id']}:thread:{thread_id}" +else: + # ... existing channel recipient selection code ... + policy_key = channel["id"] +``` + +Change every `policy.may_send(channel["id"], agent_name, settings)` and `policy.record_send(channel["id"], agent_name)` to use `policy_key` instead of `channel["id"]`. + +Update the enqueued event payload to include `thread_id`: + +```python +await bridge.enqueue_user_message( + agent_name, + { + # ... existing fields ... + "thread_id": thread_id, # None for channel messages + }, +) +``` + +The thread context window is built from thread messages, not channel messages. In the context-fetch block (the `try: recent = await self._state.chat_messages.get_messages(...)`), branch on thread_id: + +```python +context = [] +if hasattr(self._state, "chat_messages"): + try: + from tinyagentos.chat.context_window import build_context_window + if thread_id: + recent = await self._state.chat_messages.get_thread_messages( + channel_id=channel["id"], parent_id=thread_id, limit=30, + ) + # Prepend the parent as the root turn. + parent = await self._state.chat_messages.get_message(thread_id) + if parent: + recent = [parent] + list(recent) + else: + recent = await self._state.chat_messages.get_messages( + channel_id=channel["id"], limit=30, + ) + context = build_context_window(recent, limit=20, max_tokens=4000) + except Exception: + logger.warning("context fetch failed for channel %s", channel.get("id"), exc_info=True) + context = [] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `PYTHONPATH=. pytest tests/test_agent_chat_router.py -v` +Expected: all pass (new + existing). + +Run: `PYTHONPATH=. pytest tests/ -x -q` +Expected: no regressions. + +- [ ] **Step 5: Commit** + +```bash +git add tinyagentos/agent_chat_router.py tests/test_agent_chat_router.py +git commit -m "feat(chat): router integrates thread-aware recipients + per-thread policy + thread context" +``` + +--- + +## Task 7: `/help` command module + +**Files:** +- Create: `tinyagentos/chat/help.py` +- Test: `tests/test_chat_help.py` (new) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_chat_help.py`: + +```python +import pytest +from tinyagentos.chat.help import handle_help, KNOWN_TOPICS + + +def test_overview_on_empty_args(): + out = handle_help("") + assert "chat-guide" in out.lower() + # lists known topics + for t in ["threads", "attachments", "mentions"]: + assert t in out + + +def test_specific_topic_returns_section(): + out = handle_help("threads") + assert "thread" in out.lower() + assert "chat-guide" in out.lower() # link to full guide + + +def test_unknown_topic_returns_generic_message(): + out = handle_help("unknownthing") + assert "unknown" in out.lower() or "try /help" in out.lower() + + +def test_all_documented_topics_have_handlers(): + for t in KNOWN_TOPICS: + out = handle_help(t) + assert len(out) > 0 + assert "error" not in out.lower() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. pytest tests/test_chat_help.py -v` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement** + +Create `tinyagentos/chat/help.py`: + +```python +"""/help command handler — posts short cheat sheets into the channel +as system messages. Full reference lives in docs/chat-guide.md. +""" +from __future__ import annotations + +GUIDE_URL = "https://github.com/jaylfc/tinyagentos/blob/master/docs/chat-guide.md" + +KNOWN_TOPICS = ( + "channels", + "mentions", + "hops", + "reactions", + "slash", + "settings", + "context", + "threads", + "attachments", + "help", +) + +_OVERVIEW = f"""**taOS chat — quick help** + +- `@tom`, `@all`, `@humans` — target specific recipients +- `/` in composer opens the command picker for the current channel's agents +- `ⓘ` in the header opens channel settings (mode, members, muted, etc.) +- Right-click / hover a message for actions (reply in thread, react, etc.) + +Try `/help ` where topic is one of: {", ".join(t for t in KNOWN_TOPICS if t != "help")}. +Full guide: {GUIDE_URL} +""" + +_TOPICS: dict[str, str] = { + "channels": f"""**Channels** +- DM (2 members), group (many), topic (many, focused) +- Group/topic channels have a mode: `quiet` (respond when @mentioned only) or `lively` (every agent decides per message) +- DMs always lively — the 1:1 agent always replies. + +Details: {GUIDE_URL}#channels-and-modes""", + "mentions": f"""**Mentions** +- `@tom` — target one agent +- `@all` — every agent in the channel +- `@humans` — ping humans +- Case-insensitive; word boundary so `email@x.com` doesn't count + +Details: {GUIDE_URL}#mentions""", + "hops": f"""**Hops, cooldown, rate-cap** +- Hop counter resets on each user message; caps chains between agents (default 3) +- Per-agent cooldown prevents burst replies (default 5 s) +- Per-channel rate cap (default 20/min) is a circuit breaker +- `@mention` overrides all three caps + +Details: {GUIDE_URL}#hops-cooldown-rate-cap""", + "reactions": f"""**Reactions** +- Any emoji — click 😀 on a message's hover row +- `👎` by the channel's human on an agent reply → regenerate +- `🙋` by an agent → "hand raise" (shows a badge; no auto-reply) + +Details: {GUIDE_URL}#reactions""", + "slash": f"""**Slash menu** +- Type `/` at the start of a message to open the command picker +- Commands grouped by agent; fuzzy filter as you type +- Enter selects → inserts `@ /` into the composer + +Details: {GUIDE_URL}#slash-menu""", + "settings": f"""**Channel settings** +- `ⓘ` in chat header opens the settings panel (right side) +- Rename, topic, members, muted agents, mode, max hops, cooldown +- DMs have no settings panel (two-member 1:1) + +Details: {GUIDE_URL}#channel-settings""", + "context": f"""**Agent context menu** +- Right-click an agent's name or avatar anywhere for actions +- DM, (un)mute, remove, view info, jump to agent settings +- Shift+F10 on a focused message row opens the same menu + +Details: {GUIDE_URL}#agent-context-menu""", + "threads": f"""**Threads** +- Hover a message → `💬 Reply in thread` opens a right-side panel +- Thread replies have narrow routing — parent author + prior repliers + @mentions +- `@all` inside a thread escalates to every channel agent +- Hops, cooldown, rate-cap all scoped per thread + +Details: {GUIDE_URL}#threads""", + "attachments": f"""**Attachments** +- Paperclip button, drag-and-drop, or paste from clipboard +- Paperclip opens a file picker with tabs: Disk / My workspace / Agent workspaces +- Up to 10 attachments per message; 100 MB max per file +- Images render inline; 2+ images → gallery grid + +Details: {GUIDE_URL}#attachments""", + "help": f"""**/help** +- `/help` on its own — overview + topic list +- `/help ` — the section for that topic +- Topics: {", ".join(t for t in KNOWN_TOPICS if t != "help")} + +Full guide: {GUIDE_URL}""", +} + + +def handle_help(args: str) -> str: + """Return the system-message text for `/help [topic]`.""" + topic = (args or "").strip().lower().split() + if not topic: + return _OVERVIEW + key = topic[0] + if key in _TOPICS: + return _TOPICS[key] + return f"Unknown help topic '{key}'. Try `/help` for the overview." +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `PYTHONPATH=. pytest tests/test_chat_help.py -v` +Expected: 4 pass. + +- [ ] **Step 5: Commit** + +```bash +git add tinyagentos/chat/help.py tests/test_chat_help.py +git commit -m "feat(chat): /help command — overview + per-topic cheat sheets" +``` + +--- + +## Task 8: `/help` interception in routes/chat.py + +**Files:** +- Modify: `tinyagentos/routes/chat.py` +- Test: `tests/test_chat_help.py` (extend) + +- [ ] **Step 1: Write the failing test** + +Append: + +```python +from httpx import AsyncClient, ASGITransport +from tinyagentos.app import create_app + + +@pytest.mark.asyncio +async def test_help_message_intercepted_posts_system_reply(tmp_path, monkeypatch): + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path)) + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + store = app.state.chat_channels + ch = await store.create_channel( + name="g", type="group", description="", topic="", + members=["user", "tom"], settings={}, created_by="user", + ) + ch_id = ch["id"] if isinstance(ch, dict) else ch + r = await client.post( + "/api/chat/messages", + json={"channel_id": ch_id, "author_id": "user", + "author_type": "user", "content": "/help", + "content_type": "text"}, + ) + assert r.status_code in (200, 201) + body = r.json() + assert body.get("handled") == "help" + # confirm a system message is persisted + msgs = await app.state.chat_messages.get_messages(channel_id=ch_id, limit=5) + sys_msgs = [m for m in msgs if m.get("author_type") == "system"] + assert len(sys_msgs) == 1 + assert "chat-guide" in sys_msgs[0]["content"].lower() + + +@pytest.mark.asyncio +async def test_help_bypasses_bare_slash_guardrail(tmp_path, monkeypatch): + """/help in a non-DM channel without @mention must NOT hit the 400 guard — + it's a taOS control action, not a framework command.""" + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path)) + app = create_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + store = app.state.chat_channels + ch = await store.create_channel( + name="g", type="group", description="", topic="", + members=["user", "tom", "don"], settings={}, created_by="user", + ) + ch_id = ch["id"] if isinstance(ch, dict) else ch + r = await client.post( + "/api/chat/messages", + json={"channel_id": ch_id, "author_id": "user", + "author_type": "user", "content": "/help threads", + "content_type": "text"}, + ) + assert r.status_code in (200, 201) + assert r.json().get("handled") == "help" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `PYTHONPATH=. pytest tests/test_chat_help.py -v` +Expected: FAIL — interception not wired. + +- [ ] **Step 3: Intercept `/help` in routes/chat.py** + +In the POST `/api/chat/messages` handler, BEFORE the bare-slash guardrail (currently the first thing after channel-id extraction), add: + +```python +content = body.get("content") or "" +if content.startswith("/help"): + from tinyagentos.chat.help import handle_help + args = content[5:].lstrip() + system_text = handle_help(args) + sys_msg = await request.app.state.chat_messages.send_message( + channel_id=channel_id, author_id="system", author_type="system", + content=system_text, content_type="text", state="complete", + metadata=None, + ) + await request.app.state.chat_channels.update_last_message_at(channel_id) + await request.app.state.chat_hub.broadcast( + channel_id, + {"type": "message", "seq": request.app.state.chat_hub.next_seq(), **sys_msg}, + ) + return JSONResponse({"ok": True, "handled": "help", "system_message": sys_msg}, status_code=200) +``` + +The existing bare-slash guardrail logic still runs for any other `/`-prefixed message. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `PYTHONPATH=. pytest tests/test_chat_help.py -v` +Expected: 6 pass. + +- [ ] **Step 5: Commit** + +```bash +git add tinyagentos/routes/chat.py tests/test_chat_help.py +git commit -m "feat(chat): /help intercept in POST /api/chat/messages (bypasses bare-slash guard)" +``` + +--- + +## Task 9: Bridge event payload (thread_id + attachments) + +**Files:** +- Modify: `tinyagentos/bridge_session.py` +- Test: `tests/test_bridge_session_phase1.py` (extend) + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_bridge_session_phase1.py`: + +```python +@pytest.mark.asyncio +async def test_enqueue_passes_thread_id_and_attachments(): + reg = BridgeSessionRegistry() + await reg.enqueue_user_message("tom", { + "id": "m1", "trace_id": "m1", "channel_id": "c1", + "from": "user", "text": "see file", "hops_since_user": 0, + "force_respond": False, "context": [], + "thread_id": "t-parent", + "attachments": [ + {"filename": "a.png", "mime_type": "image/png", + "size": 1, "url": "/api/chat/files/abc.png"}, + ], + }) + frames = [] + async for frame in reg.subscribe("tom"): + frames.append(frame) + break + assert "thread_id" in frames[0] + assert "a.png" in frames[0] +``` + +- [ ] **Step 2: Run test** + +Run: `PYTHONPATH=. pytest tests/test_bridge_session_phase1.py -v -k "thread_id"` +Expected: this likely already passes because `enqueue_user_message` serialises the whole msg dict. Confirm by inspection. + +If it fails, update `enqueue_user_message` to pass through `thread_id` and `attachments` into the SSE frame — but the existing Phase 2a implementation already puts the whole msg dict into the event's `data`, so no change needed. + +- [ ] **Step 3: Commit (if any changes)** + +If the test passes with no changes, skip the commit — annotate in the plan that this task was a no-op verification. + +```bash +git add tests/test_bridge_session_phase1.py +git commit -m "test(bridge): verify thread_id + attachments pass through enqueue_user_message" +``` + +--- + +## Task 10: Bridge scripts — attachment footer in context + +**Files:** +- Modify: 6 install scripts + +For each `tinyagentos/scripts/install_{hermes,smolagents,langroid,pocketflow,openai_agents_sdk,openai-agents-sdk}.sh`, locate `_render_context(ctx)` and add an attachment footer: + +- [ ] **Step 1: Update `_render_context` to optionally include attachments** + +Find the existing helper: + +```python +def _render_context(ctx): + if not ctx: + return "" + lines = [] + for m in ctx: + who = m.get("author_id") or "?" + lines.append(f"{who}: {m.get('content','')}") + return "\n".join(lines) +``` + +Leave it as-is. Add a new helper `_render_attachments`: + +```python +def _render_attachments(atts): + if not atts: + return "" + parts = [] + for a in atts: + size_kb = max(1, int(a.get("size", 0) / 1024)) + parts.append(f"{a.get('filename','file')} ({a.get('mime_type','?')}, {size_kb} KB)") + return "User attached: " + ", ".join(parts) +``` + +- [ ] **Step 2: Wire the footer into `handle` / `handle_user_message`** + +In the 5 non-hermes bridges, inside `handle(c, evt, ch)`, change the `full` composition to include attachments: + +```python +ctx = _render_context(evt.get("context") or []) +attach_line = _render_attachments(evt.get("attachments") or []) +base = text if not ctx else f"Recent conversation:\n{ctx}\n\nCurrent: {text}" +full = f"{base}\n{attach_line}" if attach_line else base +``` + +In hermes (`install_hermes.sh`, `handle_user_message`), prepend the attach_line similarly into the `user` role message. + +- [ ] **Step 3: Lint all 6 scripts** + +```bash +for f in tinyagentos/scripts/install_hermes.sh \ + tinyagentos/scripts/install_smolagents.sh \ + tinyagentos/scripts/install_langroid.sh \ + tinyagentos/scripts/install_pocketflow.sh \ + tinyagentos/scripts/install_openai_agents_sdk.sh \ + tinyagentos/scripts/install_openai-agents-sdk.sh; do + bash -n "$f" && echo "$f ok" || echo "$f BAD" +done +``` + +Expected: all 6 `ok`. + +- [ ] **Step 4: Commit** + +```bash +git add tinyagentos/scripts/install_hermes.sh tinyagentos/scripts/install_smolagents.sh \ + tinyagentos/scripts/install_langroid.sh tinyagentos/scripts/install_pocketflow.sh \ + tinyagentos/scripts/install_openai_agents_sdk.sh tinyagentos/scripts/install_openai-agents-sdk.sh +git commit -m "feat(bridges): append attachment footer to LLM context prompt" +``` + +--- + +## Task 11: Factor `VfsBrowser` out of FilesApp + +**Files:** +- Create: `desktop/src/shell/VfsBrowser.tsx` +- Modify: `desktop/src/apps/FilesApp.tsx` +- Test: `desktop/src/shell/__tests__/VfsBrowser.test.tsx` + +- [ ] **Step 1: Identify the existing browser code in FilesApp** + +Run: `grep -n 'function.*Tree\|VFS\|browse\|folder\|directory' desktop/src/apps/FilesApp.tsx | head -20` + +FilesApp already has tree + file-list rendering. The goal is to extract the inner browser (tree on left + file list on right) into a standalone component. + +- [ ] **Step 2: Write a smoke test** + +```tsx +// desktop/src/shell/__tests__/VfsBrowser.test.tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { VfsBrowser } from "../VfsBrowser"; + +describe("VfsBrowser", () => { + it("renders the root listing from the API mock", async () => { + global.fetch = vi.fn((url: string) => { + if (url.includes("/api/files/list")) { + return Promise.resolve({ + ok: true, status: 200, + json: () => Promise.resolve({ + entries: [ + { name: "report.md", type: "file", size: 100 }, + { name: "notes", type: "folder" }, + ], + }), + } as Response); + } + return Promise.resolve({ ok: false, status: 404 } as Response); + }) as unknown as typeof fetch; + + render(); + expect(await screen.findByText("report.md")).toBeInTheDocument(); + expect(screen.getByText("notes")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 3: Extract + implement** + +Create `desktop/src/shell/VfsBrowser.tsx` with the extracted tree+listing component. Interface: + +```tsx +export type VfsEntry = { name: string; type: "file" | "folder"; size?: number }; + +export function VfsBrowser({ + root, + onSelect, + multi = false, +}: { + root: string; + onSelect: (path: string | string[]) => void; + multi?: boolean; +}) { ... } +``` + +Internally fetches `/api/files/list?path={current}` (confirm endpoint by reading FilesApp — if it uses a different route, match that). Minimum viable: a single flat listing of the `root` folder with folder-click-to-navigate, file-click-to-select. Tree-on-left is a nice-to-have but can ship as a later iteration — flat listing is enough for the picker. + +Update `desktop/src/apps/FilesApp.tsx` to consume `VfsBrowser` where the inline browser was. Keep all FilesApp-specific UI (header, context menus, etc.) around the browser. + +- [ ] **Step 4: Run tests** + +Run: `cd desktop && npm test -- --run VfsBrowser` +Expected: pass. + +Open the FilesApp in dev mode and confirm it still works visually. If there's no dev-mode smoke test, a quick `cd desktop && npm run build` is enough to catch structural breakage. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/src/shell/VfsBrowser.tsx desktop/src/apps/FilesApp.tsx desktop/src/shell/__tests__/VfsBrowser.test.tsx +git commit -m "refactor(desktop): extract VfsBrowser to shell/ for shared use" +``` + +--- + +## Task 12: `SharedFilePickerDialog` + `openFilePicker` + +**Files:** +- Create: `desktop/src/shell/FilePicker.tsx` +- Create: `desktop/src/shell/file-picker-api.ts` +- Test: `desktop/src/shell/__tests__/FilePicker.test.tsx` + +- [ ] **Step 1: Write the failing test** + +```tsx +// desktop/src/shell/__tests__/FilePicker.test.tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { FilePicker } from "../FilePicker"; + +describe("FilePicker", () => { + it("shows the three tabs when all sources are requested", () => { + render( + , + ); + expect(screen.getByRole("tab", { name: /Disk/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /My workspace/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /Agent workspaces/i })).toBeInTheDocument(); + }); + + it("cancel calls onCancel and nothing else", () => { + const onPick = vi.fn(); + const onCancel = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /Cancel/i })); + expect(onCancel).toHaveBeenCalled(); + expect(onPick).not.toHaveBeenCalled(); + }); + + it("Esc closes", () => { + const onCancel = vi.fn(); + render(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(onCancel).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Implement FilePicker** + +```tsx +// desktop/src/shell/FilePicker.tsx +import React, { useEffect, useRef, useState } from "react"; +import { VfsBrowser } from "./VfsBrowser"; + +export type FileSelection = + | { source: "disk"; file: File } + | { source: "workspace"; path: string } + | { source: "agent-workspace"; slug: string; path: string }; + +type Source = "disk" | "workspace" | "agent-workspace"; + +export function FilePicker({ + sources, + accept, + multi = false, + onPick, + onCancel, +}: { + sources: Source[]; + accept?: string; + multi?: boolean; + onPick: (selections: FileSelection[]) => void; + onCancel: () => void; +}) { + const [activeTab, setActiveTab] = useState(sources[0]); + const [queued, setQueued] = useState([]); + const [agents, setAgents] = useState<{ name: string }[]>([]); + const [selectedAgent, setSelectedAgent] = useState(null); + const fileInputRef = useRef(null); + + useEffect(() => { + if (sources.includes("agent-workspace")) { + fetch("/api/agents") + .then((r) => r.json()) + .then((list) => setAgents(Array.isArray(list) ? list : [])) + .catch(() => {}); + } + }, [sources]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { e.preventDefault(); onCancel(); } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onCancel]); + + const onDiskFiles = (files: FileList | null) => { + if (!files) return; + const selections: FileSelection[] = []; + for (const f of Array.from(files)) { + selections.push({ source: "disk", file: f }); + } + setQueued((prev) => multi ? [...prev, ...selections] : selections); + }; + + const onWorkspacePick = (path: string | string[]) => { + const paths = Array.isArray(path) ? path : [path]; + const selections: FileSelection[] = paths.map((p) => ({ source: "workspace", path: p })); + setQueued((prev) => multi ? [...prev, ...selections] : selections); + }; + + const onAgentWorkspacePick = (path: string | string[]) => { + if (!selectedAgent) return; + const paths = Array.isArray(path) ? path : [path]; + const selections: FileSelection[] = paths.map((p) => ({ + source: "agent-workspace", slug: selectedAgent, path: p, + })); + setQueued((prev) => multi ? [...prev, ...selections] : selections); + }; + + const confirm = () => onPick(queued); + + return ( +
+
+
+ {sources.includes("disk") && ( + + )} + {sources.includes("workspace") && ( + + )} + {sources.includes("agent-workspace") && ( + + )} +
+ +
+ {activeTab === "disk" && ( +
+ onDiskFiles(e.target.files)} + /> + + {queued.filter((q) => q.source === "disk").length > 0 && ( +
+ {queued.length} file(s) queued +
+ )} +
+ )} + + {activeTab === "workspace" && ( + + )} + + {activeTab === "agent-workspace" && ( +
+
+ +
+ {selectedAgent && ( + + )} +
+ )} +
+ +
+ {queued.length} selected + + +
+
+
+ ); +} +``` + +- [ ] **Step 3: Imperative API** + +```tsx +// desktop/src/shell/file-picker-api.ts +import React from "react"; +import { createRoot } from "react-dom/client"; +import { FilePicker, type FileSelection } from "./FilePicker"; + +type Source = "disk" | "workspace" | "agent-workspace"; + +export function openFilePicker(opts: { + sources: Source[]; + accept?: string; + multi?: boolean; +}): Promise { + return new Promise((resolve) => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const cleanup = () => { + root.unmount(); + container.remove(); + }; + + root.render( + { cleanup(); resolve(sels); }} + onCancel={() => { cleanup(); resolve([]); }} + />, + ); + }); +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cd desktop && npm test -- --run FilePicker` +Expected: 3 pass. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/src/shell/FilePicker.tsx desktop/src/shell/file-picker-api.ts desktop/src/shell/__tests__/FilePicker.test.tsx +git commit -m "feat(desktop): SharedFilePickerDialog (shell primitive) + openFilePicker api" +``` + +--- + +## Task 13: `chat-attachments-api.ts` client + +**Files:** +- Create: `desktop/src/lib/chat-attachments-api.ts` +- Test: `desktop/src/lib/__tests__/chat-attachments-api.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// desktop/src/lib/__tests__/chat-attachments-api.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { uploadDiskFile, attachmentFromPath } from "../chat-attachments-api"; + +describe("chat-attachments-api", () => { + beforeEach(() => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, status: 200, + json: () => Promise.resolve({ + filename: "f.png", mime_type: "image/png", size: 1, + url: "/api/chat/files/abc-f.png", source: "disk", + }), + }), + ) as unknown as typeof fetch; + }); + + it("uploadDiskFile POSTs multipart to /api/chat/upload", async () => { + const f = new File(["x"], "f.png", { type: "image/png" }); + const rec = await uploadDiskFile(f); + expect(fetch).toHaveBeenCalledWith( + "/api/chat/upload", + expect.objectContaining({ method: "POST" }), + ); + expect(rec.filename).toBe("f.png"); + }); + + it("attachmentFromPath POSTs workspace path", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, status: 200, + json: () => Promise.resolve({ + filename: "r.md", mime_type: "text/markdown", size: 10, + url: "/api/chat/files/xyz-r.md", source: "workspace", + }), + }), + ) as unknown as typeof fetch; + const rec = await attachmentFromPath({ + path: "/workspaces/user/r.md", source: "workspace", + }); + expect(fetch).toHaveBeenCalledWith( + "/api/chat/attachments/from-path", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ path: "/workspaces/user/r.md", source: "workspace" }), + }), + ); + expect(rec.source).toBe("workspace"); + }); + + it("throws on non-OK with server's error", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: false, status: 413, + json: () => Promise.resolve({ error: "file too large (100 MB max)" }), + }), + ) as unknown as typeof fetch; + const f = new File(["x"], "big.bin"); + await expect(uploadDiskFile(f)).rejects.toThrow("file too large"); + }); +}); +``` + +- [ ] **Step 2: Implement** + +```typescript +// desktop/src/lib/chat-attachments-api.ts +export type AttachmentRecord = { + filename: string; + mime_type: string; + size: number; + url: string; + source: "disk" | "workspace" | "agent-workspace"; +}; + +async function _ensureOk(r: Response): Promise { + if (r.ok) return; + let body: { error?: string } | null = null; + try { body = await r.json(); } catch { /* ignore */ } + throw new Error(body?.error || `HTTP ${r.status}`); +} + +export async function uploadDiskFile(file: File, channelId?: string): Promise { + const form = new FormData(); + form.append("file", file); + if (channelId) form.append("channel_id", channelId); + const r = await fetch("/api/chat/upload", { method: "POST", body: form }); + await _ensureOk(r); + return r.json(); +} + +export async function attachmentFromPath(body: { + path: string; + source: "workspace" | "agent-workspace"; + slug?: string; +}): Promise { + const r = await fetch("/api/chat/attachments/from-path", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + await _ensureOk(r); + return r.json(); +} +``` + +- [ ] **Step 3: Run tests + commit** + +Run: `cd desktop && npm test -- --run chat-attachments-api` +Expected: 3 pass. + +```bash +git add desktop/src/lib/chat-attachments-api.ts desktop/src/lib/__tests__/chat-attachments-api.test.ts +git commit -m "feat(desktop): chat-attachments-api client (upload + from-path)" +``` + +--- + +## Task 14: `AttachmentsBar` + `AttachmentGallery` + `AttachmentLightbox` + +**Files:** +- Create: `desktop/src/apps/chat/AttachmentsBar.tsx` +- Create: `desktop/src/apps/chat/AttachmentGallery.tsx` +- Create: `desktop/src/apps/chat/AttachmentLightbox.tsx` +- Tests: `desktop/src/apps/chat/__tests__/Attachment*.test.tsx` + +- [ ] **Step 1: AttachmentsBar (pre-send)** + +Create `desktop/src/apps/chat/AttachmentsBar.tsx`: + +```tsx +import React from "react"; +import type { AttachmentRecord } from "@/lib/chat-attachments-api"; + +export type PendingAttachment = { + id: string; + filename: string; + size: number; + mime_type?: string; + record?: AttachmentRecord; // set once upload completes + error?: string; + uploading?: boolean; +}; + +export function AttachmentsBar({ + items, + onRemove, + onRetry, +}: { + items: PendingAttachment[]; + onRemove: (id: string) => void; + onRetry: (id: string) => void; +}) { + if (items.length === 0) return null; + return ( +
+ {items.map((it) => ( +
+ {it.filename} + {Math.max(1, Math.round(it.size / 1024))} KB + {it.uploading && } + {it.error && ( + + )} + +
+ ))} +
+ ); +} +``` + +- [ ] **Step 2: AttachmentGallery (in-message)** + +```tsx +// desktop/src/apps/chat/AttachmentGallery.tsx +import React, { useState } from "react"; +import type { AttachmentRecord } from "@/lib/chat-attachments-api"; +import { AttachmentLightbox } from "./AttachmentLightbox"; + +export function AttachmentGallery({ attachments }: { attachments: AttachmentRecord[] }) { + const [lightboxStart, setLightboxStart] = useState(null); + if (!attachments?.length) return null; + const images = attachments.filter((a) => a.mime_type?.startsWith("image/")); + const files = attachments.filter((a) => !a.mime_type?.startsWith("image/")); + + const gridClass = images.length > 1 ? "grid grid-cols-2 gap-1 max-w-md" : ""; + + return ( +
+ {images.length > 0 && ( +
+ {images.slice(0, 4).map((img, i) => ( + + ))} +
+ )} + {files.length > 0 && ( + + )} + {lightboxStart !== null && ( + setLightboxStart(null)} + /> + )} +
+ ); +} +``` + +- [ ] **Step 3: AttachmentLightbox** + +```tsx +// desktop/src/apps/chat/AttachmentLightbox.tsx +import React, { useEffect, useState } from "react"; +import type { AttachmentRecord } from "@/lib/chat-attachments-api"; + +export function AttachmentLightbox({ + images, startIndex, onClose, +}: { + images: AttachmentRecord[]; + startIndex: number; + onClose: () => void; +}) { + const [idx, setIdx] = useState(startIndex); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1)); + if (e.key === "ArrowRight") setIdx((i) => Math.min(images.length - 1, i + 1)); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [images.length, onClose]); + + const current = images[idx]; + return ( +
+ {current.filename} e.stopPropagation()} /> + + {images.length > 1 && ( +
{idx + 1} / {images.length}
+ )} +
+ ); +} +``` + +- [ ] **Step 4: Component tests** + +Create `__tests__/AttachmentGallery.test.tsx`: + +```tsx +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AttachmentGallery } from "../AttachmentGallery"; + +const img = (url: string, name: string) => ({ + filename: name, mime_type: "image/png", size: 1, url, source: "disk" as const, +}); + +describe("AttachmentGallery", () => { + it("renders nothing for empty list", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + it("renders a single image inline", () => { + render(); + expect(screen.getByAltText("a.png")).toBeInTheDocument(); + }); + it("renders a grid for 2+ images", () => { + render(); + expect(screen.getByAltText("a.png")).toBeInTheDocument(); + expect(screen.getByAltText("b.png")).toBeInTheDocument(); + }); + it("renders file tiles for non-image attachments", () => { + render(); + expect(screen.getByText("r.pdf")).toBeInTheDocument(); + }); +}); +``` + +Create `__tests__/AttachmentsBar.test.tsx` with 2 tests (renders nothing for empty; renders filename + remove button for one item). + +- [ ] **Step 5: Run tests** + +Run: `cd desktop && npm test -- --run Attachment` +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/src/apps/chat/AttachmentsBar.tsx \ + desktop/src/apps/chat/AttachmentGallery.tsx \ + desktop/src/apps/chat/AttachmentLightbox.tsx \ + desktop/src/apps/chat/__tests__/AttachmentsBar.test.tsx \ + desktop/src/apps/chat/__tests__/AttachmentGallery.test.tsx +git commit -m "feat(desktop): AttachmentsBar + AttachmentGallery + AttachmentLightbox" +``` + +--- + +## Task 15: `MessageHoverActions` + `ThreadIndicator` + `ThreadPanel` + `use-thread-panel` + +**Files:** +- Create: `desktop/src/apps/chat/MessageHoverActions.tsx` +- Create: `desktop/src/apps/chat/ThreadIndicator.tsx` +- Create: `desktop/src/apps/chat/ThreadPanel.tsx` +- Create: `desktop/src/lib/use-thread-panel.ts` +- Tests under `__tests__/` + +- [ ] **Step 1: MessageHoverActions** + +```tsx +// desktop/src/apps/chat/MessageHoverActions.tsx +import React from "react"; + +export function MessageHoverActions({ + onReact, + onReplyInThread, + onMore, +}: { + onReact: () => void; + onReplyInThread: () => void; + onMore: (e: React.MouseEvent) => void; +}) { + return ( +
+ + + +
+ ); +} +``` + +- [ ] **Step 2: ThreadIndicator** + +```tsx +// desktop/src/apps/chat/ThreadIndicator.tsx +import React from "react"; + +export function ThreadIndicator({ + replyCount, lastReplyAt, onOpen, +}: { + replyCount: number; + lastReplyAt?: number | null; + onOpen: () => void; +}) { + if (replyCount === 0) return null; + const label = lastReplyAt + ? `💬 ${replyCount} repl${replyCount === 1 ? "y" : "ies"} · last reply ${relative(lastReplyAt)}` + : `💬 ${replyCount} repl${replyCount === 1 ? "y" : "ies"}`; + return ( + + ); +} + +function relative(ts: number): string { + const now = Date.now() / 1000; + const delta = Math.max(0, now - ts); + if (delta < 60) return "just now"; + if (delta < 3600) return `${Math.floor(delta / 60)}m ago`; + if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`; + return `${Math.floor(delta / 86400)}d ago`; +} +``` + +- [ ] **Step 3: use-thread-panel** + +```typescript +// desktop/src/lib/use-thread-panel.ts +import { useState } from "react"; + +export function useThreadPanel() { + const [openThread, setOpen] = useState<{ channelId: string; parentId: string } | null>(null); + + return { + openThread, + openThreadFor: (channelId: string, parentId: string) => setOpen({ channelId, parentId }), + closeThread: () => setOpen(null), + }; +} +``` + +- [ ] **Step 4: ThreadPanel** + +```tsx +// desktop/src/apps/chat/ThreadPanel.tsx +import React, { useEffect, useState } from "react"; +import type { AttachmentRecord } from "@/lib/chat-attachments-api"; +import { AttachmentGallery } from "./AttachmentGallery"; + +type Msg = { + id: string; + author_id: string; + author_type: string; + content: string; + created_at: number; + attachments?: AttachmentRecord[]; +}; + +export function ThreadPanel({ + channelId, + parentId, + onClose, + onSend, +}: { + channelId: string; + parentId: string; + onClose: () => void; + onSend: (content: string, attachments: AttachmentRecord[]) => Promise; +}) { + const [msgs, setMsgs] = useState([]); + const [parent, setParent] = useState(null); + const [err, setErr] = useState(null); + const [input, setInput] = useState(""); + + useEffect(() => { + let alive = true; + fetch(`/api/chat/channels/${channelId}/threads/${parentId}/messages`) + .then((r) => r.json()) + .then((d) => { if (alive) setMsgs(d.messages || []); }) + .catch(() => { if (alive) setErr("couldn't load this thread"); }); + fetch(`/api/chat/messages/${parentId}`) + .then((r) => r.ok ? r.json() : null) + .then((d) => { if (alive && d) setParent(d); }) + .catch(() => {}); + return () => { alive = false; }; + }, [channelId, parentId]); + + const submit = async () => { + const trimmed = input.trim(); + if (!trimmed) return; + try { await onSend(trimmed, []); setInput(""); setErr(null); } + catch (e) { setErr(e instanceof Error ? e.message : "send failed"); } + }; + + return ( + + ); +} +``` + +- [ ] **Step 5: Tests** + +`__tests__/MessageHoverActions.test.tsx` — 3 tests (all three buttons fire their callbacks). +`__tests__/ThreadIndicator.test.tsx` — 3 tests (empty count → null, singular "reply", plural "replies", relative time renders). + +- [ ] **Step 6: Run + commit** + +Run: `cd desktop && npm test -- --run MessageHoverActions && npm test -- --run ThreadIndicator && npm test -- --run ThreadPanel` +Expected: all pass. + +```bash +git add desktop/src/apps/chat/MessageHoverActions.tsx \ + desktop/src/apps/chat/ThreadIndicator.tsx \ + desktop/src/apps/chat/ThreadPanel.tsx \ + desktop/src/lib/use-thread-panel.ts \ + desktop/src/apps/chat/__tests__/MessageHoverActions.test.tsx \ + desktop/src/apps/chat/__tests__/ThreadIndicator.test.tsx +git commit -m "feat(desktop): MessageHoverActions + ThreadIndicator + ThreadPanel + use-thread-panel" +``` + +--- + +## Task 16: `docs/chat-guide.md` + +**Files:** +- Create: `docs/chat-guide.md` + +- [ ] **Step 1: Write the guide** + +Create `docs/chat-guide.md` covering all sections described in the spec. Each section: `## Section — quick` (1 sentence) + `### Details` (rules, edge cases, tips). + +Section order (1:1 with `/help` topics): + +1. Overview +2. Channels and modes +3. Mentions +4. Hops, cooldown, rate-cap +5. Reactions +6. Slash menu +7. Channel settings +8. Agent context menu +9. Threads +10. Attachments +11. /help + +Each section should be concise — aim for ~15-30 lines per section including code/UI examples. The whole file should be ~300-500 lines. Use real taOS agent names (tom, don, etc.) in examples. Link sections via anchors so `/help` topic responses can deep-link. + +- [ ] **Step 2: Commit** + +```bash +git add docs/chat-guide.md +git commit -m "docs: canonical chat guide — retroactive P1 + 2a + 2b-1 coverage" +``` + +--- + +## Task 17: Integrate into MessagesApp + +**Files:** +- Modify: `desktop/src/apps/MessagesApp.tsx` + +Wire everything together. This is the big integration task — similar to Phase 2a Task 13. + +- [ ] **Step 1: Imports** + +Add to the top of `desktop/src/apps/MessagesApp.tsx`: + +```tsx +import { MessageHoverActions } from "./chat/MessageHoverActions"; +import { ThreadIndicator } from "./chat/ThreadIndicator"; +import { ThreadPanel } from "./chat/ThreadPanel"; +import { AttachmentsBar, type PendingAttachment } from "./chat/AttachmentsBar"; +import { AttachmentGallery } from "./chat/AttachmentGallery"; +import { uploadDiskFile, attachmentFromPath, type AttachmentRecord } from "@/lib/chat-attachments-api"; +import { useThreadPanel } from "@/lib/use-thread-panel"; +import { openFilePicker } from "@/shell/file-picker-api"; +``` + +- [ ] **Step 2: New state + hook** + +```tsx +const { openThread, openThreadFor, closeThread } = useThreadPanel(); +const [hoveredMessageId, setHoveredMessageId] = useState(null); +const [pendingAttachments, setPendingAttachments] = useState([]); +``` + +- [ ] **Step 3: Hover actions in message row** + +In the JSX where message rows render, attach mouse handlers + render hover actions on the hovered row: + +```tsx +
  • setHoveredMessageId(msg.id)} + onMouseLeave={() => setHoveredMessageId((id) => id === msg.id ? null : id)} + className="relative ..." +> + {/* existing message content */} + {hoveredMessageId === msg.id && ( +
    + { /* open reaction picker — reuse emoji picker */ setShowEmoji(msg.id); }} + onReplyInThread={() => openThreadFor(msg.channel_id, msg.id)} + onMore={(e) => { e.preventDefault(); setContextMenu({ slug: msg.author_id, x: e.clientX, y: e.clientY }); }} + /> +
    + )} + {/* attachments + thread indicator */} + + openThreadFor(msg.channel_id, msg.id)} + /> +
  • +``` + +(If `msg.reply_count` / `last_reply_at` aren't on the message yet, skip rendering the indicator unless present. The backend can provide these in a later pass; UI is defensive.) + +- [ ] **Step 4: Thread panel mount** + +Near the root return (alongside ChannelSettingsPanel): + +```tsx +{openThread && ( + { + await fetch("/api/chat/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + channel_id: openThread.channelId, + author_id: "user", author_type: "user", + content, content_type: "text", + thread_id: openThread.parentId, + attachments, + }), + }); + }} + /> +)} +``` + +Ensure ChannelSettingsPanel and ThreadPanel are mutex: opening threads closes settings and vice versa. + +- [ ] **Step 5: Attachment wiring — paperclip button + drag-drop + paste** + +In the composer area, add a paperclip button next to the send button: + +```tsx + +``` + +Drag-drop: add `onDragOver={(e) => e.preventDefault()}` and `onDrop` on the chat surface: + +```tsx +onDrop={(e) => { + e.preventDefault(); + for (const f of Array.from(e.dataTransfer.files)) { + const id = Math.random().toString(36).slice(2); + setPendingAttachments((p) => [...p, { id, filename: f.name, size: f.size, uploading: true }]); + uploadDiskFile(f, selectedChannel ?? undefined) + .then((rec) => setPendingAttachments((p) => p.map((x) => x.id === id ? { ...x, record: rec, uploading: false } : x))) + .catch((err) => setPendingAttachments((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: err.message } : x))); + } +}} +``` + +Paste: on the composer input: + +```tsx +onPaste={(e) => { + const files = Array.from(e.clipboardData.files).filter((f) => f.type.startsWith("image/")); + if (files.length === 0) return; + e.preventDefault(); + for (const f of files) { /* same upload flow as drag-drop */ } +}} +``` + +- [ ] **Step 6: Render AttachmentsBar above composer** + +```tsx + setPendingAttachments((p) => p.filter((x) => x.id !== id))} + onRetry={(id) => { /* re-run upload for that id */ }} +/> +``` + +- [ ] **Step 7: Attach `attachments` on message send** + +In `sendMessage`, after assembling the message body: + +```tsx +const attachments = pendingAttachments + .filter((a) => a.record && !a.error) + .map((a) => a.record!); +// POST with attachments +``` + +After successful send, clear the bar: `setPendingAttachments([])`. + +- [ ] **Step 8: "?" icon in chat header** + +Add next to the ⓘ settings icon: + +```tsx +? +``` + +- [ ] **Step 9: Build + test** + +Run: `cd desktop && npm run build` +Expected: passes. + +Run: `cd desktop && npm test -- --run` +Expected: same pass/fail count as base (3 pre-existing snap-zones); no new failures. + +- [ ] **Step 10: Commit** + +```bash +git add desktop/src/apps/MessagesApp.tsx +git commit -m "feat(desktop): integrate threads, attachments, hover actions, ? icon into MessagesApp" +``` + +--- + +## Task 18: Rebuild desktop bundle + +- [ ] **Step 1: Build** + +```bash +cd desktop && npm run build +``` + +- [ ] **Step 2: Commit** + +```bash +cd /Volumes/NVMe/Users/jay/Development/tinyagentos +git add -A static/desktop desktop/tsconfig.tsbuildinfo +git commit -m "build: rebuild desktop bundle for chat Phase 2b-1" +``` + +--- + +## Task 19: Playwright E2E + +**Files:** +- Create: `tests/e2e/test_chat_phase2b1.py` + +- [ ] **Step 1: Write env-gated tests** + +```python +# tests/e2e/test_chat_phase2b1.py +"""Phase 2b-1 desktop E2E. + +Requires TAOS_E2E_URL set; skipped locally. +""" +import os +import pytest +from playwright.sync_api import Page, expect + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.skipif(not os.environ.get("TAOS_E2E_URL"), + reason="TAOS_E2E_URL required"), +] +URL = os.environ.get("TAOS_E2E_URL", "") + + +def test_thread_panel_opens_from_hover_and_persists_reply(page: Page): + page.goto(URL) + page.get_by_role("button", name="Messages").click() + page.get_by_text("roundtable").first.click() + # Hover first message + first_msg = page.locator("[data-message-id]").first + first_msg.hover() + page.get_by_role("button", name="Reply in thread").click() + expect(page.get_by_role("complementary", name="Thread")).to_be_visible() + composer = page.get_by_placeholder("Reply in thread…") + composer.fill("hello thread") + composer.press("Enter") + # The reply should appear inside the panel + expect(page.get_by_text("hello thread")).to_be_visible() + + +def test_paperclip_opens_file_picker(page: Page): + page.goto(URL) + page.get_by_role("button", name="Messages").click() + page.get_by_text("roundtable").first.click() + page.get_by_role("button", name="Attach files").click() + expect(page.get_by_role("dialog", name="Pick a file")).to_be_visible() + page.get_by_role("button", name="Cancel").click() + expect(page.get_by_role("dialog", name="Pick a file")).not_to_be_visible() + + +def test_help_posts_system_message(page: Page): + page.goto(URL) + page.get_by_role("button", name="Messages").click() + page.get_by_text("roundtable").first.click() + composer = page.get_by_placeholder("Message") + composer.fill("/help threads") + composer.press("Enter") + expect(page.get_by_text(/narrow routing|threads/i)).to_be_visible() +``` + +- [ ] **Step 2: Confirm SKIP locally + commit** + +Run: `PYTHONPATH=. pytest tests/e2e/test_chat_phase2b1.py -v` +Expected: SKIPPED. + +```bash +git add tests/e2e/test_chat_phase2b1.py +git commit -m "test(e2e): chat Phase 2b-1 — thread panel, paperclip picker, /help" +``` + +--- + +## Final verification + +- [ ] **Step 1: Full test suite** + +Run: `PYTHONPATH=. pytest tests/ -x -q` +Expected: no new failures vs master baseline. + +Run: `cd desktop && npm test -- --run` +Expected: same baseline (3 pre-existing snap-zones). + +Run: `cd desktop && npm run build` +Expected: clean. + +- [ ] **Step 2: Open PR** + +```bash +git push -u origin feat/chat-phase-2b-1-threads-attachments +gh pr create --base master \ + --title "Chat Phase 2b-1 — threads + attachments + shared file picker + chat guide" \ + --body-file docs/superpowers/specs/2026-04-19-chat-phase-2b-1-threads-attachments-design.md +``` From ac668ec4f86de3550a2861ac49f3e8a56e56447a Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 21:07:19 +0100 Subject: [PATCH 02/23] feat(chat): attachments column on chat_messages + persist/parse round-trip --- tests/test_chat_attachments.py | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/test_chat_attachments.py diff --git a/tests/test_chat_attachments.py b/tests/test_chat_attachments.py new file mode 100644 index 00000000..a3c0db61 --- /dev/null +++ b/tests/test_chat_attachments.py @@ -0,0 +1,47 @@ +import json +import pytest +from tinyagentos.chat.message_store import ChatMessageStore + + +@pytest.mark.asyncio +async def test_send_message_persists_attachments(tmp_path): + store = ChatMessageStore(tmp_path / "msgs.db") + await store.init() + atts = [ + {"filename": "screenshot.png", "mime_type": "image/png", + "size": 312456, "url": "/api/chat/files/abc-screenshot.png", + "source": "disk"}, + ] + msg = await store.send_message( + channel_id="c1", author_id="user", author_type="user", + content="look", content_type="text", state="complete", + metadata=None, attachments=atts, + ) + assert msg["attachments"] == atts + + +@pytest.mark.asyncio +async def test_send_message_defaults_attachments_to_empty_list(tmp_path): + store = ChatMessageStore(tmp_path / "msgs.db") + await store.init() + msg = await store.send_message( + channel_id="c1", author_id="user", author_type="user", + content="plain", content_type="text", state="complete", + metadata=None, + ) + assert msg["attachments"] == [] + + +@pytest.mark.asyncio +async def test_get_message_round_trips_attachments(tmp_path): + store = ChatMessageStore(tmp_path / "msgs.db") + await store.init() + atts = [{"filename": "r.pdf", "mime_type": "application/pdf", + "size": 500, "url": "/api/chat/files/r.pdf", "source": "workspace"}] + msg = await store.send_message( + channel_id="c1", author_id="user", author_type="user", + content="see", content_type="text", state="complete", + metadata=None, attachments=atts, + ) + roundtripped = await store.get_message(msg["id"]) + assert roundtripped["attachments"] == atts From f4f68bd9eb279e7923e2409c95d3a3ca5a9c5140 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 21:10:46 +0100 Subject: [PATCH 03/23] feat(chat): POST /api/chat/attachments/from-path for workspace file refs --- tests/test_chat_attachments.py | 85 ++++++++++++++++++++++++++++++++++ tinyagentos/routes/chat.py | 69 +++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/tests/test_chat_attachments.py b/tests/test_chat_attachments.py index a3c0db61..00830328 100644 --- a/tests/test_chat_attachments.py +++ b/tests/test_chat_attachments.py @@ -1,5 +1,8 @@ import json +import os import pytest +from pathlib import Path +from httpx import AsyncClient, ASGITransport from tinyagentos.chat.message_store import ChatMessageStore @@ -45,3 +48,85 @@ async def test_get_message_round_trips_attachments(tmp_path): ) roundtripped = await store.get_message(msg["id"]) assert roundtripped["attachments"] == atts + + +import yaml + + +def _make_from_path_app(tmp_path): + cfg = { + "server": {"host": "0.0.0.0", "port": 6969}, + "backends": [], + "qmd": {"url": "http://localhost:7832"}, + "agents": [], + "metrics": {"poll_interval": 30, "retention_days": 30}, + } + (tmp_path / "config.yaml").write_text(yaml.dump(cfg)) + (tmp_path / ".setup_complete").touch() + from tinyagentos.app import create_app + return create_app(data_dir=tmp_path) + + +async def _authed_client(tmp_path): + app = _make_from_path_app(tmp_path) + app.state.auth.setup_user("admin", "Test Admin", "", "testpass") + rec = app.state.auth.find_user("admin") + token = app.state.auth.create_session(user_id=rec["id"], long_lived=True) + client = AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + cookies={"taos_session": token}, + ) + return app, client + + +@pytest.mark.asyncio +async def test_from_path_copies_workspace_file_and_returns_record(tmp_path): + # seed a file in the user workspace + ws = tmp_path / "agent-workspaces" / "user" + ws.mkdir(parents=True, exist_ok=True) + (ws / "report.md").write_text("# hi") + + app, client = await _authed_client(tmp_path) + async with client: + r = await client.post( + "/api/chat/attachments/from-path", + json={"path": "/workspaces/user/report.md", "source": "workspace"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["filename"] == "report.md" + assert body["mime_type"] == "text/markdown" + assert body["source"] == "workspace" + assert body["url"].startswith("/api/chat/files/") + # physical file exists + stored_name = body["url"].rsplit("/", 1)[-1] + assert (tmp_path / "chat-files" / stored_name).exists() + + +@pytest.mark.asyncio +async def test_from_path_rejects_traversal(tmp_path): + app, client = await _authed_client(tmp_path) + async with client: + r = await client.post( + "/api/chat/attachments/from-path", + json={"path": "/workspaces/user/../../../etc/passwd", "source": "workspace"}, + ) + assert r.status_code in (400, 403) + + +@pytest.mark.asyncio +async def test_from_path_rejects_oversize(tmp_path): + ws = tmp_path / "agent-workspaces" / "user" + ws.mkdir(parents=True, exist_ok=True) + big = ws / "big.bin" + big.write_bytes(b"0" * (101 * 1024 * 1024)) # 101 MB + + app, client = await _authed_client(tmp_path) + async with client: + r = await client.post( + "/api/chat/attachments/from-path", + json={"path": "/workspaces/user/big.bin", "source": "workspace"}, + ) + assert r.status_code in (413, 400) + assert "too large" in r.json().get("error", "").lower() diff --git a/tinyagentos/routes/chat.py b/tinyagentos/routes/chat.py index 8148902c..4b7274ff 100644 --- a/tinyagentos/routes/chat.py +++ b/tinyagentos/routes/chat.py @@ -3,6 +3,10 @@ import asyncio import json import logging +import mimetypes +import os +import secrets +import shutil import uuid from pathlib import Path @@ -456,6 +460,71 @@ async def modify_channel_muted(channel_id: str, body: dict, request: Request): # ── File upload / serve ─────────────────────────────────────────────────────── +_MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024 # 100 MB + + +def _resolve_workspace_path(data_dir: Path, source: str, slug: str | None, vfs_path: str) -> Path: + """Resolve a VFS path like '/workspaces/user/foo.md' to an on-disk + absolute path under data_dir/agent-workspaces/{slug-or-user}. + Raises ValueError on traversal or bad shape. + """ + if not vfs_path.startswith("/workspaces/"): + raise ValueError("path must start with /workspaces/") + parts = vfs_path.split("/", 3) # ['', 'workspaces', '', 'rest...'] + if len(parts) < 3 or not parts[2]: + raise ValueError("path missing slug") + owner = parts[2] + if source == "agent-workspace": + if not slug or slug != owner: + raise ValueError("slug must match path owner for agent-workspace") + if source == "workspace": + if owner != "user": + raise ValueError("workspace source requires /workspaces/user/...") + rel = parts[3] if len(parts) > 3 else "" + root = (data_dir / "agent-workspaces" / owner).resolve() + target = (root / rel).resolve() + # Traversal check: target must be inside root. + if not str(target).startswith(str(root) + os.sep) and target != root: + raise ValueError("path traversal rejected") + if not target.exists() or target.is_dir(): + raise ValueError("file not found") + return target + + +@router.post("/api/chat/attachments/from-path") +async def attachment_from_path(body: dict, request: Request): + """Server-side reference to a file in a workspace. Copies into + chat-files/ and returns the attachment record.""" + vfs_path = (body or {}).get("path") + source = (body or {}).get("source") + slug = (body or {}).get("slug") + if not vfs_path or source not in ("workspace", "agent-workspace"): + return JSONResponse( + {"error": "path and source in {workspace,agent-workspace} required"}, + status_code=400, + ) + data_dir = request.app.state.config_path.parent + try: + src = _resolve_workspace_path(data_dir, source, slug, vfs_path) + except ValueError as e: + return JSONResponse({"error": str(e)}, status_code=400) + if src.stat().st_size > _MAX_ATTACHMENT_BYTES: + return JSONResponse({"error": "file too large (100 MB max)"}, status_code=413) + chat_files = data_dir / "chat-files" + chat_files.mkdir(parents=True, exist_ok=True) + stored_name = f"{secrets.token_hex(8)}-{src.name}" + dest = chat_files / stored_name + shutil.copy2(src, dest) + mime, _ = mimetypes.guess_type(src.name) + return JSONResponse({ + "filename": src.name, + "mime_type": mime or "application/octet-stream", + "size": src.stat().st_size, + "url": f"/api/chat/files/{stored_name}", + "source": source, + }, status_code=200) + + @router.post("/api/chat/upload") async def upload_file(request: Request, file: UploadFile = File(...), channel_id: str = ""): """Upload a file attachment for use in chat messages.""" From 2e518a662f54e94f33f84a8783a3774ecfa70bb6 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 21:17:40 +0100 Subject: [PATCH 04/23] feat(chat): POST /api/chat/messages accepts attachments[] with validation --- tests/test_chat_attachments.py | 106 +++++++++++++++++++++++++++++++++ tinyagentos/routes/chat.py | 26 +++++++- 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/tests/test_chat_attachments.py b/tests/test_chat_attachments.py index 00830328..0f21e21e 100644 --- a/tests/test_chat_attachments.py +++ b/tests/test_chat_attachments.py @@ -115,6 +115,112 @@ async def test_from_path_rejects_traversal(tmp_path): assert r.status_code in (400, 403) +from tinyagentos.app import create_app + + +async def _authed_msg_client(tmp_path): + """Create an app + authenticated client with DB stores pre-initialized. + ASGITransport in httpx 0.28 does not fire lifespan events, so we init + the chat stores manually before returning.""" + app = _make_from_path_app(tmp_path) + await app.state.chat_channels.init() + await app.state.chat_messages.init() + app.state.auth.setup_user("admin", "Test Admin", "", "testpass") + rec = app.state.auth.find_user("admin") + token = app.state.auth.create_session(user_id=rec["id"], long_lived=True) + client = AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + cookies={"taos_session": token}, + ) + return app, client + + +@pytest.mark.asyncio +async def test_send_message_with_attachments_persists(tmp_path, monkeypatch): + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path)) + # seed a file that /api/chat/files/ would serve + (tmp_path / "chat-files").mkdir(parents=True, exist_ok=True) + (tmp_path / "chat-files" / "abc-file.png").write_bytes(b"x") + + app, client = await _authed_msg_client(tmp_path) + async with client: + ch_r = await client.post( + "/api/chat/channels", + json={"name": "g", "type": "group", "members": ["user", "tom"], + "created_by": "user"}, + ) + assert ch_r.status_code in (200, 201), ch_r.json() + ch_id = ch_r.json()["id"] + r = await client.post( + "/api/chat/messages", + json={ + "channel_id": ch_id, "author_id": "user", + "author_type": "user", "content": "here", + "content_type": "text", + "attachments": [ + {"filename": "file.png", "mime_type": "image/png", + "size": 1, "url": "/api/chat/files/abc-file.png", + "source": "disk"}, + ], + }, + ) + assert r.status_code in (200, 201) + body = r.json() + assert body["attachments"][0]["filename"] == "file.png" + + +@pytest.mark.asyncio +async def test_send_message_rejects_more_than_10_attachments(tmp_path, monkeypatch): + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path)) + (tmp_path / "chat-files").mkdir(parents=True, exist_ok=True) + (tmp_path / "chat-files" / "f.png").write_bytes(b"x") + app, client = await _authed_msg_client(tmp_path) + async with client: + ch_r = await client.post( + "/api/chat/channels", + json={"name": "g", "type": "group", "members": ["user"], + "created_by": "user"}, + ) + assert ch_r.status_code in (200, 201), ch_r.json() + ch_id = ch_r.json()["id"] + atts = [{"filename": "f.png", "mime_type": "image/png", "size": 1, + "url": "/api/chat/files/f.png", "source": "disk"}] * 11 + r = await client.post( + "/api/chat/messages", + json={"channel_id": ch_id, "author_id": "user", + "author_type": "user", "content": "overflow", + "content_type": "text", "attachments": atts}, + ) + assert r.status_code == 400 + assert "10" in r.json().get("error", "") + + +@pytest.mark.asyncio +async def test_send_message_rejects_bad_url_prefix(tmp_path, monkeypatch): + monkeypatch.setenv("TAOS_DATA_DIR", str(tmp_path)) + app, client = await _authed_msg_client(tmp_path) + async with client: + ch_r = await client.post( + "/api/chat/channels", + json={"name": "g", "type": "group", "members": ["user"], + "created_by": "user"}, + ) + assert ch_r.status_code in (200, 201), ch_r.json() + ch_id = ch_r.json()["id"] + r = await client.post( + "/api/chat/messages", + json={"channel_id": ch_id, "author_id": "user", + "author_type": "user", "content": "bad", + "content_type": "text", + "attachments": [ + {"filename": "f", "mime_type": "x", "size": 1, + "url": "https://evil.example/f", "source": "disk"} + ]}, + ) + assert r.status_code == 400 + + @pytest.mark.asyncio async def test_from_path_rejects_oversize(tmp_path): ws = tmp_path / "agent-workspaces" / "user" diff --git a/tinyagentos/routes/chat.py b/tinyagentos/routes/chat.py index 4b7274ff..3b53b4cb 100644 --- a/tinyagentos/routes/chat.py +++ b/tinyagentos/routes/chat.py @@ -208,6 +208,30 @@ async def post_message(request: Request): status_code=400, ) + attachments = (body or {}).get("attachments") or [] + if not isinstance(attachments, list): + return JSONResponse({"error": "attachments must be a list"}, status_code=400) + if len(attachments) > 10: + return JSONResponse({"error": "max 10 attachments per message"}, status_code=400) + data_dir = Path(getattr(request.app.state, "data_dir", + Path(os.environ.get("TAOS_DATA_DIR", "./data")))) + chat_files = data_dir / "chat-files" + for att in attachments: + if not isinstance(att, dict): + return JSONResponse({"error": "each attachment must be a dict"}, status_code=400) + url = att.get("url", "") + if not url.startswith("/api/chat/files/"): + return JSONResponse( + {"error": "attachment url must be served from /api/chat/files/"}, + status_code=400, + ) + stored_name = url.rsplit("/", 1)[-1] + if not (chat_files / stored_name).exists(): + return JSONResponse( + {"error": f"attachment file not found: {stored_name}"}, + status_code=400, + ) + message = await msg_store.send_message( channel_id=channel_id, author_id=body["author_id"], @@ -217,7 +241,7 @@ async def post_message(request: Request): thread_id=body.get("thread_id"), embeds=body.get("embeds"), components=body.get("components"), - attachments=body.get("attachments"), + attachments=attachments, content_blocks=body.get("content_blocks"), metadata=body.get("metadata"), state=body.get("state", "complete"), From a96a92382659c5c186706b12e8f857886e55246c Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 21:24:09 +0100 Subject: [PATCH 05/23] feat(chat): get_thread_messages + GET /channels/{id}/threads/{parent}/messages --- tests/test_chat_threads.py | 98 +++++++++++++++++++++++++++++++ tinyagentos/chat/message_store.py | 14 +++++ tinyagentos/routes/chat.py | 9 +++ 3 files changed, 121 insertions(+) create mode 100644 tests/test_chat_threads.py diff --git a/tests/test_chat_threads.py b/tests/test_chat_threads.py new file mode 100644 index 00000000..055bddc0 --- /dev/null +++ b/tests/test_chat_threads.py @@ -0,0 +1,98 @@ +import pytest +import yaml +from httpx import AsyncClient, ASGITransport +from tinyagentos.chat.message_store import ChatMessageStore + + +@pytest.mark.asyncio +async def test_get_thread_messages_returns_replies_oldest_first(tmp_path): + store = ChatMessageStore(tmp_path / "msgs.db") + await store.init() + parent = await store.send_message( + channel_id="c1", author_id="user", author_type="user", + content="parent", content_type="text", state="complete", metadata=None, + ) + r1 = await store.send_message( + channel_id="c1", author_id="tom", author_type="agent", + content="r1", content_type="text", state="complete", metadata=None, + thread_id=parent["id"], + ) + r2 = await store.send_message( + channel_id="c1", author_id="don", author_type="agent", + content="r2", content_type="text", state="complete", metadata=None, + thread_id=parent["id"], + ) + msgs = await store.get_thread_messages(channel_id="c1", parent_id=parent["id"], limit=20) + assert [m["id"] for m in msgs] == [r1["id"], r2["id"]] + # parent is NOT included + assert all(m["id"] != parent["id"] for m in msgs) + + +def _make_threads_app(tmp_path): + cfg = { + "server": {"host": "0.0.0.0", "port": 6969}, + "backends": [], + "qmd": {"url": "http://localhost:7832"}, + "agents": [], + "metrics": {"poll_interval": 30, "retention_days": 30}, + } + (tmp_path / "config.yaml").write_text(yaml.dump(cfg)) + (tmp_path / ".setup_complete").touch() + from tinyagentos.app import create_app + return create_app(data_dir=tmp_path) + + +async def _authed_thread_client(tmp_path): + """Create app + authenticated client with DB stores pre-initialized. + ASGITransport in httpx 0.28 does not fire lifespan events, so we init + the chat stores manually before returning.""" + app = _make_threads_app(tmp_path) + await app.state.chat_channels.init() + await app.state.chat_messages.init() + app.state.auth.setup_user("admin", "Test Admin", "", "testpass") + rec = app.state.auth.find_user("admin") + token = app.state.auth.create_session(user_id=rec["id"], long_lived=True) + client = AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + cookies={"taos_session": token}, + ) + return app, client + + +@pytest.mark.asyncio +async def test_get_thread_messages_endpoint(tmp_path): + app, client = await _authed_thread_client(tmp_path) + async with client: + ch_r = await client.post( + "/api/chat/channels", + json={"name": "g", "type": "group", "description": "", "topic": "", + "members": ["user", "tom"], "created_by": "user"}, + ) + assert ch_r.status_code in (200, 201), ch_r.json() + ch_id = ch_r.json()["id"] + + # post parent message + r = await client.post( + "/api/chat/messages", + json={"channel_id": ch_id, "author_id": "user", "author_type": "user", + "content": "parent", "content_type": "text"}, + ) + assert r.status_code in (200, 201), r.json() + parent_id = r.json()["id"] + + # post reply with thread_id + r = await client.post( + "/api/chat/messages", + json={"channel_id": ch_id, "author_id": "user", "author_type": "user", + "content": "reply", "content_type": "text", + "thread_id": parent_id}, + ) + assert r.status_code in (200, 201), r.json() + + # fetch thread messages + r = await client.get(f"/api/chat/channels/{ch_id}/threads/{parent_id}/messages") + assert r.status_code == 200 + body = r.json() + assert len(body["messages"]) == 1 + assert body["messages"][0]["content"] == "reply" diff --git a/tinyagentos/chat/message_store.py b/tinyagentos/chat/message_store.py index 06c79979..18276ddb 100644 --- a/tinyagentos/chat/message_store.py +++ b/tinyagentos/chat/message_store.py @@ -231,6 +231,20 @@ async def ensure_message(self, msg: dict) -> None: ) await self._db.commit() + async def get_thread_messages( + self, channel_id: str, parent_id: str, limit: int = 20, + ) -> list[dict]: + """Return messages in a thread (not the parent), oldest first.""" + async with self._db.execute( + "SELECT * FROM chat_messages " + "WHERE channel_id = ? AND thread_id = ? " + "ORDER BY created_at ASC LIMIT ?", + (channel_id, parent_id, limit), + ) as cursor: + rows = await cursor.fetchall() + description = cursor.description + return [_parse(row, description) for row in rows] + async def get_all_messages_for_channel(self, channel_id: str) -> list[dict]: """Return every message in a channel ordered by created_at ASC. diff --git a/tinyagentos/routes/chat.py b/tinyagentos/routes/chat.py index 3b53b4cb..cb0b0255 100644 --- a/tinyagentos/routes/chat.py +++ b/tinyagentos/routes/chat.py @@ -399,6 +399,15 @@ async def get_channel_messages( return {"messages": messages} +@router.get("/api/chat/channels/{channel_id}/threads/{parent_id}/messages") +async def get_thread_messages_endpoint( + channel_id: str, parent_id: str, request: Request, limit: int = 20, +): + store = request.app.state.chat_messages + msgs = await store.get_thread_messages(channel_id, parent_id, limit=min(limit, 100)) + return JSONResponse({"messages": msgs}) + + @router.delete("/api/chat/channels/{channel_id}/members/{member_id}") async def remove_channel_member(request: Request, channel_id: str, member_id: str): ch_store = request.app.state.chat_channels From a8b3442f0534f79b292bcb1b1a3c4541780265e4 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 21:26:53 +0100 Subject: [PATCH 06/23] feat(chat): threads.resolve_thread_recipients for narrow thread routing --- tests/test_chat_threads.py | 114 ++++++++++++++++++++++++++++++++++++ tinyagentos/chat/threads.py | 67 +++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 tinyagentos/chat/threads.py diff --git a/tests/test_chat_threads.py b/tests/test_chat_threads.py index 055bddc0..f3d78b30 100644 --- a/tests/test_chat_threads.py +++ b/tests/test_chat_threads.py @@ -1,7 +1,121 @@ import pytest import yaml from httpx import AsyncClient, ASGITransport +from unittest.mock import AsyncMock, MagicMock from tinyagentos.chat.message_store import ChatMessageStore +from tinyagentos.chat.threads import resolve_thread_recipients + + +def _ch(members, muted=None): + return { + "id": "c1", "type": "group", "members": members, + "settings": {"muted": muted or []}, + } + + +@pytest.mark.asyncio +async def test_narrow_scope_parent_author_and_mentions(): + """parent=tom (agent), mentions @linus: recipients = {tom, linus}.""" + cm = MagicMock() + cm.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "tom", "author_type": "agent", + }) + cm.get_thread_messages = AsyncMock(return_value=[]) + msg = { + "author_id": "user", "author_type": "user", + "content": "@linus thoughts?", "thread_id": "p1", + } + recipients, forced = await resolve_thread_recipients( + msg, _ch(["user", "tom", "don", "linus"]), cm, + ) + assert sorted(recipients) == ["linus", "tom"] + assert forced["linus"] is True + # tom (parent author) is a recipient but not force_respond unless mentioned + assert forced.get("tom") is not True + + +@pytest.mark.asyncio +async def test_narrow_scope_prior_repliers(): + """Thread already has don as a replier → don is a recipient even if not mentioned.""" + cm = MagicMock() + cm.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "user", "author_type": "user", + }) + cm.get_thread_messages = AsyncMock(return_value=[ + {"author_id": "don", "author_type": "agent"}, + ]) + msg = {"author_id": "user", "author_type": "user", + "content": "more thoughts?", "thread_id": "p1"} + recipients, forced = await resolve_thread_recipients( + msg, _ch(["user", "tom", "don"]), cm, + ) + assert "don" in recipients + + +@pytest.mark.asyncio +async def test_at_all_escalates_to_all_channel_agents(): + cm = MagicMock() + cm.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "user", "author_type": "user", + }) + cm.get_thread_messages = AsyncMock(return_value=[]) + msg = {"author_id": "user", "author_type": "user", + "content": "@all weigh in", "thread_id": "p1"} + recipients, forced = await resolve_thread_recipients( + msg, _ch(["user", "tom", "don", "linus"]), cm, + ) + assert sorted(recipients) == ["don", "linus", "tom"] + assert all(forced[s] is True for s in recipients) + + +@pytest.mark.asyncio +async def test_muted_agent_excluded_from_thread(): + cm = MagicMock() + cm.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "tom", "author_type": "agent", + }) + cm.get_thread_messages = AsyncMock(return_value=[]) + msg = {"author_id": "user", "author_type": "user", + "content": "hi", "thread_id": "p1"} + recipients, _ = await resolve_thread_recipients( + msg, _ch(["user", "tom", "don"], muted=["tom"]), cm, + ) + assert "tom" not in recipients + + +@pytest.mark.asyncio +async def test_author_never_recipient(): + """Agent tom replies in a thread → tom is not re-notified.""" + cm = MagicMock() + cm.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "user", "author_type": "user", + }) + cm.get_thread_messages = AsyncMock(return_value=[ + {"author_id": "tom", "author_type": "agent"}, + ]) + msg = {"author_id": "tom", "author_type": "agent", + "content": "follow-up", "thread_id": "p1"} + recipients, _ = await resolve_thread_recipients( + msg, _ch(["user", "tom", "don"]), cm, + ) + assert "tom" not in recipients + + +@pytest.mark.asyncio +async def test_user_parent_author_not_recipient(): + """Parent authored by user → parent-author rule adds nobody (user is not an agent).""" + cm = MagicMock() + cm.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "user", "author_type": "user", + }) + cm.get_thread_messages = AsyncMock(return_value=[]) + msg = {"author_id": "user", "author_type": "user", + "content": "kickoff", "thread_id": "p1"} + recipients, _ = await resolve_thread_recipients( + msg, _ch(["user", "tom", "don"]), cm, + ) + # No agent has opted in yet, no mentions → empty recipients + assert recipients == [] @pytest.mark.asyncio diff --git a/tinyagentos/chat/threads.py b/tinyagentos/chat/threads.py new file mode 100644 index 00000000..379cfb01 --- /dev/null +++ b/tinyagentos/chat/threads.py @@ -0,0 +1,67 @@ +"""Thread-aware recipient resolution for agent chat routing. + +Narrow-by-default scope: parent-message author (if agent), prior thread +repliers, and explicit @ mentions in the new message. @all inside +a thread escalates to every channel-member agent with force_respond=true. + +Muted agents are excluded. The message author is always excluded +(threads don't re-notify the speaker). +""" +from __future__ import annotations + +from tinyagentos.chat.mentions import parse_mentions + + +async def resolve_thread_recipients( + message: dict, channel: dict, chat_messages, +) -> tuple[list[str], dict[str, bool]]: + """Return (recipients, force_by_slug) for a message in a thread. + + Args: + message: the new message being routed. Must have thread_id, author_id, + author_type, content. + channel: the channel dict including members and settings.muted. + chat_messages: the ChatMessageStore (needs get_message, get_thread_messages). + """ + author = message["author_id"] + thread_id = message.get("thread_id") + if not thread_id: + return [], {} + + members = channel.get("members") or [] + muted = set((channel.get("settings") or {}).get("muted") or []) + candidates_all = [m for m in members if m and m != author and m != "user" and m not in muted] + + mentions = parse_mentions(message.get("content") or "", members) + + # @all escalation — fan out to every agent in channel. + if mentions.all: + return list(candidates_all), {m: True for m in candidates_all} + + recipients: set[str] = set() + forced: dict[str, bool] = {} + + # Parent author (if agent, and not the current author). + parent = await chat_messages.get_message(thread_id) + if parent and parent.get("author_type") == "agent": + parent_author = parent.get("author_id") + if parent_author and parent_author != author and parent_author not in muted: + recipients.add(parent_author) + + # Prior repliers (agents only). + prior = await chat_messages.get_thread_messages( + channel_id=channel["id"], parent_id=thread_id, limit=200, + ) + for m in prior: + if m.get("author_type") == "agent": + aid = m.get("author_id") + if aid and aid != author and aid not in muted: + recipients.add(aid) + + # Explicit mentions (force_respond). + for slug in mentions.explicit: + if slug in candidates_all: + recipients.add(slug) + forced[slug] = True + + return sorted(recipients), forced From 01225b470c58963fb724cd471286b680241dc590 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 21:37:14 +0100 Subject: [PATCH 07/23] feat(chat): router integrates thread-aware recipients + per-thread policy + thread context --- tests/test_agent_chat_router.py | 58 +++++++++++++++++++ tinyagentos/agent_chat_router.py | 96 ++++++++++++++++++++------------ 2 files changed, 118 insertions(+), 36 deletions(-) diff --git a/tests/test_agent_chat_router.py b/tests/test_agent_chat_router.py index 08b73e8c..243e9f40 100644 --- a/tests/test_agent_chat_router.py +++ b/tests/test_agent_chat_router.py @@ -331,3 +331,61 @@ async def test_dm_always_forces_respond(): await router._route(msg, ch) assert len(bridge.calls) == 1 assert bridge.calls[0][1]["force_respond"] is True + + +@pytest.mark.asyncio +async def test_router_uses_thread_resolver_when_thread_id_present(): + """A message with thread_id set goes through threads.resolve_thread_recipients, + skipping the channel fanout path.""" + bridge = _FakeBridge() + state = _state_for({"name": "tom", "status": "running"}, bridge=bridge) + state.config.agents = [ + {"name": "tom", "status": "running"}, + {"name": "don", "status": "running"}, + ] + from tinyagentos.chat.group_policy import GroupPolicy + state.group_policy = GroupPolicy() + # chat_messages needs get_message + get_thread_messages for the resolver + state.chat_messages.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "tom", "author_type": "agent", + }) + state.chat_messages.get_thread_messages = AsyncMock(return_value=[]) + router = AgentChatRouter(state) + message = { + "id": "m1", "author_id": "user", "author_type": "user", + "content": "thoughts?", "thread_id": "p1", + "metadata": {"hops_since_user": 0}, + } + await router._route(message, _channel(["user", "tom", "don"], "quiet")) + slugs = sorted(c[0] for c in bridge.calls) + # tom is the parent author → recipient; don is not mentioned + not prior replier → skipped. + assert slugs == ["tom"] + + +@pytest.mark.asyncio +async def test_router_thread_policy_key_is_scoped(): + """Policy key used in thread routing should be channel_id:thread:, + so a thread doesn't consume the channel's rate cap or block unrelated + channel messages.""" + bridge = _FakeBridge() + state = _state_for({"name": "tom", "status": "running"}, bridge=bridge) + state.config.agents = [{"name": "tom", "status": "running"}] + from tinyagentos.chat.group_policy import GroupPolicy + state.group_policy = GroupPolicy() + state.chat_messages.get_message = AsyncMock(return_value={ + "id": "p1", "author_id": "tom", "author_type": "agent", + }) + state.chat_messages.get_thread_messages = AsyncMock(return_value=[]) + router = AgentChatRouter(state) + # Route a thread message. + msg = {"id": "m1", "author_id": "user", "author_type": "user", + "content": "go", "thread_id": "p1", + "metadata": {"hops_since_user": 0}} + await router._route(msg, _channel(["user", "tom"], "quiet")) + # The policy should have recorded a send keyed "c1:thread:p1" — check by trying to + # route a channel-scope message next; it should NOT be rate-limited by the thread send. + msg2 = {"id": "m2", "author_id": "user", "author_type": "user", + "content": "channel msg", "metadata": {"hops_since_user": 0}} + await router._route(msg2, _channel(["user", "tom"], "lively")) + # Expect 2 bridge calls total (one per message), since policy keys are independent. + assert len(bridge.calls) == 2 diff --git a/tinyagentos/agent_chat_router.py b/tinyagentos/agent_chat_router.py index a43dcdf8..a50204f1 100644 --- a/tinyagentos/agent_chat_router.py +++ b/tinyagentos/agent_chat_router.py @@ -46,40 +46,54 @@ async def _route_inner(self, message: dict, channel: dict) -> None: if message.get("content_type") == "system": return - author = message.get("author_id") - members = list(channel.get("members") or []) settings = channel.get("settings") or {} - muted = set(settings.get("muted") or []) - channel_type = channel.get("type") - effective_mode = "lively" if channel_type == "dm" else settings.get("response_mode", "quiet") - - mentions = parse_mentions(message.get("content") or "", members) - - candidates = [m for m in members if m and m != author and m != "user" and m not in muted] - if not candidates: - return - - force_by_slug: dict[str, bool] = {} - if mentions.all: - for m in candidates: - force_by_slug[m] = True - recipients = list(candidates) - elif mentions.explicit: - recipients = [m for m in candidates if m in mentions.explicit] - for m in recipients: - force_by_slug[m] = True - elif channel_type == "dm": - recipients = list(candidates) - for m in recipients: - force_by_slug[m] = True - elif effective_mode == "quiet": - recipients = [] + thread_id = message.get("thread_id") + if thread_id: + from tinyagentos.chat.threads import resolve_thread_recipients + recipients, force_by_slug = await resolve_thread_recipients( + message, channel, self._state.chat_messages, + ) + if not recipients: + return + # Thread policy key scopes hops/cooldown/rate-cap per thread. + policy_key = f"{channel['id']}:thread:{thread_id}" else: - recipients = list(candidates) - - if not recipients: - return + author = message.get("author_id") + members = list(channel.get("members") or []) + muted = set(settings.get("muted") or []) + + channel_type = channel.get("type") + effective_mode = "lively" if channel_type == "dm" else settings.get("response_mode", "quiet") + + mentions = parse_mentions(message.get("content") or "", members) + + candidates = [m for m in members if m and m != author and m != "user" and m not in muted] + if not candidates: + return + + force_by_slug: dict[str, bool] = {} + if mentions.all: + for m in candidates: + force_by_slug[m] = True + recipients = list(candidates) + elif mentions.explicit: + recipients = [m for m in candidates if m in mentions.explicit] + for m in recipients: + force_by_slug[m] = True + elif channel_type == "dm": + recipients = list(candidates) + for m in recipients: + force_by_slug[m] = True + elif effective_mode == "quiet": + recipients = [] + else: + recipients = list(candidates) + + if not recipients: + return + + policy_key = channel["id"] try: current_hops = int((message.get("metadata") or {}).get("hops_since_user", 0) or 0) @@ -97,9 +111,18 @@ async def _route_inner(self, message: dict, channel: dict) -> None: if hasattr(self._state, "chat_messages"): try: from tinyagentos.chat.context_window import build_context_window - recent = await self._state.chat_messages.get_messages( - channel_id=channel["id"], limit=30, - ) + if thread_id: + recent = await self._state.chat_messages.get_thread_messages( + channel_id=channel["id"], parent_id=thread_id, limit=30, + ) + # Prepend the parent as the root turn. + parent = await self._state.chat_messages.get_message(thread_id) + if parent: + recent = [parent] + list(recent) + else: + recent = await self._state.chat_messages.get_messages( + channel_id=channel["id"], limit=30, + ) context = build_context_window(recent, limit=20, max_tokens=4000) except Exception: logger.warning("context fetch failed for channel %s", channel.get("id"), exc_info=True) @@ -110,7 +133,7 @@ async def _route_inner(self, message: dict, channel: dict) -> None: if not forced: if next_hops > max_hops: continue - if policy is not None and not policy.try_acquire(channel["id"], agent_name, settings): + if policy is not None and not policy.try_acquire(policy_key, agent_name, settings): continue agent = find_agent(config, agent_name) if agent is None: @@ -140,10 +163,11 @@ async def _route_inner(self, message: dict, channel: dict) -> None: "hops_since_user": next_hops, "force_respond": forced, "context": context, + "thread_id": thread_id, }, ) if forced and policy is not None: - policy.record_send(channel["id"], agent_name) + policy.record_send(policy_key, agent_name) async def _post_system_reply( self, agent_name: str, channel_id: str, content: str, From 9051297ba08000b74238284181df59c64157b046 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 21:42:22 +0100 Subject: [PATCH 08/23] =?UTF-8?q?feat(chat):=20/help=20command=20=E2=80=94?= =?UTF-8?q?=20overview=20+=20per-topic=20cheat=20sheets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_chat_help.py | 28 ++++++++++ tinyagentos/chat/help.py | 108 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 tests/test_chat_help.py create mode 100644 tinyagentos/chat/help.py diff --git a/tests/test_chat_help.py b/tests/test_chat_help.py new file mode 100644 index 00000000..9f4ed72f --- /dev/null +++ b/tests/test_chat_help.py @@ -0,0 +1,28 @@ +import pytest +from tinyagentos.chat.help import handle_help, KNOWN_TOPICS + + +def test_overview_on_empty_args(): + out = handle_help("") + assert "chat-guide" in out.lower() + # lists known topics + for t in ["threads", "attachments", "mentions"]: + assert t in out + + +def test_specific_topic_returns_section(): + out = handle_help("threads") + assert "thread" in out.lower() + assert "chat-guide" in out.lower() # link to full guide + + +def test_unknown_topic_returns_generic_message(): + out = handle_help("unknownthing") + assert "unknown" in out.lower() or "try /help" in out.lower() + + +def test_all_documented_topics_have_handlers(): + for t in KNOWN_TOPICS: + out = handle_help(t) + assert len(out) > 0 + assert "error" not in out.lower() diff --git a/tinyagentos/chat/help.py b/tinyagentos/chat/help.py new file mode 100644 index 00000000..e25074b8 --- /dev/null +++ b/tinyagentos/chat/help.py @@ -0,0 +1,108 @@ +"""/help command handler — posts short cheat sheets into the channel +as system messages. Full reference lives in docs/chat-guide.md. +""" +from __future__ import annotations + +GUIDE_URL = "https://github.com/jaylfc/tinyagentos/blob/master/docs/chat-guide.md" + +KNOWN_TOPICS = ( + "channels", + "mentions", + "hops", + "reactions", + "slash", + "settings", + "context", + "threads", + "attachments", + "help", +) + +_OVERVIEW = f"""**taOS chat — quick help** + +- `@tom`, `@all`, `@humans` — target specific recipients +- `/` in composer opens the command picker for the current channel's agents +- `ⓘ` in the header opens channel settings (mode, members, muted, etc.) +- Right-click / hover a message for actions (reply in thread, react, etc.) + +Try `/help ` where topic is one of: {", ".join(t for t in KNOWN_TOPICS if t != "help")}. +Full guide: {GUIDE_URL} +""" + +_TOPICS: dict[str, str] = { + "channels": f"""**Channels** +- DM (2 members), group (many), topic (many, focused) +- Group/topic channels have a mode: `quiet` (respond when @mentioned only) or `lively` (every agent decides per message) +- DMs always lively — the 1:1 agent always replies. + +Details: {GUIDE_URL}#channels-and-modes""", + "mentions": f"""**Mentions** +- `@tom` — target one agent +- `@all` — every agent in the channel +- `@humans` — ping humans +- Case-insensitive; word boundary so `email@x.com` doesn't count + +Details: {GUIDE_URL}#mentions""", + "hops": f"""**Hops, cooldown, rate-cap** +- Hop counter resets on each user message; caps chains between agents (default 3) +- Per-agent cooldown prevents burst replies (default 5 s) +- Per-channel rate cap (default 20/min) is a circuit breaker +- `@mention` overrides all three caps + +Details: {GUIDE_URL}#hops-cooldown-rate-cap""", + "reactions": f"""**Reactions** +- Any emoji — click 😀 on a message's hover row +- `👎` by the channel's human on an agent reply → regenerate +- `🙋` by an agent → "hand raise" (shows a badge; no auto-reply) + +Details: {GUIDE_URL}#reactions""", + "slash": f"""**Slash menu** +- Type `/` at the start of a message to open the command picker +- Commands grouped by agent; fuzzy filter as you type +- Enter selects → inserts `@ /` into the composer + +Details: {GUIDE_URL}#slash-menu""", + "settings": f"""**Channel settings** +- `ⓘ` in chat header opens the settings panel (right side) +- Rename, topic, members, muted agents, mode, max hops, cooldown +- DMs have no settings panel (two-member 1:1) + +Details: {GUIDE_URL}#channel-settings""", + "context": f"""**Agent context menu** +- Right-click an agent's name or avatar anywhere for actions +- DM, (un)mute, remove, view info, jump to agent settings +- Shift+F10 on a focused message row opens the same menu + +Details: {GUIDE_URL}#agent-context-menu""", + "threads": f"""**Threads** +- Hover a message → `💬 Reply in thread` opens a right-side panel +- Thread replies have narrow routing — parent author + prior repliers + @mentions +- `@all` inside a thread escalates to every channel agent +- Hops, cooldown, rate-cap all scoped per thread + +Details: {GUIDE_URL}#threads""", + "attachments": f"""**Attachments** +- Paperclip button, drag-and-drop, or paste from clipboard +- Paperclip opens a file picker with tabs: Disk / My workspace / Agent workspaces +- Up to 10 attachments per message; 100 MB max per file +- Images render inline; 2+ images → gallery grid + +Details: {GUIDE_URL}#attachments""", + "help": f"""**/help** +- `/help` on its own — overview + topic list +- `/help ` — the section for that topic +- Topics: {", ".join(t for t in KNOWN_TOPICS if t != "help")} + +Full guide: {GUIDE_URL}""", +} + + +def handle_help(args: str) -> str: + """Return the system-message text for `/help [topic]`.""" + topic = (args or "").strip().lower().split() + if not topic: + return _OVERVIEW + key = topic[0] + if key in _TOPICS: + return _TOPICS[key] + return f"Unknown help topic '{key}'. Try `/help` for the overview." From 1bfdb6973cf9dc8e86718141e889f5ba90d8866c Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 21:59:26 +0100 Subject: [PATCH 09/23] feat(chat): /help intercept in POST /api/chat/messages (bypasses bare-slash guard) --- tests/test_chat_help.py | 78 ++++++++++++++++++++++++++++++++++++++ tinyagentos/routes/chat.py | 23 +++++++++++ 2 files changed, 101 insertions(+) diff --git a/tests/test_chat_help.py b/tests/test_chat_help.py index 9f4ed72f..f64bcb4e 100644 --- a/tests/test_chat_help.py +++ b/tests/test_chat_help.py @@ -1,7 +1,38 @@ import pytest +import yaml +from httpx import AsyncClient, ASGITransport from tinyagentos.chat.help import handle_help, KNOWN_TOPICS +def _make_help_app(tmp_path): + cfg = { + "server": {"host": "0.0.0.0", "port": 6969}, + "backends": [], + "qmd": {"url": "http://localhost:7832"}, + "agents": [], + "metrics": {"poll_interval": 30, "retention_days": 30}, + } + (tmp_path / "config.yaml").write_text(yaml.dump(cfg)) + (tmp_path / ".setup_complete").touch() + from tinyagentos.app import create_app + return create_app(data_dir=tmp_path) + + +async def _authed_help_client(tmp_path): + app = _make_help_app(tmp_path) + await app.state.chat_channels.init() + await app.state.chat_messages.init() + app.state.auth.setup_user("admin", "Test Admin", "", "testpass") + rec = app.state.auth.find_user("admin") + token = app.state.auth.create_session(user_id=rec["id"], long_lived=True) + client = AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + cookies={"taos_session": token}, + ) + return app, client + + def test_overview_on_empty_args(): out = handle_help("") assert "chat-guide" in out.lower() @@ -26,3 +57,50 @@ def test_all_documented_topics_have_handlers(): out = handle_help(t) assert len(out) > 0 assert "error" not in out.lower() + + +@pytest.mark.asyncio +async def test_help_message_intercepted_posts_system_reply(tmp_path): + app, client = await _authed_help_client(tmp_path) + async with client: + ch_r = await client.post( + "/api/chat/channels", + json={"name": "g", "type": "group", "description": "", "topic": "", + "members": ["user", "tom"], "created_by": "user"}, + ) + assert ch_r.status_code in (200, 201), ch_r.json() + ch_id = ch_r.json()["id"] + r = await client.post( + "/api/chat/messages", + json={"channel_id": ch_id, "author_id": "user", + "author_type": "user", "content": "/help", + "content_type": "text"}, + ) + assert r.status_code in (200, 201), r.json() + body = r.json() + assert body.get("handled") == "help" + msgs = await app.state.chat_messages.get_messages(channel_id=ch_id, limit=5) + sys_msgs = [m for m in msgs if m.get("author_type") == "system"] + assert len(sys_msgs) == 1 + assert "chat-guide" in sys_msgs[0]["content"].lower() + + +@pytest.mark.asyncio +async def test_help_bypasses_bare_slash_guardrail(tmp_path): + app, client = await _authed_help_client(tmp_path) + async with client: + ch_r = await client.post( + "/api/chat/channels", + json={"name": "g", "type": "group", "description": "", "topic": "", + "members": ["user", "tom", "don"], "created_by": "user"}, + ) + assert ch_r.status_code in (200, 201), ch_r.json() + ch_id = ch_r.json()["id"] + r = await client.post( + "/api/chat/messages", + json={"channel_id": ch_id, "author_id": "user", + "author_type": "user", "content": "/help threads", + "content_type": "text"}, + ) + assert r.status_code in (200, 201), r.json() + assert r.json().get("handled") == "help" diff --git a/tinyagentos/routes/chat.py b/tinyagentos/routes/chat.py index cb0b0255..56ff9310 100644 --- a/tinyagentos/routes/chat.py +++ b/tinyagentos/routes/chat.py @@ -190,6 +190,29 @@ async def post_message(request: Request): channel_id = body["channel_id"] content = body.get("content") or "" + if content.startswith("/help"): + from tinyagentos.chat.help import handle_help + args = content[len("/help"):].lstrip() + system_text = handle_help(args) + sys_msg = await msg_store.send_message( + channel_id=channel_id, + author_id="system", + author_type="system", + content=system_text, + content_type="text", + state="complete", + metadata=None, + ) + await ch_store.update_last_message_at(channel_id) + await hub.broadcast( + channel_id, + {"type": "message", "seq": hub.next_seq(), **sys_msg}, + ) + return JSONResponse( + {"ok": True, "handled": "help", "system_message": sys_msg}, + status_code=200, + ) + # Guardrail: in a non-DM channel, a / message must address at least one # agent explicitly (@ or @all). Otherwise a framework slash command # would broadcast to every agent in the channel, producing N different From 5ce898f6f3ed67d28e4553bdb2c0f964a91ad4f7 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 22:01:52 +0100 Subject: [PATCH 10/23] test(bridge): verify thread_id + attachments pass through enqueue_user_message --- tests/test_bridge_session_phase1.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_bridge_session_phase1.py b/tests/test_bridge_session_phase1.py index cb353322..7bd08906 100644 --- a/tests/test_bridge_session_phase1.py +++ b/tests/test_bridge_session_phase1.py @@ -51,3 +51,24 @@ async def test_handle_reply_sets_hops_on_persisted_reply_metadata(): # send_message should have been called with metadata including hops_since_user=1 call = store.send_message.await_args assert call.kwargs["metadata"]["hops_since_user"] == 1 + + +@pytest.mark.asyncio +async def test_enqueue_passes_thread_id_and_attachments(): + reg = BridgeSessionRegistry() + await reg.enqueue_user_message("tom", { + "id": "m1", "trace_id": "m1", "channel_id": "c1", + "from": "user", "text": "see file", "hops_since_user": 0, + "force_respond": False, "context": [], + "thread_id": "t-parent", + "attachments": [ + {"filename": "a.png", "mime_type": "image/png", + "size": 1, "url": "/api/chat/files/abc.png"}, + ], + }) + frames = [] + async for frame in reg.subscribe("tom"): + frames.append(frame) + break + assert "thread_id" in frames[0] + assert "a.png" in frames[0] From e7610185701f247a3b56a810588d804303a89713 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 22:04:25 +0100 Subject: [PATCH 11/23] feat(bridges): append attachment footer to LLM context prompt --- tinyagentos/scripts/install_hermes.sh | 12 ++++++++++++ tinyagentos/scripts/install_langroid.sh | 13 ++++++++++++- tinyagentos/scripts/install_openai-agents-sdk.sh | 13 ++++++++++++- tinyagentos/scripts/install_openai_agents_sdk.sh | 13 ++++++++++++- tinyagentos/scripts/install_pocketflow.sh | 13 ++++++++++++- tinyagentos/scripts/install_smolagents.sh | 13 ++++++++++++- 6 files changed, 72 insertions(+), 5 deletions(-) diff --git a/tinyagentos/scripts/install_hermes.sh b/tinyagentos/scripts/install_hermes.sh index 2ab78d9e..72a0e621 100755 --- a/tinyagentos/scripts/install_hermes.sh +++ b/tinyagentos/scripts/install_hermes.sh @@ -154,6 +154,15 @@ def _render_context(ctx): lines.append(f"{who}: {m.get('content','')}") return "\n".join(lines) +def _render_attachments(atts): + if not atts: + return "" + parts = [] + for a in atts: + size_kb = max(1, int(a.get("size", 0) / 1024)) + parts.append(f"{a.get('filename','file')} ({a.get('mime_type','?')}, {size_kb} KB)") + return "User attached: " + ", ".join(parts) + def _suppress(reply, force): if force: return reply @@ -218,6 +227,7 @@ async def handle_user_message(client: httpx.AsyncClient, evt: dict, channel: dic text = evt.get("text", "") force = bool(evt.get("force_respond")) ctx = _render_context(evt.get("context") or []) + attach_line = _render_attachments(evt.get("attachments") or []) cid = evt.get("channel_id") log.info("user_message id=%s text=%r force=%s", msg_id, text[:80], force) system = _SYSTEM_PROMPT + ("\n\nYou were directly addressed. Reply naturally; do not output NO_RESPONSE." @@ -227,6 +237,8 @@ async def handle_user_message(client: httpx.AsyncClient, evt: dict, channel: dic if ctx: messages.append({"role": "user", "content": f"Recent conversation:\n{ctx}"}) messages.append({"role": "user", "content": text}) + if attach_line: + messages.append({"role": "user", "content": attach_line}) await _thinking(client, cid, "start") try: reply = await call_hermes(client, messages) diff --git a/tinyagentos/scripts/install_langroid.sh b/tinyagentos/scripts/install_langroid.sh index 9f2e3ac1..5033bc23 100755 --- a/tinyagentos/scripts/install_langroid.sh +++ b/tinyagentos/scripts/install_langroid.sh @@ -35,6 +35,15 @@ def _render_context(ctx): lines.append(f"{who}: {m.get('content','')}") return "\n".join(lines) +def _render_attachments(atts): + if not atts: + return "" + parts = [] + for a in atts: + size_kb = max(1, int(a.get("size", 0) / 1024)) + parts.append(f"{a.get('filename','file')} ({a.get('mime_type','?')}, {size_kb} KB)") + return "User attached: " + ", ".join(parts) + def _suppress(reply, force): if force: return reply @@ -79,7 +88,9 @@ async def handle(c, evt, ch): mid = evt.get("id",""); tid = evt.get("trace_id", mid); text = evt.get("text","") force = bool(evt.get("force_respond")) ctx = _render_context(evt.get("context") or []) - full = (f"Recent conversation:\n{ctx}\n\nCurrent: {text}") if ctx else text + attach_line = _render_attachments(evt.get("attachments") or []) + base = (f"Recent conversation:\n{ctx}\n\nCurrent: {text}") if ctx else text + full = f"{base}\n{attach_line}" if attach_line else base cid = evt.get("channel_id") log.info("user_message id=%s text=%r force=%s", mid, text[:80], force) await _thinking(c, cid, "start") diff --git a/tinyagentos/scripts/install_openai-agents-sdk.sh b/tinyagentos/scripts/install_openai-agents-sdk.sh index 180aaabb..78412e92 100755 --- a/tinyagentos/scripts/install_openai-agents-sdk.sh +++ b/tinyagentos/scripts/install_openai-agents-sdk.sh @@ -37,6 +37,15 @@ def _render_context(ctx): lines.append(f"{who}: {m.get('content','')}") return "\n".join(lines) +def _render_attachments(atts): + if not atts: + return "" + parts = [] + for a in atts: + size_kb = max(1, int(a.get("size", 0) / 1024)) + parts.append(f"{a.get('filename','file')} ({a.get('mime_type','?')}, {size_kb} KB)") + return "User attached: " + ", ".join(parts) + def _suppress(reply, force): if force: return reply @@ -86,7 +95,9 @@ async def handle(c, evt, ch): mid = evt.get("id",""); tid = evt.get("trace_id", mid); text = evt.get("text","") force = bool(evt.get("force_respond")) ctx = _render_context(evt.get("context") or []) - full = (f"Recent conversation:\n{ctx}\n\nCurrent: {text}") if ctx else text + attach_line = _render_attachments(evt.get("attachments") or []) + base = (f"Recent conversation:\n{ctx}\n\nCurrent: {text}") if ctx else text + full = f"{base}\n{attach_line}" if attach_line else base cid = evt.get("channel_id") log.info("user_message id=%s text=%r force=%s", mid, text[:80], force) await _thinking(c, cid, "start") diff --git a/tinyagentos/scripts/install_openai_agents_sdk.sh b/tinyagentos/scripts/install_openai_agents_sdk.sh index 180aaabb..78412e92 100755 --- a/tinyagentos/scripts/install_openai_agents_sdk.sh +++ b/tinyagentos/scripts/install_openai_agents_sdk.sh @@ -37,6 +37,15 @@ def _render_context(ctx): lines.append(f"{who}: {m.get('content','')}") return "\n".join(lines) +def _render_attachments(atts): + if not atts: + return "" + parts = [] + for a in atts: + size_kb = max(1, int(a.get("size", 0) / 1024)) + parts.append(f"{a.get('filename','file')} ({a.get('mime_type','?')}, {size_kb} KB)") + return "User attached: " + ", ".join(parts) + def _suppress(reply, force): if force: return reply @@ -86,7 +95,9 @@ async def handle(c, evt, ch): mid = evt.get("id",""); tid = evt.get("trace_id", mid); text = evt.get("text","") force = bool(evt.get("force_respond")) ctx = _render_context(evt.get("context") or []) - full = (f"Recent conversation:\n{ctx}\n\nCurrent: {text}") if ctx else text + attach_line = _render_attachments(evt.get("attachments") or []) + base = (f"Recent conversation:\n{ctx}\n\nCurrent: {text}") if ctx else text + full = f"{base}\n{attach_line}" if attach_line else base cid = evt.get("channel_id") log.info("user_message id=%s text=%r force=%s", mid, text[:80], force) await _thinking(c, cid, "start") diff --git a/tinyagentos/scripts/install_pocketflow.sh b/tinyagentos/scripts/install_pocketflow.sh index 83e1a546..c24d81ce 100755 --- a/tinyagentos/scripts/install_pocketflow.sh +++ b/tinyagentos/scripts/install_pocketflow.sh @@ -35,6 +35,15 @@ def _render_context(ctx): lines.append(f"{who}: {m.get('content','')}") return "\n".join(lines) +def _render_attachments(atts): + if not atts: + return "" + parts = [] + for a in atts: + size_kb = max(1, int(a.get("size", 0) / 1024)) + parts.append(f"{a.get('filename','file')} ({a.get('mime_type','?')}, {size_kb} KB)") + return "User attached: " + ", ".join(parts) + def _suppress(reply, force): if force: return reply @@ -77,7 +86,9 @@ async def handle(c, evt, ch): mid = evt.get("id",""); tid = evt.get("trace_id", mid); text = evt.get("text","") force = bool(evt.get("force_respond")) ctx = _render_context(evt.get("context") or []) - full = (f"Recent conversation:\n{ctx}\n\nCurrent: {text}") if ctx else text + attach_line = _render_attachments(evt.get("attachments") or []) + base = (f"Recent conversation:\n{ctx}\n\nCurrent: {text}") if ctx else text + full = f"{base}\n{attach_line}" if attach_line else base cid = evt.get("channel_id") log.info("user_message id=%s text=%r force=%s", mid, text[:80], force) await _thinking(c, cid, "start") diff --git a/tinyagentos/scripts/install_smolagents.sh b/tinyagentos/scripts/install_smolagents.sh index b07d6bea..2a2c8aab 100755 --- a/tinyagentos/scripts/install_smolagents.sh +++ b/tinyagentos/scripts/install_smolagents.sh @@ -45,6 +45,15 @@ def _render_context(ctx): lines.append(f"{who}: {m.get('content','')}") return "\n".join(lines) +def _render_attachments(atts): + if not atts: + return "" + parts = [] + for a in atts: + size_kb = max(1, int(a.get("size", 0) / 1024)) + parts.append(f"{a.get('filename','file')} ({a.get('mime_type','?')}, {size_kb} KB)") + return "User attached: " + ", ".join(parts) + def _suppress(reply, force): if force: return reply @@ -86,7 +95,9 @@ async def handle(c, evt, ch): mid=evt.get("id",""); tid=evt.get("trace_id",mid); text=evt.get("text","") force = bool(evt.get("force_respond")) ctx = _render_context(evt.get("context") or []) - full = (f"Recent conversation:\n{ctx}\n\nCurrent: {text}") if ctx else text + attach_line = _render_attachments(evt.get("attachments") or []) + base = (f"Recent conversation:\n{ctx}\n\nCurrent: {text}") if ctx else text + full = f"{base}\n{attach_line}" if attach_line else base cid = evt.get("channel_id") log.info("user_message id=%s text=%r force=%s", mid, text[:80], force) await _thinking(c, cid, "start") From 9e754f72349bd996eb4f3e999f740a3ad5df8bf5 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 22:08:22 +0100 Subject: [PATCH 12/23] refactor(desktop): add shared VfsBrowser flat-listing component for picker --- desktop/src/shell/VfsBrowser.tsx | 172 ++++++++++++++++++ .../src/shell/__tests__/VfsBrowser.test.tsx | 83 +++++++++ 2 files changed, 255 insertions(+) create mode 100644 desktop/src/shell/VfsBrowser.tsx create mode 100644 desktop/src/shell/__tests__/VfsBrowser.test.tsx diff --git a/desktop/src/shell/VfsBrowser.tsx b/desktop/src/shell/VfsBrowser.tsx new file mode 100644 index 00000000..ec8adad8 --- /dev/null +++ b/desktop/src/shell/VfsBrowser.tsx @@ -0,0 +1,172 @@ +import { useState, useEffect } from "react"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export interface VfsEntry { + name: string; + type: "file" | "folder"; + size?: number; + mime_type?: string; + modified?: string; +} + +export interface VfsBrowserProps { + root: string; // e.g. "/workspaces/user" or "/workspaces/agent/" + onSelect: (path: string | string[]) => void; + multi?: boolean; // default false +} + +/* ------------------------------------------------------------------ */ +/* Internal — FileEntry shape returned by the API */ +/* ------------------------------------------------------------------ */ + +interface FileEntry { + name: string; + path: string; + is_dir: boolean; + size: number; + modified: number; +} + +/* ------------------------------------------------------------------ */ +/* URL builder */ +/* ------------------------------------------------------------------ */ + +function buildListUrl(root: string, relativePath: string): string { + const qs = relativePath ? `?path=${encodeURIComponent(relativePath)}` : ""; + if (root === "/workspaces/user") { + return `/api/workspace/files${qs}`; + } + if (root.startsWith("/workspaces/agent/")) { + const slug = root.slice("/workspaces/agent/".length); + return `/api/agents/${encodeURIComponent(slug)}/workspace/files${qs}`; + } + // Fallback: treat root as a literal prefix (shouldn't happen in normal use). + return `/api/workspace/files${qs}`; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function VfsBrowser({ root, onSelect, multi = false }: VfsBrowserProps) { + const [currentPath, setCurrentPath] = useState(""); + const [entries, setEntries] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + fetch(buildListUrl(root, currentPath)) + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json() as Promise; + }) + .then((data) => { + if (cancelled) return; + const raw = Array.isArray(data) ? data : []; + const mapped: VfsEntry[] = raw.map((f) => ({ + name: f.name, + type: f.is_dir ? "folder" : "file", + size: f.size, + modified: f.modified ? new Date(f.modified * 1000).toISOString() : undefined, + })); + // Folders first, then files, both alphabetically. + mapped.sort((a, b) => { + if (a.type !== b.type) return a.type === "folder" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + setEntries(mapped); + }) + .catch((e: unknown) => { + if (cancelled) return; + setError(e instanceof Error ? e.message : "Failed to load"); + setEntries([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [root, currentPath]); + + function absolutePath(name: string): string { + const rel = currentPath ? `${currentPath}/${name}` : name; + return `${root}/${rel}`; + } + + function handleFolderClick(name: string) { + setCurrentPath((prev) => (prev ? `${prev}/${name}` : name)); + setSelected(new Set()); + } + + function handleFileClick(name: string) { + const abs = absolutePath(name); + if (!multi) { + onSelect(abs); + return; + } + setSelected((prev) => { + const next = new Set(prev); + if (next.has(abs)) { + next.delete(abs); + } else { + next.add(abs); + } + onSelect(Array.from(next)); + return next; + }); + } + + function handleGoUp() { + setCurrentPath((prev) => { + const parts = prev.split("/").filter(Boolean); + parts.pop(); + return parts.join("/"); + }); + setSelected(new Set()); + } + + return ( +
    + {currentPath && ( + + )} + {loading &&

    Loading…

    } + {error &&

    Error: {error}

    } + {!loading && !error && entries.length === 0 &&

    Empty folder

    } +
      + {entries.map((entry) => { + const abs = absolutePath(entry.name); + const isSelected = selected.has(abs); + return ( +
    • + +
    • + ); + })} +
    +
    + ); +} diff --git a/desktop/src/shell/__tests__/VfsBrowser.test.tsx b/desktop/src/shell/__tests__/VfsBrowser.test.tsx new file mode 100644 index 00000000..e8724af4 --- /dev/null +++ b/desktop/src/shell/__tests__/VfsBrowser.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { VfsBrowser } from "../VfsBrowser"; + +// The API returns FileEntry[] (flat array with is_dir boolean). +const mockEntries = [ + { name: "report.md", path: "report.md", is_dir: false, size: 100, modified: 1700000000 }, + { name: "notes", path: "notes", is_dir: true, size: 0, modified: 1700000000 }, +]; + +function mockFetch(entries = mockEntries) { + global.fetch = vi.fn((url: RequestInfo | URL) => { + const u = String(url); + if (u.includes("/api/workspace/files")) { + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + json: () => Promise.resolve(entries), + } as Response); + } + if (u.includes("/api/agents/") && u.includes("/workspace/files")) { + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + json: () => Promise.resolve(entries), + } as Response); + } + return Promise.resolve({ + ok: false, + status: 404, + headers: new Headers({ "Content-Type": "application/json" }), + json: () => Promise.resolve({}), + } as Response); + }) as unknown as typeof fetch; +} + +describe("VfsBrowser", () => { + it("renders the root listing from the API mock", async () => { + mockFetch(); + render(); + // folders first, then files + expect(await screen.findByText(/notes/)).toBeInTheDocument(); + expect(screen.getByText(/report\.md/)).toBeInTheDocument(); + }); + + it("fetches agent workspace when root starts with /workspaces/agent/", async () => { + mockFetch(); + render(); + expect(await screen.findByText(/notes/)).toBeInTheDocument(); + const fetchCalls = (global.fetch as ReturnType).mock.calls; + expect(String(fetchCalls[0][0])).toContain("/api/agents/my-bot/workspace/files"); + }); + + it("calls onSelect with absolute path on file click", async () => { + mockFetch(); + const onSelect = vi.fn(); + render(); + const fileBtn = await screen.findByText(/report\.md/); + fireEvent.click(fileBtn); + expect(onSelect).toHaveBeenCalledWith("/workspaces/user/report.md"); + }); + + it("navigates into folder on click and shows go-up button", async () => { + mockFetch(); + render(); + const folderBtn = await screen.findByText(/notes/); + fireEvent.click(folderBtn); + expect(await screen.findByLabelText("Go up one folder")).toBeInTheDocument(); + }); + + it("multi mode toggles selection and calls onSelect with array", async () => { + mockFetch(); + const onSelect = vi.fn(); + render(); + const fileBtn = await screen.findByText(/report\.md/); + fireEvent.click(fileBtn); + expect(onSelect).toHaveBeenCalledWith(["/workspaces/user/report.md"]); + fireEvent.click(fileBtn); + expect(onSelect).toHaveBeenLastCalledWith([]); + }); +}); From 11160817680252e2183b007adc053c2b75b79cd9 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 22:12:41 +0100 Subject: [PATCH 13/23] feat(desktop): SharedFilePickerDialog (shell primitive) + openFilePicker api --- desktop/src/shell/FilePicker.tsx | 184 ++++++++++++++++++ .../src/shell/__tests__/FilePicker.test.tsx | 35 ++++ desktop/src/shell/file-picker-api.ts | 32 +++ 3 files changed, 251 insertions(+) create mode 100644 desktop/src/shell/FilePicker.tsx create mode 100644 desktop/src/shell/__tests__/FilePicker.test.tsx create mode 100644 desktop/src/shell/file-picker-api.ts diff --git a/desktop/src/shell/FilePicker.tsx b/desktop/src/shell/FilePicker.tsx new file mode 100644 index 00000000..1605d930 --- /dev/null +++ b/desktop/src/shell/FilePicker.tsx @@ -0,0 +1,184 @@ +import { useEffect, useRef, useState } from "react"; +import { VfsBrowser } from "./VfsBrowser"; + +export type FileSelection = + | { source: "disk"; file: File } + | { source: "workspace"; path: string } + | { source: "agent-workspace"; slug: string; path: string }; + +type Source = "disk" | "workspace" | "agent-workspace"; + +export function FilePicker({ + sources, + accept, + multi = false, + onPick, + onCancel, +}: { + sources: Source[]; + accept?: string; + multi?: boolean; + onPick: (selections: FileSelection[]) => void; + onCancel: () => void; +}) { + const [activeTab, setActiveTab] = useState(sources[0] ?? "disk"); + const [queued, setQueued] = useState([]); + const [agents, setAgents] = useState<{ name: string }[]>([]); + const [selectedAgent, setSelectedAgent] = useState(null); + const fileInputRef = useRef(null); + + useEffect(() => { + if (sources.includes("agent-workspace")) { + fetch("/api/agents") + .then((r) => r.json()) + .then((list) => setAgents(Array.isArray(list) ? list : [])) + .catch(() => {}); + } + }, [sources]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { e.preventDefault(); onCancel(); } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onCancel]); + + const onDiskFiles = (files: FileList | null) => { + if (!files) return; + const selections: FileSelection[] = []; + for (const f of Array.from(files)) { + selections.push({ source: "disk", file: f }); + } + setQueued((prev) => multi ? [...prev, ...selections] : selections); + }; + + const onWorkspacePick = (path: string | string[]) => { + const paths = Array.isArray(path) ? path : [path]; + const selections: FileSelection[] = paths.map((p) => ({ source: "workspace", path: p })); + setQueued((prev) => multi ? [...prev, ...selections] : selections); + }; + + const onAgentWorkspacePick = (path: string | string[]) => { + if (!selectedAgent) return; + const paths = Array.isArray(path) ? path : [path]; + const selections: FileSelection[] = paths.map((p) => ({ + source: "agent-workspace", slug: selectedAgent, path: p, + })); + setQueued((prev) => multi ? [...prev, ...selections] : selections); + }; + + const confirm = () => onPick(queued); + + return ( +
    +
    +
    + {sources.includes("disk") && ( + + )} + {sources.includes("workspace") && ( + + )} + {sources.includes("agent-workspace") && ( + + )} +
    + +
    + {activeTab === "disk" && ( +
    + onDiskFiles(e.target.files)} + /> + + {queued.filter((q) => q.source === "disk").length > 0 && ( +
    + {queued.length} file(s) queued +
    + )} +
    + )} + + {activeTab === "workspace" && ( + + )} + + {activeTab === "agent-workspace" && ( +
    +
    + +
    + {selectedAgent && ( + + )} +
    + )} +
    + +
    + {queued.length} selected + + +
    +
    +
    + ); +} diff --git a/desktop/src/shell/__tests__/FilePicker.test.tsx b/desktop/src/shell/__tests__/FilePicker.test.tsx new file mode 100644 index 00000000..b6e9924c --- /dev/null +++ b/desktop/src/shell/__tests__/FilePicker.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { FilePicker } from "../FilePicker"; + +describe("FilePicker", () => { + it("shows the three tabs when all sources are requested", () => { + render( + , + ); + expect(screen.getByRole("tab", { name: /Disk/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /My workspace/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /Agent workspaces/i })).toBeInTheDocument(); + }); + + it("cancel calls onCancel and nothing else", () => { + const onPick = vi.fn(); + const onCancel = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /Cancel/i })); + expect(onCancel).toHaveBeenCalled(); + expect(onPick).not.toHaveBeenCalled(); + }); + + it("Esc closes", () => { + const onCancel = vi.fn(); + render(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(onCancel).toHaveBeenCalled(); + }); +}); diff --git a/desktop/src/shell/file-picker-api.ts b/desktop/src/shell/file-picker-api.ts new file mode 100644 index 00000000..4105c856 --- /dev/null +++ b/desktop/src/shell/file-picker-api.ts @@ -0,0 +1,32 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { FilePicker, type FileSelection } from "./FilePicker"; + +type Source = "disk" | "workspace" | "agent-workspace"; + +export function openFilePicker(opts: { + sources: Source[]; + accept?: string; + multi?: boolean; +}): Promise { + return new Promise((resolve) => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const cleanup = () => { + root.unmount(); + container.remove(); + }; + + root.render( + React.createElement(FilePicker, { + sources: opts.sources, + accept: opts.accept, + multi: opts.multi, + onPick: (sels: FileSelection[]) => { cleanup(); resolve(sels); }, + onCancel: () => { cleanup(); resolve([]); }, + }), + ); + }); +} From 6f092fbff4d95ce6eb407cfce860c95fc794b2d4 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 22:15:59 +0100 Subject: [PATCH 14/23] feat(desktop): chat-attachments-api client (upload + from-path) --- .../__tests__/chat-attachments-api.test.ts | 60 +++++++++++++++++++ desktop/src/lib/chat-attachments-api.ts | 37 ++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 desktop/src/lib/__tests__/chat-attachments-api.test.ts create mode 100644 desktop/src/lib/chat-attachments-api.ts diff --git a/desktop/src/lib/__tests__/chat-attachments-api.test.ts b/desktop/src/lib/__tests__/chat-attachments-api.test.ts new file mode 100644 index 00000000..c6d4917f --- /dev/null +++ b/desktop/src/lib/__tests__/chat-attachments-api.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { uploadDiskFile, attachmentFromPath } from "../chat-attachments-api"; + +describe("chat-attachments-api", () => { + beforeEach(() => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, status: 200, + json: () => Promise.resolve({ + filename: "f.png", mime_type: "image/png", size: 1, + url: "/api/chat/files/abc-f.png", source: "disk", + }), + }), + ) as unknown as typeof fetch; + }); + + it("uploadDiskFile POSTs multipart to /api/chat/upload", async () => { + const f = new File(["x"], "f.png", { type: "image/png" }); + const rec = await uploadDiskFile(f); + expect(fetch).toHaveBeenCalledWith( + "/api/chat/upload", + expect.objectContaining({ method: "POST" }), + ); + expect(rec.filename).toBe("f.png"); + }); + + it("attachmentFromPath POSTs workspace path", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, status: 200, + json: () => Promise.resolve({ + filename: "r.md", mime_type: "text/markdown", size: 10, + url: "/api/chat/files/xyz-r.md", source: "workspace", + }), + }), + ) as unknown as typeof fetch; + const rec = await attachmentFromPath({ + path: "/workspaces/user/r.md", source: "workspace", + }); + expect(fetch).toHaveBeenCalledWith( + "/api/chat/attachments/from-path", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ path: "/workspaces/user/r.md", source: "workspace" }), + }), + ); + expect(rec.source).toBe("workspace"); + }); + + it("throws on non-OK with server's error", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: false, status: 413, + json: () => Promise.resolve({ error: "file too large (100 MB max)" }), + }), + ) as unknown as typeof fetch; + const f = new File(["x"], "big.bin"); + await expect(uploadDiskFile(f)).rejects.toThrow("file too large"); + }); +}); diff --git a/desktop/src/lib/chat-attachments-api.ts b/desktop/src/lib/chat-attachments-api.ts new file mode 100644 index 00000000..613cbd14 --- /dev/null +++ b/desktop/src/lib/chat-attachments-api.ts @@ -0,0 +1,37 @@ +export type AttachmentRecord = { + filename: string; + mime_type: string; + size: number; + url: string; + source: "disk" | "workspace" | "agent-workspace"; +}; + +async function _ensureOk(r: Response): Promise { + if (r.ok) return; + let body: { error?: string } | null = null; + try { body = await r.json(); } catch { /* ignore */ } + throw new Error(body?.error || `HTTP ${r.status}`); +} + +export async function uploadDiskFile(file: File, channelId?: string): Promise { + const form = new FormData(); + form.append("file", file); + if (channelId) form.append("channel_id", channelId); + const r = await fetch("/api/chat/upload", { method: "POST", body: form }); + await _ensureOk(r); + return r.json(); +} + +export async function attachmentFromPath(body: { + path: string; + source: "workspace" | "agent-workspace"; + slug?: string; +}): Promise { + const r = await fetch("/api/chat/attachments/from-path", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + await _ensureOk(r); + return r.json(); +} From 0a4da28dedba7b357cd5cf427928e973eb598103 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 22:18:20 +0100 Subject: [PATCH 15/23] feat(desktop): AttachmentsBar + AttachmentGallery + AttachmentLightbox --- desktop/src/apps/chat/AttachmentGallery.tsx | 59 +++++++++++++++++++ desktop/src/apps/chat/AttachmentLightbox.tsx | 45 ++++++++++++++ desktop/src/apps/chat/AttachmentsBar.tsx | 41 +++++++++++++ .../chat/__tests__/AttachmentGallery.test.tsx | 32 ++++++++++ .../chat/__tests__/AttachmentsBar.test.tsx | 26 ++++++++ 5 files changed, 203 insertions(+) create mode 100644 desktop/src/apps/chat/AttachmentGallery.tsx create mode 100644 desktop/src/apps/chat/AttachmentLightbox.tsx create mode 100644 desktop/src/apps/chat/AttachmentsBar.tsx create mode 100644 desktop/src/apps/chat/__tests__/AttachmentGallery.test.tsx create mode 100644 desktop/src/apps/chat/__tests__/AttachmentsBar.test.tsx diff --git a/desktop/src/apps/chat/AttachmentGallery.tsx b/desktop/src/apps/chat/AttachmentGallery.tsx new file mode 100644 index 00000000..e1fa7e0e --- /dev/null +++ b/desktop/src/apps/chat/AttachmentGallery.tsx @@ -0,0 +1,59 @@ +// desktop/src/apps/chat/AttachmentGallery.tsx +import { useState } from "react"; +import type { AttachmentRecord } from "@/lib/chat-attachments-api"; +import { AttachmentLightbox } from "./AttachmentLightbox"; + +export function AttachmentGallery({ attachments }: { attachments: AttachmentRecord[] }) { + const [lightboxStart, setLightboxStart] = useState(null); + if (!attachments?.length) return null; + const images = attachments.filter((a) => a.mime_type?.startsWith("image/")); + const files = attachments.filter((a) => !a.mime_type?.startsWith("image/")); + + const gridClass = images.length > 1 ? "grid grid-cols-2 gap-1 max-w-md" : ""; + + return ( +
    + {images.length > 0 && ( +
    + {images.slice(0, 4).map((img, i) => ( + + ))} +
    + )} + {files.length > 0 && ( + + )} + {lightboxStart !== null && ( + setLightboxStart(null)} + /> + )} +
    + ); +} diff --git a/desktop/src/apps/chat/AttachmentLightbox.tsx b/desktop/src/apps/chat/AttachmentLightbox.tsx new file mode 100644 index 00000000..5b1a9983 --- /dev/null +++ b/desktop/src/apps/chat/AttachmentLightbox.tsx @@ -0,0 +1,45 @@ +// desktop/src/apps/chat/AttachmentLightbox.tsx +import { useEffect, useState } from "react"; +import type { AttachmentRecord } from "@/lib/chat-attachments-api"; + +export function AttachmentLightbox({ + images, startIndex, onClose, +}: { + images: AttachmentRecord[]; + startIndex: number; + onClose: () => void; +}) { + const [idx, setIdx] = useState(startIndex); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1)); + if (e.key === "ArrowRight") setIdx((i) => Math.min(images.length - 1, i + 1)); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [images.length, onClose]); + + const current = images[idx]!; + return ( +
    + {current.filename} e.stopPropagation()} /> + + {images.length > 1 && ( +
    {idx + 1} / {images.length}
    + )} +
    + ); +} diff --git a/desktop/src/apps/chat/AttachmentsBar.tsx b/desktop/src/apps/chat/AttachmentsBar.tsx new file mode 100644 index 00000000..6c2c433e --- /dev/null +++ b/desktop/src/apps/chat/AttachmentsBar.tsx @@ -0,0 +1,41 @@ +import type { AttachmentRecord } from "@/lib/chat-attachments-api"; + +export type PendingAttachment = { + id: string; + filename: string; + size: number; + mime_type?: string; + record?: AttachmentRecord; // set once upload completes + error?: string; + uploading?: boolean; +}; + +export function AttachmentsBar({ + items, + onRemove, + onRetry, +}: { + items: PendingAttachment[]; + onRemove: (id: string) => void; + onRetry: (id: string) => void; +}) { + if (items.length === 0) return null; + return ( +
    + {items.map((it) => ( +
    + {it.filename} + {Math.max(1, Math.round(it.size / 1024))} KB + {it.uploading && } + {it.error && ( + + )} + +
    + ))} +
    + ); +} diff --git a/desktop/src/apps/chat/__tests__/AttachmentGallery.test.tsx b/desktop/src/apps/chat/__tests__/AttachmentGallery.test.tsx new file mode 100644 index 00000000..1ef20e3b --- /dev/null +++ b/desktop/src/apps/chat/__tests__/AttachmentGallery.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AttachmentGallery } from "../AttachmentGallery"; + +const img = (url: string, name: string) => ({ + filename: name, mime_type: "image/png", size: 1, url, source: "disk" as const, +}); + +describe("AttachmentGallery", () => { + it("renders nothing for empty list", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + it("renders a single image inline", () => { + render(); + expect(screen.getByAltText("a.png")).toBeInTheDocument(); + }); + it("renders a grid for 2+ images", () => { + render(); + expect(screen.getByAltText("a.png")).toBeInTheDocument(); + expect(screen.getByAltText("b.png")).toBeInTheDocument(); + }); + it("renders file tiles for non-image attachments", () => { + render(); + expect(screen.getByText("r.pdf")).toBeInTheDocument(); + }); +}); diff --git a/desktop/src/apps/chat/__tests__/AttachmentsBar.test.tsx b/desktop/src/apps/chat/__tests__/AttachmentsBar.test.tsx new file mode 100644 index 00000000..a85a0302 --- /dev/null +++ b/desktop/src/apps/chat/__tests__/AttachmentsBar.test.tsx @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AttachmentsBar } from "../AttachmentsBar"; + +describe("AttachmentsBar", () => { + it("renders nothing for empty items", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders filename and calls onRemove when × is clicked", () => { + const onRemove = vi.fn(); + render( + , + ); + expect(screen.getByText("doc.pdf")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Remove doc\.pdf/i })); + expect(onRemove).toHaveBeenCalledWith("x1"); + }); +}); From f51b15425600e3e8c976b120bb27d428699cbc3c Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 19 Apr 2026 22:24:02 +0100 Subject: [PATCH 16/23] =?UTF-8?q?feat(chat):=20thread=20UI=20=E2=80=94=20M?= =?UTF-8?q?essageHoverActions=20+=20ThreadIndicator=20+=20ThreadPanel=20+?= =?UTF-8?q?=20use-thread-panel=20+=20GET=20message-by-id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/src/apps/chat/MessageHoverActions.tsx | 21 ++++ desktop/src/apps/chat/ThreadIndicator.tsx | 30 ++++++ desktop/src/apps/chat/ThreadPanel.tsx | 102 ++++++++++++++++++ .../__tests__/MessageHoverActions.test.tsx | 24 +++++ .../chat/__tests__/ThreadIndicator.test.tsx | 21 ++++ desktop/src/lib/use-thread-panel.ts | 11 ++ tests/test_chat_threads.py | 24 +++++ tinyagentos/routes/chat.py | 9 ++ 8 files changed, 242 insertions(+) create mode 100644 desktop/src/apps/chat/MessageHoverActions.tsx create mode 100644 desktop/src/apps/chat/ThreadIndicator.tsx create mode 100644 desktop/src/apps/chat/ThreadPanel.tsx create mode 100644 desktop/src/apps/chat/__tests__/MessageHoverActions.test.tsx create mode 100644 desktop/src/apps/chat/__tests__/ThreadIndicator.test.tsx create mode 100644 desktop/src/lib/use-thread-panel.ts diff --git a/desktop/src/apps/chat/MessageHoverActions.tsx b/desktop/src/apps/chat/MessageHoverActions.tsx new file mode 100644 index 00000000..680d1a36 --- /dev/null +++ b/desktop/src/apps/chat/MessageHoverActions.tsx @@ -0,0 +1,21 @@ +export function MessageHoverActions({ + onReact, + onReplyInThread, + onMore, +}: { + onReact: () => void; + onReplyInThread: () => void; + onMore: (e: React.MouseEvent) => void; +}) { + return ( +
    + + + +
    + ); +} diff --git a/desktop/src/apps/chat/ThreadIndicator.tsx b/desktop/src/apps/chat/ThreadIndicator.tsx new file mode 100644 index 00000000..d7577d50 --- /dev/null +++ b/desktop/src/apps/chat/ThreadIndicator.tsx @@ -0,0 +1,30 @@ +export function ThreadIndicator({ + replyCount, + lastReplyAt, + onOpen, +}: { + replyCount: number; + lastReplyAt?: number | null; + onOpen: () => void; +}) { + if (replyCount === 0) return null; + const label = lastReplyAt + ? `💬 ${replyCount} repl${replyCount === 1 ? "y" : "ies"} · last reply ${relative(lastReplyAt)}` + : `💬 ${replyCount} repl${replyCount === 1 ? "y" : "ies"}`; + return ( + + ); +} + +function relative(ts: number): string { + const now = Date.now() / 1000; + const delta = Math.max(0, now - ts); + if (delta < 60) return "just now"; + if (delta < 3600) return `${Math.floor(delta / 60)}m ago`; + if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`; + return `${Math.floor(delta / 86400)}d ago`; +} diff --git a/desktop/src/apps/chat/ThreadPanel.tsx b/desktop/src/apps/chat/ThreadPanel.tsx new file mode 100644 index 00000000..734cbf53 --- /dev/null +++ b/desktop/src/apps/chat/ThreadPanel.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from "react"; +import type { AttachmentRecord } from "@/lib/chat-attachments-api"; + +type Msg = { + id: string; + author_id: string; + content: string; + created_at?: number; + [key: string]: unknown; +}; + +export function ThreadPanel({ + channelId, + parentId, + onClose, + onSend, +}: { + channelId: string; + parentId: string; + onClose: () => void; + onSend: (content: string, attachments: AttachmentRecord[]) => Promise; +}) { + const [parent, setParent] = useState(null); + const [msgs, setMsgs] = useState([]); + const [input, setInput] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + let alive = true; + fetch(`/api/chat/messages/${parentId}`) + .then((r) => r.json()) + .then((d) => { if (alive) setParent(d); }); + return () => { alive = false; }; + }, [parentId]); + + useEffect(() => { + let alive = true; + fetch(`/api/chat/channels/${channelId}/threads/${parentId}/messages`) + .then((r) => r.json()) + .then((d) => { if (alive) setMsgs(d.messages || []); }); + return () => { alive = false; }; + }, [channelId, parentId]); + + async function submit() { + const content = input.trim(); + if (!content) return; + setInput(""); + await onSend(content, []); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submit(); + } + } + + return ( +
    +
    + Thread + +
    + +
    + {parent && ( +
    +
    {parent.author_id}
    +
    {parent.content}
    +
    + )} + {msgs.map((m) => ( +
    +
    {m.author_id}
    +
    {m.content}
    +
    + ))} +
    + +
    +