From c83bd6a89bf0d5b2c91b9cc77ba0bcc4d37348f5 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 23:46:14 +1000 Subject: [PATCH] docs(dross): Phase 1 implementation plan (bubble + global Dross + settings) Co-Authored-By: Claude Opus 4.8 --- .../2026-06-09-floating-dross-chat-phase1.md | 654 ++++++++++++++++++ 1 file changed, 654 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-floating-dross-chat-phase1.md diff --git a/docs/superpowers/plans/2026-06-09-floating-dross-chat-phase1.md b/docs/superpowers/plans/2026-06-09-floating-dross-chat-phase1.md new file mode 100644 index 0000000..57cf32c --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-floating-dross-chat-phase1.md @@ -0,0 +1,654 @@ +# Floating Dross Chat — Phase 1 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:** Replace the per-Space right-rail companion with a global, draggable floating "Dross" bubble (orb → chat panel) plus a Settings panel for his avatar, colour, persona, and voice-mode. No voice yet (Phase 2). + +**Architecture:** A new backend router `/api/dross` resolves a single space-less ("global") conversation for the existing `companion` agent (Dross) via the already-existing `conversations.findOrCreateGlobal`, streams turns over SSE exactly like `companion.js`, and stores per-user preferences in the generic `app_settings` store (key `dross`). The frontend gets a self-contained `dross_bubble.js` component that reuses the existing `wireAgentChat` engine and mounts globally (replacing `renderRightrail`), with avatars rendered by `dross_avatar.js`. + +**Tech Stack:** Node/Express, Postgres (`app_settings`, `conversations`, `messages`), vanilla-JS frontend, vitest + supertest for backend tests, headless Playwright (already on CT 300) for UI verification. + +**Spec:** `docs/superpowers/specs/2026-06-09-floating-dross-chat-design.md` + +--- + +### Task 1: Dross settings endpoint (`/api/dross/settings`) + +Stores `{avatar, accent, persona, voiceMode}` in `app_settings` key `dross`. Reuses `lib/db/repos/app_settings.js` (get/set already exist). + +**Files:** +- Create: `lib/api/routes/dross.js` +- Modify: `lib/api/index.js` (import + mount) +- Test: `tests/routes/dross.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/routes/dross.test.js +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import { createApp } from '../../server.js'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; + +let app; +const owner = { Authorization: 'Bearer test-token' }; +beforeAll(async () => { + await resetDb(); await migrateUp(); + process.env.OWNER_TOKEN = 'test-token'; + app = createApp(); +}); + +describe('dross settings', () => { + it('GET /api/dross/settings returns defaults', async () => { + const res = await request(app).get('/api/dross/settings').set(owner); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' }); + }); + + it('PUT /api/dross/settings persists and round-trips', async () => { + const body = { avatar: 'wisp', accent: '#aa66ff', persona: 'Be terse.', voiceMode: 'handsfree' }; + const put = await request(app).put('/api/dross/settings').set(owner).send(body); + expect(put.status).toBe(200); + const get = await request(app).get('/api/dross/settings').set(owner); + expect(get.body).toMatchObject(body); + }); + + it('PUT rejects a bad avatar (400)', async () => { + const res = await request(app).put('/api/dross/settings').set(owner) + .send({ avatar: 'nope', accent: '#aa66ff', persona: '', voiceMode: 'review' }); + expect(res.status).toBe(400); + }); +}); +``` + +- [ ] **Step 2: Run it — expect FAIL** (`route not found` / 404, then 401 once mounted) + +Run: `npx vitest run tests/routes/dross.test.js` +Expected: FAIL (router doesn't exist yet). + +- [ ] **Step 3: Create the router with the settings half** + +```js +// lib/api/routes/dross.js +import { Router } from 'express'; +import { z } from 'zod'; +import { validate } from '../validate.js'; +import { asyncWrap } from '../errors.js'; +import { requireOwner } from '../cap.js'; +import * as conversations from '../../db/repos/conversations.js'; +import * as messages from '../../db/repos/messages.js'; +import * as agents from '../../db/repos/agents.js'; +import * as settings from '../../db/repos/app_settings.js'; +import { runAgentTurn } from '../../ai/agent/run_turn.js'; +import { personaFor } from '../../ai/personas/index.js'; + +const COMPANION_SLUG = 'companion'; +const DEFAULT_SETTINGS = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' }; + +export const router = Router(); + +async function getCfg() { return { ...DEFAULT_SETTINGS, ...(await settings.get('dross', {})) }; } + +router.get('/settings', asyncWrap(async (_req, res) => res.json(await getCfg()))); + +const settingsBody = z.object({ + avatar: z.enum(['soft-eye', 'wisp', 'motes']), + accent: z.string().regex(/^#[0-9a-fA-F]{6}$/), + persona: z.string().max(8000), + voiceMode: z.enum(['review', 'handsfree', 'action']) +}); +router.put('/settings', requireOwner, validate({ body: settingsBody }), + asyncWrap(async (req, res) => res.json(await settings.set('dross', req.body)))); +``` + +- [ ] **Step 4: Mount it** + +In `lib/api/index.js`, add the import after the kutt/theme imports: +```js +import { router as drossRouter } from './routes/dross.js'; +``` +and the mount alongside the others (e.g. after `api.use('/theme', themeRouter);`): +```js + api.use('/dross', drossRouter); +``` + +- [ ] **Step 5: Run tests — expect the 3 settings tests PASS** + +Run: `npx vitest run tests/routes/dross.test.js` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add lib/api/routes/dross.js lib/api/index.js tests/routes/dross.test.js +git commit -m "feat(dross): settings endpoint (avatar/accent/persona/voiceMode)" +``` + +--- + +### Task 2: Global Dross chat route (`GET /api/dross`, `POST /api/dross/turn`) + +A space-less conversation for the `companion` agent, streamed over SSE. Mirrors `lib/api/routes/companion.js` but uses `findOrCreateGlobal`, `spaceId: null`, and the persona from settings (falling back to `personaFor('companion')`). + +**Files:** +- Modify: `lib/api/routes/dross.js` +- Test: `tests/routes/dross.test.js` (add cases) + +- [ ] **Step 1: Add failing tests for history + validation** + +Append inside the `describe('dross settings'…` file a new block: +```js +describe('dross chat', () => { + it('GET /api/dross returns a global conversation + Dross agent', async () => { + const res = await request(app).get('/api/dross').set(owner); + expect(res.status).toBe(200); + expect(res.body.conversation_id).toBeTruthy(); + expect(res.body.agent.slug).toBe('companion'); + expect(Array.isArray(res.body.messages)).toBe(true); + }); + + it('POST /api/dross/turn rejects empty text (400)', async () => { + const res = await request(app).post('/api/dross/turn').set(owner).send({ text: '' }); + expect(res.status).toBe(400); + }); + + it('GET /api/dross without token is 401', async () => { + const res = await request(app).get('/api/dross'); + expect(res.status).toBe(401); + }); +}); +``` +> Note: a full turn shells out to the `claude` CLI, so we don't unit-test the SSE happy-path here (it's covered by the live smoke test in Task 7). We test resolution, validation, and auth. + +- [ ] **Step 2: Run — expect FAIL** (GET `/api/dross` is 404 until added) + +Run: `npx vitest run tests/routes/dross.test.js` +Expected: FAIL on the chat block. + +- [ ] **Step 3: Add the history + turn handlers to `dross.js`** + +Append to `lib/api/routes/dross.js`: +```js +async function resolve() { + const agent = await agents.getBySlug(COMPANION_SLUG); + const convo = await conversations.findOrCreateGlobal(agent.id, { kind: 'user', id: null }); + return { agent, convo }; +} + +router.get('/', asyncWrap(async (_req, res) => { + const { agent, convo } = await resolve(); + const rows = await messages.listByConversation(convo.id); + res.json({ + conversation_id: convo.id, + agent: { id: agent.id, slug: agent.slug, name: agent.name }, + messages: rows + }); +})); + +const turnSchema = z.object({ + text: z.string().min(1), + view: z.object({ entityType: z.string(), entityId: z.string() }).partial().nullish() +}); + +router.post('/turn', requireOwner, validate({ body: turnSchema }), asyncWrap(async (req, res) => { + const { agent, convo } = await resolve(); + const { text, view } = req.body; + const cfg = await getCfg(); + const persona = (cfg.persona && cfg.persona.trim()) ? cfg.persona : personaFor(COMPANION_SLUG); + + const priorTurns = (await messages.listByConversation(convo.id)).length; + const resume = priorTurns > 0; + await messages.append(convo.id, { role: 'user', body: text }); + + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + const claudeExe = req.app.locals.claudeExe || process.env.CLAUDE_EXE || 'claude'; + const companionTools = ['mcp__void__search', 'mcp__void__read', 'mcp__void__context', 'mcp__void__propose_change']; + const draftIds = []; + + let result; + try { + result = await runAgentTurn({ + agent, persona, registryName: undefined, toolNames: companionTools, + spaceId: null, view, sessionId: convo.id, resume, userText: text, claudeExe, + home: process.env.VOID_CLAUDE_HOME || undefined, + onEvent: (e) => { + if (e.type === 'delta') send('delta', { type: 'delta', text: e.text }); + else if (e.type === 'tool') send('tool', { type: 'tool', tool: e.tool, status: e.status }); + else if (e.type === 'tool_result') { + let parsed = null; const tryParse = (s) => { try { return JSON.parse(s); } catch { return null; } }; + if (typeof e.result === 'string') parsed = tryParse(e.result); + else if (e.result?.structuredContent?.pending_change_id) parsed = e.result.structuredContent; + else if (Array.isArray(e.result)) for (const b of e.result) { + const c = b?.type === 'text' && b.text ? tryParse(b.text) : null; + if (c?.pending_change_id) { parsed = c; break; } + } + if (parsed?.pending_change_id) { + draftIds.push(parsed.pending_change_id); + send('draft', { type: 'draft', pending_change_id: parsed.pending_change_id, summary: parsed.summary || 'a change' }); + } + } else if (e.type === 'error') send('error', { type: 'error', message: e.message }); + } + }); + } catch (e) { send('error', { message: String(e?.message || e) }); res.end(); return; } + + const assistant = await messages.append(convo.id, { + role: 'assistant', body: result.text, agent_id: agent.id, + metadata: { tool_trace: result.toolTrace, draft_ids: draftIds, usage: result.usage } + }); + send('done', { assistant_message_id: assistant.id, draft_ids: draftIds, usage: result.usage }); + res.end(); +})); +``` + +- [ ] **Step 4: Run tests — expect PASS** + +Run: `npx vitest run tests/routes/dross.test.js` +Expected: PASS (all settings + chat cases). + +- [ ] **Step 5: Commit** + +```bash +git add lib/api/routes/dross.js tests/routes/dross.test.js +git commit -m "feat(dross): global (space-less) Dross conversation + SSE turn" +``` + +--- + +### Task 3: Dross avatar component + +Pure render of the three violet avatars at any size. Reused by the orb, the panel header, and the Settings preview. + +**Files:** +- Create: `public/components/dross_avatar.js` +- Test: `tests/views/dross_avatar.test.js` + +- [ ] **Step 1: Write the failing test (jsdom)** + +```js +// tests/views/dross_avatar.test.js +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { drossAvatar } from '../../public/components/dross_avatar.js'; + +describe('drossAvatar', () => { + it('renders the requested variant class', () => { + const eye = drossAvatar('soft-eye', 60); + expect(eye.classList.contains('dross-orb')).toBe(true); + expect(eye.querySelector('.av-eye')).toBeTruthy(); + expect(drossAvatar('wisp', 30).querySelector('.b-core')).toBeTruthy(); + expect(drossAvatar('motes', 30).querySelector('.d-core')).toBeTruthy(); + }); + it('falls back to soft-eye for unknown variants', () => { + expect(drossAvatar('bogus', 60).querySelector('.av-eye')).toBeTruthy(); + }); + it('sets the pixel size', () => { + const a = drossAvatar('wisp', 42); + expect(a.style.width).toBe('42px'); + expect(a.style.height).toBe('42px'); + }); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** (module missing) + +Run: `npx vitest run tests/views/dross_avatar.test.js` +Expected: FAIL. + +- [ ] **Step 3: Implement `dross_avatar.js`** (markup ported from `docs/mockups/dross-chat.html`) + +```js +// public/components/dross_avatar.js +import { el } from '../dom.js'; + +// Returns a .dross-orb element rendering the chosen avatar. Colours come from +// CSS vars (--dross*), set on the element by the caller for per-user accent. +export function drossAvatar(variant = 'soft-eye', size = 60) { + let inner; + if (variant === 'wisp') { + inner = [el('div', { class: 'b-core' }), el('div', { class: 'b-bright' })]; + } else if (variant === 'motes') { + inner = [ + el('div', { class: 'd-ring' }, el('div', { class: 'd-mote' })), + el('div', { class: 'd-ring r2' }, el('div', { class: 'd-mote' })), + el('div', { class: 'd-core' }) + ]; + } else { // soft-eye (default) + inner = [el('div', { class: 'av-eye' }, el('div', { class: 'av-pupil' }))]; + } + return el('div', { class: 'dross-orb', style: { width: size + 'px', height: size + 'px' } }, ...inner); +} +``` + +- [ ] **Step 4: Run — expect PASS** + +Run: `npx vitest run tests/views/dross_avatar.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add public/components/dross_avatar.js tests/views/dross_avatar.test.js +git commit -m "feat(dross): avatar component (soft-eye / wisp / motes)" +``` + +--- + +### Task 4: Bubble CSS + +Port the orb/panel/avatar/mic/collapse styles from the mockup into `style.css` under `dross-*` class names, driven by `--dross*` vars. + +**Files:** +- Modify: `public/style.css` (append a `/* ---- Dross floating chat ---- */` block) + +- [ ] **Step 1: Append the CSS block** + +Append to `public/style.css` (values lifted verbatim from `docs/mockups/dross-chat.html`; replace the mock's `.orb`/`.panel`/etc. selectors with `.dross-orb`/`.dross-panel`/etc.): + +```css +/* ---- Dross floating chat ---- */ +:root{ --dross:#a86adf; --dross-dim:#5a2e8a; --dross-soft:#1e1030; --dross-glow:#c79bff; } +.dross-orb{position:relative;border-radius:50%;display:grid;place-items:center;overflow:hidden;flex:none; + background:radial-gradient(circle at 38% 30%, #2a1640, #1a0f2a 70%, #120a1e); + box-shadow:0 0 0 1px #ffffff12, 0 6px 22px -6px #000, 0 0 26px -4px var(--dross-dim)} +.dross-fab{position:fixed;right:20px;bottom:20px;z-index:40;cursor:grab;touch-action:none;animation:dross-bob 5s ease-in-out infinite} +.dross-fab:active{cursor:grabbing} +@keyframes dross-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}} +.dross-fab .dross-orb{width:60px;height:60px} +.dross-ping{position:absolute;right:-2px;top:-2px;width:17px;height:17px;border-radius:50%;background:var(--accent); + color:#0a0a0e;font-size:10px;display:grid;place-items:center;box-shadow:0 0 0 2px var(--bg);z-index:2;font-family:var(--font-ui)} +/* soft eye */ +.av-eye{width:54%;height:54%;border-radius:50%;background:radial-gradient(circle at 50% 40%, #2a1c3a, #140b20);display:grid;place-items:center;box-shadow:inset 0 0 10px #000} +.av-pupil{width:44%;height:44%;border-radius:50%;position:relative;background:radial-gradient(circle at 38% 32%, #fff, var(--dross-glow) 50%, var(--dross) 100%);box-shadow:0 0 10px var(--dross-glow);animation:dross-look 7s ease-in-out infinite} +.av-pupil::after{content:"";position:absolute;right:14%;bottom:18%;width:26%;height:26%;border-radius:50%;background:#fff;opacity:.8} +@keyframes dross-look{0%,45%{transform:translate(0,0)}58%{transform:translate(3px,-2px)}72%{transform:translate(-2px,1px)}88%,100%{transform:translate(0,0)}} +/* wisp */ +.b-core{position:absolute;inset:13%;border-radius:50%;filter:blur(3px);animation:dross-spin 7s linear infinite; + background:conic-gradient(from 0deg, var(--dross-dim), var(--dross-glow), var(--dross), var(--dross-soft), var(--dross-dim))} +.b-bright{position:absolute;inset:32%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,transparent 75%);animation:dross-pulse 3s ease-in-out infinite} +@keyframes dross-spin{to{transform:rotate(360deg)}} +@keyframes dross-pulse{0%,100%{opacity:.55;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}} +/* motes */ +.d-core{width:22%;height:22%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,var(--dross));box-shadow:0 0 12px var(--dross-glow)} +.d-ring{position:absolute;inset:0;animation:dross-spin 5s linear infinite} +.d-ring.r2{animation-duration:8s;animation-direction:reverse} +.d-mote{position:absolute;top:11%;left:50%;width:11%;height:11%;margin-left:-5.5%;border-radius:50%;background:var(--dross-glow);box-shadow:0 0 8px var(--dross-glow)} +.d-ring.r2 .d-mote{top:auto;bottom:14%;background:var(--dross);width:8%;height:8%} +/* panel */ +.dross-panel{position:fixed;right:20px;bottom:20px;width:340px;max-width:calc(100vw - 24px);height:480px;max-height:calc(100vh - 24px); + display:none;flex-direction:column;z-index:41;border:1px solid var(--dross-dim);border-radius:16px;overflow:hidden; + background:linear-gradient(180deg, rgba(30,16,48,.6), rgba(20,20,28,.96) 22%); + box-shadow:0 24px 70px -18px #000, 0 0 0 1px #00000060, 0 0 40px -16px var(--dross-dim);backdrop-filter:blur(6px)} +.dross-panel.open{display:flex} +.dross-hd{display:flex;align-items:center;gap:10px;padding:11px 12px;cursor:grab;touch-action:none; + background:linear-gradient(180deg, var(--dross-soft), transparent);border-bottom:1px solid var(--border)} +.dross-hd .dross-orb{width:30px;height:30px} +.dross-who{font-family:var(--font-display);letter-spacing:.16em;text-transform:uppercase;font-size:13px;color:#efe9f6;flex:1} +.dross-who small{display:block;font-family:var(--font-mono);letter-spacing:0;text-transform:none;font-size:10px;color:var(--dross-glow);opacity:.85} +.dross-x{background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 6px} +.dross-x:hover{color:var(--text)} +.dross-log{flex:1;overflow:auto;padding:12px 12px;display:flex;flex-direction:column;gap:10px} +.dross-inwrap{padding:10px;border-top:1px solid var(--border);background:#0d0a12;display:flex;flex-direction:column;gap:9px} +.dross-inwrap textarea{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:10px;padding:10px 12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:none;height:46px;max-height:96px} +.dross-btnrow{display:flex;gap:10px} +.dross-mic{flex:1;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:var(--dross-soft);color:var(--dross-glow);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;font-family:var(--font-ui);font-size:13px} +.dross-mic[disabled]{opacity:.5;cursor:not-allowed} +.dross-send{width:64px;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:linear-gradient(180deg,var(--dross),var(--dross-dim));color:#fff;cursor:pointer;display:grid;place-items:center} +.dross-collapse{display:flex;align-items:center;justify-content:center;gap:8px;height:34px;cursor:pointer;color:var(--muted); + font-family:var(--font-ui);font-size:11px;letter-spacing:.12em;text-transform:uppercase;background:#0b0810;border-top:1px solid var(--border)} +.dross-collapse:hover{color:var(--dross-glow)} +.dross-collapse .grip{width:42px;height:4px;border-radius:3px;background:var(--border)} +/* reuse existing .turn/.msg/.tools/.chip chat styles from the rail */ +``` + +- [ ] **Step 2: Commit** + +```bash +git add public/style.css +git commit -m "feat(dross): floating bubble + avatar styles" +``` + +--- + +### Task 5: Bubble component (`dross_bubble.js`) + mount globally + +Self-contained component: a fixed FAB orb that opens a draggable, anchored panel with the chat (via `wireAgentChat`), a top-right ✕ and a bottom "⌄ collapse", both minimising to the orb. Reads `dross` settings for avatar/accent and applies `--dross*` accent. (Mic button is rendered but **disabled** with title "Voice arrives in Phase 2".) + +**Files:** +- Create: `public/components/dross_bubble.js` +- Modify: `public/app.js` (replace `renderRightrail` with `renderDrossBubble`) +- Modify: `public/index.html` (remove the now-unused `