Changeset 0.37.14 (#226)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-23 16:47:48 +00:00
committed by xcaliber
parent fcb998bff9
commit b7746c3004
164 changed files with 6972 additions and 3527 deletions

View File

@@ -202,10 +202,16 @@ jobs:
- name: Syntax check (all JS) - name: Syntax check (all JS)
run: | run: |
echo "Checking JS syntax..." echo "Checking JS syntax..."
for f in src/js/*.js; do err=0
node --check "$f" || exit 1 while IFS= read -r f; do
echo " ✓ $f" # Feed file as module to node --check via stdin
done node --input-type=module --check < "$f" 2>/dev/null || {
echo " ✗ $f"
err=1
}
done < <(find src/js -name '*.js' -not -path '*/vendor/*' -not -path '*node_modules*')
[ "$err" -eq 0 ] && echo " ✓ All JS files passed syntax check"
exit $err
- name: Run frontend tests - name: Run frontend tests
run: node --test src/js/__tests__/*.test.js run: node --test src/js/__tests__/*.test.js

View File

@@ -1,5 +1,188 @@
# Changelog # Changelog
## [0.37.14.23] — 2026-03-23
### Summary
Extension surface user menu fix + roadmap workflow integration. The ICD Runner
(and all extension surfaces) now mount the Preact UserMenu via `sw.userMenu()`
instead of relying on the dead Go template hydration.
### Fixed
- **Extension surface "? User" label:** extension template included static
`{{template "user-menu"}}` which rendered placeholder HTML ("? User") since
the legacy hydration script was removed in v0.37.13. Removed the dead
template inclusion. (`server/pages/templates/surfaces/extension.html`)
- **Extension surface user menu click does nothing:** ICD Runner never called
`sw.userMenu()` to mount the Preact UserMenu component. Added topbar with
`sw.userMenu(topbar, { flyout: 'down' })` in `renderShell`. Also uses
`sw.auth.user` instead of dead `window.__USER__` for header display.
(`packages/icd-test-runner/js/ui.js`, `packages/icd-test-runner/js/main.js`)
### Changed
- **Roadmap:** added v0.37.15 reference to `DESIGN-0.37.15.md` workflow design
doc, added v0.38.x Workflow Product Maturity section referencing
`ROADMAP-0.38.md`. Trimmed completed version descriptions.
---
## [0.37.14.22] — 2026-03-23
### Summary
Thinking tags persistence + deleteMessage endpoint. Two features that close
out the v0.37.14 pane audit. Also fixes an S9 latent bug (messages paginated
response not unwrapped) and a SQLite NULL scan crash in GetByID.
### Added
- **deleteMessage server endpoint** — `DELETE /channels/:id/messages/:msgId`
with soft-delete, channel access check, and `message.deleted` WS broadcast
to other participants. (`server/handlers/messages.go`, `server/main.go`)
- **`message.deleted` event type** — registered as `DirToClient` for
cross-tab message removal. (`server/events/types.go`)
- **`deleteMessage` SDK method** — `rc.del(...)` in channels namespace.
(`src/js/sw/sdk/api-domains.js`)
- **`message.deleted` WS handler** — filters by active channel, removes
message from state. (`src/js/sw/components/chat-pane/use-chat.js`)
- **`onDelete` wired in ChatPane** — delete button now visible on all
messages. (`src/js/sw/components/chat-pane/index.js`)
- **`extractThinking` / `hydrateThinking` helpers** — parse `<think>` blocks
from message content on the load path (switchChannel, loadMore, WS
message.created). (`src/js/sw/components/chat-pane/use-chat.js`)
### Fixed
- **Thinking tags lost on refresh:** `<think>` content rendered correctly
during streaming (via SSE `reasoning_content`) but spilled as raw text
after page reload. Now extracted on the load path and rendered in the
collapsible `<details>` block. Frontend-only fix, no DB migration.
- **S9 messages unwrap bug:** messages endpoint returns paginated response
with 5 sibling keys, so `_unwrap` preserves the full object. `resp || []`
produced an object, not an array. Changed to `resp.data || resp || []`.
- **SQLite NULL scan crash:** `GetByID` fails on user messages where `model`
and `tokens_used` are NULL (struct uses `string`/`int`, not pointers).
Delete handler now uses a direct `EXISTS` query instead.
---
## [0.37.14.21] — 2026-03-23
### Summary
Double-unwrap cleanup + API envelope normalization. Eliminated 93 defensive
`resp.data || resp || []` patterns across the frontend by fixing `_unwrap` to
preserve sibling fields and normalizing all Go handlers to use `{data: [...]}`
standard envelopes. Three latent bugs fixed (roles, provider types, audit).
### Changed
- `src/js/sw/sdk/rest-client.js``_unwrap` now only strips `{data: [...]}` when
`data` is the sole key (`Object.keys(json).length === 1`). Responses with siblings
(e.g. `{data: [...], total: N}`) pass through intact.
- **15 Go handlers** normalized from named keys (`users`, `configs`, `models`,
`channels`, `participants`, `tools`, `folders`, `messages`, `values`, `packages`)
to standard `{data: [...]}` envelope:
- `server/handlers/admin.go` — ListUsers, ListGlobalConfigs, ListModelConfigs,
ListArchivedChannels
- `server/handlers/apiconfigs.go` — ListConfigs, ListModels
- `server/handlers/channel_models.go` — List, Add, Update, Delete
- `server/handlers/participants.go` — List, Add, Update, Remove
- `server/handlers/presence.go` — SearchUsers
- `server/handlers/completion.go` — ListTools
- `server/handlers/folders.go` — List
- `server/handlers/notes.go` — ListFolders
- `server/handlers/messages.go` — GetActivePath, SetCursor
- `server/handlers/teams.go` — ListAvailableModels
- `server/handlers/team_providers.go` — ListTeamProviderModels
- `server/handlers/packages.go` — UpdatePackageSettings
- `server/handlers/package_registry.go` — BrowseRegistry
- `server/handlers/roles.go` — ListRoles (bare map → wrapped in `{data: map}`)
- **~45 frontend files** — simplified all double-unwrap patterns:
- Sole-key endpoints (unwrapped): `resp || []`
- Sibling endpoints (not unwrapped): `resp.data || []`, `resp.total || 0`
- Roles map: `r.data || {}` + `Object.entries()` for iteration
- Go integration + live provider tests updated for new envelope keys
- Frontend contract + policy-gating tests updated
### Fixed
- **Admin → Roles always empty:** `ListRoles` returned a raw map; `r.roles`
was undefined on it. Now wrapped in `{data: map}`, frontend iterates via
`Object.entries(roles)`.
- **Admin → Provider Types dropdown always empty:** `GetProviderTypes` returned
`{data: types}`, `_unwrap` stripped it to bare array, `t.types` was undefined.
Now correctly uses `t || []`.
- **Admin → Audit entries always empty:** `_unwrap` stripped `{data, total}` to
just the array, losing `total`. Now preserved because of sibling-aware `_unwrap`.
### Design Notes
- **Envelope contract:** All list endpoints now return `{data: [...]}` or
`{data: [...], ...metadata}`. No more `{users: [...]}`, `{models: [...]}`, etc.
- **`_unwrap` contract:** sole-key `{data: [...]}` → bare array;
sibling keys present → full object (frontend accesses `.data` and siblings).
- **Editor package skipped:** `packages/editor/js/main.js` uses `API._get` which
bypasses `_unwrap`; defensive patterns intentionally preserved.
---
## [0.37.14] — 2026-03-22
### Summary
Scorched Earth IV + Pane Audit — fourth dead-code purge. 4 files deleted (~1,672 lines).
The old global layer (`sb.js`, `events.js`, `switchboard-sdk.js`, `workflow-surfaces.js`)
is fully removed. Two survivors (`debug.js`, `repl.js`) cleaned of all old-layer dependencies
and now stand alone with direct `window.*` exports.
### Removed
- `src/js/sb.js` (133 LOC) — global action registry (`window.sb`); onclick handlers
in base.html rewritten to direct function calls
- `src/js/events.js` (361 LOC) — event bus + WebSocket bridge (`window.Events`);
WebSocket auth called deleted `API` global; new SDK (`sw/sdk/events.js`) is the
sole event system
- `src/js/switchboard-sdk.js` (~697 LOC) — composition layer (`window.Switchboard`);
`window.sw` was immediately overwritten by Preact SDK `boot()`; most methods
referenced deleted globals (`API`, `UI`)
- `src/js/workflow-surfaces.js` (481 LOC) — workflow stage surface registry; loaded
deleted `ui-primitives.js` as dependency (already broken at runtime since v0.37.13)
- `base.html`: removed 3 script tags, removed `Switchboard.init()` block
(including dead `handleLogout` referencing deleted `Events`/`API`)
### Changed
- `src/js/debug.js` — replaced `sb.ns()`/`sb.register()` with direct `window.*`
exports; replaced dead `API`/`App`/`Events`/`Extensions` state snapshot with
Preact SDK `window.sw` introspection; diagnostics use `sw.auth.token` and
`sw.events.connected`; fixed `copyDebugLog` referencing deleted `UI.toast`
- `src/js/repl.js` — replaced `sb.ns()` with `window.REPL`; admin gate uses
`window.sw.auth.user.role` instead of deleted `API.user.role`; injected globals
reduced to `sw` + `DebugLog`; removed dead event label tab-completion
- `server/pages/templates/base.html` — 11 `sb.call()` onclick handlers rewritten
to direct function calls
- `server/pages/templates/workflow.html` — broken custom surface loader replaced
with graceful error message
- `src/sw.js` — removed 3 entries from SHELL_FILES (sb.js, events.js,
switchboard-sdk.js)
- `packages/icd-test-runner/js/tier-sdk.js` — boot tests updated from
`Switchboard.init()` to Preact SDK `boot()`; identity tests use `sw.auth.user`;
theme test uses `sw.emit()`; removed extension compat shim test
- `src/js/sw/sdk/index.js` — SDK version marker updated to 0.37.14
### Design Notes
- **2 old JS files survive:** `debug.js` (debug modal + diagnostics) and `repl.js`
(admin REPL console). Both are standalone with zero old-layer dependencies.
Scheduled for Preact rewrite at v0.37.17.
- **Cumulative scorched earth:** 53 files deleted, ~19,382 lines across four
passes (v0.37.10, v0.37.12, v0.37.13, v0.37.14).
---
## [0.37.13] — 2026-03-22 ## [0.37.13] — 2026-03-22
### Summary ### Summary

View File

@@ -0,0 +1,237 @@
# FE Rewrite Review — CS-0.37.14
**Reviewer:** Claude (Opus 4.6)
**Date:** 2026-03-23
**Scope:** Full critical review of the Preact+htm frontend rewrite (src/js/sw/) and supporting backend changes (CS-0.37.1 permission enforcement).
**Stats:** ~16,200 lines of new JS across 115 files, ~6,800 lines of CSS across 17 files. Backend: 2 new handler files, 9 route edits, 12 new integration tests.
---
## Severity Legend
- **P0** — Breaks functionality or security in a live deployment. Fix before merging.
- **P1** — Significant regression or correctness issue. Fix before tagging.
- **P2** — Design debt or missing wiring. Track for next changeset.
- **P3** — Cleanup, dead code, cosmetic. Fix opportunistically.
---
## P0 — Fix Before Merge
### 1. Pipe/filter pipeline is wired but never invoked
`sw.pipe` is fully built (pre/stream/render stages, scoping, stats, dedup detection) but no component calls `_runPre`, `_runStream`, or `_runRender`. The chat completion flow in `use-chat.js` calls `sw.api.channels.complete()` directly. `use-stream.js` parses SSE without running stream filters. Markdown rendering skips render filters entirely.
**Impact:** Extensions that register pipe filters are silently ignored. The v0.30.0 compaction-as-filter roadmap item cannot work. This is a hard regression from the old FE where the pipe was invoked during completion.
**Fix:** Wire `sw.pipe._runPre(ctx)` before the completion call in `use-chat.sendMessage()`, `sw.pipe._runStream(ctx)` in the SSE loop in `use-stream.js`, and `sw.pipe._runRender(ctx)` in the markdown rendering path. The calling conventions match the old SDK — the hooks just need to invoke them at the right points.
**Files:** `src/js/sw/components/chat-pane/use-chat.js`, `src/js/sw/components/chat-pane/use-stream.js`, `src/js/sw/components/chat-pane/markdown.js`
### 2. User menu RBAC gating is inverted
```js
if ((!authenticated || sw?.isAdmin) && current !== 'admin') {
list.push({ label: 'Admin', ... });
}
```
The `!authenticated` branch means unauthenticated users see Admin, Team Admin, and Debug menu items. The same inverted pattern appears on all three privileged items.
**Impact:** Security-facing. Non-admin users see (and can navigate to) admin pages. The backend still enforces `RequireAdmin()` on the route, so it's defense-in-depth, but the menu exposes attack surface.
**Fix:** Change `!authenticated || sw?.isAdmin` to `sw?.isAdmin` (and similarly for Team Admin/Debug). Remove the `!authenticated` fallback — it was clearly a dev-time bypass.
**File:** `src/js/sw/shell/user-menu.js`, lines 86100
### 3. `_unwrap` misses bare `{data:[...]}` envelopes
The REST client strips the `data` array only when pagination fields (`page`, `total`, `per_page`) are present:
```js
if (json && Array.isArray(json.data) && ('page' in json || 'total' in json || 'per_page' in json)) {
return json.data;
}
```
Multiple backend endpoints return `{"data": [...]}` without pagination fields: notes backlinks, notes titles, notes graph, notes search results, channel KBs, and others. These pass through as `{data: [...]}` objects. **24 call sites** defensively work around this with `resp.data || resp || []`.
**Impact:** The SDK's stated contract — "List methods always return the unwrapped array" — is silently broken. Every consumer must know which endpoints include pagination and which don't. New code will get this wrong.
**Fix:** Change the condition to `json && Array.isArray(json.data)`. This matches the response envelope convention (`{"data": []}` for all lists). Single-object responses don't have a `data` array, so they pass through correctly.
**File:** `src/js/sw/sdk/rest-client.js`, `_unwrap` function
---
## P1 — Fix Before Tag
### 4. `deleteMessage` calls a phantom API
`use-chat.js` calls `sw.api.channels.deleteMessage(...)` which doesn't exist in `api-domains.js` and has no corresponding backend endpoint in the ICD. The `?.` guard makes it a no-op, but the optimistic UI remove (`setMessages(prev => prev.filter(...))`) means messages disappear from the UI until page refresh, then reappear.
**Impact:** Users think they deleted a message; they didn't. Data inconsistency.
**Fix:** Either add the backend endpoint + SDK method, or remove the optimistic UI remove and surface a "not supported" toast.
**Files:** `src/js/sw/components/chat-pane/use-chat.js` (deleteMessage), `src/js/sw/sdk/api-domains.js` (missing method)
### 5. Menu submenu positioning is wrong
The recursive `<Menu>` for submenus uses `anchor=${menuRef.current}` — the parent menu's root div. The submenu positions relative to the entire parent menu element, not the individual item that triggered it.
**Impact:** Submenus appear at the parent menu's top-left corner instead of adjacent to the hovered row.
**Fix:** Track a ref per menu item (or pass the item's DOM element as anchor) so the submenu positions relative to its trigger row.
**File:** `src/js/sw/primitives/menu.js`, submenu rendering block
### 6. ChatDashboard recent items are not clickable
The recent channel list in the empty-state dashboard renders items without an `onClick` handler. Users can see their recent chats but cannot click to navigate to them.
**Impact:** UX dead end. The dashboard shows data it doesn't act on.
**Fix:** Add `onClick=${() => onSelect(ch.id, ch.type)}` to the recent item divs.
**File:** `src/js/sw/surfaces/chat/chat-workspace.js`, `ChatDashboard` component
### 7. Version string mismatch
Three different version strings in the same changeset:
- `sw._sdk = '0.37.14'` (sdk/index.js)
- `console.log('[sw] SDK v0.37.9 ready')` (sdk/index.js)
- `VERSION` file: `0.37.14.14` (four segments, breaks `0.<major>.<minor>` convention)
**Fix:** Unify to one version string, probably `0.37.14`. Fix the console log. Decide whether the four-segment VERSION is intentional.
**Files:** `src/js/sw/sdk/index.js`, `VERSION`
### 8. New FE code has zero CI coverage
The CI syntax check globs `src/js/*.js` (top-level only), missing all 115 files under `src/js/sw/`. Additionally, `node --check` doesn't work on ES modules with `import` statements. The existing `__tests__` test against old globals, not the new Preact stack.
**Impact:** Syntax errors, import typos, or broken modules in the new code won't be caught until runtime.
**Fix:** Add a CI step that either validates ES module syntax (e.g., a lightweight bundler dry-run or a glob that covers `src/js/sw/**/*.js`) or adds basic smoke tests for the new SDK boot path.
**File:** `.gitea/workflows/ci.yaml`, `test-frontend` job
---
## P2 — Track for Next Changeset
### 9. `sw.can()` is essentially unused
Only 1 reference in all surface code (`team-admin/index.js` uses `sw.isAdmin`, not `sw.can()`). No surface gates create/edit/delete buttons with `sw.can('kb.write')`, `sw.can('channel.create')`, etc. The backend permission enforcement from CS-0.37.1 has no client-side counterpart.
The settings surface does its own policy resolution from `__PAGE_DATA__` + a separate API fallback, rather than reading `sw.auth.policies` which is already cached from `/profile/permissions`.
**Impact:** The entire client-side RBAC story — the motivating feature for this rewrite — is infrastructure without consumers. Users will see buttons they can't use; the backend returns 403s but the UI doesn't prevent the click.
**Recommendation:** A follow-up pass through all surfaces to add `sw.can()` gates on action buttons, and migrate settings to use `sw.auth.policies`.
### 10. Pipe `_runChain` has no Promise guard
The comment says "Sync-only — filters must not return Promises" but there's no guard. If a filter returns a Promise (which is truthy), it silently replaces `ctx` with the Promise object, corrupting the chain for all downstream filters.
**Fix:** Add `if (result instanceof Promise) { console.error(...); continue; }` after the filter call.
**File:** `src/js/sw/sdk/pipe.js`, `_runChain`
### 11. ChatPane handleRef `useEffect` runs every render
The `useEffect` that sets `handleRef.current = {...}` has no dependency array, rebuilding the imperative handle object on every render cycle.
**Fix:** Add `[]` or `[handleRef]` as dependency array.
**File:** `src/js/sw/components/chat-pane/index.js`, line 66
### 12. `__USER__` / `__PAGE_DATA__` injection is mostly dead
The Go template still injects `window.__USER__` and `window.__PAGE_DATA__` on every page load. The new SDK boots from API calls. Only `settings/index.js` reads `__PAGE_DATA__` (for BYOK/persona policies) and it already has an API fallback. `window.__USER__` is never read by new code.
**Recommendation:** Remove `__USER__` injection. Migrate settings to use `sw.auth.policies` (see #9), then remove `__PAGE_DATA__` injection.
### 13. Debug modal is old-world code
The debug modal in `base.html` (~50 lines of raw HTML with inline `onclick` handlers, `getElementById`, global functions like `switchDebugTab()`) is the largest remaining "no raw DOM" violation and is shared across all surfaces.
**Recommendation:** Rebuild as a Preact component in a follow-up, or at minimum flag it as intentional legacy.
---
## P3 — Cleanup
### 14. Dual toast container
`base.html` still has `<div id="toastContainer" class="toast-container"></div>` from the old world. Every surface also renders `<ToastContainer />` (Preact). The old div is orphaned dead DOM.
**Fix:** Remove the `#toastContainer` div from `base.html`.
### 15. Dead code: `esc()` in admin/users.js
`function esc(s) {...}` is defined but never called. Leftover from a template that got Preact-ified.
**Fix:** Delete it.
**File:** `src/js/sw/surfaces/admin/users.js`, line 7
### 16. `sidebar-chats.js` reaches into `#surfaceInner`
`_getScale()` reads `document.getElementById('surfaceInner')` to compensate for CSS zoom on `position: fixed` menus. Technically correct but creates an implicit coupling between a surface component and the shell's DOM structure.
**Recommendation:** Move scale detection into the SDK (e.g., `sw.shell.getScale()`) so the coupling is explicit.
### 17. Notes markdown rendering uses raw DOM
`markdown.js` uses `innerHTML`, `querySelector`, `querySelectorAll` — inherent to the marked+DOMPurify pipeline. `attachWikilinkHandlers` adds click listeners imperatively on every render without explicit cleanup (Preact's DOM replacement handles GC, but the pattern is fragile).
**Recommendation:** Document this as an intentional exception to the "no raw DOM" principle. Consider a wrapper component that manages the imperative lifecycle.
### 18. Double-unwrap defensive pattern ~~(24 instances)~~ — **RESOLVED v0.37.14.21**
~~Once P0 #3 is fixed (`_unwrap` condition change), all 24 instances of `resp.data || resp || []` become dead fallback code.~~
**Resolved:** Actually 93 instances (not 24). Fixed `_unwrap` to preserve sibling fields (`Object.keys` check), normalized all 18 non-standard Go handler envelopes to `{data: [...]}`, and simplified all frontend call sites. Also fixed 3 latent bugs (roles data lost, provider types empty, audit entries empty). See CHANGELOG v0.37.14.21.
### 19. Keyboard shortcuts in use-notes.js use DOM presence check
`if (document.querySelector('.sw-notes-pane'))` gates Ctrl+N and Ctrl+D shortcuts. This means the shortcuts fire whenever the notes pane exists anywhere in the DOM, not when it's focused. If notes and chat are ever composed on the same page, shortcuts will conflict.
**Recommendation:** Use a focus-based check or a "surface active" flag from the SDK.
---
## Positives
### Architecture
The layer separation is clean and consistently applied. Primitives have zero business logic. The SDK has zero DOM. Shell has zero API calls. Surfaces compose from both. This matches the design doc precisely. The factory pattern for all SDK modules avoids singleton coupling — every module is testable in isolation.
### SDK internals
The auth module handles token persistence with cookie sync for Go SSR, proactive refresh at 80% expiry, coalesced refresh promises (preventing thundering herd on concurrent 401s), and a clean boot sequence with offline tolerance. The event bus correctly classifies local-only events to prevent UI concerns from crossing the WebSocket. The CRUD factory in `api-domains.js` eliminates boilerplate across 18 domain namespaces.
### Performance
The `use-stream.js` hook uses rAF-batched state updates during SSE parsing. Content accumulates in refs and flushes at display frame rate, preventing re-render storms during fast token output. This is the correct pattern for streaming LLM output into a reactive UI.
### Developer experience
The crash catcher in surface templates (`window.addEventListener('error', ...)` with a visible banner) is a smart production debugging aid for a scorched-earth rewrite. The admin surface's lazy section loading with version-busted dynamic imports means sections load on demand and cache-bust on upgrade. The `sw.chatPane()` and `sw.notesPane()` imperative render helpers let extension surfaces mount chat/notes without knowing Preact internals.
### Backend (CS-0.37.1)
The permission handler correctly gives admins `auth.AllPermissions` and resolves the full permission set for non-admins. The 12 new integration tests use a deny→grant→allow pattern that doesn't depend on the Everyone group's seed data. The route audit is thorough — 8 routes fixed, 18 verified correct, and the ICD is updated.
---
## Recommended Merge Order
1. Fix P0 #13 (pipe wiring, user menu RBAC, `_unwrap` condition)
2. Fix P1 #48 (phantom API, submenu anchor, dashboard clicks, version, CI)
3. Merge and deploy to dev
4. Track P2 items for v0.37.15 or v0.38.0
5. Clean up P3 items opportunistically

95
ICD-DRIFT-AUDIT.md Normal file
View File

@@ -0,0 +1,95 @@
# ICD Drift Audit — SDK vs Backend
**Date:** 2026-03-23
**Method:** Compared `src/js/sw/sdk/api-domains.js` against `docs/ICD/*.md` and `server/main.go`.
**Principle:** ICD is the zone of truth. SDK must conform to it.
---
## A. SDK Bugs — Wrong method or path (fix in SDK)
| SDK Method | SDK Calls | Backend Route | Issue |
|---|---|---|---|
| `workflows.update(id, data)` | `PUT /workflows/:id` | `PATCH /workflows/:id` | `crud()` generates PUT; backend is PATCH → 404/405 |
| `workspaces.update(id, data)` | `PUT /workspaces/:id` | `PATCH /workspaces/:id` | Same — `crud()` generates PUT; backend is PATCH |
| `tasks.start(id, data)` | `POST /tasks/:id/start` | `POST /tasks/:id/run` | Path mismatch → 404 |
| `tasks.stop(id)` | `POST /tasks/:id/stop` | `POST /tasks/:id/kill` | Path mismatch → 404 |
| `dataPortability.deleteAccount(data)` | `POST /profile/delete` | `DELETE /me` | Both method and path wrong |
## B. SDK Bug — Duplicate key overwrites (fix in SDK)
`notifications` is defined **three times** as a top-level key in the `createDomains` return object:
1. **Line 186** — Domain #11 (has `prefs`, `setPref`, `delPref`, uses `POST` for markRead)
2. **Line 431** — Inside `admin` namespace (correct, no conflict)
3. **Line 501** — "Misc" section (uses `PATCH` for markRead, no preference methods)
JavaScript last-write-wins: #3 overwrites #1. The notification preference methods (`prefs`, `setPref`, `delPref`) are silently lost. The `markRead` method changes from POST to PATCH (PATCH matches the ICD, so #3 has the correct HTTP method but is missing methods).
**Fix:** Delete the duplicate at line 501. Merge its `del` method and `PATCH` fix into the domain #11 block at line 186.
## C. ICD Gaps — Endpoints exist in main.go, not documented in ICD
These endpoints are real (registered in main.go, used by the SDK) but have no ICD documentation.
| Endpoint | Belongs in ICD File | Notes |
|---|---|---|
| `POST /channels/:id/mark-read` | channels.md | SDK: `channels.markRead()` |
| `POST /channels/:id/generate-title` | channels.md | SDK: `channels.generateTitle()` |
| `POST /channels/:id/summarize` | channels.md | SDK: `channels.summarize()` |
| `GET /channels/:id/messages/:msgId/siblings` | channels.md | SDK: `channels.siblings()` |
| `GET /knowledge-bases-discoverable` | knowledge.md | SDK: `knowledge.discoverable()` |
| `GET /folders`, `POST`, `PUT /:id`, `DELETE /:id` | (new section) | Full CRUD, needs own ICD section or add to channels.md |
| `GET /users/search?q=...` | (new section) | SDK: `users.search()` |
| `POST /presence/heartbeat`, `GET /presence` | (new section) | SDK: `presence.heartbeat()` |
| `GET /usage` | (new section) | SDK: `usage.mine()` |
| `GET /groups/mine` | (new section) | SDK: `groups.mine()` |
| `GET /export/me` | utility.md | SDK: `dataPortability.exportMe()` |
| `DELETE /me` | utility.md | GDPR account deletion |
| `GET /git-credentials`, `POST`, `DELETE /:id` | (new section) | SDK: `git.credentials.*` |
| `POST /git-credentials/generate` | (new section) | Not in SDK — SSH key generation |
| `GET /git-credentials/:id/public-key` | (new section) | Not in SDK |
| `GET /tools` | (new section) | SDK: `tools.list()` |
## D. ICD-Documented Endpoints Missing from SDK
In the ICD but the SDK doesn't expose them. Add only as surfaces need them.
| Endpoint | ICD File | Notes |
|---|---|---|
| Workflow stages CRUD (`GET/POST/PUT/DELETE /workflows/:id/stages/*`) | workflows.md | Workflow editor needs these |
| `PATCH /workflows/:id/stages/reorder` | workflows.md | |
| `POST /workflows/:id/publish` | workflows.md | Team-scoped version exists in SDK |
| `POST /workflows/:id/start` | workflows.md | Instance creation |
| `GET /channels/:id/workflow/status` | workflows.md | |
| `POST /channels/:id/workflow/advance` | workflows.md | |
| Workspace archive (`download`, `upload`) | workspaces.md | |
| `POST /workspaces/:id/reconcile` | workspaces.md | |
| `GET /workspaces/:id/stats` | workspaces.md | |
| `GET /workspaces/:id/index-status` | workspaces.md | |
| `POST /workspaces/:id/git/clone`, `git/pull` | workspaces.md | |
| `GET /extensions/:id/manifest` | extensions.md | |
| `GET /extensions/tools` | extensions.md | |
| `POST /personas/:id/avatar`, `DELETE` | personas.md | User-scoped (admin-scoped exists) |
| `POST /knowledge-bases/:id/rebuild` | knowledge.md | |
## E. Response Shape Mismatches
| Endpoint | Response Shape | Convention | Notes |
|---|---|---|---|
| `GET /api-configs` | `{"configs": [...]}` | Should be `{"data": [...]}` | SDK `_unwrap` won't strip |
| `GET /admin/configs` | `{"configs": [...]}` | Should be `{"data": [...]}` | Same |
| `GET /admin/models` | `{"models": [...]}` | Should be `{"data": [...]}` | Same |
| `GET /admin/users` | `{"users": [...]}` | Should be `{"data": [...]}` | Same |
These use named keys instead of the `{"data": [...]}` envelope convention. The SDK's `_unwrap` doesn't strip these, so consumers get the envelope object.
---
## Recommended Priority
1. Fix §A (SDK 404s) — these are broken in production
2. Fix §B (duplicate notifications key)
3. Document §C (ICD gaps) — endpoints work, just undocumented
4. Add §D methods to SDK — only as surfaces need them
5. Migrate §E response shapes to `{"data": [...]}` — breaking change, defer

129
TURNOVER-0.37.14-S8.md Normal file
View File

@@ -0,0 +1,129 @@
---
**Turnover — changeset-0.37.14 Session 8**
**Version:** 0.37.14.20 | 3 commits (2 yours + 1 triage) | PR #226
**Branch:** `changeset-0.37.14` | Commit: `e2481a2`
---
## Commits this session
- `b8e03c4` — Upload 3 new, 3 modified files (profile bootstrap, ICD drift audit)
- `0ceb20a` — Delete Critical Review.md (renamed to FE-REWRITE-REVIEW-0.37.14.md)
- `e2481a2` — Critical Review triage: RBAC fix, _unwrap, pipe wiring, menu/dashboard, CI
## What was done (triage commit)
### P0 Fixes (3) — Fix Before Merge
1. **RBAC inversion** (1 file, 3 lines) — `user-menu.js` lines 85/91/96: `!authenticated || sw?.isAdmin` was inverted, showing Admin/Team Admin/Debug to unauthenticated users. Fixed to `authenticated && sw?.isAdmin`. Backend already enforced 403, but UI now correctly gates visibility.
2. **`_unwrap` SDK contract** (1 file, 1 line) — `rest-client.js` line 101: condition required pagination fields (`page`, `total`, `per_page`) to unwrap `{data: [...]}` envelopes. 24 call sites defensively did `resp.data || resp || []`. Relaxed to just check `Array.isArray(json.data)`.
3. **Pipe/filter pipeline wiring** (3 files) — `sw.pipe._runPre/Stream/Render` was fully built but never invoked. Wired into:
- `use-chat.js`: pre-send filter before completion call (can block/transform)
- `use-stream.js`: per-chunk stream filter in SSE loop
- `markdown.js`: render filter after sanitization + @mention highlighting
### P1 Fixes (5) — Fix Before Tag
4. **deleteMessage phantom API** (1 file) — No server DELETE `/channels/:id/messages/:msgId` route exists. Removed `onDelete` prop from ChatPane so delete button is hidden until backend is wired.
5. **Menu submenu positioning** (1 file) — `menu.js`: submenu anchored to parent menu root div instead of triggering item. Added `itemRefs` map, `setSubmenu` now stores `{ item, anchorEl }`, submenu renders at correct position.
6. **Dashboard recent items** (1 file) — `chat-workspace.js`: added `onNavigate` prop to ChatDashboard, recent items now have `onClick` handler + cursor pointer.
7. **Version string mismatch** (1 file) — `sdk/index.js` line 164: hardcoded `'v0.37.9'` replaced with template literal `` `v${sw._sdk}` ``. Console now shows correct version.
8. **CI frontend coverage** (1 file) — `ci.yaml`: glob changed from `src/js/*.js` (top-level only) to `find src/js -name '*.js'` (recursive). Uses `--input-type=module` for ES module syntax checking.
### P2/P3 Quick Fixes (4) + Additional (2)
9. **Pipe Promise guard**`pipe.js` `_runChain`: detects async filters returning Promises, logs error + increments error counter, continues chain.
10. **useEffect no-deps documented**`chat-pane/index.js` line 66: added comment explaining intentional missing dependency array (handle must reference latest closures).
11. **Dual toast container**`base.html`: removed orphaned `#toastContainer` div (Preact toast primitive creates its own).
12. **Dead `esc()` function**`admin/users.js` line 7: deleted unused function.
13. **WS message deduplication**`use-chat.js` line 288: added `prev.some(m => m.id && m.id === msg.id)` check before appending from WebSocket. Prevents duplicates on reconnect.
14. **Optimistic message key stability**`use-chat.js` line 103: optimistic user messages now get `id: 'tmp-' + Date.now()` so Preact key is always stable.
### Roadmap Updates
- `docs/ROADMAP.md` v0.37.17 row: added "debug modal Preact rebuild (CR P2-5)"
- `docs/ROADMAP.md` v0.37.18 row: added "sw.can() gates (P2-1), __USER__/__PAGE_DATA__ removal (P2-4), _getScale SDK (P3-3)"
- `FE-REWRITE-REVIEW-0.37.14.md`: P0/P1 sections marked resolved, P2/P3 sections annotated with version assignments.
## Files changed (+353/35, 21 files)
| File | Change |
|---|---|
| src/js/sw/shell/user-menu.js | ±6 RBAC condition fix |
| src/js/sw/sdk/rest-client.js | ±1 _unwrap condition |
| src/js/sw/components/chat-pane/use-chat.js | +11/2 pipe pre, dedup, temp ID |
| src/js/sw/components/chat-pane/use-stream.js | +9/1 pipe stream filter |
| src/js/sw/components/chat-pane/markdown.js | +12 pipe render, doc comment |
| src/js/sw/components/chat-pane/index.js | ±4 remove onDelete, useEffect doc |
| src/js/sw/primitives/menu.js | +16/7 submenu anchor refactor |
| src/js/sw/surfaces/chat/chat-workspace.js | +8/3 dashboard onClick, onNavigate |
| src/js/sw/sdk/index.js | ±1 version template literal |
| src/js/sw/sdk/pipe.js | +6 Promise guard |
| src/js/sw/surfaces/admin/users.js | 2 dead code |
| server/pages/templates/base.html | 1 orphaned toast div |
| .gitea/workflows/ci.yaml | +14/4 recursive glob + ES module |
| docs/ROADMAP.md | ±4 v0.37.17/18 deferred items |
| FE-REWRITE-REVIEW-0.37.14.md | ±10 resolution annotations |
| VERSION | 0.37.14.20 |
| server/handlers/profile_bootstrap.go | +117 (your commit) |
| server/main.go | +4 (your commit) |
| server/handlers/integration_test.go | +4 (your commit) |
| ICD-DRIFT-AUDIT.md | +95 (your commit) |
| docs/ICD/profile.md | +59 (your commit) |
## Verification results
| Check | Result |
|---|---|
| Admin menu items visible for admin | PASS |
| Channel messages load (unwrap working) | PASS |
| Dashboard recent items clickable | PASS |
| Delete button hidden | PASS |
| Console version: `SDK v0.37.14` | PASS |
| Zero console errors | PASS |
| @mentions rendered correctly | PASS |
| Docker build + server start | PASS |
## Deferred items — version mapping
| Item | Assigned To | Description |
|---|---|---|
| P3-5: Double-unwrap cleanup | **DONE v0.37.14.21 (S9)** | 93 patterns (not 24) cleaned; API envelope normalization; 3 bug fixes |
| P2-5: Debug modal rebuild | **v0.37.17** | Raw HTML + inline onclick in base.html → Preact component |
| P2-1: sw.can() RBAC gates | **v0.37.18** | Gate action buttons with `sw.can()` across all surfaces |
| P2-4: __USER__/__PAGE_DATA__ | **v0.37.18** | Remove dead Go template injection, migrate settings to sw.auth.policies |
| P3-3: _getScale SDK | **v0.37.18** | Move sidebar DOM coupling to `sw.shell.getScale()` API |
| P3-4: Notes markdown raw DOM | **documented** | Intentional for perf, comment added in markdown.js |
## Known issues (carried forward)
1. **Thinking tags lost on refresh**`<think>` content spills into message body after reload. DB stores raw, load path doesn't extract like streaming path does.
2. **Model settings select/unselect all** — no bulk toggle. Deferred to per-provider model management UI.
3. **Double-unwrap cleanup** — 24 call sites still have defensive `resp.data || resp || []`. Safe now but redundant. Next session.
## Open backlog
1. Double-unwrap cleanup (24 sites, next session)
2. Thinking tags persistence across refresh
3. ICD remaining failures (Venice stream, knowledge 403-vs-404)
4. Extension surface "? User" label
5. Admin panel audit
6. Model qualified display + per-provider management UI
7. SDK gaps (20+ missing endpoints)
8. Team-admin surface audit
9. Settings surface audit
10. Notes surface audit
11. deleteMessage server endpoint (server route + handler needed)
---

View File

@@ -1 +1 @@
0.37.13 0.37.14.23

View File

@@ -32,6 +32,8 @@ services:
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
LOG_FORMAT: ${LOG_FORMAT:-text} LOG_FORMAT: ${LOG_FORMAT:-text}
LOG_LEVEL: ${LOG_LEVEL:-info} LOG_LEVEL: ${LOG_LEVEL:-info}
# Dev seed users — ignored if ENVIRONMENT=production
SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user}
volumes: volumes:
- ./data:/data - ./data:/data
ports: ports:

View File

@@ -50,12 +50,15 @@ GET /settings/public
"allow_registration": "true", "allow_registration": "true",
"allow_user_byok": "true", "allow_user_byok": "true",
"allow_user_personas": "true", "allow_user_personas": "true",
"channel_retention_mode": "flexible" "retention_ttl_days": 0
} }
} }
``` ```
Note: policy values are strings (`"true"` / `"false"`), not booleans. Note: policy values are strings (`"true"` / `"false"`), not booleans.
`retention_ttl_days` is an integer (days). When > 0, channels using
global/team providers are archived on delete and purged after TTL.
Personal (BYOK) provider channels are always immediately deleted.
### Banner Configuration ### Banner Configuration
@@ -202,7 +205,7 @@ admin's own email address using the configured SMTP settings.
### Archived Channels ### Archived Channels
``` ```
GET /admin/channels/archived → { "channels": [...], "total", "page", "per_page", "retention_mode" } GET /admin/channels/archived → { "channels": [...], "total", "page", "per_page", "retention_ttl_days" }
DELETE /admin/channels/:id/purge → permanent delete (ignores retention) DELETE /admin/channels/:id/purge → permanent delete (ignores retention)
``` ```

View File

@@ -116,10 +116,34 @@ Same field set as create, plus `is_archived`, `is_pinned`, `settings`
DELETE /channels/:id DELETE /channels/:id
``` ```
Cascades: deletes messages, files, channel_models, channel_kbs, **Retention policy (v0.37.14):** When `retention_ttl_days > 0`, all
channels are archived on delete and purged after the TTL. The only
exception is channels using a Personal (BYOK) provider — those are
always hard-deleted immediately.
| Provider scope | TTL > 0 | TTL = 0 |
|---------------|---------|---------|
| `personal` (BYOK) | Hard delete | Hard delete |
| `global` | Archive + purge after TTL | Hard delete |
| `team` | Archive + purge after TTL | Hard delete |
| NULL (no provider) | Archive + purge after TTL | Hard delete |
When retention applies, the channel is archived (`is_archived = true`)
and stamped with `purge_after`. A background scanner purges channels
past their `purge_after` hourly. Archived channels are hidden from
the user's sidebar.
Non-owners who call DELETE are removed as participants ("leave channel")
instead of deleting.
**Response (immediate delete):** `{"message": "channel deleted"}`
**Response (retention):** `{"message": "channel archived for retention", "purge_after": "..."}`
**Response (leave):** `{"message": "left channel"}`
Hard-delete cascades: messages, files, channel_models, channel_kbs,
channel_participants. Storage cleanup runs asynchronously. channel_participants. Storage cleanup runs asynchronously.
**Auth:** Owner only. **Auth:** Owner for delete/archive; any participant for leave.
### Message Tree ### Message Tree

View File

@@ -248,3 +248,62 @@ This endpoint is the SDK's bootstrap source for `sw.can(permission)`.
Called once at login and on each token refresh. Called once at login and on each token refresh.
--- ---
### Bootstrap (v0.37.15)
```
GET /profile/bootstrap
```
**Auth:** Authenticated user
Single-call boot payload for the SDK. Collapses what previously required
34 sequential requests (profile, permissions, teams/mine, settings) into
one call. The SDK calls this at startup and on token refresh.
**Response:**
```json
{
"user": {
"id": "uuid",
"username": "jdoe",
"display_name": "Jane Doe",
"email": "jdoe@example.com",
"role": "user",
"avatar": "data:image/png;base64,..."
},
"permissions": ["channel.create", "kb.read", "model.use"],
"groups": ["group-id-1", "00000000-0000-0000-0000-000000000001"],
"teams": [
{ "id": "uuid", "name": "Engineering", "my_role": "member" }
],
"policies": {
"allow_user_byok": false,
"allow_user_personas": false,
"allow_raw_model_access": true,
"kb_direct_access": true
},
"settings": {
"theme": "dark",
"editor_keybindings": "vim"
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `user` | object | Curated profile (same fields as `GET /profile` minus timestamps) |
| `permissions` | string[] | Resolved permission set (sorted). Admins get all. |
| `groups` | string[] | Contributing group IDs (always includes Everyone) |
| `teams` | object[] | Active team memberships with `my_role` |
| `policies` | object | Boolean policy flags for UI feature gating |
| `settings` | object | User preferences (`{}` if never saved) |
`user.avatar` is omitted when unset (not `null`).
This is a **superset** of `GET /profile/permissions` — it includes
everything that endpoint returns plus `user` and `settings`. Clients
that only need permissions can continue using the narrower endpoint.
---

View File

@@ -48,8 +48,8 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
│ v0.37.2 Primitives ✅ │ │ v0.37.2 Primitives ✅ │
│ v0.37.3 SDK │ │ v0.37.3 SDK │
│ v0.37.4 Shell │ │ v0.37.4 Shell │
│ v0.37.513 Surfaces ✅ │ │ v0.37.514 Surfaces ✅ │
│ v0.37.1418 Surfaces │ │ v0.37.1518 Surfaces │
│ │ │ │ │ │
══════╪════════════╪═════════════════╪══════ ══════╪════════════╪═════════════════╪══════
│ MVP v0.50.0 │ │ MVP v0.50.0 │
@@ -62,475 +62,19 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
STT/TTS, desktop app) STT/TTS, desktop app)
``` ```
## Completed: v0.28.0 — Platform Polish ## Completed Versions (see CHANGELOG.md for details)
Audit arc, frontend decomposition, security, infrastructure. Eight | Version | Summary |
sub-versions, all complete. See CHANGELOG.md for detailed release notes. |---------|---------|
| v0.28.0v0.28.8 | Platform polish, ICD green board, security, infrastructure |
| Version | Summary | Key Deliverables | | v0.29.0v0.29.3 | Extension track: Starlark sandbox, API extensions, DB extensions, workflow forms |
|---------|---------|------------------| | v0.30.0v0.30.2 | Package lifecycle, SDK adoption, workflow packages |
| v0.28.1 | Surfaces ICD Audit ✅ | 6 ICD fixes, 19 E2E tests, 36 Go tests | | v0.31.0v0.31.2 | Editor package, SDK composability, team workflow self-service |
| v0.28.2 | ICD Audit: All Domains ✅ | 469/469 (100%), full methodology documented | | v0.32.0 | Multi-replica HA (PG SKIP LOCKED, cross-pod WS, shared tickets/rate limits) |
| v0.28.3 | ICD Close-out + FE Decomp ✅ | WebSocket ICD rewrite, 47 JS files → ES modules, `sb.js` registry | | v0.33.0 | Observability (slog, Prometheus, Grafana, OpenAPI, admin dashboard) |
| v0.28.4 | Security Tier ✅ | 58 red-team tests, 5 real bugs found+fixed, JWT role from DB | | v0.34.0 | Data portability (export/import, GDPR, backup/restore) |
| v0.28.5 | Frontend SDK + Pipes ✅ | `switchboard-sdk.js`, 3-stage pipe/filter pipeline, 36 SDK tests | | v0.35.0 | Workflow product (forms, data pipeline, conditional routing, structured review) |
| v0.28.6 | Infrastructure ✅ | Virtual scroll, Helm chart, system tasks, git keygen, broadcast | | v0.36.0 | Full OpenAPI spec (20 ICD domains, auth docs, WebSocket schemas) |
| v0.28.7 | Unified Packaging ✅ | `.pkg` format, `packages` table, task RBAC, `task.starlark` gate |
**Deferred from v0.28.x (relocated with version pins):**
- `sw.notes()` factory → v0.30.1
- Phase 5 FE decomp (`import`/`export`) → v0.30.1
- Cross-visitor isolation E2E test → v0.29.3
- Admin UI task permission management → v0.29.0
- ICD runner packaging test tier → v0.29.0
---
## v0.28.8 — ICD Green Board ✅
Close out pre-existing ICD test failures and infrastructure issues
discovered during the green board push.
Depends on: v0.28.7 (unified packaging).
**Root cause analysis (discovered during deployment):**
The 502/503 cascade that failed 810 ICD tests across provider, BYOK,
and SDK tiers was caused by **OOMKilled** — the dev backend pod had a
256Mi memory limit. The Go process peaked at ~216Mi during the 569-test
ICD suite and exceeded 256Mi during SSE completion streams, triggering
exit code 137. Traefik returned `503 no available server` until the pod
restarted (~15s).
Memory profile (measured on cluster via `kubectl top`):
- 17Mi idle after fresh start
- 216Mi peak during full ICD suite (569 requests, SSE streams)
- 60Mi post-test (Go GC ran, heap arena retained)
- 34Mi settled (~3 min, OS reclaimed freed pages via MADV_DONTNEED)
- Zero leak — memory returns to baseline, flat steady state
**Infrastructure fixes:**
- [x] Backend memory limits: dev/test 128Mi/256Mi → 256Mi/512Mi
- [x] Batched `UpsertFromSync`: single transaction, prepared
`INSERT ON CONFLICT DO UPDATE` (300 round-trips → 2, PG + SQLite)
- [x] DB connection lifecycle: `SetConnMaxLifetime(5m)` +
`SetConnMaxIdleTime(1m)`
- [x] HTTP transport pool: `sync.Map` keyed by proxy config
- [x] Provider sync timeout: `context.WithTimeout(30s)`, 504 on timeout
- [x] ICD runner: `apiPostRetry`, environment-aware CORS test,
ticket exchange verification test
**Security transport:**
- [x] CORS startup warning, `GetAllowedOrigins()`, WebSocket `CheckOrigin`
- [x] WebSocket ticket exchange: `POST /api/v1/ws/ticket`, `WsAuth`
middleware, `TicketStore` with TTL reaper
- [x] `events.js` async ticket-first auth with legacy fallback
**Kubernetes:**
- [x] Traefik retry `Middleware` CRD + Helm template
- [x] RBAC for Gitea runner → `traefik.io` middleware resources
- [x] CI non-fatal middleware apply with post-success ingress annotation
---
## Extension Track
Sequential. Each version builds on the previous. Delivers the package
ecosystem, workflow capabilities, and SDK-based surface architecture.
### v0.29.0 — Starlark Sandbox + Permission Model ✅
Server-side extension runtime. Eval loop, permission pipeline,
pre-completion filter chain, and admin review workflow.
Depends on: v0.28.8.
**Phase 0 — Store cleanup (prerequisite): ✅**
- [x] Raw SQL hunt: all ~242 `database.DB.*` calls outside `store/`
migrated to store interface methods (CS0CS7b, 12 changesets)
- [x] CI green on both PG and SQLite pipelines
- [x] ICD runner: 579/580 pass, 1 expected skip
- [x] Documented exception: `events/pg_broadcast.go` (`pg_notify`,
PG-only, no store abstraction needed)
**Phase 1 — Starlark runtime: ✅**
- [x] Pre-completion filter chain: composable `PreCompletionFilter`
interface + `Chain` registry. KB auto-inject refactored as first
built-in filter. Extension filters register at order 100+ (CS0)
- [x] `go.starlark.net` integration: sandboxed eval with step limits
(1M ops default), context timeout, captured print output,
disabled `load()`. `MakeModule` helper for Go→Starlark (CS1)
- [x] Permission model: `extension_permissions` table (in 016),
`status` column on `packages` (`active`/`pending_review`/
`suspended`). Manifest `"permissions"` array parsed on install.
Admin review, grant, revoke, grant-all endpoints (CS2)
- [x] Runtime enforcement: `Runner.buildModules()` injects only
granted modules into sandbox namespace (CS3)
- [x] Extension lifecycle: `install → pending_review → grant-all →
active`, revoke → `suspended`. Auto-transitions on grant/revoke.
- [x] Initial modules: `secrets` (GlobalConfig-backed, per-package
key-value store, admin CRUD), `notifications` (wraps
notification service, `send(user_id, title, body?, type?)`) (CS3)
- [x] `task_type: "starlark"`: executor `executeStarlark` loads
package by ID, calls `on_run()` entry point via runner.
RBAC gate `task.starlark` enforced. `system_function` field
holds package ID (CS4)
- [x] KB auto-injection: server-side pre-completion filter chain.
Reference implementation for the filter model Starlark
extensions mirror via `on_pre_completion(ctx)` (CS0+CS3)
- [x] Starlark filter discovery: `DiscoverStarlarkFilters` scans
active packages with `filters.pre_completion` grant (CS3)
- [x] ICD runner: `packaging` test tier — 18 tests covering
permission lifecycle + secrets CRUD (CS5)
### v0.29.1 — API Extensions ✅
Starlark route handlers. Surfaces serve custom JSON endpoints.
Depends on: v0.29.0.
- [x] `api_routes` manifest key, mounted at `/s/{id}/api/...`
- [x] Starlark request/response primitives
- [x] `http` outbound module with allowlist/blocklist
- [x] `requires_provider` manifest key (provider resolution via BYOK chain)
- [x] `capability_match` routing policy (cheapest model with required caps)
- [x] Config-file provider types (JSON, no code deploy)
**Deferred to v0.29.2:**
- Server-side tool execution in completion handler (requires tool
registry integration with sandbox; aligns with DB extensions scope)
### v0.29.2 — DB Extensions ✅
Namespaced tables for extension data. Structured API, not raw SQL.
Server-side tool execution (deferred from v0.29.1) included.
Depends on: v0.29.1.
- [x] `ext_{id}_*` tables, dialect-correct DDL (PG + SQLite)
- [x] `db` Starlark module: structured `query/insert/update/delete/list_tables/view`
(structured API instead of raw `exec()` — prevents SQL injection)
- [x] Views as read contract over platform tables (`ext_view_users`, `ext_view_channels`)
- [x] Schema creation on install, drop on uninstall
- [x] Server-side tool execution in completion handler (deferred from v0.29.1)
### v0.29.3 — Workflow Forms ✅
`form_template` renders as real UI. LLM is optional for data collection.
Depends on: v0.29.2, v0.29.0 (Starlark validators).
- [x] Typed `form_template` schema (`text`, `email`, `select`, `number`,
`date`, `textarea`, `checkbox`, `file`) with validation rules
- [x] Stage renders as form when `form_template` has typed fields
- [x] LLM-optional stages: form-only, form+chat, chat-only
- [x] Starlark `validate` / `on_submit` hooks
- [x] Visitor form entry (branded page, no chat widget)
- [x] Form builder in workflow admin (visual field editor)
- [x] Cross-visitor isolation E2E test (deferred from v0.28.4)
### v0.30.0 — Package Lifecycle ✅
Lifecycle sophistication for `.pkg` format.
Depends on: v0.29.2.
- [x] Schema versioning + migrations in manifest
- [x] Settings extension point (packages declare settings sections)
- [x] Export/import format for cross-instance sharing
- [x] Package marketplace (discovery, not hosting)
- [x] User-installable packages (RBAC-gated, team/personal scope)
### v0.30.1 — SDK Adoption ✅
Migrate core surfaces to `sw.*` SDK.
Depends on: v0.28.5, v0.30.0.
- [x] `sw.notes()`, `sw.chat()`, `sw.panels()` real factories
- [x] Core surface migration: chat, editor, notes, settings
- [x] Phase 5 FE decomp: `import`/`export` statements
- [x] Memory compaction: summarize, confidence decay, prune
### v0.30.2 — Workflow Packages ✅
Workflows as installable packages. Visual builder for team admins.
Depends on: v0.29.3, v0.30.0, v0.30.1.
- [x] Stage surfaces: form, chat, review, custom `.pkg`
- [x] `.pkg` type `"workflow"` bundles definition + surfaces + handlers
- [x] Team admin workflow builder (visual, no JSON editing)
- [x] `sw.workflow` SDK namespace
- [x] ICD test fixes (auth rate limiter burst 30→8, vault UEK pre-warm)
- [x] Starlark `workflow.*` module (get_definition, get_stage_data, advance, reject)
- [x] Workflow package export/import (.pkg round-trip)
- [x] E2E tests: export/import, surface_pkg_id persistence
### v0.31.0 — Editor Package + SDK Composability ✅
E2E proof: rebuild editor as installable `.pkg`. Zero platform
special-casing. Full SDK composability — every UI piece created
through `sw.*` factories, no component duplication.
Depends on: v0.30.2.
**Editor Package (CS0CS2):**
- [x] Editor `.pkg` (type: `full`), settings via extension point
- [x] State persistence via localStorage (per-user, per-workspace UI state)
- [x] Remove editor from core (`surface-editor` template, data loader)
- [x] E2E tests: install, settings, export/import round-trip, core removal
- [x] Self-mounting components (`Component.mount()` is canonical entry point)
**SDK Composability (CS3CS4):**
- [x] Delete `NoteEditor` — `NotePanel` is single canonical notes component
- [x] Platform scripts in `base.html` (chat-pane, note-panel, note-graph available to all surfaces)
- [x] `UserMenu.mount()` — self-contained, works in any container
- [x] `sw.userMenu()` — mount user menu anywhere, `flyout: 'up'|'down'`
- [x] `sw.chat()` uses `ChatPane.mount()` (not manual DOM + `.create()`)
- [x] `sw.fileTree()`, `sw.codeEditor()`, `sw.layout()` SDK wrappers
- [x] `sw.menu()`, `sw.dropdown()`, `sw.toolbar()`, `sw.tabs()` UI primitives
- [x] `--bg-elevated` / `--border-elevated` CSS tokens for floating panels
- [x] Editor package uses SDK exclusively (`sw.layout`, `sw.fileTree`, `sw.codeEditor`, `sw.chat`, `sw.notes`, `sw.userMenu`)
- [x] Nginx caching: JS/CSS use `must-revalidate` (not `immutable`) for dev reload safety
- [x] NotePanel: pagination, select mode, sticky selection bar, `note-panel-root` class (no PanelRegistry conflict)
**Known remaining (visual polish, not blocking):**
- [x] UserMenu flyout contrast on very dark backgrounds — fixed in v0.31.1 CS0+CS1
- [x] Editor chat pane duplicates streaming/model-selector logic — absorbed into ChatPane in v0.31.1 CS2
### v0.31.1 — SDK Exercise Surface ✅
Build a dashboard surface package exercising every `sw.*` primitive
with zero component CSS overrides. Fix 4 SDK bugs first.
Depends on: v0.31.0.
**SDK Bug Fixes (CS0CS2):**
- [x] Flyout unification — 3 competing CSS systems consolidated into `.sw-menu-flyout` in primitives.css
- [x] Flyout positioning — `position:fixed` escape hatch for overflow-clipped extension surfaces
- [x] Menu unification — UserMenu uses `.sw-menu-flyout` + `data-position`, same as `sw.menu()`
- [x] ChatPane self-contained — standalone mode with streaming, model selector, history (editor slimmed ~220 lines)
**Dashboard Package (CS3CS4):**
- [x] `packages/dashboard/` — type "full", route `/s/dashboard`
- [x] Exercises all 14 primitives: userMenu, menu, toolbar, tabs, dropdown, chat, notes, modal, confirm, toast, on/emit, api, user/isAdmin, theme
- [x] Layout-only CSS — zero references to SDK component classes
- [x] E2E tests: install, settings, export/import round-trip (7 tests)
### v0.31.2 — Team Workflow Self-Service ✅
Close the gap: team admins can create and manage workflows for their
team without requiring platform admin access. Clean public URLs via
team slugs (`/w/engineering/intake`).
Depends on: v0.31.1.
**CS0 — Backend: Team-Scoped Workflow Routes + Team Slugs:**
- [x] `/teams/:teamId/workflows` route group behind `RequireTeamAdmin`
- [x] CRUD: GET (list), POST (create, inject team_id), PATCH (update), DELETE
- [x] Stage CRUD: GET, POST, PUT, DELETE, PATCH reorder — scoped to team workflows
- [x] Publish: `POST /teams/:teamId/workflows/:id/publish`
- [x] Ownership guard: all mutating ops verify `workflow.team_id == :teamId`
- [x] Reuse existing `WorkflowHandler` methods — team ID injection, not new logic
- [x] Team `slug` column — auto-generated from name, UNIQUE, `GetBySlug` store method
- [x] Workflow entry + page renderer resolve scope via team slug (UUID fallback)
- [x] Auth rate limiter burst 8→5
**CS1 — Frontend: Workflows Tab in Team Admin:**
- [x] 9th tab "Workflows" in team admin modal
- [x] List team workflows with status badge (active/inactive)
- [x] Create/edit workflow form (name, slug, entry_mode, branding, retention)
- [x] Stage builder (add/reorder/delete stages, persona picker, history_mode)
- [x] Publish button with version display
- [x] Adapted from platform admin workflow UI (`_loadAdminWorkflows` reference)
- [x] Public URL display uses team slug (`/w/engineering/intake`)
- [x] ICD runner: team workflow CRUD tests, package settings fix, SSE reasoning_content fix
**Future (not blocking):**
- Team admin modal → full surface package (when tab count justifies it)
---
## Operations Track
Parallel to extension track. No Starlark dependency. Delivers
production readiness for the target multi-team deployment.
### v0.32.0 — Multi-Replica HA ✅
Run 23 backend replicas across nodes for node-level availability.
Depends on: v0.28.8.
**Design decision:** PG `SKIP LOCKED` replaces Kubernetes Lease-based
leader election. Every replica runs the scheduler poll loop; PG
serializes task claims atomically. No K8s API dependency, no Redis,
no new coordination infrastructure. See `docs/DESIGN-0.32.0.md`.
**What already works multi-replica:**
- REST API (stateless, JWT auth) ✅
- PG + S3 + CephFS (shared infrastructure) ✅
- `pg_broadcast` LISTEN/NOTIFY (cross-pod event bus) ✅
**Delivered (5 changesets):**
- [x] Task scheduler: `FOR UPDATE SKIP LOCKED` atomic claim replaces
`ListDue`. `CreateRunExclusive` conditional insert for
belt-and-suspenders uniqueness. Startup jitter (015s).
- [x] WebSocket cross-pod delivery: `PublishToUser` routes through
bus → `pg_broadcast` → remote pod. 18 `SendToUser` call sites
migrated. `tool.result.*` routing changed to `DirBoth` for
cross-pod `WaitFor`. `TargetUserID` field on `Event`.
- [x] Shared ticket store: `ws_tickets` PG table with 30s TTL,
atomic `DELETE ... RETURNING` validation. Replaces `sync.Map`.
- [x] Shared rate limiter: `rate_limit_counters` PG table with
fixed-window counters. Fail-open policy on DB errors.
- [x] Health probes: `/healthz/ready` (PG ping, 2s timeout),
`/healthz/live` (process alive). Helm: `replicaCount: 2`,
pod anti-affinity, readiness/liveness probes.
**Schema:** `020_ha.sql` — `ws_tickets`, `rate_limit_counters`.
### v0.33.0 — Observability ✅
Metrics, dashboards, alerting. Operate the platform without reading
Go source code.
Depends on: v0.32.0 (multi-replica metrics aggregation).
**Delivered (6 changesets):**
- [x] Structured logging (`slog`): `LOG_FORMAT=json|text`,
`LOG_LEVEL=debug|info|warn|error`. Request ID middleware
(`X-Request-Id`), correlation IDs through completion chain.
- [x] Prometheus `/metrics` endpoint: HTTP request counters/histograms,
WebSocket gauge, completion tokens/duration, provider status,
DB pool stats, task/sandbox execution counters.
- [x] OpenAPI 3.0.3 spec (hand-curated, core API groups) + Swagger UI
at `/api/docs` with system dark/light mode, WCAG AA contrast.
- [x] Grafana dashboard JSON: request rate, latency percentiles,
provider health, token usage, DB pool, Go runtime.
- [x] PrometheusRule alerting: OOM recovery, provider down, pool
exhaustion, high error rate, task failure spike.
- [x] Admin monitoring dashboard: provider health, 24h usage, DB pool,
WS connections, Go runtime stats, storage status, recent errors,
30s auto-refresh.
**Deferred to v0.36.0 (delivered):**
- Full OpenAPI spec coverage (all 20 ICD domains)
- Bearer token / mTLS auth documentation in spec
### v0.34.0 — Data Portability ✅
Export, import, backup, compliance.
Depends on: v0.29.2 (DB extensions — extension data in exports).
- [x] Bulk export/import: account data, conversations, settings, files
- [x] GDPR "download my data" + "delete my data" (cascade + audit trail)
- [x] ChatGPT/other tool import (conversation format mapping)
- [x] Backup/restore CronJob manifests for K8s
- [x] Admin export: team/user config (excludes vault-encrypted keys)
### v0.35.0 — Workflow Product
Bridge from workflow infrastructure to real business process automation.
Visitors see forms (not just chat), collected data flows through Starlark
enrichment, stages branch conditionally, and team members review
structured data — not chat transcripts.
Depends on: v0.31.2 (team workflow self-service), v0.29.0 (Starlark sandbox).
**Form Rendering Surface:**
- [x] Visitor form renderer — reads `form_template` from stage, renders
HTML inputs (text, email, select, date, textarea, checkbox, file),
validates client-side, submits via `/w/:id/form-submit`
- [x] Progressive form — multi-step within a single stage (fieldsets)
- [x] Conditional fields — show/hide based on previous answers
- [x] File upload in forms — attach to stage_data via storage API
- [x] Branded form page — uses workflow `branding` (colors, logo, tagline)
**Data Pipeline (between-stage processing):**
- [x] `on_advance` hook — Starlark entry point fires after each stage
transition, receives `stage_data`, can enrich/transform/reject
- [x] External data enrichment — Starlark `http.fetch` pulls from
external APIs, merges results into `stage_data` (reduce double entry)
- [x] Data validation rules — Starlark `on_validate` can enforce
cross-field business rules beyond per-field type checks
- [x] Stage data schema — typed `stage_data` with declared fields,
not opaque JSON blob. Enables structured review views
**Conditional Routing:**
- [x] Branch expressions — `transition_rules.condition` evaluated against
`stage_data` to select next stage (not always ordinal+1)
- [x] AI-triggered routing — persona calls `workflow_route` tool with
a target stage name based on conversation analysis
- [x] Escalation pattern — "if AI confidence < threshold, route to
human review stage" (help desk use case)
- [x] Loop stages — return to a previous stage for correction without
using reject (iterative data gathering)
**Structured Review:**
- [x] Assignment review view — team member sees `stage_data` as a
structured card/form, not just chat history
- [x] Approval/reject with comments — reviewer adds notes visible
to the next stage (not buried in chat)
- [x] Side-by-side view — chat history + structured data together
- [x] Bulk review — multiple assignments in a queue with keyboard nav
**Monitoring Dashboard:**
- [x] Active instances list — workflow name, current stage, assignee,
age, last activity
- [x] Stage funnel — how many instances at each stage, bottleneck detection
- [x] SLA timers — configurable per-stage, visible in review + dashboard
- [x] Stale instance alerts — notify team admins when instances age out
**Use case validation targets:**
- Help desk: AI + KB → escalation to human → resolution tracking
- Data intake: form → Starlark enrichment → human review → follow-up
- Onboarding: multi-stage form → conditional branching → team assignment
### v0.36.0 — Full OpenAPI Spec ✅
Complete OpenAPI 3.0.3 coverage for every API domain. Expand the
hand-curated spec from v0.33.0 (core groups only) to cover all 20 ICD
test domains. Document Bearer token auth and mTLS modes.
Depends on: v0.33.0 (Swagger UI + initial spec).
**API domains to document (matching ICD test suite):**
- [x] Admin (system settings, stats, storage, users, provider config)
- [x] Channels (full CRUD + membership, already partially covered)
- [x] Completions (streaming + non-streaming, already partially covered)
- [x] Extensions (package install, permissions, lifecycle)
- [x] Knowledge (KB articles, sources, search, embeddings)
- [x] Memory (conversation memory, compaction, search)
- [x] Models (provider models, BYOK, capability matching)
- [x] Notes (CRUD, graph links, search)
- [x] Notifications (list, mark read, preferences)
- [x] Personas (CRUD, system prompts, tool config)
- [x] Profile (user profile, preferences, avatar)
- [x] Projects (CRUD, membership, file uploads)
- [x] Surfaces (extension surfaces, mounting, lifecycle)
- [x] Tasks (scheduled tasks, execution history, Starlark tasks)
- [x] Teams (CRUD, membership, roles, slugs)
- [x] Team Workflows (team-scoped CRUD, stages, publish)
- [x] Workflows (platform-level CRUD, stages, instances, assignments)
- [x] Workspaces (CRUD, membership, settings)
- [x] Dashboard Package (package-specific API routes)
- [x] Editor Package (package-specific API routes)
**Auth documentation:**
- [x] Bearer JWT flow (login → token → `Authorization: Bearer <token>`)
- [x] mTLS mode (NPE-to-NPE, no Bearer needed)
- [x] API key mode (for service-to-service integrations)
- [x] Security scheme definitions in OpenAPI spec
**Quality:**
- [x] Request/response schemas with examples for every endpoint
- [x] Error response schemas (400, 401, 403, 404, 409, 422, 429, 500)
- [x] Pagination parameters documented consistently
- [x] WebSocket event schemas (connection, ticket, event types)
--- ---
@@ -638,109 +182,57 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface.
| v0.37.11 | Notes surface ✅ | Preact surface (7 JS + 1 CSS), sidebar folders/tags, UserMenu, template rewrite, Menu primitive fix | | v0.37.11 | Notes surface ✅ | Preact surface (7 JS + 1 CSS), sidebar folders/tags, UserMenu, template rewrite, Menu primitive fix |
| v0.37.12 | Scorched Earth II ✅ | 23 files deleted (~2,600 lines): 9 JS, 6 CSS, 8 admin templates; sw.js + base.html cleaned | | v0.37.12 | Scorched Earth II ✅ | 23 files deleted (~2,600 lines): 9 JS, 6 CSS, 8 admin templates; sw.js + base.html cleaned |
| v0.37.13 | Scorched Earth III ✅ | 5 JS deleted (2,269 lines): ui-primitives, code-editor, file-tree, user-menu, app-state; SDK slimmed, Theme→Preact | | v0.37.13 | Scorched Earth III ✅ | 5 JS deleted (2,269 lines): ui-primitives, code-editor, file-tree, user-menu, app-state; SDK slimmed, Theme→Preact |
| v0.37.14 | Pane audit | Collaborative walkthrough: core features + theming checked | | v0.37.14 | SE IV + Pane audit ✅ | 4 JS deleted (~1,672 lines); double-unwrap cleanup (93 sites); API envelope normalization (18 Go handlers); thinking tags persistence; deleteMessage endpoint; extension surface user menu fix; 7 bug fixes |
| v0.37.15 | Workflow surfaces | Form renderer, review views (design-first) | | v0.37.15 | Workflow surfaces | Form renderer, review views (design-first) |
| v0.37.16 | Projects surface | Project views, note/channel associations (design-first) | | v0.37.16 | Projects surface | Project views, note/channel associations (design-first) |
| v0.37.17 | Debug / model surface | Debug tooling, model configuration UI | | v0.37.17 | Debug / model surface | Debug tooling, model configuration UI; debug modal Preact rebuild (CR P2-5) |
| v0.37.18 | Tag | Light mode CSS audit, dead code hunt, all features verified | | v0.37.18 | Tag | Light mode CSS audit, dead code hunt, all features verified; `sw.can()` RBAC gates across surfaces (CR P2-1), `__USER__`/`__PAGE_DATA__` removal (CR P2-4), `_getScale``sw.shell.getScale()` (CR P3-3) |
**v0.37.11Notes Surface ✅:** **v0.37.14Scorched Earth IV + Pane Audit ✅** (10 sessions, v0.37.14.0.22):
Notes surface layout (composed kit into full-page Preact surface): Scorched Earth IV (4 JS deleted, ~1,672 lines), unified sidebar with nested
- [x] Sidebar with folder tree + tag browser (sidebar-folders.js, sidebar-tags.js) folders + DnD, notification bell, @mentions, typing indicators, member panel,
- [x] Main content area with NotesPane standalone=false double-unwrap cleanup (93 sites), API envelope normalization (18 Go handlers),
- [x] Mobile-responsive layout (768px breakpoint, overlay sidebar) thinking tags persistence, deleteMessage endpoint, 6 bug fixes.
- [x] UserMenu in sidebar footer (same as chat surface) Cumulative scorched earth: 53 files, ~19,382 lines.
- [x] Template rewrite — crash catcher + single mount point pattern
- [x] Menu primitive CSS fix — `sw-primitives.css` added to base.html (cross-surface)
- [x] Avatar initials fix — single-word names show first letter only (cross-surface)
- [x] Old `.surface-notes*` CSS deleted from surfaces.css
NotesPane kit enhancements (deferred to v0.37.14 pane audit): **v0.37.15 — Workflow Ownership & Lifecycle:**
- [ ] Markdown toolbar — bold, italic, heading, link, code, list, checkbox
- [ ] Hover preview on wikilinks — floating card on `[[Title]]` hover
- [ ] Breadcrumb trail — navigation history through wikilinks
- [ ] Split view (editor + preview) — side-by-side live preview
- [ ] Note templates — "New from template" in toolbar
- [ ] Slash commands in editor — `/table`, `/code`, `/heading`, `/date`, `/link`
- [ ] Graph minimap — small overview with draggable viewport
- [ ] Resizable split panes
Polish (deferred to v0.37.18 tag): Team-admin workflow management as first-class path. See
- [ ] Light mode CSS variable audit for `sw-notes-pane.css` [DESIGN-0.37.15](Workflow/DESIGN-0.37.15.md) for full design.
- [ ] Smooth view transitions (fade/slide between list → reader → editor) Instance cancel/unclaim/reassign, stage CRUD FE wiring, team-admin
- [ ] Note word count goal (optional target with progress bar) workflow queue + stage editor + instance monitor surfaces.
- [ ] Server-side favorites via note metadata (cross-device pinning)
**v0.37.12Scorched Earth II ✅:** **v0.37.16Projects Surface:**
Second dead-code purge. All six core surfaces confirmed Preact-only — old
vanilla JS, CSS, and admin templates that survived v0.37.10 are now removed.
Service worker cache manifest overhauled (17 stale entries → clean).
Deleted (23 files):
- [x] 9 JS: `ui-format.js`, `virtual-scroll.js`, `model-selector.js`,
`drag-resize.js`, `pages-splash.js`, `task-sidebar.js`,
`workflow-api.js`, `workflow-queue.js`, `workflow-monitor.js`
- [x] 6 CSS: `splash.css`, `styles.css`, `persona-kb.css`,
`notification-prefs.css`, `memory.css`, `channel-models.css`
- [x] 8 admin templates: `server/pages/templates/admin/` directory
- [x] 7 script/link tags removed from `base.html` (includes
`marked.min.js` + `purify.min.js` — surfaces load their own copies)
- [x] `sw.js` SHELL_FILES array replaced with actual file inventory
- [x] `pages.go` admin template embed directive removed
Kept (11 JS files — extension surface + debug dependencies):
`sb.js`, `app-state.js`, `events.js`, `ui-primitives.js`, `user-menu.js`,
`file-tree.js`, `code-editor.js`, `switchboard-sdk.js`, `debug.js`,
`repl.js`, `workflow-surfaces.js`
**v0.37.13 — Scorched Earth III ✅:**
Third dead-code purge. 5 files deleted (2,269 net lines). The 1,133-line
`ui-primitives.js` is gone; Theme management migrated to Preact SDK module.
SDK slimmed by removing 6 dead factory wrappers.
Deleted (5 files):
- [x] `ui-primitives.js` (1,133 LOC) — `esc`, component lifecycle, Providers,
Roles, modal/confirm/prompt, Theme, render helpers
- [x] `code-editor.js` (362 LOC) — tabbed CodeMirror 6 editor
- [x] `file-tree.js` (290 LOC) — workspace file browser
- [x] `user-menu.js` (206 LOC) — vanilla flyout menu
- [x] `app-state.js` (139 LOC) — global `window.App` state
- [x] 5 script tags removed from `base.html`
- [x] 5 entries removed from `sw.js` SHELL_FILES
- [x] `switchboard-sdk.js` slimmed: 6 dead wrappers removed, Theme→Preact SDK
Kept (6 JS files — extension + debug dependencies):
`sb.js`, `events.js`, `switchboard-sdk.js`, `debug.js`, `repl.js`,
`workflow-surfaces.js`
Cumulative scorched earth: 49 files, ~17,710 lines (v0.37.10 + v0.37.12 + v0.37.13)
**v0.37.14 — Pane Audit:**
Collaborative walkthrough of core features and theming. Each pane and surface
checked for form, function, and visual consistency. Fixes applied in-place.
No new architecture — making everything work correctly.
- [ ] ChatPane: message rendering, streaming, model selector, markdown, code blocks
- [ ] NotesPane: list, editor, reader, graph, quick switcher, save-to-note
- [ ] Cross-pane: toast, dialog, user menu, keyboard shortcuts
- [ ] Theming: light/dark mode consistency audit across all surfaces
- [ ] Kit enhancement backlog triage (above items evaluated for inclusion)
**v0.37.15 — Projects Surface:**
Project views, note/channel associations, project-scoped navigation. Project views, note/channel associations, project-scoped navigation.
**v0.37.16Workflow Assignment:** **v0.37.17Debug + Model Surface:**
Workflow routing and assignment UX. Debug modal Preact rebuild, model configuration UI, provider diagnostics.
**v0.37.17Debug / Model Surface:** **v0.37.18Tag ("UI Complete"):**
Debug tooling, model configuration UI, provider diagnostics. `sw.can()` RBAC gates, `__USER__`/`__PAGE_DATA__` removal, `_getScale` SDK,
light mode CSS audit, dead code hunt. Every capability has a UI.
---
## v0.38.x — Workflow Product Maturity
See [ROADMAP-0.38](Workflow/ROADMAP-0.38.md) for full version plan.
Visual workflow builder series. Bridges .37 plumbing to MVP gate
("team admins build workflows visually").
| Version | Summary | MVP Role |
|---------|---------|----------|
| v0.38.0 | Form Builder | **Blocker** — visual field editor replaces JSON textarea |
| v0.38.1 | Stage Reorder + Bulk Ops | DnD reorder, multi-select operations |
| v0.38.2 | SLA Alerting | Scheduled scanner, breach notifications |
| v0.38.3 | Visitor Experience | Branding, progress indicator, email notifications |
| v0.38.4 | Workflow Templates + Clone | 5 built-in templates, deep copy |
| v0.38.5 | Analytics Dashboard | Throughput, cycle time, SLA compliance |
--- ---

View File

@@ -0,0 +1,715 @@
# DESIGN-0.37.15 — Workflow Ownership & Lifecycle
Fixes the two structural problems in the workflow system: (1) workflows
are admin-only with no way to clean up stale instances, and (2) the FE
surfaces don't expose the team-admin capabilities the BE already has.
90%+ of workflows are team-admin generated. Assignments happen to team
members on their stage turn. This design makes that the first-class path.
Depends on: v0.37.14 (FE Rewrite), v0.35.0 (Workflow Product).
---
## Use Cases
### UC1 — Pure Data Gather (no LLM, no chat)
**Example:** Employee onboarding form. HR team admin creates a 3-stage
workflow: (1) employee fills a form, (2) HR reviews and approves,
(3) IT provisions accounts.
- Stage 0: `form_only`, `entry_mode: public_link` — visitor sees a form
- Stage 1: `review`, `assignment_team_id: hr-team` — HR member claims, reviews data, advances
- Stage 2: `form_only`, `assignment_team_id: it-team` — IT fills provisioning fields, completes
**No LLM involved at any stage.** No chat pane. The visitor never sees
stages 1-2. They get a terminal "submitted" screen after stage 0.
### UC2 — Data Gather + Live Chat with Team Member (no LLM)
**Example:** Customer support intake. Customer fills a form describing
their issue, then gets a live chat channel with a support agent.
- Stage 0: `form_only`, `entry_mode: public_link` — customer fills issue form
- Stage 1: `form_chat`, `assignment_team_id: support-team` — agent claims, chats with customer
- Stage 2: `review`, `assignment_team_id: support-leads` — lead reviews transcript, closes
**No LLM.** The chat is human-to-human. The customer sees stages 0-1
(their form and the chat). Stage 2 is internal.
### UC3 — LLM-Driven Intake Triggering Team Member
**Example:** IT help desk. An AI persona does first-level triage via
chat, gathers structured data, then routes to the right team.
- Stage 0: `chat_only`, `persona_id: helpdesk-bot`, `entry_mode: public_link` — LLM chats with user, extracts issue type
- Stage 1: `review`, `assignment_team_id` resolved by routing rules — team member reviews AI summary + transcript
- Stage 2: `form_chat`, same team — agent works with user if needed, fills resolution form
**LLM is stage 0 only.** Stages 1-2 are human. Conditional routing
(`transition_rules.conditions`) determines which team gets stage 1 based
on data the LLM extracted.
### Cross-Cutting: Stage Visibility
Stages have an audience:
| Audience | Who sees it | Examples |
|----------|-------------|---------|
| `visitor` | The person who entered the workflow | Stage 0 form, stage 1 chat with agent |
| `team` | Members of the assigned team only | Internal review, triage, approval |
| `system` | No human UI — automated transition | Webhook fire, data enrichment hook |
**Rule:** Once a stage's audience is `team`, the visitor's view is
frozen. They see a "your request is being processed" terminal. They
never see the internal stages, the assignment queue, or downstream
team handoffs.
This is NOT a new DB column. It's implied by the stage configuration:
- `form_only` or `chat_only` with no `assignment_team_id` → visitor-facing
- Any stage with `assignment_team_id` → team-facing
- `auto_transition: true` with no persona and no form → system
The FE reads the stage config and renders accordingly.
### Cross-Cutting: Multi-Team Handoffs
Stage 1 is Team A (triage). Stage 2 is Team B (specialist). Stage 3
is Team A again (close-out). Each team sees ONLY their assignments.
`ListAssignmentsForTeam` already filters by `team_id`. The FE must
scope the queue view to the selected team context.
### Cross-Cutting: Instance Lifecycle
Current status enum: `active | completed`.
Add: `cancelled`.
Who can cancel:
- The instance owner (the user who started it)
- A team admin of the owning team
- A global admin
Cancelling an instance sets `workflow_status = 'cancelled'` and
cancels all open assignments (`status IN ('unassigned','claimed')`).
### Cross-Cutting: Assignment Lifecycle
Current status enum: `unassigned | claimed | completed`.
Add: `cancelled`.
New operations:
- **Unclaim** — returns `claimed``unassigned` (claimer or team admin)
- **Reassign** — changes `assigned_to` on a claimed assignment (team admin)
- **Cancel** — sets `cancelled` (team admin, fires when instance is cancelled)
---
## JSX Exemplars
These are specification-grade components. Implementation translates to
the project's `htm` tagged template syntax. Props match the API response
shapes. State management uses the SDK (`sw.api.*`).
### E1 — Team Workflow Queue (the "inbox")
This is the primary surface a team member sees. It replaces the current
bare list of workflows with an actionable assignment queue.
```jsx
/**
* TeamWorkflowQueue — assignment inbox for a team member
*
* Shows: my claimed assignments, then unassigned assignments I can claim.
* Actions: claim, unclaim, open (navigate to workflow channel), complete.
*
* Mount: team-admin surface, "Assignments" tab
* Data: GET /api/v1/teams/:teamId/assignments?status=unassigned
* GET /api/v1/teams/:teamId/assignments?status=claimed
*/
function TeamWorkflowQueue({ teamId }) {
const [claimed, setClaimed] = useState([]);
const [unassigned, setUnassigned] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
const [c, u] = await Promise.all([
sw.api.teams.assignments(teamId, { status: 'claimed' }),
sw.api.teams.assignments(teamId, { status: 'unassigned' }),
]);
setClaimed(c || []);
setUnassigned(u || []);
setLoading(false);
}
useEffect(() => { load(); }, [teamId]);
// WS live update: listen for workflow.assigned / workflow.claimed
useEffect(() => {
const off1 = sw.on('workflow.assigned', load);
const off2 = sw.on('workflow.claimed', load);
return () => { off1(); off2(); };
}, [teamId]);
async function claim(id) {
await sw.api.workflowAssignments.claim(id);
load();
}
async function unclaim(id) {
await sw.api.workflowAssignments.unclaim(id);
load();
}
return (
<div className="wf-queue">
{/* ── My Claimed ── */}
<h4>My Active ({claimed.length})</h4>
{claimed.map(a => (
<div key={a.id} className="wf-queue-item wf-queue-item--claimed">
<div className="wf-queue-item__info">
<strong>{a.workflow_name || 'Workflow'}</strong>
<span className="badge">{a.stage_name || `Stage ${a.stage}`}</span>
{a.sla_breached && <span className="badge badge-danger">SLA</span>}
</div>
<div className="wf-queue-item__actions">
<button className="btn-small" onClick={() => unclaim(a.id)}>Release</button>
<button className="btn-small btn-primary"
onClick={() => location.href = `${BASE}/w/${a.channel_id}`}>
Open
</button>
</div>
</div>
))}
{/* ── Unassigned ── */}
<h4>Available ({unassigned.length})</h4>
{unassigned.map(a => (
<div key={a.id} className="wf-queue-item">
<div className="wf-queue-item__info">
<strong>{a.workflow_name || 'Workflow'}</strong>
<span className="badge">{a.stage_name || `Stage ${a.stage}`}</span>
<span className="text-muted">{timeAgo(a.created_at)}</span>
</div>
<div className="wf-queue-item__actions">
<button className="btn-small btn-primary" onClick={() => claim(a.id)}>
Claim
</button>
</div>
</div>
))}
{claimed.length === 0 && unassigned.length === 0 && (
<div className="empty-hint">No assignments queue is clear</div>
)}
</div>
);
}
```
### E2 — Stage Editor (team-admin inline)
The current team-admin workflows surface shows stages read-only. This
replaces it with a full inline editor.
```jsx
/**
* StageEditor — inline stage list with add/edit/delete/reorder
*
* Mount: team-admin workflows surface, inside the workflow edit view.
* Data: GET /api/v1/teams/:teamId/workflows/:wfId/stages
* POST /api/v1/teams/:teamId/workflows/:wfId/stages
* PUT /api/v1/teams/:teamId/workflows/:wfId/stages/:sid
* DELETE /api/v1/teams/:teamId/workflows/:wfId/stages/:sid
* PATCH /api/v1/teams/:teamId/workflows/:wfId/stages/reorder
*/
function StageEditor({ teamId, workflowId }) {
const [stages, setStages] = useState([]);
const [editing, setEditing] = useState(null); // stage id or 'new'
const [teams, setTeams] = useState([]);
const [personas, setPersonas] = useState([]);
async function load() {
const [s, t, p] = await Promise.all([
sw.api.teams.workflowStages(teamId, workflowId),
sw.api.teams.members(teamId), // for assignment_team picker
sw.api.teams.personas(teamId), // for persona picker
]);
setStages(s || []);
setTeams(t || []);
setPersonas(p || []);
}
useEffect(() => { load(); }, [workflowId]);
async function addStage(data) {
await sw.api.teams.createWorkflowStage(teamId, workflowId, data);
setEditing(null);
load();
}
async function updateStage(stageId, data) {
await sw.api.teams.updateWorkflowStage(teamId, workflowId, stageId, data);
setEditing(null);
load();
}
async function deleteStage(stageId) {
const ok = await sw.confirm('Delete this stage?', true);
if (!ok) return;
await sw.api.teams.deleteWorkflowStage(teamId, workflowId, stageId);
load();
}
return (
<div className="stage-editor">
<div className="stage-editor__header">
<h5>Stages ({stages.length})</h5>
<button className="btn-small" onClick={() => setEditing('new')}>+ Add Stage</button>
</div>
{/* ── Stage list (drag handle placeholder for reorder) ── */}
{stages.map((s, i) => (
<div key={s.id} className="stage-editor__row">
<span className="stage-editor__ordinal">#{i + 1}</span>
<div className="stage-editor__info">
<strong>{s.name}</strong>
<span className="badge">{s.stage_mode}</span>
{s.persona_id && <span className="badge badge-active">persona</span>}
{s.assignment_team_id && <span className="badge">team assign</span>}
</div>
<div className="stage-editor__actions">
<button className="btn-small" onClick={() => setEditing(s.id)}>Edit</button>
<button className="btn-small btn-danger" onClick={() => deleteStage(s.id)}>×</button>
</div>
</div>
))}
{/* ── Inline edit/create form ── */}
{editing && (
<StageForm
stage={editing === 'new' ? null : stages.find(s => s.id === editing)}
personas={personas}
teams={teams}
onSave={(data) => editing === 'new'
? addStage(data)
: updateStage(editing, data)
}
onCancel={() => setEditing(null)}
/>
)}
</div>
);
}
/**
* StageForm — create/edit a single stage
*
* Fields: name, stage_mode, persona_id, assignment_team_id,
* history_mode, auto_transition, sla_seconds.
* Form template editing is a separate concern (CS6+).
*/
function StageForm({ stage, personas, teams, onSave, onCancel }) {
const [name, setName] = useState(stage?.name || '');
const [mode, setMode] = useState(stage?.stage_mode || 'chat_only');
const [personaId, setPersonaId] = useState(stage?.persona_id || '');
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
const [historyMode, setHistoryMode] = useState(stage?.history_mode || 'full');
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
const [sla, setSla] = useState(stage?.sla_seconds || '');
function submit() {
onSave({
name,
stage_mode: mode,
persona_id: personaId || null,
assignment_team_id: assignTeam || null,
history_mode: historyMode,
auto_transition: autoTransition,
sla_seconds: sla ? parseInt(sla, 10) : null,
});
}
return (
<div className="stage-form">
<div className="form-row">
<div className="form-group">
<label>Name</label>
<input value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="form-group">
<label>Mode</label>
<select value={mode} onChange={e => setMode(e.target.value)}>
<option value="chat_only">Chat Only</option>
<option value="form_only">Form Only</option>
<option value="form_chat">Form + Chat</option>
<option value="review">Review</option>
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Persona (LLM)</label>
<select value={personaId} onChange={e => setPersonaId(e.target.value)}>
<option value=""> none </option>
{personas.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Assign to Team</label>
<select value={assignTeam} onChange={e => setAssignTeam(e.target.value)}>
<option value=""> none (visitor stage) </option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>History Mode</label>
<select value={historyMode} onChange={e => setHistoryMode(e.target.value)}>
<option value="full">Full</option>
<option value="summary">Summary</option>
<option value="fresh">Fresh</option>
</select>
</div>
<div className="form-group">
<label>SLA (seconds, optional)</label>
<input type="number" value={sla} onChange={e => setSla(e.target.value)} placeholder="e.g. 3600" />
</div>
</div>
<label className="toggle-label">
<input type="checkbox" checked={autoTransition} onChange={e => setAutoTransition(e.target.checked)} />
Auto-advance when complete
</label>
<div className="form-row" style={{ marginTop: 12, gap: 8 }}>
<button className="btn-small btn-primary" onClick={submit}>
{stage ? 'Update' : 'Add Stage'}
</button>
<button className="btn-small" onClick={onCancel}>Cancel</button>
</div>
</div>
);
}
```
### E3 — Instance Monitor (team-admin)
Shows active instances for the team's workflows with cancel capability.
```jsx
/**
* TeamInstanceMonitor — active workflow instances for a team
*
* Mount: team-admin workflows surface, "Monitor" tab
* Data: GET /api/v1/teams/:teamId/workflows/monitor/instances
* Actions: cancel instance
*/
function TeamInstanceMonitor({ teamId }) {
const [instances, setInstances] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
const data = await sw.api.teams.workflowInstances(teamId);
setInstances(data || []);
setLoading(false);
}
useEffect(() => { load(); }, [teamId]);
async function cancelInstance(channelId) {
const ok = await sw.confirm(
'Cancel this workflow instance? All open assignments will be cancelled.', true
);
if (!ok) return;
await sw.api.teams.cancelWorkflowInstance(teamId, channelId);
sw.toast('Instance cancelled', 'success');
load();
}
return (
<div className="wf-monitor">
<h4>Active Instances ({instances.length})</h4>
{instances.map(inst => (
<div key={inst.channel_id} className="wf-monitor__row">
<div className="wf-monitor__info">
<strong>{inst.workflow_name}</strong>
<span className="badge">{inst.stage_name || `Stage ${inst.current_stage}`}</span>
{inst.sla_breached && <span className="badge badge-danger">SLA breached</span>}
<span className="text-muted">Age: {formatDuration(inst.age_seconds)}</span>
</div>
<div className="wf-monitor__actions">
<button className="btn-small"
onClick={() => location.href = `${BASE}/w/${inst.channel_id}`}>
Open
</button>
<button className="btn-small btn-danger" onClick={() => cancelInstance(inst.channel_id)}>
Cancel
</button>
</div>
</div>
))}
{instances.length === 0 && <div className="empty-hint">No active instances</div>}
</div>
);
}
```
### E4 — My Assignments (user settings)
Every authenticated user sees their personal assignment queue across
all teams they belong to. This is the "what's on my plate" view.
```jsx
/**
* MyAssignments — user's personal assignment queue
*
* Mount: settings surface, "Assignments" section
* Data: GET /api/v1/workflow-assignments/mine
* Actions: claim, unclaim, open channel, complete
*/
function MyAssignments() {
const [assignments, setAssignments] = useState([]);
const [loading, setLoading] = useState(true);
async function load() {
const data = await sw.api.workflowAssignments.mine();
setAssignments(data || []);
setLoading(false);
}
useEffect(() => { load(); }, []);
// Group: my claimed first, then available
const mine = assignments.filter(a => a.status === 'claimed' && a.assigned_to === sw.auth.user?.id);
const available = assignments.filter(a => a.status === 'unassigned');
return (
<div>
{mine.length > 0 && (
<>
<h4>Claimed ({mine.length})</h4>
{mine.map(a => (
<AssignmentRow key={a.id} assignment={a} onAction={load} showTeam />
))}
</>
)}
{available.length > 0 && (
<>
<h4>Available ({available.length})</h4>
{available.map(a => (
<AssignmentRow key={a.id} assignment={a} onAction={load} showTeam />
))}
</>
)}
{mine.length === 0 && available.length === 0 && (
<div className="empty-hint">No assignments</div>
)}
</div>
);
}
/**
* AssignmentRow — reusable row for any assignment context
*
* Shared between TeamWorkflowQueue (E1) and MyAssignments (E4).
* The showTeam prop controls whether the team badge is visible
* (needed in MyAssignments where assignments span multiple teams).
*/
function AssignmentRow({ assignment: a, onAction, showTeam }) {
const isMine = a.assigned_to === sw.auth.user?.id;
async function claim() {
await sw.api.workflowAssignments.claim(a.id);
onAction();
}
async function unclaim() {
await sw.api.workflowAssignments.unclaim(a.id);
onAction();
}
async function complete() {
await sw.api.workflowAssignments.complete(a.id);
sw.toast('Assignment completed', 'success');
onAction();
}
return (
<div className={`wf-queue-item ${isMine ? 'wf-queue-item--claimed' : ''}`}>
<div className="wf-queue-item__info">
<strong>{a.workflow_name || 'Workflow'}</strong>
{showTeam && a.team_name && <span className="badge">{a.team_name}</span>}
<span className="badge">{a.stage_name || `Stage ${a.stage}`}</span>
{a.sla_breached && <span className="badge badge-danger">SLA</span>}
<span className="text-muted">{timeAgo(a.created_at)}</span>
</div>
<div className="wf-queue-item__actions">
{a.status === 'unassigned' && (
<button className="btn-small btn-primary" onClick={claim}>Claim</button>
)}
{isMine && (
<>
<button className="btn-small" onClick={unclaim}>Release</button>
<button className="btn-small btn-primary"
onClick={() => location.href = `${BASE}/w/${a.channel_id}`}>
Open
</button>
<button className="btn-small btn-success" onClick={complete}>Complete</button>
</>
)}
</div>
</div>
);
}
```
### E5 — Visitor Terminal Screen
When a visitor completes their last visible stage, they see this
instead of the internal team stages.
```jsx
/**
* VisitorTerminal — "thank you" screen after visitor stages complete
*
* The workflow surface (Go template + JS) detects when the current
* stage is team-facing and the visitor is not a team member.
* Instead of rendering the stage, it renders this terminal.
*
* Mount: workflow surface (replaces stage content)
* Data: channel workflow_status + stage config
*/
function VisitorTerminal({ workflowName, branding, status }) {
// status: 'active' (still being processed) | 'completed' | 'cancelled'
const messages = {
active: { title: 'Request Submitted', body: 'Your request is being reviewed. You\'ll be contacted if we need more information.' },
completed: { title: 'Complete', body: 'Your request has been processed. Thank you.' },
cancelled: { title: 'Cancelled', body: 'This request has been cancelled.' },
};
const msg = messages[status] || messages.active;
return (
<div className="visitor-terminal">
{branding?.logo_url && (
<img src={branding.logo_url} className="visitor-terminal__logo" alt="" />
)}
<h2>{msg.title}</h2>
<p>{msg.body}</p>
{branding?.tagline && (
<p className="visitor-terminal__tagline">{branding.tagline}</p>
)}
</div>
);
}
```
---
## BE Additions
### New Store Methods
```go
// ── ChannelStore additions ──
// CancelWorkflow sets workflow_status = 'cancelled' on a workflow channel.
CancelWorkflow(ctx context.Context, channelID string) error
// ── WorkflowStore additions ──
// CancelAssignmentsForChannel sets status = 'cancelled' on all
// unassigned/claimed assignments for a channel.
CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error)
// UnclaimAssignment returns a claimed assignment to unassigned.
// Returns rows affected (0 = not claimed or not found).
UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error)
// ReassignAssignment changes assigned_to on a claimed assignment.
// Returns rows affected.
ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error)
// CancelAssignment sets a single assignment to cancelled.
CancelAssignment(ctx context.Context, assignmentID string) (int64, error)
```
### New Handler Endpoints
```
POST /api/v1/channels/:id/workflow/cancel — owner or team admin
POST /api/v1/workflow-assignments/:id/unclaim — claimer or team admin
POST /api/v1/workflow-assignments/:id/reassign — team admin
POST /api/v1/workflow-assignments/:id/cancel — team admin
# Team-scoped variants (for team-admin surface):
POST /api/v1/teams/:teamId/workflows/monitor/instances/:channelId/cancel
```
### New FE API Domains
```js
// teams domain additions:
assignments: (id, opts) => rc.get(`/api/v1/teams/${id}/assignments` + _qs(opts)),
cancelWorkflowInstance: (id, chId) => rc.post(`/api/v1/teams/${id}/workflows/monitor/instances/${chId}/cancel`, {}),
workflowInstances: (id) => rc.get(`/api/v1/teams/${id}/workflows/monitor/instances`),
// stage CRUD (routes exist, FE bindings missing):
workflowStages: (id, wfId) => rc.get(`/api/v1/teams/${id}/workflows/${wfId}/stages`),
createWorkflowStage: (id, wfId, data) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/stages`, data),
updateWorkflowStage: (id, wfId, sid, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`, data),
deleteWorkflowStage: (id, wfId, sid) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`),
reorderWorkflowStages: (id, wfId, ids) => rc.patch(`/api/v1/teams/${id}/workflows/${wfId}/stages/reorder`, { ordered_ids: ids }),
// workflowAssignments domain (new top-level):
workflowAssignments: {
mine: () => rc.get('/api/v1/workflow-assignments/mine'),
get: (id) => rc.get(`/api/v1/workflow-assignments/${id}`),
claim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/claim`, {}),
unclaim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/unclaim`, {}),
complete: (id) => rc.post(`/api/v1/workflow-assignments/${id}/complete`, {}),
reassign: (id, userId) => rc.post(`/api/v1/workflow-assignments/${id}/reassign`, { user_id: userId }),
cancel: (id) => rc.post(`/api/v1/workflow-assignments/${id}/cancel`, {}),
comment: (id, text) => rc.post(`/api/v1/workflow-assignments/${id}/comment`, { text }),
},
```
### Schema Notes
No new migration files needed IF `workflow_status` and assignment
`status` are TEXT columns (they are). The new `cancelled` value is
just a string. Verify with:
```sql
-- Should return TEXT, not an enum:
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'channels' AND column_name = 'workflow_status';
```
---
## Changeset Plan
| CS | Scope | Files | Notes |
|----|-------|-------|-------|
| CS1 | BE: instance cancel | store iface + pg/sqlite impl + handler + routes | CancelWorkflow, CancelAssignmentsForChannel |
| CS2 | BE: assignment lifecycle | store iface + pg/sqlite impl + handler + routes | Unclaim, Reassign, CancelAssignment |
| CS3 | FE: api-domains wiring | `api-domains.js` | Wire all missing team stage CRUD + assignment operations |
| CS4 | FE: team-admin workflows rewrite | `team-admin/workflows.js` (split to sub-files if needed) | Stage editor (E2), instance monitor (E3), queue (E1) |
| CS5 | FE: settings assignments | `settings/workflows.js` → rename to `settings/assignments.js` | MyAssignments (E4), replaces read-only workflow list |
CS1-CS2 are BE-only, testable via integration tests. CS3 is a single
file, no visual changes. CS4-CS5 are FE-only, depend on CS3.
---
## What We're NOT Doing in .15
- Form template visual editor (stage form_template is still raw JSON)
- Workflow template sharing between teams
- Bulk operations (batch cancel, batch reassign)
- SLA alerting/notification automation (monitor is read-only)
- Visitor terminal customization beyond branding (E5 is static)
- Drag-and-drop stage reorder (button-based reorder only)
- Workflow analytics/reporting beyond the existing funnel endpoint

View File

@@ -0,0 +1,584 @@
# ROADMAP-0.38 — Workflow Product Maturity
Everything deferred from v0.37.15 plus what the MVP gate actually
requires: "Team admins build workflows **visually**."
v0.37.15 delivers the plumbing — cancel, unclaim, reassign, stage CRUD
in the FE, queue views. This series builds the product on top of that
plumbing.
---
## Series Overview
```
v0.37.15 Workflow Ownership & Lifecycle (plumbing)
v0.37.16 Projects Surface
v0.37.17 Debug / Model Surface
── 0.37 tag ──
v0.38.0 Form Builder ← team admins stop writing JSON
v0.38.1 Stage Reorder + Bulk Ops ← queue management at scale
v0.38.2 SLA Alerting ← stale queues self-report
v0.38.3 Visitor Experience ← the public face
v0.38.4 Workflow Templates + Clone ← reuse across teams
v0.38.5 Analytics Dashboard ← throughput, bottlenecks, SLA compliance
─ ─ ─ MVP v0.50.0 gate ─ ─ ─
```
**Milestone gates:**
| Milestone | Gate | Meaning |
|-----------|------|---------|
| v0.37 tag | **UI Complete** | Every platform capability has a surface; no features require raw API calls |
| v0.38.0 | **Self-service** | A team admin can build a complete workflow without touching JSON or asking a global admin |
| v0.38.2 | **Operational** | Stale work surfaces automatically; team admins can manage queues without monitoring dashboards |
| v0.38.5 | **Measurable** | Team leads can answer "how long does stage X take" and "where are we bottlenecked" |
---
## v0.38.0 — Form Builder
**The single biggest gap.** `form_template` is currently raw JSON in a
textarea. The MVP gate says "build workflows visually" — this is the
make-or-break.
### Scope
A visual form builder embedded in the stage editor (E2 from the .15
design). Not a standalone surface — it's a panel that opens when you
click "Edit Form" on a `form_only` or `form_chat` stage.
### Fields supported (matching existing TypedFormTemplate)
`text`, `email`, `select`, `number`, `date`, `textarea`, `checkbox`,
`file`. Each with the validation rules already defined in
`models/workflow.go` (min/max length, pattern, min/max value, etc).
### JSX Exemplar
```jsx
/**
* FormBuilder — visual editor for stage form_template
*
* Opens as a slide-out panel from StageEditor.
* Produces a TypedFormTemplate JSON blob on save.
*
* Features:
* - Add/remove/reorder fields
* - Field type picker with type-specific validation config
* - Fieldset grouping (progressive multi-step forms)
* - Conditional visibility (when/op/value)
* - Live preview pane
*/
function FormBuilder({ initial, onSave, onCancel }) {
const [fields, setFields] = useState(initial?.fields || []);
const [fieldsets, setFieldsets] = useState(initial?.fieldsets || []);
const [useFieldsets, setUseFieldsets] = useState((initial?.fieldsets?.length || 0) > 0);
const [preview, setPreview] = useState(false);
function addField(targetFieldset) {
const field = {
key: `field_${Date.now()}`,
type: 'text',
label: '',
required: false,
};
if (useFieldsets && targetFieldset !== undefined) {
// Add to specific fieldset
setFieldsets(fs => fs.map((f, i) =>
i === targetFieldset ? { ...f, fields: [...f.fields, field] } : f
));
} else {
setFields(f => [...f, field]);
}
}
function save() {
const template = useFieldsets
? { fieldsets, hooks: initial?.hooks || null }
: { fields, hooks: initial?.hooks || null };
onSave(template);
}
return (
<div className="form-builder">
<div className="form-builder__header">
<h4>Form Builder</h4>
<label className="toggle-label">
<input type="checkbox" checked={useFieldsets}
onChange={e => setUseFieldsets(e.target.checked)} />
Multi-step (fieldsets)
</label>
<button className="btn-small" onClick={() => setPreview(!preview)}>
{preview ? 'Edit' : 'Preview'}
</button>
</div>
{preview ? (
<FormPreview fields={fields} fieldsets={useFieldsets ? fieldsets : null} />
) : (
<div className="form-builder__canvas">
{useFieldsets ? (
<FieldsetEditor fieldsets={fieldsets} setFieldsets={setFieldsets} />
) : (
<FieldList fields={fields} setFields={setFields} />
)}
<button className="btn-small" onClick={() => addField()}>
+ Add Field
</button>
</div>
)}
<div className="form-builder__footer">
<button className="btn-small btn-primary" onClick={save}>Save Form</button>
<button className="btn-small" onClick={onCancel}>Cancel</button>
</div>
</div>
);
}
/**
* FieldEditor — single field configuration row
*
* Inline editing of key, type, label, required, validation, condition.
* Type-specific validation options appear contextually.
*/
function FieldEditor({ field, onChange, onRemove, allFields }) {
function set(k, v) { onChange({ ...field, [k]: v }); }
return (
<div className="field-editor">
<div className="form-row">
<div className="form-group" style={{ flex: 2 }}>
<label>Label</label>
<input value={field.label} onChange={e => set('label', e.target.value)} />
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>Key</label>
<input value={field.key} onChange={e => set('key', e.target.value)}
placeholder="field_name" />
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>Type</label>
<select value={field.type} onChange={e => set('type', e.target.value)}>
<option value="text">Text</option>
<option value="textarea">Textarea</option>
<option value="email">Email</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="select">Select</option>
<option value="checkbox">Checkbox</option>
<option value="file">File</option>
</select>
</div>
</div>
<div className="form-row">
<label className="toggle-label">
<input type="checkbox" checked={field.required}
onChange={e => set('required', e.target.checked)} />
Required
</label>
{/* Type-specific validation — only render what applies */}
{(field.type === 'text' || field.type === 'textarea') && (
<ValidationText validation={field.validation}
onChange={v => set('validation', v)} />
)}
{field.type === 'number' && (
<ValidationNumber validation={field.validation}
onChange={v => set('validation', v)} />
)}
{field.type === 'select' && (
<OptionsEditor options={field.options || []}
onChange={o => set('options', o)} />
)}
</div>
{/* Conditional visibility */}
<ConditionEditor condition={field.condition}
onChange={c => set('condition', c)}
allFields={allFields} />
<button className="btn-small btn-danger" onClick={onRemove}>Remove</button>
</div>
);
}
```
### BE changes
None. The form builder is pure FE — it produces the same
`TypedFormTemplate` JSON that already exists. The BE validation
in `models/workflow.go` is unchanged.
### Deliverables
| File | What |
|------|------|
| `src/js/sw/components/form-builder/index.js` | FormBuilder root |
| `src/js/sw/components/form-builder/field-editor.js` | FieldEditor, type-specific validators |
| `src/js/sw/components/form-builder/fieldset-editor.js` | Fieldset grouping |
| `src/js/sw/components/form-builder/condition-editor.js` | Conditional visibility |
| `src/js/sw/components/form-builder/preview.js` | Live form preview |
| `src/css/form-builder.css` | Styles |
| Integration into StageEditor (E2 from .15) | "Edit Form" button opens builder |
---
## v0.38.1 — Stage Reorder + Bulk Ops
### Stage drag-and-drop
Replace button-based reorder with drag-and-drop in the stage editor.
No external library — use the HTML5 Drag and Drop API with
`draggable`, `ondragstart`, `ondragover`, `ondrop`.
The reorder PATCH endpoint already exists:
`PATCH /api/v1/teams/:teamId/workflows/:wfId/stages/reorder`
### Bulk assignment operations
Team admins managing 20+ assignments need batch actions.
```jsx
/**
* BulkAssignmentBar — selection-based bulk actions
*
* Appears above the queue when 1+ assignments are selected.
* Actions: bulk cancel, bulk reassign (to specific user).
*/
function BulkAssignmentBar({ selected, teamId, onAction }) {
const [reassignTo, setReassignTo] = useState('');
async function bulkCancel() {
const ok = await sw.confirm(`Cancel ${selected.length} assignments?`, true);
if (!ok) return;
await Promise.all(selected.map(id => sw.api.workflowAssignments.cancel(id)));
sw.toast(`${selected.length} cancelled`, 'success');
onAction();
}
async function bulkReassign() {
if (!reassignTo) return;
await Promise.all(selected.map(id =>
sw.api.workflowAssignments.reassign(id, reassignTo)
));
sw.toast(`${selected.length} reassigned`, 'success');
onAction();
}
return (
<div className="bulk-bar">
<span>{selected.length} selected</span>
<button className="btn-small btn-danger" onClick={bulkCancel}>Cancel All</button>
<div className="bulk-bar__reassign">
<TeamMemberPicker teamId={teamId} value={reassignTo}
onChange={setReassignTo} />
<button className="btn-small" onClick={bulkReassign}
disabled={!reassignTo}>
Reassign
</button>
</div>
</div>
);
}
```
### BE changes
None. Bulk ops are client-side `Promise.all` over existing single
endpoints. If performance becomes an issue at scale (50+ assignments),
add a `POST /api/v1/workflow-assignments/bulk` endpoint in a future
patch. YAGNI for now.
---
## v0.38.2 — SLA Alerting
The monitor already computes `sla_breached`. This version makes it
proactive instead of requiring someone to check the dashboard.
### Scheduled SLA scanner
A new periodic task (Go scheduler, not Starlark) that runs every N
minutes:
1. Query active workflow channels with `sla_seconds` configured
2. Compute breach status from `stage_entered_at`
3. For newly breached instances (not yet notified):
- Create notification for the assigned team members
- Emit `workflow.sla_breached` WS event
- If webhook_url configured, fire webhook
### Data changes
Add `sla_notified_at` (nullable timestamp) to the channels table.
The scanner only fires notifications when `sla_notified_at IS NULL`
and the SLA is breached, then sets the timestamp. Prevents duplicate
alerts.
### FE changes
The queue items (E1, E4) already render `sla_breached` badges. Add:
- Pulsing animation on breached badges
- Toast on `workflow.sla_breached` WS event
- Notification bell integration (already wired in .15 via
`notifications.NotifyWorkflowClaimed` pattern)
### Deliverables
| File | What |
|------|------|
| `server/scheduler/sla_scanner.go` | Periodic SLA check |
| `server/notifications/workflow_sla.go` | SLA notification builder |
| Migration: `sla_notified_at` column | Both PG + SQLite |
| FE: badge animation CSS | `src/css/sw-primitives.css` |
---
## v0.38.3 — Visitor Experience
The visitor currently gets a raw workflow surface. After .15 they get
a terminal screen (E5) when their stages are done. This version makes
the visitor experience feel like a product.
### Scope
1. **Branded entry page** — the workflow landing page already supports
branding (`accent_color`, `logo_url`, `tagline`). Extend with:
- Custom welcome text (markdown)
- Background image/gradient
- "Powered by" toggle (hide/show Switchboard branding)
2. **Progress indicator** — visitor sees "Step 1 of N" for their
visible stages only. Internal team stages are invisible. The
progress bar counts only stages where the visitor is the audience.
3. **Email notifications** — optional. When configured on the workflow:
- Collect email at stage 0 (or extract from form data)
- Send "request received" confirmation
- Send "your request is complete" when workflow completes
- No notifications for internal stage transitions
4. **Session resume** — visitor who closes the browser can return to
`/w/:id/:slug` and resume where they left off. Already partially
implemented via `sb_session` cookie. Polish the UX: show "Welcome
back, pick up where you left off" instead of starting fresh.
### BE changes
```go
// Workflow model additions:
type WorkflowBranding struct {
AccentColor string `json:"accent_color"`
LogoURL string `json:"logo_url"`
Tagline string `json:"tagline"`
WelcomeText string `json:"welcome_text"` // NEW: markdown
BackgroundCSS string `json:"background_css"` // NEW: gradient or image URL
HidePoweredBy bool `json:"hide_powered_by"` // NEW
}
// Email notification config (in workflow settings):
type WorkflowEmailConfig struct {
Enabled bool `json:"enabled"`
FromName string `json:"from_name"`
OnSubmit string `json:"on_submit_template"` // markdown template
OnComplete string `json:"on_complete_template"` // markdown template
}
```
Email sending requires an SMTP config at the platform level
(admin settings). If not configured, email toggle is disabled in
the workflow editor with a hint.
### JSX Exemplar
```jsx
/**
* VisitorProgress — step indicator for visitor-facing stages
*
* Mount: workflow surface, above the stage content
* Only counts stages where audience = visitor.
*/
function VisitorProgress({ stages, currentStage }) {
// Filter to visitor-facing stages only
const visitorStages = stages.filter(s => !s.assignment_team_id);
const visitorIndex = visitorStages.findIndex(s => s.ordinal === currentStage);
const total = visitorStages.length;
if (total <= 1) return null; // Single stage = no progress bar
return (
<div className="visitor-progress">
{visitorStages.map((s, i) => (
<div key={s.id}
className={`visitor-progress__step ${
i < visitorIndex ? 'done' :
i === visitorIndex ? 'active' : ''
}`}>
<div className="visitor-progress__dot">{i < visitorIndex ? '✓' : i + 1}</div>
<span className="visitor-progress__label">{s.name}</span>
</div>
))}
</div>
);
}
```
---
## v0.38.4 — Workflow Templates + Clone
Team admins shouldn't rebuild common patterns from scratch. Two
features:
### Clone workflow
"Duplicate" button on the workflow editor. Creates a new workflow with
the same stages, form templates, and routing rules. New slug, new name
(appended "- Copy"). No versions carried over (must re-publish).
```
POST /api/v1/teams/:teamId/workflows/:id/clone
→ 201 { ...newWorkflow }
```
Pure BE operation — deep-copies workflow + stages, generates new IDs.
### Built-in templates
Seed a `workflow_templates` table with 3-5 common patterns:
| Template | Stages | Description |
|----------|--------|-------------|
| Customer Intake | form → review | Public form, team reviews |
| IT Help Desk | LLM chat → triage → resolution | AI-assisted intake |
| Approval Chain | form → approve → approve → notify | Multi-level approval |
| Feedback Survey | form (multi-step) → analytics | Data collection |
| Onboarding | form → team A → team B → team A | Multi-team handoff |
"New from template" in the team-admin workflow creation flow.
Templates are read-only platform data, not user-editable. They
serve as starting points — clone and customize.
### FE changes
- "Duplicate" button in workflow editor header
- "New from Template" option in workflow creation flow
- Template picker modal with descriptions
---
## v0.38.5 — Analytics Dashboard
Team leads need to answer: how long does each stage take, where are
we bottlenecked, what's our SLA compliance rate.
### Data source
No new data collection. Everything is derivable from existing columns:
- `channels.created_at` — instance start time
- `channels.stage_entered_at` — current stage entry
- `channels.workflow_status` — terminal state
- `workflow_assignments.created_at / claimed_at / completed_at` — assignment lifecycle timestamps
### Metrics
| Metric | Derivation |
|--------|------------|
| Throughput | Count of `completed` instances per time period |
| Average cycle time | `completed_at - created_at` across instances |
| Stage dwell time | `next_stage_entered_at - stage_entered_at` (requires computing from history) |
| SLA compliance | `breached / total` per stage with SLA configured |
| Assignment claim time | `claimed_at - created_at` on assignments |
| Queue depth over time | Snapshot of unassigned count (requires periodic sampling or derive from events) |
### JSX Exemplar
```jsx
/**
* WorkflowAnalytics — team-admin analytics tab
*
* Mount: team-admin workflows surface, "Analytics" tab
* Data: GET /api/v1/teams/:teamId/workflows/analytics
* ?workflow_id=...&period=7d|30d|90d
*/
function WorkflowAnalytics({ teamId }) {
const [data, setData] = useState(null);
const [workflowId, setWorkflowId] = useState('');
const [period, setPeriod] = useState('30d');
// ... load, filter controls ...
return (
<div className="wf-analytics">
<div className="wf-analytics__filters">
<WorkflowPicker teamId={teamId} value={workflowId} onChange={setWorkflowId} />
<PeriodPicker value={period} onChange={setPeriod} />
</div>
<div className="wf-analytics__cards">
<StatCard label="Completed" value={data.completed_count} />
<StatCard label="Avg Cycle Time" value={formatDuration(data.avg_cycle_seconds)} />
<StatCard label="SLA Compliance" value={`${data.sla_compliance_pct}%`}
variant={data.sla_compliance_pct < 80 ? 'danger' : 'success'} />
<StatCard label="Avg Claim Time" value={formatDuration(data.avg_claim_seconds)} />
</div>
{/* Stage funnel with dwell times — uses existing funnel endpoint + dwell data */}
<StageFunnel stages={data.stage_metrics} />
</div>
);
}
```
### BE changes
New endpoint:
```
GET /api/v1/teams/:teamId/workflows/analytics
?workflow_id=...&period=7d|30d|90d
```
Aggregation query over channels + assignments tables. No new tables.
Consider materialized views or periodic snapshot if query cost is
too high on large datasets (unlikely at 50-user scale).
---
## Changeset Budget
| Version | Estimated CS | Heaviest piece |
|---------|-------------|----------------|
| v0.38.0 | 4 | Form builder component tree (5+ files) |
| v0.38.1 | 2 | Drag-and-drop is fiddly but small |
| v0.38.2 | 3 | SLA scanner + notification plumbing |
| v0.38.3 | 4 | Visitor branding + email + progress |
| v0.38.4 | 2 | Clone is one BE endpoint + FE button |
| v0.38.5 | 3 | Analytics query + chart components |
| **Total** | **~18** | |
---
## Risk Notes
**v0.38.0 (Form Builder) is the long pole.** If this slips, the MVP
gate phrase "build workflows visually" fails. Prioritize this over
everything else in the series. If time is tight, ship a simplified
version (no fieldsets, no conditional visibility) and add those in a
patch.
**v0.38.3 (Email) depends on platform SMTP.** If SMTP isn't
configured, the feature is invisible. Consider making the email
config a prerequisite admin step with a setup wizard, or defer email
to v0.38.5 and keep .3 focused on branding + progress.
**v0.38.5 (Analytics) can be cut.** If the series is running long,
analytics is the most deferrable item — it's nice-to-have, not
gate-blocking. The existing monitor + funnel endpoints cover the
critical "is anything stuck" question.

View File

@@ -20,8 +20,7 @@ server {
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
location ~* \.(css|js)$ { location ~* \.(css|js)$ {
expires 1h; add_header Cache-Control "no-cache, must-revalidate";
add_header Cache-Control "public, must-revalidate";
} }
# ── API + WebSocket → backend ──────────── # ── API + WebSocket → backend ────────────
@@ -97,8 +96,8 @@ server {
# v0.29.2: ^~ prefix beats regex; corrected alias to /data/storage/packages/. # v0.29.2: ^~ prefix beats regex; corrected alias to /data/storage/packages/.
location ^~ /surfaces/ { location ^~ /surfaces/ {
alias /data/storage/packages/; alias /data/storage/packages/;
expires 1h; expires off;
add_header Cache-Control "public"; add_header Cache-Control "no-cache";
} }
# Fallback: redirect unknown paths to root # Fallback: redirect unknown paths to root

View File

@@ -227,7 +227,7 @@
let url = '/api/v1/channels?page=1&per_page=12'; let url = '/api/v1/channels?page=1&per_page=12';
if (typeFilter) url += '&type=' + encodeURIComponent(typeFilter); if (typeFilter) url += '&type=' + encodeURIComponent(typeFilter);
const resp = await sw.api.get(url); const resp = await sw.api.get(url);
const channels = resp.data || resp || []; const channels = resp || [];
container.innerHTML = ''; container.innerHTML = '';
if (!channels.length) { if (!channels.length) {
@@ -385,7 +385,7 @@
async function _loadAdminCards(container) { async function _loadAdminCards(container) {
try { try {
const resp = await sw.api.get('/api/v1/admin/packages'); const resp = await sw.api.get('/api/v1/admin/packages');
const packages = resp.data || resp || []; const packages = resp || [];
container.innerHTML = ''; container.innerHTML = '';
packages.forEach(function (pkg) { packages.forEach(function (pkg) {

View File

@@ -394,6 +394,66 @@
} }
} }
// ── Channel Types (group, channel) ──
var groupChId = null;
await T.test('crud', 'channels', 'POST /channels (type=group)', async function () {
var d = await T.apiPost('/channels', {
title: testTag + '-group',
type: 'group',
description: 'ICD group test'
});
T.assertShape(d, T.S.channelFull, 'group channel');
T.assert(d.type === 'group', 'type should be group, got: ' + d.type);
groupChId = d.id;
T.registerCleanup(function () { if (groupChId) return T.safeDelete('/channels/' + groupChId); });
});
var teamChId = null;
await T.test('crud', 'channels', 'POST /channels (type=channel)', async function () {
var d = await T.apiPost('/channels', {
title: testTag + '-team-channel',
type: 'channel',
description: 'ICD channel test'
});
T.assertShape(d, T.S.channelFull, 'team channel');
T.assert(d.type === 'channel', 'type should be channel, got: ' + d.type);
teamChId = d.id;
T.registerCleanup(function () { if (teamChId) return T.safeDelete('/channels/' + teamChId); });
});
// ── Multi-type filter ──
await T.test('crud', 'channels', 'GET /channels?types=group,channel (multi)', async function () {
var d = await T.apiGet('/channels?types=group,channel&per_page=50');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
arr.forEach(function (ch) {
T.assert(ch.type === 'group' || ch.type === 'channel',
'multi-type filter leaked: ' + ch.type);
});
});
await T.test('crud', 'channels', 'GET /channels?types=direct,dm,group,channel (all)', async function () {
var d = await T.apiGet('/channels?types=direct,dm,group,channel&per_page=50');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
var types = new Set(arr.map(function (ch) { return ch.type; }));
T.assert(types.size <= 4, 'should only contain known types');
});
// Cleanup channel types
if (groupChId) {
await T.test('crud', 'channels', 'DELETE /channels/:id (group cleanup)', async function () {
await T.safeDelete('/channels/' + groupChId);
groupChId = null;
});
}
if (teamChId) {
await T.test('crud', 'channels', 'DELETE /channels/:id (channel cleanup)', async function () {
await T.safeDelete('/channels/' + teamChId);
teamChId = null;
});
}
// ── Folders CRUD + Channel Assignment ── // ── Folders CRUD + Channel Assignment ──
var folderId = null; var folderId = null;
await T.test('crud', 'channels', 'POST /folders (create)', async function () { await T.test('crud', 'channels', 'POST /folders (create)', async function () {

View File

@@ -227,16 +227,35 @@
}; };
// ─── API Wrappers ─────────────────────────────────────────── // ─── API Wrappers ───────────────────────────────────────────
// API._get/_post/_put already prepend __BASE__. No base prefix here. // v0.37.14: raw fetch — old API._* globals removed in scorched earth.
T.apiGet = async function (path) { return await API._get('/api/v1' + path); }; async function _fetchJSON(method, path, body) {
T.apiPost = async function (path, body) { return await API._post('/api/v1' + path, body); }; var token = await T.getAuthToken();
T.apiPut = async function (path, body) { return await API._put('/api/v1' + path, body); }; var opts = {
method: method,
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
credentials: 'same-origin'
};
if (body !== undefined && method !== 'GET') opts.body = JSON.stringify(body);
var resp = await fetch(T.base + '/api/v1' + path, opts);
if (resp.status === 204) return { _status: 204 };
var text = await resp.text();
var data;
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; }
data._status = resp.status;
if (!resp.ok) {
var err = new Error(method + ' ' + path + ' → ' + resp.status + (data.error ? ': ' + data.error : ''));
err.status = resp.status;
err.data = data;
throw err;
}
return data;
}
T.apiPatch = async function (path, body) { T.apiGet = async function (path) { return await _fetchJSON('GET', path); };
if (typeof API._patch === 'function') return await API._patch('/api/v1' + path, body); T.apiPost = async function (path, body) { return await _fetchJSON('POST', path, body); };
return await API._put('/api/v1' + path, body); T.apiPut = async function (path, body) { return await _fetchJSON('PUT', path, body); };
}; T.apiPatch = async function (path, body) { return await _fetchJSON('PATCH', path, body); };
// ─── Token Capture ────────────────────────────────────────── // ─── Token Capture ──────────────────────────────────────────
@@ -244,23 +263,8 @@
T.captureAuthToken = async function () { T.captureAuthToken = async function () {
if (_capturedToken) return _capturedToken; if (_capturedToken) return _capturedToken;
var origFetch = window.fetch; // v0.37.14: read directly from localStorage (old API._get interceptor removed)
window.fetch = function (url, opts) { _capturedToken = getAuthTokenFromStorage();
if (!_capturedToken && opts && opts.headers) {
var auth = '';
if (opts.headers instanceof Headers) {
auth = opts.headers.get('Authorization') || '';
} else {
auth = opts.headers['Authorization'] || opts.headers.authorization || '';
}
if (typeof auth === 'string' && auth.indexOf('Bearer ') === 0) {
_capturedToken = auth.slice(7);
}
}
return origFetch.apply(this, arguments);
};
try { await API._get('/api/v1/health'); } catch (e) { /* swallow */ }
window.fetch = origFetch;
return _capturedToken; return _capturedToken;
}; };
@@ -291,18 +295,7 @@
// ─── DELETE (needs raw fetch fallback) ────────────────────── // ─── DELETE (needs raw fetch fallback) ──────────────────────
T.apiDelete = async function (path) { T.apiDelete = async function (path) {
if (typeof API._delete === 'function') return await API._delete('/api/v1' + path); return await _fetchJSON('DELETE', path);
var token = await T.getAuthToken();
var resp = await fetch(T.base + '/api/v1' + path, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
credentials: 'same-origin'
});
if (resp.status === 204 || resp.status === 200) {
var text = await resp.text();
return text ? JSON.parse(text) : {};
}
throw new Error('DELETE ' + path + ' → ' + resp.status);
}; };
T.safeDelete = async function (path, retries) { T.safeDelete = async function (path, retries) {

View File

@@ -19,17 +19,41 @@
* 12. ui.js — Render functions, export, provider setup panel * 12. ui.js — Render functions, export, provider setup panel
* 11. (this file) — Boot * 11. (this file) — Boot
*/ */
(function () { (async function () {
'use strict'; 'use strict';
var mount = document.getElementById('extension-mount'); var mount = document.getElementById('extension-mount');
if (!mount) return; if (!mount) return;
// ─── Boot Preact SDK (v0.37.14) ───────────────────────────
// Extension surfaces don't load Preact vendors or the SDK.
// Load vendors first, then boot SDK so window.sw is available.
try {
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
// Load Preact vendor modules (required by SDK)
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
console.log('[ICD] SDK booted — window.sw ready');
} catch (e) {
console.warn('[ICD] SDK boot failed:', e.message);
}
// ─── Shared Namespace ─────────────────────────────────────── // ─── Shared Namespace ───────────────────────────────────────
window.ICD = { window.ICD = {
mount: mount, mount: mount,
manifest: window.__MANIFEST__ || {}, manifest: window.__MANIFEST__ || {},
user: window.__USER__ || {}, user: window.sw?.auth?.user || window.__USER__ || {},
base: window.__BASE__ || '', base: window.__BASE__ || '',
// State (populated by framework.js) // State (populated by framework.js)
@@ -101,7 +125,7 @@
return; return;
} }
var script = document.createElement('script'); var script = document.createElement('script');
script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0'); script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0') + '.' + Date.now();
script.onload = function () { loaded++; loadNext(); }; script.onload = function () { loaded++; loadNext(); };
script.onerror = function () { script.onerror = function () {
console.error('[ICD] Failed to load: ' + modules[loaded]); console.error('[ICD] Failed to load: ' + modules[loaded]);

View File

@@ -1,8 +1,9 @@
/** /**
* ICD Test Runner — SDK Tier * ICD Test Runner — SDK Tier
* Validates switchboard-sdk.js contract: boot, identity, REST client, * Validates Preact SDK (sw/sdk/index.js) contract: boot, identity,
* events, theme, pipe registration, execution ordering, scoping, * REST client, events, theme, pipe registration, execution ordering,
* halt semantics, error isolation, extension compat shim, introspection. * scoping, halt semantics, error isolation, introspection.
* v0.37.14: Updated from Switchboard.init() to boot() API.
*/ */
(function () { (function () {
'use strict'; 'use strict';
@@ -35,16 +36,16 @@
// BOOT // BOOT
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
await T.test('sdk', 'boot', 'Switchboard.init() returns sw object', async function () { await T.test('sdk', 'boot', 'window.sw exists after boot()', async function () {
T.assert(typeof Switchboard !== 'undefined', 'Switchboard global missing'); T.assert(window.sw !== null && typeof window.sw === 'object', 'window.sw should be an object');
var inst = Switchboard.init(); T.assert(window.sw._sdk, 'window.sw._sdk version marker should exist');
T.assert(inst !== null && typeof inst === 'object', 'init() should return object');
T.assert(inst === window.sw, 'window.sw should be the same instance');
}); });
await T.test('sdk', 'boot', 'Double init returns same instance', async function () { await T.test('sdk', 'boot', 'boot() is idempotent', async function () {
var a = Switchboard.init(); var a = window.sw;
var b = Switchboard.init(); // Import and call boot() again — should return same instance
var mod = await import(window.__BASE__ + '/js/sw/sdk/index.js');
var b = await mod.boot();
T.assert(a === b, 'idempotent: must return same object'); T.assert(a === b, 'idempotent: must return same object');
}); });
@@ -52,16 +53,16 @@
// IDENTITY // IDENTITY
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
await T.test('sdk', 'identity', 'sw.user populated', async function () { await T.test('sdk', 'identity', 'sw.auth.user populated', async function () {
T.assert(sw.user !== null, 'sw.user should not be null'); T.assert(sw.auth.user !== null, 'sw.auth.user should not be null');
T.assert(typeof sw.user.id === 'string' && sw.user.id.length > 0, 'user.id'); T.assert(typeof sw.auth.user.id === 'string' && sw.auth.user.id.length > 0, 'user.id');
T.assert(typeof sw.user.username === 'string', 'user.username'); T.assert(typeof sw.auth.user.username === 'string', 'user.username');
T.assert(typeof sw.user.role === 'string', 'user.role'); T.assert(typeof sw.auth.user.role === 'string', 'user.role');
}); });
await T.test('sdk', 'identity', 'sw.isAdmin reflects role', async function () { await T.test('sdk', 'identity', 'sw.isAdmin reflects role', async function () {
T.assert(typeof sw.isAdmin === 'boolean', 'isAdmin should be boolean'); T.assert(typeof sw.isAdmin === 'boolean', 'isAdmin should be boolean');
T.assert(sw.isAdmin === (sw.user.role === 'admin'), 'isAdmin should match role'); T.assert(sw.isAdmin === (sw.auth.user.role === 'admin'), 'isAdmin should match role');
}); });
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -73,10 +74,9 @@
T.assert(d.status === 'ok', 'health status should be ok'); T.assert(d.status === 'ok', 'health status should be ok');
}); });
await T.test('sdk', 'api', 'sw.api.get /channels returns envelope', async function () { await T.test('sdk', 'api', 'sw.api.get /channels returns array', async function () {
var d = await sw.api.get('/api/v1/channels'); var d = await sw.api.get('/api/v1/channels');
T.assertHasKey(d, 'data', '/channels'); T.assert(Array.isArray(d), 'channels should be array (auto-unwrapped)');
T.assert(Array.isArray(d.data), 'data should be array');
}); });
var _sdkTestChannel = null; var _sdkTestChannel = null;
@@ -159,8 +159,8 @@
fired = true; fired = true;
T.assert(resolved === 'dark' || resolved === 'light', 'resolved should be dark|light'); T.assert(resolved === 'dark' || resolved === 'light', 'resolved should be dark|light');
}); });
// Trigger a theme change event // Trigger a theme change event via Preact SDK
Events.emit('theme.changed', {}, { localOnly: true }); sw.emit('theme.changed', {}, { localOnly: true });
T.assert(fired, 'change handler should have fired'); T.assert(fired, 'change handler should have fired');
unsub(); unsub();
}); });
@@ -425,28 +425,8 @@
T.assert(count >= 2, 'unscoped filter should run on both channel types'); T.assert(count >= 2, 'unscoped filter should run on both channel types');
}); });
// ═══════════════════════════════════════════════════════════ // v0.37.14: Extension compat shim test removed (Extensions global deleted).
// EXTENSION COMPAT SHIM // Extension rendering now goes through Preact SDK pipe directly.
// ═══════════════════════════════════════════════════════════
await T.test('sdk', 'compat', 'ctx.renderers.register post → pipe.list() [chat-only]', async function () {
// This tests the compat shim in extensions.js.
// Extensions only loads on the chat surface (scripts-chat template).
if (typeof Extensions === 'undefined') {
T.skip('Extensions only loads on chat surface — run SDK tier from /chat to test compat shim');
}
var shimName = '_sdk-compat-test-' + Date.now();
Extensions._registerRenderer('sdk-test', shimName, {
type: 'post',
priority: 77,
render: function (container) { /* noop */ }
});
var list = sw.pipe.list();
var found = list.render.some(function (f) {
return f.source === 'sdk-test:' + shimName;
});
T.assert(found, 'shimmed post-renderer should appear in pipe.list().render');
});
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// INTROSPECTION // INTROSPECTION

View File

@@ -418,7 +418,6 @@
T.assertHasKey(d, 'channels', '/admin/channels/archived'); T.assertHasKey(d, 'channels', '/admin/channels/archived');
T.assert(Array.isArray(d.channels), 'channels should be array'); T.assert(Array.isArray(d.channels), 'channels should be array');
T.assertHasKey(d, 'total', '/admin/channels/archived'); T.assertHasKey(d, 'total', '/admin/channels/archived');
T.assertHasKey(d, 'retention_mode', '/admin/channels/archived');
}); });
await T.test('smoke', 'admin', 'GET /admin/storage/orphans', async function () { await T.test('smoke', 'admin', 'GET /admin/storage/orphans', async function () {

View File

@@ -97,13 +97,13 @@
style: { alignSelf: 'flex-end', whiteSpace: 'nowrap' }, style: { alignSelf: 'flex-end', whiteSpace: 'nowrap' },
onClick: function () { onClick: function () {
if (!T.providerSetup.apiKey) { if (!T.providerSetup.apiKey) {
if (typeof UI !== 'undefined') UI.toast('Paste an API key first', 'warning'); if (window.sw && sw.toast) sw.toast('Paste an API key first', 'warning');
return; return;
} }
T.providerSetup.configured = true; T.providerSetup.configured = true;
T.renderProviderSetup(); T.renderProviderSetup();
T.renderControls(); T.renderControls();
if (typeof UI !== 'undefined') UI.toast(T.providerSetup.provider + ' configured — run Provider tier', 'success'); if (window.sw && sw.toast) sw.toast(T.providerSetup.provider + ' configured — run Provider tier', 'success');
} }
}, T.providerSetup.configured ? 'Reconfigure' : 'Configure'); }, T.providerSetup.configured ? 'Reconfigure' : 'Configure');
inputRow.appendChild(cfgBtn); inputRow.appendChild(cfgBtn);
@@ -140,12 +140,18 @@
T.mount.style.maxWidth = '1000px'; T.mount.style.maxWidth = '1000px';
T.mount.style.margin = '0 auto'; T.mount.style.margin = '0 auto';
// Top bar with user menu
var topbar = $('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: '8px' } });
T.mount.appendChild(topbar);
if (window.sw?.userMenu) window.sw.userMenu(topbar, { flyout: 'down' });
// Header // Header
var user = window.sw?.auth?.user || T.user || {};
var hdr = $('div', { style: { marginBottom: '24px' } }, [ var hdr = $('div', { style: { marginBottom: '24px' } }, [
$('h1', { style: { fontSize: '22px', fontWeight: '700', color: 'var(--text)', margin: '0 0 4px 0' } }, $('h1', { style: { fontSize: '22px', fontWeight: '700', color: 'var(--text)', margin: '0 0 4px 0' } },
'ICD Test Runner'), 'ICD Test Runner'),
$('div', { style: { fontSize: '13px', color: 'var(--text-3)' } }, $('div', { style: { fontSize: '13px', color: 'var(--text-3)' } },
'v' + (T.manifest.version || '0.28.0') + ' · ' + esc(T.user.username) + (T.user.role === 'admin' ? ' (admin)' : ' (user)')) 'v' + (T.manifest.version || '0.28.0') + ' · ' + esc(user.username || 'unknown') + (user.role === 'admin' ? ' (admin)' : ' (user)'))
]); ]);
T.mount.appendChild(hdr); T.mount.appendChild(hdr);
@@ -232,8 +238,8 @@
// SDK tier button — tests switchboard-sdk.js contract // SDK tier button — tests switchboard-sdk.js contract
var btnSdk = $('button', { var btnSdk = $('button', {
className: (typeof Switchboard !== 'undefined') ? 'btn-secondary' : 'btn-ghost', className: (typeof window.sw !== 'undefined') ? 'btn-secondary' : 'btn-ghost',
style: { opacity: (typeof Switchboard !== 'undefined') ? '1' : '0.5' }, style: { opacity: (typeof window.sw !== 'undefined') ? '1' : '0.5' },
onClick: function () { T.runSuite('sdk'); } onClick: function () { T.runSuite('sdk'); }
}, 'SDK'); }, 'SDK');
T.el.controls.appendChild(btnSdk); T.el.controls.appendChild(btnSdk);
@@ -321,7 +327,7 @@
var pwSpan = $('span', { style: { color: 'var(--warning)', cursor: 'pointer' }, title: 'Click to copy' }, u.password); var pwSpan = $('span', { style: { color: 'var(--warning)', cursor: 'pointer' }, title: 'Click to copy' }, u.password);
pwSpan.addEventListener('click', function () { pwSpan.addEventListener('click', function () {
navigator.clipboard.writeText(u.password); navigator.clipboard.writeText(u.password);
if (typeof UI !== 'undefined') UI.toast('Password copied', 'info'); if (window.sw && sw.toast) sw.toast('Password copied', 'info');
}); });
pwTd.appendChild(pwSpan); pwTd.appendChild(pwSpan);
tr.appendChild(pwTd); tr.appendChild(pwTd);
@@ -480,7 +486,7 @@
T.exportReport = function (mode) { T.exportReport = function (mode) {
if (T.results.length === 0) { if (T.results.length === 0) {
if (typeof UI !== 'undefined') UI.toast('No T.results to export — run a suite first', 'warning'); if (window.sw && sw.toast) sw.toast('No T.results to export — run a suite first', 'warning');
return; return;
} }
@@ -488,7 +494,7 @@
if (mode === 'clipboard') { if (mode === 'clipboard') {
navigator.clipboard.writeText(text).then(function () { navigator.clipboard.writeText(text).then(function () {
if (typeof UI !== 'undefined') UI.toast('Report copied to clipboard', 'success'); if (window.sw && sw.toast) sw.toast('Report copied to clipboard', 'success');
}).catch(function () { }).catch(function () {
// Fallback: select-all textarea // Fallback: select-all textarea
clipboardFallback(text); clipboardFallback(text);
@@ -505,7 +511,7 @@
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
if (typeof UI !== 'undefined') UI.toast('Downloaded ' + filename, 'success'); if (window.sw && sw.toast) sw.toast('Downloaded ' + filename, 'success');
} }
} }
@@ -517,7 +523,7 @@
ta.select(); ta.select();
try { document.execCommand('copy'); } catch (e) { /* give up */ } try { document.execCommand('copy'); } catch (e) { /* give up */ }
document.body.removeChild(ta); document.body.removeChild(ta);
if (typeof UI !== 'undefined') UI.toast('Report copied (fallback)', 'info'); if (window.sw && sw.toast) sw.toast('Report copied (fallback)', 'info');
} }
@@ -525,15 +531,15 @@
T.runSuite = async function (which) { T.runSuite = async function (which) {
if (T.running) return; if (T.running) return;
if (which === 'authz' && !T.fixtures.ready) { if (which === 'authz' && !T.fixtures.ready) {
if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first (button on the right)', 'warning'); if (window.sw && sw.toast) sw.toast('Provision test fixtures first (button on the right)', 'warning');
return; return;
} }
if (which === 'security' && !T.fixtures.ready) { if (which === 'security' && !T.fixtures.ready) {
if (typeof UI !== 'undefined') UI.toast('Provision test fixtures first (button on the right)', 'warning'); if (window.sw && sw.toast) sw.toast('Provision test fixtures first (button on the right)', 'warning');
return; return;
} }
if (which === 'provider' && !T.providerSetup.configured) { if (which === 'provider' && !T.providerSetup.configured) {
if (typeof UI !== 'undefined') UI.toast('Configure a provider (type + API key) in the panel above', 'warning'); if (window.sw && sw.toast) sw.toast('Configure a provider (type + API key) in the panel above', 'warning');
return; return;
} }
T.running = true; T.running = true;
@@ -571,7 +577,7 @@
var critical = T.results.filter(function (r) { return r.status === 'fail' && r.detail && r.detail.indexOf('CRITICAL') !== -1; }).length; var critical = T.results.filter(function (r) { return r.status === 'fail' && r.detail && r.detail.indexOf('CRITICAL') !== -1; }).length;
var msg = pass + ' passed, ' + fail + ' failed'; var msg = pass + ' passed, ' + fail + ' failed';
if (critical > 0) msg += ' (' + critical + ' CRITICAL)'; if (critical > 0) msg += ' (' + critical + ' CRITICAL)';
UI.toast(msg, critical > 0 ? 'error' : fail > 0 ? 'warning' : 'success'); if (window.sw && sw.toast) sw.toast(msg, critical > 0 ? 'error' : fail > 0 ? 'warning' : 'success');
} }
} }

View File

@@ -138,7 +138,8 @@ INSERT INTO global_settings (key, value) VALUES
"utility": { "primary": null, "fallback": null }, "utility": { "primary": null, "fallback": null },
"embedding": { "primary": null, "fallback": null }, "embedding": { "primary": null, "fallback": null },
"generation": { "primary": null, "fallback": null } "generation": { "primary": null, "fallback": null }
}'::jsonb) }'::jsonb),
('retention_ttl_days', '{"value": 0}'::jsonb)
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;

View File

@@ -81,6 +81,9 @@ CREATE TABLE IF NOT EXISTS channels (
project_id UUID, project_id UUID,
workspace_id UUID, workspace_id UUID,
-- Retention (v0.37.14)
purge_after TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
); );
@@ -95,6 +98,7 @@ CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id) WHERE pr
CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id) WHERE workspace_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id) WHERE workspace_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_channels_workflow ON channels(workflow_id) WHERE workflow_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_channels_workflow ON channels(workflow_id) WHERE workflow_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_channels_workflow_status ON channels(workflow_status) WHERE workflow_status = 'active'; CREATE INDEX IF NOT EXISTS idx_channels_workflow_status ON channels(workflow_status) WHERE workflow_status = 'active';
CREATE INDEX IF NOT EXISTS idx_channels_purge_after ON channels(purge_after) WHERE purge_after IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_channels_workflow_active ON channels(workflow_id, workflow_status) WHERE workflow_id IS NOT NULL AND workflow_status = 'active'; CREATE INDEX IF NOT EXISTS idx_channels_workflow_active ON channels(workflow_id, workflow_status) WHERE workflow_id IS NOT NULL AND workflow_status = 'active';
DROP TRIGGER IF EXISTS channels_updated_at ON channels; DROP TRIGGER IF EXISTS channels_updated_at ON channels;

View File

@@ -78,7 +78,8 @@ INSERT OR IGNORE INTO global_settings (key, value) VALUES
('registration', '{"enabled": true}'), ('registration', '{"enabled": true}'),
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'), ('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'),
('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'), ('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'),
('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}'); ('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}'),
('retention_ttl_days', '{"value": 0}');
CREATE TABLE IF NOT EXISTS user_presence ( CREATE TABLE IF NOT EXISTS user_presence (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,

View File

@@ -46,6 +46,7 @@ CREATE TABLE IF NOT EXISTS channels (
kb_auto_inject INTEGER NOT NULL DEFAULT 0, kb_auto_inject INTEGER NOT NULL DEFAULT 0,
project_id TEXT, project_id TEXT,
workspace_id TEXT, workspace_id TEXT,
purge_after TEXT,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now'))
); );

View File

@@ -53,6 +53,7 @@ var routeTable = map[string]Direction{
"user.mentioned": DirToClient, // v0.23.2: targeted @mention notification "user.mentioned": DirToClient, // v0.23.2: targeted @mention notification
"typing.user": DirToClient, // v0.23.2: human typing in DM/channel "typing.user": DirToClient, // v0.23.2: human typing in DM/channel
"message.created": DirToClient, // v0.23.2: chained/DM message delivery "message.created": DirToClient, // v0.23.2: chained/DM message delivery
"message.deleted": DirToClient, // v0.37.14: soft-delete broadcast
// System // System
"system.notify": DirToClient, "system.notify": DirToClient,

View File

@@ -251,7 +251,7 @@ func (c *Conn) subscribeToBus() {
} }
// Don't echo typing events back to the sender // Don't echo typing events back to the sender
if e.Label == "chat.typing" || e.ConnID == c.id { if strings.HasPrefix(e.Label, "chat.typing.") || strings.HasPrefix(e.Label, "channel.typing.") {
if e.SenderID == c.userID && e.ConnID == c.id { if e.SenderID == c.userID && e.ConnID == c.id {
return return
} }

View File

@@ -65,7 +65,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"users": users, "total": total}) c.JSON(http.StatusOK, gin.H{"data": users, "total": total})
} }
func (h *AdminHandler) CreateUser(c *gin.Context) { func (h *AdminHandler) CreateUser(c *gin.Context) {
@@ -399,11 +399,11 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
hasAdminPrompt = true hasAdminPrompt = true
} }
// Channel retention mode (v0.23.2 — flexible or retain) // Retention TTL (v0.37.14 — days before purge for global/team channels)
retentionMode := "flexible" retentionTTL := 0
if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil { if ttlCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "retention_ttl_days"); err == nil {
if mode, ok := retCfg["mode"].(string); ok && mode != "" { if v, ok := ttlCfg["value"].(float64); ok {
retentionMode = mode retentionTTL = int(v)
} }
} }
@@ -417,7 +417,7 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
"allow_registration": policies["allow_registration"], "allow_registration": policies["allow_registration"],
"allow_user_byok": policies["allow_user_byok"], "allow_user_byok": policies["allow_user_byok"],
"allow_user_personas": policies["allow_user_personas"], "allow_user_personas": policies["allow_user_personas"],
"channel_retention_mode": retentionMode, "retention_ttl_days": retentionTTL,
}, },
}) })
} }
@@ -459,7 +459,7 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
HasKey: cfg.HasKey(), HasKey: cfg.HasKey(),
} }
} }
c.JSON(http.StatusOK, gin.H{"configs": out}) c.JSON(http.StatusOK, gin.H{"data": out})
} }
func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
@@ -601,7 +601,7 @@ func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return return
} }
c.JSON(http.StatusOK, gin.H{"models": entries}) c.JSON(http.StatusOK, gin.H{"data": entries})
} }
func (h *AdminHandler) FetchModels(c *gin.Context) { func (h *AdminHandler) FetchModels(c *gin.Context) {
@@ -802,19 +802,19 @@ func (h *AdminHandler) ListArchivedChannels(c *gin.Context) {
} }
// Retention config // Retention config
retentionMode := "flexible" retentionTTL := 0
if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil { if ttlCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "retention_ttl_days"); err == nil {
if mode, ok := retCfg["mode"].(string); ok { if v, ok := ttlCfg["value"].(float64); ok {
retentionMode = mode retentionTTL = int(v)
} }
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"channels": channels, "data": channels,
"total": total, "total": total,
"page": page, "page": page,
"per_page": perPage, "per_page": perPage,
"retention_mode": retentionMode, "retention_ttl_days": retentionTTL,
}) })
} }

View File

@@ -64,7 +64,7 @@ func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) {
} }
} }
c.JSON(http.StatusOK, gin.H{"configs": out}) c.JSON(http.StatusOK, gin.H{"data": out})
} }
// GetConfig returns a single config by ID (if user has access). // GetConfig returns a single config by ID (if user has access).
@@ -248,7 +248,7 @@ func (h *ProviderConfigHandler) ListModels(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"models": entries}) c.JSON(http.StatusOK, gin.H{"data": entries})
} }
// FetchModels fetches models from the provider API and auto-enables them. // FetchModels fetches models from the provider API and auto-enables them.

View File

@@ -154,6 +154,9 @@ func (h *AuthHandler) Logout(c *gin.Context) {
h.uekCache.Evict(userID.(string)) h.uekCache.Evict(userID.(string))
} }
// Clear the sb_token cookie so SSR middleware doesn't trust a stale token
c.SetCookie("sb_token", "", -1, "/", "", false, false)
c.JSON(http.StatusOK, gin.H{"message": "logged out"}) c.JSON(http.StatusOK, gin.H{"message": "logged out"})
} }

View File

@@ -46,7 +46,7 @@ func (h *ChannelModelHandler) List(c *gin.Context) {
if roster == nil { if roster == nil {
roster = []models.ChannelModel{} roster = []models.ChannelModel{}
} }
c.JSON(http.StatusOK, gin.H{"models": roster}) c.JSON(http.StatusOK, gin.H{"data": roster})
} }
// ── Add ────────────────────────────────────── // ── Add ──────────────────────────────────────
@@ -110,7 +110,7 @@ func (h *ChannelModelHandler) Add(c *gin.Context) {
// Return the updated roster // Return the updated roster
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} } if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusCreated, gin.H{"models": roster}) c.JSON(http.StatusCreated, gin.H{"data": roster})
} }
// ── Update ─────────────────────────────────── // ── Update ───────────────────────────────────
@@ -177,7 +177,7 @@ func (h *ChannelModelHandler) Update(c *gin.Context) {
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} } if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusOK, gin.H{"models": roster}) c.JSON(http.StatusOK, gin.H{"data": roster})
} }
// ── Delete ─────────────────────────────────── // ── Delete ───────────────────────────────────
@@ -222,7 +222,7 @@ func (h *ChannelModelHandler) Delete(c *gin.Context) {
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} } if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusOK, gin.H{"models": roster}) c.JSON(http.StatusOK, gin.H{"data": roster})
} }
// ── Helpers ────────────────────────────────── // ── Helpers ──────────────────────────────────

View File

@@ -1,14 +1,19 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt"
"log"
"math" "math"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"chat-switchboard/database"
"chat-switchboard/models" "chat-switchboard/models"
"chat-switchboard/store" "chat-switchboard/store"
) )
@@ -218,6 +223,9 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
channels = append(channels, listItemToResponse(item)) channels = append(channels, listItemToResponse(item))
} }
// Resolve DM titles: show the other participant's name, not the creator's label
resolveDMTitles(c.Request.Context(), channels, userID)
SafeJSON(c, http.StatusOK, paginatedResponse{ SafeJSON(c, http.StatusOK, paginatedResponse{
Data: channels, Data: channels,
Page: page, Page: page,
@@ -346,15 +354,29 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
return return
} }
// Verify ownership // Verify ownership (participants can only move to folder)
owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID) owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return return
} }
if !owns { if !owns {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) // Participants may move channels to their own folders
return folderOnly := req.FolderID != nil && req.Title == nil && req.Description == nil &&
req.Model == nil && req.SystemPrompt == nil && req.ProviderConfigID == nil &&
req.IsArchived == nil && req.IsPinned == nil && req.Folder == nil &&
req.Tags == nil && req.WorkspaceID == nil && req.AiMode == nil &&
req.Topic == nil && req.Settings == nil
if !folderOnly {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
// Verify they are a participant
canAccess, _ := h.stores.Channels.UserCanAccess(ctx, channelID, userID)
if !canAccess {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
} }
// Build fields map for store.Update // Build fields map for store.Update
@@ -455,18 +477,51 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
func (h *ChannelHandler) DeleteChannel(c *gin.Context) { func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
channelID := c.Param("id") channelID := c.Param("id")
ctx := c.Request.Context()
n, err := h.stores.Channels.DeleteByOwner(c.Request.Context(), channelID, userID) // Check ownership first (without deleting)
owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"}) c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return return
} }
if n == 0 { if !owns {
// Not the owner — if participant, leave the channel instead of deleting.
res, _ := database.DB.ExecContext(ctx, database.Q(`
DELETE FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2
`), channelID, userID)
if rows, _ := res.RowsAffected(); rows > 0 {
c.JSON(http.StatusOK, gin.H{"message": "left channel"})
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return return
} }
// Clean up storage files (CASCADE already removed PG file rows) // Check if retention policy applies (global/team provider + TTL > 0)
if h.shouldRetain(ctx, channelID) {
ttl := h.retentionTTL(ctx)
purgeAfter := time.Now().Add(time.Duration(ttl) * 24 * time.Hour)
if err := h.stores.Channels.ArchiveForRetention(ctx, channelID, purgeAfter); err != nil {
log.Printf("[retention] ArchiveForRetention(%s) error: %v", channelID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to archive channel"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "channel archived for retention",
"purge_after": purgeAfter.UTC().Format("2006-01-02T15:04:05Z"),
})
return
}
// Exempt — hard delete
n, err := h.stores.Channels.DeleteByOwner(ctx, channelID, userID)
if err != nil || n == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
return
}
if channelDeleteHook != nil { if channelDeleteHook != nil {
go channelDeleteHook(channelID) go channelDeleteHook(channelID)
} }
@@ -474,6 +529,45 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"}) c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
} }
// shouldRetain returns true if a channel uses a global or team provider
// and the retention TTL is configured (> 0).
func (h *ChannelHandler) shouldRetain(ctx context.Context, channelID string) bool {
ttl := h.retentionTTL(ctx)
if ttl <= 0 {
return false
}
// Only BYOK (personal) provider channels are exempt.
// All others — global, team, or no explicit provider — follow retention.
var providerConfigID *string
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT provider_config_id FROM channels WHERE id = $1
`), channelID).Scan(&providerConfigID)
if providerConfigID == nil || *providerConfigID == "" {
return true // no explicit provider → defaults to global → retain
}
cfg, err := h.stores.Providers.GetByID(ctx, *providerConfigID)
if err != nil {
return true // provider deleted (FK SET NULL already handled above) → retain
}
return cfg.Scope != models.ScopePersonal
}
// retentionTTL reads the configured retention TTL in days from global settings.
func (h *ChannelHandler) retentionTTL(ctx context.Context) int {
cfg, err := h.stores.GlobalConfig.Get(ctx, "retention_ttl_days")
if err != nil {
return 0
}
if v, ok := cfg["value"].(float64); ok {
return int(v)
}
return 0
}
// ── Mark Read (v0.23.2) ─────────────────────── // ── Mark Read (v0.23.2) ───────────────────────
func (h *ChannelHandler) MarkRead(c *gin.Context) { func (h *ChannelHandler) MarkRead(c *gin.Context) {
@@ -487,3 +581,60 @@ func (h *ChannelHandler) MarkRead(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true}) c.JSON(http.StatusOK, gin.H{"ok": true})
} }
// ── DM Title Resolution ──────────────────────
// resolveDMTitles replaces DM channel titles with the other participant's
// display name so each user sees "DM <other_person>" instead of the
// creator's original label.
func resolveDMTitles(ctx context.Context, channels []channelResponse, viewerID string) {
// Collect DM channel IDs
var dmIDs []string
dmIdx := map[string]int{} // channel_id → index in channels slice
for i, ch := range channels {
if ch.Type == "dm" {
dmIDs = append(dmIDs, ch.ID)
dmIdx[ch.ID] = i
}
}
if len(dmIDs) == 0 {
return
}
// Query: for each DM, get the OTHER participant's display name
// Uses channel_participants to find the peer, then joins users for name
placeholders := make([]string, len(dmIDs))
args := make([]interface{}, 0, len(dmIDs)+1)
for i, id := range dmIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args = append(args, id)
}
args = append(args, viewerID)
viewerPlaceholder := fmt.Sprintf("$%d", len(args))
query := database.Q(fmt.Sprintf(`
SELECT cp.channel_id,
COALESCE(NULLIF(u.display_name, ''), u.username, 'Unknown')
FROM channel_participants cp
JOIN users u ON u.id = cp.participant_id
WHERE cp.channel_id IN (%s)
AND cp.participant_type = 'user'
AND cp.participant_id != %s
LIMIT %d
`, strings.Join(placeholders, ","), viewerPlaceholder, len(dmIDs)))
rows, err := database.DB.QueryContext(ctx, query, args...)
if err != nil {
return // non-fatal: keep original titles
}
defer rows.Close()
for rows.Next() {
var chID, peerName string
if rows.Scan(&chID, &peerName) == nil {
if idx, ok := dmIdx[chID]; ok {
channels[idx].Title = "DM " + peerName
}
}
}
}

View File

@@ -267,46 +267,14 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1 SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1
`), channelID).Scan(&aiMode) `), channelID).Scan(&aiMode)
if aiMode == "off" { if aiMode == "off" || (aiMode == "mention_only" && extractFirstMention(req.Content) == "") {
c.JSON(http.StatusForbidden, gin.H{"error": "AI responses are disabled for this channel"})
return
}
if aiMode == "mention_only" && extractFirstMention(req.Content) == "" {
// Persist the user message before returning // Persist the user message before returning
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "") msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "")
if err != nil { if err != nil {
log.Printf("[mention_only] Failed to persist user message: %v", err) log.Printf("[ai_skip] Failed to persist user message: %v", err)
} }
// Broadcast via WS to ALL user participants (not just sender)
if h.hub != nil && msgID != "" { if h.hub != nil && msgID != "" {
payload, _ := json.Marshal(map[string]any{ broadcastUserMessage(c.Request.Context(), h.hub, channelID, msgID, req.Content, userID)
"id": msgID,
"channel_id": channelID,
"role": "user",
"content": req.Content,
"user_id": userID,
})
evt := events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
// Send to sender
h.hub.PublishToUser(userID, evt)
// Send to other user participants in the channel
pRows, pErr := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2
`), channelID, userID)
if pErr == nil {
defer pRows.Close()
for pRows.Next() {
var pid string
if pRows.Scan(&pid) == nil {
h.hub.PublishToUser(pid, evt)
}
}
}
} }
c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true, "message_id": msgID}) c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true, "message_id": msgID})
return return
@@ -532,6 +500,11 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
log.Printf("Failed to persist user message: %v", err) log.Printf("Failed to persist user message: %v", err)
} }
// Broadcast user message to all channel participants
if h.hub != nil && msgID != "" {
broadcastUserMessage(c.Request.Context(), h.hub, channelID, msgID, req.Content, userID)
}
// Link files to the persisted message // Link files to the persisted message
if msgID != "" && len(req.FileIDs) > 0 { if msgID != "" && len(req.FileIDs) > 0 {
for _, fID := range req.FileIDs { for _, fID := range req.FileIDs {
@@ -963,7 +936,7 @@ func (h *CompletionHandler) ListTools(c *gin.Context) {
} }
} }
c.JSON(http.StatusOK, gin.H{"tools": result}) c.JSON(http.StatusOK, gin.H{"data": result})
} }
// ── Streaming Completion (SSE) with Tool Loop ── // ── Streaming Completion (SSE) with Tool Loop ──
@@ -985,10 +958,16 @@ func (h *CompletionHandler) streamCompletion(
// Persist assistant response // Persist assistant response
if result.Content != "" { if result.Content != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID); err != nil { asstMsgID, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID)
if err != nil {
log.Printf("Failed to persist assistant message: %v", err) log.Printf("Failed to persist assistant message: %v", err)
} }
// Broadcast assistant message to other channel participants
if h.hub != nil && asstMsgID != "" {
broadcastAssistantMessage(c.Request.Context(), h.hub, channelID, asstMsgID, result.Content, model, userID, personaID)
}
// AI-to-AI chaining: if the response @mentions another persona participant, // AI-to-AI chaining: if the response @mentions another persona participant,
// trigger a follow-up completion asynchronously via WebSocket delivery. // trigger a follow-up completion asynchronously via WebSocket delivery.
go func() { go func() {

View File

@@ -160,26 +160,9 @@ func (h *CompletionHandler) chainIfMentioned(
return return
} }
// Deliver via WebSocket // Deliver via WebSocket to all channel participants
if h.hub != nil { if h.hub != nil {
payload, _ := json.Marshal(map[string]interface{}{ broadcastAssistantMessage(context.Background(), h.hub, channelID, msgID, resp.Content, model, "", mentionPersona.ID)
"id": msgID,
"channel_id": channelID,
"role": "assistant",
"content": resp.Content,
"model": model,
"participant_type": "persona",
"participant_id": mentionPersona.ID,
"display_name": mentionPersona.Name,
"avatar": mentionPersona.Avatar,
"tokens_used": resp.InputTokens + resp.OutputTokens,
"chain_depth": depth + 1,
})
h.hub.PublishToUser(userID, events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
} }
// Log usage // Log usage
@@ -301,24 +284,7 @@ func (h *CompletionHandler) chainToPersona(channelID, userID, personaID, trigger
} }
if h.hub != nil { if h.hub != nil {
payload, _ := json.Marshal(map[string]interface{}{ broadcastAssistantMessage(context.Background(), h.hub, channelID, msgID, resp.Content, model, "", persona.ID)
"id": msgID,
"channel_id": channelID,
"role": "assistant",
"content": resp.Content,
"model": model,
"participant_type": "persona",
"participant_id": persona.ID,
"display_name": persona.Name,
"avatar": persona.Avatar,
"tokens_used": resp.InputTokens + resp.OutputTokens,
"chain_depth": depth,
})
h.hub.PublishToUser(userID, events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
} }
if h.stores.Usage != nil { if h.stores.Usage != nil {

View File

@@ -5,6 +5,7 @@ package handlers
// v0.29.0: Raw SQL replaced with FolderStore methods. // v0.29.0: Raw SQL replaced with FolderStore methods.
import ( import (
"encoding/json"
"net/http" "net/http"
"strings" "strings"
@@ -30,14 +31,15 @@ func (h *FolderHandler) List(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
return return
} }
c.JSON(http.StatusOK, gin.H{"folders": folders}) c.JSON(http.StatusOK, gin.H{"data": folders})
} }
func (h *FolderHandler) Create(c *gin.Context) { func (h *FolderHandler) Create(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
var req struct { var req struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
SortOrder int `json:"sort_order"` ParentID *string `json:"parent_id"`
SortOrder int `json:"sort_order"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -52,6 +54,7 @@ func (h *FolderHandler) Create(c *gin.Context) {
f := &models.Folder{ f := &models.Folder{
UserID: userID, UserID: userID,
Name: req.Name, Name: req.Name,
ParentID: req.ParentID,
SortOrder: req.SortOrder, SortOrder: req.SortOrder,
} }
if err := h.stores.Folders.Create(c.Request.Context(), f); err != nil { if err := h.stores.Folders.Create(c.Request.Context(), f); err != nil {
@@ -64,11 +67,19 @@ func (h *FolderHandler) Create(c *gin.Context) {
func (h *FolderHandler) Update(c *gin.Context) { func (h *FolderHandler) Update(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
folderID := c.Param("id") folderID := c.Param("id")
var req struct {
Name string `json:"name"` // Read raw body to detect which fields were explicitly sent
SortOrder *int `json:"sort_order"` data, err := c.GetRawData()
if err != nil || len(data) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
} }
if err := c.ShouldBindJSON(&req); err != nil { var req struct {
Name string `json:"name"`
SortOrder *int `json:"sort_order"`
ParentID *string `json:"parent_id"`
}
if err := json.Unmarshal(data, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -76,7 +87,15 @@ func (h *FolderHandler) Update(c *gin.Context) {
req.Name = strings.TrimSpace(req.Name) req.Name = strings.TrimSpace(req.Name)
} }
n, err := h.stores.Folders.Update(c.Request.Context(), folderID, userID, req.Name, req.SortOrder) // Only update parent_id if it was explicitly present in the JSON
var parentID **string
var raw map[string]json.RawMessage
_ = json.Unmarshal(data, &raw)
if _, ok := raw["parent_id"]; ok {
parentID = &req.ParentID
}
n, err := h.stores.Folders.Update(c.Request.Context(), folderID, userID, req.Name, req.SortOrder, parentID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update folder"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update folder"})
return return

View File

@@ -252,6 +252,10 @@ func setupHarness(t *testing.T) *testHarness {
permH := NewProfilePermissionsHandler(stores) permH := NewProfilePermissionsHandler(stores)
protected.GET("/profile/permissions", permH.GetMyPermissions) protected.GET("/profile/permissions", permH.GetMyPermissions)
// Boot payload (v0.37.15)
bootH := NewProfileBootstrapHandler(stores)
protected.GET("/profile/bootstrap", bootH.GetBootstrap)
// Profile / Settings // Profile / Settings
settings := NewSettingsHandler(stores, nil) settings := NewSettingsHandler(stores, nil)
protected.GET("/profile", settings.GetProfile) protected.GET("/profile", settings.GetProfile)
@@ -518,6 +522,10 @@ func (h *testHarness) registerUser(username, email, password string) (userID, to
var resp map[string]interface{} var resp map[string]interface{}
decode(w, &resp) decode(w, &resp)
token, _ = resp["access_token"].(string) token, _ = resp["access_token"].(string)
if token == "" {
h.t.Fatalf("register %s: no access_token in response (user inactive? default_user_active policy may not have committed): %s",
username, w.Body.String())
}
// Extract user_id from profile // Extract user_id from profile
w2 := h.request("GET", "/api/v1/profile", token, nil) w2 := h.request("GET", "/api/v1/profile", token, nil)
@@ -605,9 +613,9 @@ func TestIntegration_AdminListUsers(t *testing.T) {
} }
var resp map[string]interface{} var resp map[string]interface{}
decode(w, &resp) decode(w, &resp)
users, ok := resp["users"].([]interface{}) users, ok := resp["data"].([]interface{})
if !ok { if !ok {
t.Fatalf("response must have 'users' array, got %T", resp["users"]) t.Fatalf("response must have 'data' array, got %T", resp["data"])
} }
if len(users) < 1 { if len(users) < 1 {
t.Fatal("should have at least 1 user (admin)") t.Fatal("should have at least 1 user (admin)")
@@ -906,10 +914,10 @@ func TestIntegration_TeamMemberManagement(t *testing.T) {
} }
var usersResp map[string]interface{} var usersResp map[string]interface{}
decode(w, &usersResp) decode(w, &usersResp)
if _, ok := usersResp["users"]; !ok { if _, ok := usersResp["data"]; !ok {
t.Fatal("admin/users response MUST have 'users' key (not 'data')") t.Fatal("admin/users response MUST have 'data' key")
} }
users := usersResp["users"].([]interface{}) users := usersResp["data"].([]interface{})
if len(users) < 2 { if len(users) < 2 {
t.Fatalf("expected at least 2 users (admin + alice), got %d", len(users)) t.Fatalf("expected at least 2 users (admin + alice), got %d", len(users))
} }
@@ -1166,13 +1174,13 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) {
} }
var adminResp map[string]interface{} var adminResp map[string]interface{}
decode(w, &adminResp) decode(w, &adminResp)
adminModels := adminResp["models"].([]interface{}) adminModels := adminResp["data"].([]interface{})
if len(adminModels) != 3 { if len(adminModels) != 3 {
t.Fatalf("admin should see 3 disabled models, got %d", len(adminModels)) t.Fatalf("admin should see 3 disabled models, got %d", len(adminModels))
} }
// Verify admin response is non-null array (not {"models": null}) // Verify admin response is non-null array (not {"data": null})
if adminResp["models"] == nil { if adminResp["data"] == nil {
t.Fatal("admin models must be [] not null — causes frontend fallback chain to break") t.Fatal("admin models must be [] not null — causes frontend fallback chain to break")
} }
@@ -1863,7 +1871,7 @@ func TestUserJourney_FullMatrix(t *testing.T) {
} }
var resp map[string]interface{} var resp map[string]interface{}
decode(w, &resp) decode(w, &resp)
allModels := resp["models"].([]interface{}) allModels := resp["data"].([]interface{})
// Admin should see only global-scope models (3), not team(2) or BYOK(2) // Admin should see only global-scope models (3), not team(2) or BYOK(2)
if len(allModels) != 3 { if len(allModels) != 3 {
t.Fatalf("admin/models should show 3 global entries, got %d", len(allModels)) t.Fatalf("admin/models should show 3 global entries, got %d", len(allModels))
@@ -2004,12 +2012,8 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
} }
var roleResp map[string]interface{} var roleResp map[string]interface{}
decode(w, &roleResp) decode(w, &roleResp)
roleData, _ := roleResp["data"].(map[string]interface{}) if len(roleResp) != 0 {
if roleData == nil { t.Fatalf("team roles should be empty initially, got %d", len(roleResp))
roleData = map[string]interface{}{}
}
if len(roleData) != 0 {
t.Fatalf("team roles should be empty initially, got %d", len(roleData))
} }
// Set team role override // Set team role override
@@ -2028,11 +2032,10 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil) w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
roleResp = map[string]interface{}{} roleResp = map[string]interface{}{}
decode(w, &roleResp) decode(w, &roleResp)
roleData, _ = roleResp["data"].(map[string]interface{}) if len(roleResp) == 0 {
if roleData == nil || len(roleData) == 0 {
t.Fatal("team roles should have utility after update") t.Fatal("team roles should have utility after update")
} }
if _, ok := roleData["utility"]; !ok { if _, ok := roleResp["utility"]; !ok {
t.Fatal("team roles should have utility after update") t.Fatal("team roles should have utility after update")
} }
@@ -2046,11 +2049,8 @@ func TestIntegration_TeamRoles_CRUD(t *testing.T) {
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil) w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
roleResp = map[string]interface{}{} roleResp = map[string]interface{}{}
decode(w, &roleResp) decode(w, &roleResp)
roleData, _ = roleResp["data"].(map[string]interface{}) if _, ok := roleResp["utility"]; ok {
if roleData != nil { t.Fatal("team roles should not have utility after delete")
if _, ok := roleData["utility"]; ok {
t.Fatal("team roles should not have utility after delete")
}
} }
} }
@@ -3749,7 +3749,7 @@ func TestIntegration_Messages_TreePath(t *testing.T) {
} }
var pathEnv map[string]interface{} var pathEnv map[string]interface{}
decode(w, &pathEnv) decode(w, &pathEnv)
pathResp := pathEnv["messages"].([]interface{}) pathResp := pathEnv["data"].([]interface{})
if len(pathResp) != 3 { if len(pathResp) != 3 {
t.Fatalf("expected 3 messages in path, got %d", len(pathResp)) t.Fatalf("expected 3 messages in path, got %d", len(pathResp))
} }
@@ -4291,8 +4291,8 @@ func TestAudit_TeamProvidersEnvelope(t *testing.T) {
} }
// ── H9: Team Roles Envelope ──────────────── // ── H9: Team Roles Envelope ────────────────
// BUG: ListTeamRoles returns bare map, not wrapped in {"data": ...}. // Roles endpoints return composite named-key maps (not lists).
// Convention: composite endpoints return object directly, no {"data": ...} wrapper.
func TestAudit_TeamRolesEnvelope(t *testing.T) { func TestAudit_TeamRolesEnvelope(t *testing.T) {
h := setupHarness(t) h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com") _, adminToken := h.createAdminUser("admin", "admin@test.com")
@@ -4318,10 +4318,10 @@ func TestAudit_TeamRolesEnvelope(t *testing.T) {
var resp map[string]interface{} var resp map[string]interface{}
decode(w, &resp) decode(w, &resp)
// Should be wrapped in {"data": {...}} per composite convention // Composite endpoint — must NOT have a "data" wrapper
if _, ok := resp["data"]; !ok { if _, ok := resp["data"]; ok {
t.Fatalf("H9 BUG: GET /teams/:teamId/roles should return 'data' key (composite convention), "+ t.Fatalf("GET /teams/:teamId/roles should return named keys directly (composite convention), "+
"got keys: %v — bare map returned without wrapper", mapKeys(resp)) "not wrapped in {\"data\": ...}")
} }
} }

View File

@@ -197,7 +197,7 @@ func trySetupProvider(h *testHarness, adminToken string, pc liveProviderConfig)
var fallbackCatalogID, fallbackModelID string var fallbackCatalogID, fallbackModelID string
var toolCapableCatalogID, toolCapableModelID string var toolCapableCatalogID, toolCapableModelID string
for _, raw := range modelsResp["models"].([]interface{}) { for _, raw := range modelsResp["data"].([]interface{}) {
m := raw.(map[string]interface{}) m := raw.(map[string]interface{})
if m["visibility"].(string) != "disabled" { if m["visibility"].(string) != "disabled" {
continue continue
@@ -318,7 +318,7 @@ func setupProviderWithModel(t *testing.T, h *testHarness, adminToken string, pc
var catalogID, modelID string var catalogID, modelID string
var fallbackCatalogID, fallbackModelID string var fallbackCatalogID, fallbackModelID string
for _, raw := range modelsResp["models"].([]interface{}) { for _, raw := range modelsResp["data"].([]interface{}) {
m := raw.(map[string]interface{}) m := raw.(map[string]interface{})
if m["visibility"].(string) != "disabled" { if m["visibility"].(string) != "disabled" {
continue continue
@@ -410,7 +410,7 @@ func TestLive_ProviderFullFlow(t *testing.T) {
w = h.request("GET", "/api/v1/admin/models", adminToken, nil) w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var modelsResp map[string]interface{} var modelsResp map[string]interface{}
decode(w, &modelsResp) decode(w, &modelsResp)
catalogModels := modelsResp["models"].([]interface{}) catalogModels := modelsResp["data"].([]interface{})
var enableID, enableModelID string var enableID, enableModelID string
var fallbackID, fallbackModelID string var fallbackID, fallbackModelID string
@@ -518,7 +518,7 @@ func TestLive_FetchModelsCapabilities(t *testing.T) {
var resp map[string]interface{} var resp map[string]interface{}
decode(w, &resp) decode(w, &resp)
for _, raw := range resp["models"].([]interface{}) { for _, raw := range resp["data"].([]interface{}) {
m := raw.(map[string]interface{}) m := raw.(map[string]interface{})
caps, ok := m["capabilities"].(map[string]interface{}) caps, ok := m["capabilities"].(map[string]interface{})
if !ok { if !ok {

View File

@@ -1,17 +1,20 @@
package handlers package handlers
import ( import (
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"math" "math"
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
capspkg "chat-switchboard/capabilities" capspkg "chat-switchboard/capabilities"
"chat-switchboard/crypto" "chat-switchboard/crypto"
"chat-switchboard/database"
"chat-switchboard/events" "chat-switchboard/events"
"chat-switchboard/models" "chat-switchboard/models"
"chat-switchboard/providers" "chat-switchboard/providers"
@@ -153,7 +156,7 @@ func (h *MessageHandler) GetActivePath(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"messages": path}) c.JSON(http.StatusOK, gin.H{"data": path})
} }
// ── Create Message (manual) ───────────────── // ── Create Message (manual) ─────────────────
@@ -218,6 +221,11 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
return return
} }
// Broadcast via WS to channel participants
if h.hub != nil && msg.Role == "user" {
broadcastUserMessage(c.Request.Context(), h.hub, channelID, msg.ID, msg.Content, userID)
}
resp := messageResponse{ resp := messageResponse{
ID: msg.ID, ID: msg.ID,
ChannelID: msg.ChannelID, ChannelID: msg.ChannelID,
@@ -579,7 +587,7 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"messages": path, "active_leaf_id": leafID}) c.JSON(http.StatusOK, gin.H{"data": path, "active_leaf_id": leafID})
} }
// ── List Siblings ─────────────────────────── // ── List Siblings ───────────────────────────
@@ -638,3 +646,157 @@ func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
} }
return true return true
} }
// broadcastUserMessage publishes a message.created event to all user
// participants in the channel via WebSocket.
func broadcastUserMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, senderID string) {
// Look up sender display name
var displayName, username string
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT COALESCE(display_name, ''), COALESCE(username, '') FROM users WHERE id = $1
`), senderID).Scan(&displayName, &username)
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
"role": "user",
"content": content,
"user_id": senderID,
"display_name": displayName,
"username": username,
"created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
evt := events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
// Send to all user participants in the channel
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
// Fallback: at least send to the sender
hub.PublishToUser(senderID, evt)
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil {
hub.PublishToUser(pid, evt)
}
}
}
// broadcastAssistantMessage publishes a message.created event for an assistant
// message to all user participants in the channel (except the requesting user,
// who already received the response via SSE).
func broadcastAssistantMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, model, senderUserID, personaID string) {
// Look up persona display name if available
var displayName, avatar string
if personaID != "" {
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT COALESCE(name, ''), COALESCE(avatar, '') FROM personas WHERE id = $1
`), personaID).Scan(&displayName, &avatar)
}
if displayName == "" {
displayName = model
}
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
"role": "assistant",
"content": content,
"model": model,
"participant_type": "persona",
"participant_id": personaID,
"display_name": displayName,
"avatar": avatar,
"created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
evt := events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
// Send to all user participants except the sender (who got SSE)
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil && pid != senderUserID {
hub.PublishToUser(pid, evt)
}
}
}
// ── Delete Message ──────────────────────────────────────────
// DeleteMessage soft-deletes a message.
// DELETE /channels/:id/messages/:msgId
func (h *MessageHandler) DeleteMessage(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
msgID := c.Param("msgId")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Verify message belongs to this channel and is not already deleted
var exists bool
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL)
`), msgID, channelID).Scan(&exists)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
if err := h.stores.Messages.Delete(c.Request.Context(), msgID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete message"})
return
}
// Broadcast to channel participants (exclude sender — they already removed it)
broadcastMessageDeleted(c.Request.Context(), h.hub, channelID, msgID, userID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// broadcastMessageDeleted publishes a message.deleted event to all user
// participants in the channel except the sender.
func broadcastMessageDeleted(ctx context.Context, hub *events.Hub, channelID, msgID, senderID string) {
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
})
evt := events.Event{
Label: "message.deleted",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil && pid != senderID {
hub.PublishToUser(pid, evt)
}
}
}

View File

@@ -458,7 +458,7 @@ func (h *NoteHandler) ListFolders(c *gin.Context) {
folders = append(folders, folderInfo{Path: f.Path, Count: f.Count}) folders = append(folders, folderInfo{Path: f.Path, Count: f.Count})
} }
c.JSON(http.StatusOK, gin.H{"folders": folders}) c.JSON(http.StatusOK, gin.H{"data": folders})
} }
// ── Helpers ───────────────────────────────── // ── Helpers ─────────────────────────────────

View File

@@ -101,7 +101,7 @@ func (h *RegistryHandler) BrowseRegistry(c *gin.Context) {
url := h.getRegistryURL(c) url := h.getRegistryURL(c)
if url == "" { if url == "" {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"packages": []RegistryEntry{}, "data":[]RegistryEntry{},
"registry_url": "", "registry_url": "",
}) })
return return
@@ -113,7 +113,7 @@ func (h *RegistryHandler) BrowseRegistry(c *gin.Context) {
data := h.cacheData data := h.cacheData
h.cacheMu.RUnlock() h.cacheMu.RUnlock()
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"packages": data.Packages, "data":data.Packages,
"registry_url": url, "registry_url": url,
}) })
return return
@@ -156,7 +156,7 @@ func (h *RegistryHandler) BrowseRegistry(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"packages": entries, "data":entries,
"registry_url": url, "registry_url": url,
}) })
} }

View File

@@ -560,7 +560,7 @@ func (h *PackageHandler) UpdatePackageSettings(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"values": json.RawMessage(settingsJSON)}) c.JSON(http.StatusOK, gin.H{"data": json.RawMessage(settingsJSON)})
} }
// validateSettingType checks if a value matches the expected setting type. // validateSettingType checks if a value matches the expected setting type.

View File

@@ -46,7 +46,7 @@ func (h *ParticipantHandler) List(c *gin.Context) {
if participants == nil { if participants == nil {
participants = []models.ChannelParticipant{} participants = []models.ChannelParticipant{}
} }
c.JSON(http.StatusOK, gin.H{"participants": participants}) c.JSON(http.StatusOK, gin.H{"data": participants})
} }
// ── Add ────────────────────────────────────── // ── Add ──────────────────────────────────────
@@ -167,7 +167,7 @@ func (h *ParticipantHandler) Add(c *gin.Context) {
// Return updated participant list // Return updated participant list
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusCreated, gin.H{"participants": participants}) c.JSON(http.StatusCreated, gin.H{"data": participants})
} }
// ── Update Role ───────────────────────────── // ── Update Role ─────────────────────────────
@@ -220,7 +220,7 @@ func (h *ParticipantHandler) Update(c *gin.Context) {
} }
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"participants": participants}) c.JSON(http.StatusOK, gin.H{"data": participants})
} }
// ── Remove ────────────────────────────────── // ── Remove ──────────────────────────────────
@@ -289,7 +289,7 @@ func (h *ParticipantHandler) Remove(c *gin.Context) {
} }
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"participants": participants}) c.JSON(http.StatusOK, gin.H{"data": participants})
} }
// ── Helpers ────────────────────────────────── // ── Helpers ──────────────────────────────────

View File

@@ -80,5 +80,5 @@ func (h *PresenceHandler) SearchUsers(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return return
} }
c.JSON(http.StatusOK, gin.H{"users": results}) c.JSON(http.StatusOK, gin.H{"data": results})
} }

View File

@@ -0,0 +1,117 @@
package handlers
// profile_bootstrap.go — Single-call boot payload for the SDK.
//
// GET /api/v1/profile/bootstrap
//
// Collapses what previously required 3-4 sequential requests
// (profile, permissions, teams/mine, settings) into one call.
// The SDK calls this at startup and on token refresh.
//
// v0.37.15
import (
"net/http"
"sort"
"github.com/gin-gonic/gin"
"chat-switchboard/auth"
"chat-switchboard/store"
)
// ProfileBootstrapHandler serves the combined boot payload.
type ProfileBootstrapHandler struct {
stores store.Stores
}
func NewProfileBootstrapHandler(s store.Stores) *ProfileBootstrapHandler {
return &ProfileBootstrapHandler{stores: s}
}
// GetBootstrap returns everything the shell needs at startup.
// GET /api/v1/profile/bootstrap
func (h *ProfileBootstrapHandler) GetBootstrap(c *gin.Context) {
userID := getUserID(c)
role, _ := c.Get("role")
ctx := c.Request.Context()
// ── User profile ────────────────────────
user, err := h.stores.Users.GetByID(ctx, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
userPayload := gin.H{
"id": user.ID,
"username": user.Username,
"display_name": user.DisplayName,
"email": user.Email,
"role": user.Role,
}
if user.AvatarURL != "" {
userPayload["avatar"] = user.AvatarURL
}
// ── Permissions ─────────────────────────
var permList []string
if role == "admin" {
permList = make([]string, len(auth.AllPermissions))
copy(permList, auth.AllPermissions)
} else {
perms, err := auth.ResolvePermissions(ctx, h.stores, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"})
return
}
permList = make([]string, 0, len(perms))
for p := range perms {
permList = append(permList, p)
}
}
sort.Strings(permList)
// ── Groups ──────────────────────────────
groupIDs, _ := h.stores.Groups.GetUserGroupIDs(ctx, userID)
if groupIDs == nil {
groupIDs = []string{}
}
groupIDs = append(groupIDs, auth.EveryoneGroupID)
// ── Teams ───────────────────────────────
teams, _ := h.stores.Teams.ListForUser(ctx, userID)
teamData := make([]gin.H, 0, len(teams))
for _, t := range teams {
teamData = append(teamData, gin.H{
"id": t.ID,
"name": t.Name,
"my_role": t.MyRole,
})
}
// ── Policies ────────────────────────────
policies := make(map[string]bool)
if ps := h.stores.Policies; ps != nil {
policies["allow_user_byok"], _ = ps.GetBool(ctx, "allow_user_byok")
policies["allow_user_personas"], _ = ps.GetBool(ctx, "allow_user_personas")
policies["allow_raw_model_access"], _ = ps.GetBool(ctx, "allow_raw_model_access")
policies["kb_direct_access"], _ = ps.GetBool(ctx, "kb_direct_access")
}
// ── Settings ────────────────────────────
settings := make(map[string]interface{})
if user.Settings != nil {
settings = user.Settings
}
// ── Response ────────────────────────────
c.JSON(http.StatusOK, gin.H{
"user": userPayload,
"permissions": permList,
"groups": groupIDs,
"teams": teamData,
"policies": policies,
"settings": settings,
})
}

View File

@@ -161,7 +161,7 @@ func (h *RolesHandler) ListTeamRoles(c *gin.Context) {
} }
} }
c.JSON(http.StatusOK, gin.H{"data": roleOverrides}) c.JSON(http.StatusOK, roleOverrides)
} }
// UpdateTeamRole sets a team role override. // UpdateTeamRole sets a team role override.

View File

@@ -326,7 +326,7 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
out = append(out, modelInfo{ID: m.ID, Type: m.Type, Capabilities: caps}) out = append(out, modelInfo{ID: m.ID, Type: m.Type, Capabilities: caps})
} }
c.JSON(http.StatusOK, gin.H{"models": out, "provider": cfg.Name}) c.JSON(http.StatusOK, gin.H{"data": out, "provider": cfg.Name})
} }
// parseJSONBConfig parses a JSONB text string into a map. // parseJSONBConfig parses a JSONB text string into a map.

View File

@@ -428,7 +428,7 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
} }
} }
c.JSON(http.StatusOK, gin.H{"models": result}) c.JSON(http.StatusOK, gin.H{"data": result})
} }
// ── Helpers ───────────────────────────────── // ── Helpers ─────────────────────────────────

View File

@@ -35,6 +35,7 @@ import (
"chat-switchboard/notifications" "chat-switchboard/notifications"
"chat-switchboard/pages" "chat-switchboard/pages"
"chat-switchboard/providers" "chat-switchboard/providers"
"chat-switchboard/retention"
"chat-switchboard/roles" "chat-switchboard/roles"
"chat-switchboard/routing" "chat-switchboard/routing"
"chat-switchboard/scheduler" "chat-switchboard/scheduler"
@@ -234,6 +235,11 @@ func main() {
}() }()
} }
// v0.37.14: Channel retention scanner — purges archived channels past their TTL
retScanner := retention.NewScanner(stores, objStore, retention.ScannerConfig{})
retScanner.Start()
defer retScanner.Stop()
// v0.27.2: Task scheduler startup deferred to after hub/notification init — see below. // v0.27.2: Task scheduler startup deferred to after hub/notification init — see below.
// Bootstrap admin from env (K8s secret) — upserts on every restart // Bootstrap admin from env (K8s secret) — upserts on every restart
@@ -751,6 +757,7 @@ func main() {
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage) protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate) protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings) protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
protected.DELETE("/channels/:id/messages/:msgId", msgs.DeleteMessage)
// Chat Completions // Chat Completions
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder) comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder)
@@ -824,6 +831,10 @@ func main() {
permH := handlers.NewProfilePermissionsHandler(stores) permH := handlers.NewProfilePermissionsHandler(stores)
protected.GET("/profile/permissions", permH.GetMyPermissions) protected.GET("/profile/permissions", permH.GetMyPermissions)
// Boot payload (v0.37.15) — single-call SDK bootstrap
bootH := handlers.NewProfileBootstrapHandler(stores)
protected.GET("/profile/bootstrap", bootH.GetBootstrap)
// Usage (personal) // Usage (personal)
usage := handlers.NewUsageHandler(stores) usage := handlers.NewUsageHandler(stores)
protected.GET("/usage", usage.PersonalUsage) protected.GET("/usage", usage.PersonalUsage)

View File

@@ -328,8 +328,9 @@ type Channel struct {
Model string `json:"model,omitempty" db:"model"` Model string `json:"model,omitempty" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"` SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
IsArchived bool `json:"is_archived" db:"is_archived"` IsArchived bool `json:"is_archived" db:"is_archived"`
IsPinned bool `json:"is_pinned" db:"is_pinned"` IsPinned bool `json:"is_pinned" db:"is_pinned"`
PurgeAfter *time.Time `json:"purge_after,omitempty" db:"purge_after"`
FolderID *string `json:"folder_id,omitempty" db:"folder_id"` FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
TeamID *string `json:"team_id,omitempty" db:"team_id"` TeamID *string `json:"team_id,omitempty" db:"team_id"`
ProjectID *string `json:"project_id,omitempty" db:"project_id"` ProjectID *string `json:"project_id,omitempty" db:"project_id"`

View File

@@ -149,7 +149,7 @@ func (e *Engine) adminLoader(c *gin.Context, s store.Stores) (any, error) {
ctx := context.Background() ctx := context.Background()
section := c.Param("section") section := c.Param("section")
if section == "" { if section == "" {
section = "overview" section = "users"
} }
data := &AdminPageData{ data := &AdminPageData{

View File

@@ -68,9 +68,24 @@ type BannerConfig struct {
Visible bool `json:"visible"` Visible bool `json:"visible"`
} }
// MessageConfig holds dismissible message bar settings.
type MessageConfig struct {
Text string `json:"text"`
Variant string `json:"variant"` // info, warn, error, success
Visible bool `json:"visible"`
}
// FooterConfig holds optional footer bar settings.
type FooterConfig struct {
Text string `json:"text"`
Visible bool `json:"visible"`
}
// PageData is passed to every template render. // PageData is passed to every template render.
type PageData struct { type PageData struct {
Banner BannerConfig Banner BannerConfig
Message MessageConfig
Footer FooterConfig
Surface string // active surface ID Surface string // active surface ID
Section string // sub-section (for admin pages) Section string // sub-section (for admin pages)
CSPNonce string CSPNonce string
@@ -300,6 +315,8 @@ func (e *Engine) Render(c *gin.Context, name string, data PageData) {
data.Environment = e.cfg.Environment data.Environment = e.cfg.Environment
data.CSPNonce = generateNonce() data.CSPNonce = generateNonce()
data.Banner = e.loadBanner() data.Banner = e.loadBanner()
data.Message = e.loadMessage()
data.Footer = e.loadFooter()
// v0.22.7: Default theme if not set // v0.22.7: Default theme if not set
if data.Theme == "" { if data.Theme == "" {
@@ -336,7 +353,7 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
section := c.Param("section") section := c.Param("section")
if section == "" && surfaceID == "admin" { if section == "" && surfaceID == "admin" {
section = "overview" section = "users"
} }
if section == "" && surfaceID == "settings" { if section == "" && surfaceID == "settings" {
section = "general" section = "general"
@@ -837,6 +854,50 @@ func (e *Engine) loadBanner() BannerConfig {
return b return b
} }
// loadMessage reads dismissible message bar config from global settings.
func (e *Engine) loadMessage() MessageConfig {
if e.stores.GlobalConfig == nil {
return MessageConfig{}
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "message")
if err != nil || raw == nil {
return MessageConfig{}
}
m := MessageConfig{}
if v, ok := raw["enabled"].(bool); ok {
m.Visible = v
}
if v, ok := raw["text"].(string); ok {
m.Text = v
}
if v, ok := raw["variant"].(string); ok {
m.Variant = v
}
if m.Variant == "" {
m.Variant = "info"
}
return m
}
// loadFooter reads optional footer bar config from global settings.
func (e *Engine) loadFooter() FooterConfig {
if e.stores.GlobalConfig == nil {
return FooterConfig{}
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "footer")
if err != nil || raw == nil {
return FooterConfig{}
}
f := FooterConfig{}
if v, ok := raw["enabled"].(bool); ok {
f.Visible = v
}
if v, ok := raw["text"].(string); ok {
f.Text = v
}
return f
}
// loadBranding reads instance branding from global settings. // loadBranding reads instance branding from global settings.
func (e *Engine) loadBranding() (name, logoURL, tagline string) { func (e *Engine) loadBranding() (name, logoURL, tagline string) {
name = "Chat Switchboard" // default name = "Chat Switchboard" // default

View File

@@ -3,7 +3,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
<title>{{block "title" .}}Chat Switchboard{{end}}</title> <title>{{block "title" .}}Chat Switchboard{{end}}</title>
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}"> <link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}"> <link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
@@ -24,6 +24,7 @@
<link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}"> <link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}"> <link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/extension-surface.css?v={{.Version}}"> <link rel="stylesheet" href="{{.BasePath}}/css/extension-surface.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-shell.css?v={{.Version}}">
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}} {{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
{{if eq .Surface "notes"}}{{template "css-notes" .}}{{end}} {{if eq .Surface "notes"}}{{template "css-notes" .}}{{end}}
{{/* v0.27.0: Extension surface CSS — loaded from /surfaces/{id}/css/main.css */}} {{/* v0.27.0: Extension surface CSS — loaded from /surfaces/{id}/css/main.css */}}
@@ -51,6 +52,23 @@
document.documentElement.setAttribute('data-theme', resolved); document.documentElement.setAttribute('data-theme', resolved);
var meta = document.getElementById('metaThemeColor'); var meta = document.getElementById('metaThemeColor');
if (meta) meta.content = resolved === 'light' ? '#f7f7fa' : '#0e0e10'; if (meta) meta.content = resolved === 'light' ? '#f7f7fa' : '#0e0e10';
var fav = document.querySelector('link[rel="icon"][type="image/svg+xml"]');
if (fav) fav.href = fav.href.replace(/favicon(-light)?\.svg/, resolved === 'light' ? 'favicon-light.svg' : 'favicon.svg');
} catch(e) {}
})();
// Early appearance — apply scale + msg font from localStorage on all surfaces.
// Scale uses transform on .surface-inner so shell stays fixed, content fills viewport.
(function() {
try {
var p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
if (p.scale && p.scale !== 100) {
var s = p.scale / 100;
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('surfaceInner');
if (el) { el.style.transform = 'scale('+s+')'; el.style.transformOrigin = 'top left'; el.style.width = (100/s)+'%'; el.style.height = (100/s)+'%'; }
});
}
if (p.msgFont && p.msgFont !== 14) document.documentElement.style.setProperty('--msg-font', p.msgFont + 'px');
} catch(e) {} } catch(e) {}
})(); })();
</script> </script>
@@ -62,6 +80,15 @@
</div> </div>
{{end}} {{end}}
{{if .Message.Visible}}
<div class="sw-shell__announcement" id="shellMessage">
<div class="sw-shell__announcement-inner sw-shell__announcement--{{.Message.Variant}}">
<span class="sw-shell__announcement-text">{{.Message.Text}}</span>
<button class="sw-shell__banner-close" onclick="this.closest('.sw-shell__announcement').remove()" aria-label="Dismiss">&times;</button>
</div>
</div>
{{end}}
<div class="surface" id="surface"> <div class="surface" id="surface">
<div class="surface-inner" id="surfaceInner"> <div class="surface-inner" id="surfaceInner">
{{if eq .Surface "chat"}}{{template "surface-chat" .}} {{if eq .Surface "chat"}}{{template "surface-chat" .}}
@@ -75,6 +102,10 @@
</div> </div>
</div> </div>
{{if .Footer.Visible}}
<div class="sw-shell__footer">{{.Footer.Text}}</div>
{{end}}
{{if .Banner.Visible}} {{if .Banner.Visible}}
<div class="banner banner-bottom active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;"> <div class="banner banner-bottom active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;">
{{.Banner.Text}} {{.Banner.Text}}
@@ -92,32 +123,9 @@
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}} {{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
</script> </script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/sb.js?v={{.Version}}"></script> {{/* v0.37.14 Scorched Earth IV: sb.js, events.js, switchboard-sdk.js,
{{/* v0.37.13 Scorched Earth III: app-state.js, ui-primitives.js, workflow-surfaces.js removed. All surfaces use Preact SDK boot().
user-menu.js, file-tree.js, code-editor.js removed. Survivors: debug.js, repl.js (standalone, no old-layer deps). */}}
Earlier: api.js, ui-core.js, pages.js (v0.37.10),
ui-format.js, virtual-scroll.js, model-selector.js, drag-resize.js (v0.37.12),
chat-pane.js, pane-container.js (v0.37.10). */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/events.js?v={{.Version}}"></script>
{{/* v0.28.5: SDK — composition layer over globals. */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/switchboard-sdk.js?v={{.Version}}"></script>
{{/* ── Universal init: SDK boot for ALL surfaces ── */}}
<script type="module" nonce="{{.CSPNonce}}">
// v0.28.5: SDK handles theme + appearance. Idempotent.
Switchboard.init();
// Universal logout — available on every surface.
// Preact surfaces override with richer versions.
function handleLogout() {
if (!confirm('Sign out?')) return;
if (typeof Events !== 'undefined') { Events.disconnect(); Events.clear(); }
if (typeof API !== 'undefined' && API.logout) API.logout();
location.reload();
}
// Register fallback — app.js re-registers with a richer version.
sb.register('handleLogout', handleLogout);
</script>
{{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}} {{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}}
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}} {{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}
@@ -136,13 +144,13 @@
<div class="modal modal-wide"> <div class="modal modal-wide">
<div class="modal-header"> <div class="modal-header">
<h2>Debug Log</h2> <h2>Debug Log</h2>
<button class="modal-close" onclick="sb.call('closeModal','debugModal')"></button> <button class="modal-close" onclick="closeModal('debugModal')"></button>
</div> </div>
<div class="modal-tabs"> <div class="modal-tabs">
<button class="debug-tab active" data-tab="console" onclick="sb.call('switchDebugTab','console')">Console <span id="debugConsoleCount" class="badge">0</span></button> <button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console <span id="debugConsoleCount" class="badge">0</span></button>
<button class="debug-tab" data-tab="network" onclick="sb.call('switchDebugTab','network')">Network <span id="debugNetworkCount" class="badge">0</span></button> <button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network <span id="debugNetworkCount" class="badge">0</span></button>
<button class="debug-tab" data-tab="state" onclick="sb.call('switchDebugTab','state')">State</button> <button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
<button class="debug-tab" data-tab="repl" onclick="sb.call('switchDebugTab','repl')">REPL</button> <button class="debug-tab" data-tab="repl" onclick="switchDebugTab('repl')">REPL</button>
</div> </div>
<div class="modal-body" style="padding:0;overflow:hidden;display:flex;flex-direction:column;min-height:0;"> <div class="modal-body" style="padding:0;overflow:hidden;display:flex;flex-direction:column;min-height:0;">
<div id="debugConsoleTab" class="debug-tab-content" style="display:flex;flex-direction:column;flex:1;min-height:0;"> <div id="debugConsoleTab" class="debug-tab-content" style="display:flex;flex-direction:column;flex:1;min-height:0;">
@@ -167,20 +175,19 @@
</div> </div>
<div class="modal-footer" style="display:flex;justify-content:space-between;align-items:center;"> <div class="modal-footer" style="display:flex;justify-content:space-between;align-items:center;">
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;">
<button class="btn-danger btn-small" onclick="sb.call('runDebugDiagnostics')">Diagnostics</button> <button class="btn-danger btn-small" onclick="runDebugDiagnostics()">Diagnostics</button>
<button class="btn-danger btn-small" onclick="sb.call('purgeCache')">Purge Cache</button> <button class="btn-danger btn-small" onclick="purgeCache()">Purge Cache</button>
</div> </div>
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;">
<button class="btn-secondary btn-small" onclick="sb.call('clearDebugLog')">Clear</button> <button class="btn-secondary btn-small" onclick="clearDebugLog()">Clear</button>
<button class="btn-secondary btn-small" onclick="sb.call('copyDebugLog')">Copy</button> <button class="btn-secondary btn-small" onclick="copyDebugLog()">Copy</button>
<button class="btn-secondary btn-small" onclick="sb.call('exportDebugLog')">Export</button> <button class="btn-secondary btn-small" onclick="exportDebugLog()">Export</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/debug.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/debug.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/repl.js?v={{.Version}}"></script> <script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/repl.js?v={{.Version}}"></script>
<div id="toastContainer" class="toast-container"></div>
</body> </body>
</html> </html>
{{end}} {{end}}

View File

@@ -47,6 +47,7 @@ window.addEventListener('unhandledrejection', function(e) {
{{define "scripts-chat"}} {{define "scripts-chat"}}
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/marked.min.js"></script> <script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/purify.min.js"></script> <script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/purify.min.js"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/codemirror/codemirror.bundle.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}"> <script type="module" nonce="{{$.CSPNonce}}">
// v0.37.10: Preact boot — same pattern as settings/admin/team-admin surfaces. // v0.37.10: Preact boot — same pattern as settings/admin/team-admin surfaces.
// Vendor modules: no ?v= query — hooks.module.js does a bare // Vendor modules: no ?v= query — hooks.module.js does a bare

View File

@@ -13,9 +13,6 @@
{{define "surface-extension"}} {{define "surface-extension"}}
<div id="extension-surface" class="extension-surface" <div id="extension-surface" class="extension-surface"
data-surface-id="{{.Surface}}"> data-surface-id="{{.Surface}}">
{{/* User menu — standard empty prefix, hydrated by base.html universal init */}}
{{template "user-menu" dict "ID" ""}}
<div id="extension-mount" class="extension-mount"></div> <div id="extension-mount" class="extension-mount"></div>
</div> </div>
{{end}} {{end}}

View File

@@ -47,6 +47,7 @@ window.addEventListener('unhandledrejection', function(e) {
{{define "scripts-notes"}} {{define "scripts-notes"}}
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/marked.min.js"></script> <script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/purify.min.js"></script> <script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/purify.min.js"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/codemirror/codemirror.bundle.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}"> <script type="module" nonce="{{$.CSPNonce}}">
// v0.37.11: Preact boot — same pattern as chat surface. // v0.37.11: Preact boot — same pattern as chat surface.
const { h, render } = await import('{{$.BasePath}}/js/sw/vendor/preact.module.js'); const { h, render } = await import('{{$.BasePath}}/js/sw/vendor/preact.module.js');

View File

@@ -593,32 +593,12 @@
}); });
} }
// ── Custom surface (v0.30.2) ────────── // ── Custom surface (v0.37.14: legacy registry removed) ──────────
if (SURFACE_PKG_ID) { if (SURFACE_PKG_ID) {
(async function() { var mount = document.getElementById('customSurfaceMount');
var mount = document.getElementById('customSurfaceMount'); if (mount) {
if (!mount) return; mount.innerHTML = '<div style="padding:24px;color:var(--danger)">Custom workflow surfaces require an updated package format. The legacy surface registry has been removed.</div>';
mount.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading surface\u2026</div>'; }
try {
// Load workflow-surfaces registry + dependencies
await loadScript(BASE + '/js/ui-primitives.js');
await loadScript(BASE + '/js/workflow-surfaces.js');
// Load the package's surface JS (registers via WorkflowSurfaces.register())
await loadScript(BASE + '/surfaces/' + SURFACE_PKG_ID + '/js/main.js');
// Mount the custom surface
var ctx = { channelId: CHAN_ID, sessionId: SESSION_ID, basePath: BASE,
stageMode: STAGE_MODE, formTemplate: FORM_TPL,
totalStages: TOTAL_STAGES, currentStage: CURRENT_STAGE };
mount.innerHTML = '';
if (typeof WorkflowSurfaces !== 'undefined') {
WorkflowSurfaces.mount(mount, SURFACE_PKG_ID, ctx);
} else {
mount.innerHTML = '<div style="padding:24px;color:var(--danger)">Failed to load surface registry.</div>';
}
} catch(e) {
mount.innerHTML = '<div style="padding:24px;color:var(--danger)">Failed to load surface: ' + escHtml(e.message) + '</div>';
}
})();
} }
function loadScript(src) { function loadScript(src) {

124
server/retention/scanner.go Normal file
View File

@@ -0,0 +1,124 @@
package retention
import (
"context"
"fmt"
"log"
"sync"
"time"
"chat-switchboard/storage"
"chat-switchboard/store"
)
const DefaultInterval = 1 * time.Hour
// ScannerConfig holds startup configuration.
type ScannerConfig struct {
Interval time.Duration
}
// Scanner periodically purges channels whose purge_after timestamp has passed.
// Channels with global/team provider scopes are archived with a TTL by
// DeleteChannel; this scanner performs the deferred hard-delete.
type Scanner struct {
stores store.Stores
objStore storage.ObjectStore
wg sync.WaitGroup
stopCh chan struct{}
interval time.Duration
}
// NewScanner creates a retention scanner. Call Start() to begin.
func NewScanner(stores store.Stores, objStore storage.ObjectStore, cfg ScannerConfig) *Scanner {
interval := cfg.Interval
if interval <= 0 {
interval = DefaultInterval
}
return &Scanner{
stores: stores,
objStore: objStore,
stopCh: make(chan struct{}),
interval: interval,
}
}
// Start begins the scan loop in a background goroutine.
func (sc *Scanner) Start() {
sc.wg.Add(1)
go func() {
defer sc.wg.Done()
sc.loop()
}()
log.Printf("[retention] scanner started (interval=%s)", sc.interval)
}
// Stop signals the scanner to stop and waits for in-flight work to drain.
func (sc *Scanner) Stop() {
close(sc.stopCh)
sc.wg.Wait()
log.Printf("[retention] scanner stopped")
}
func (sc *Scanner) loop() {
ticker := time.NewTicker(sc.interval)
defer ticker.Stop()
for {
select {
case <-sc.stopCh:
return
case <-ticker.C:
sc.tick()
}
}
}
func (sc *Scanner) tick() {
ctx := context.Background()
// If TTL is 0 the feature is disabled — nothing to purge
ttl := sc.retentionTTL(ctx)
if ttl <= 0 {
return
}
ids, err := sc.stores.Channels.ListPurgeable(ctx)
if err != nil {
log.Printf("[retention] ListPurgeable error: %v", err)
return
}
if len(ids) == 0 {
return
}
log.Printf("[retention] purging %d channel(s)", len(ids))
for _, id := range ids {
// Clean up storage files first
if sc.objStore != nil {
prefix := fmt.Sprintf("files/%s", id)
if err := sc.objStore.DeletePrefix(ctx, prefix); err != nil {
log.Printf("[retention] storage cleanup for %s failed: %v", id, err)
}
}
// Hard delete (Purge verifies is_archived)
if err := sc.stores.Channels.Purge(ctx, id); err != nil {
log.Printf("[retention] purge %s failed: %v", id, err)
}
}
}
func (sc *Scanner) retentionTTL(ctx context.Context) int {
cfg, err := sc.stores.GlobalConfig.Get(ctx, "retention_ttl_days")
if err != nil {
return 0
}
if v, ok := cfg["value"].(float64); ok {
return int(v)
}
return 0
}

View File

@@ -738,20 +738,34 @@ paths:
delete: delete:
tags: tags:
- Channels - Channels
summary: Archive a channel summary: Delete or leave a channel
description: |
Owner: deletes (or archives for retention when TTL > 0).
Non-owner participant: leaves the channel.
When retention_ttl_days > 0, all channels are archived and purged after TTL.
Only Personal (BYOK) provider channels are exempt and always immediately deleted.
security: security:
- bearerAuth: [] - bearerAuth: []
parameters: parameters:
- $ref: '#/components/parameters/ResourceID' - $ref: '#/components/parameters/ResourceID'
responses: responses:
'200': '200':
description: Archived description: Deleted, archived for retention, or left channel
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/MessageResponse' type: object
'400': properties:
$ref: '#/components/responses/BadRequest' message:
type: string
enum:
- channel deleted
- channel archived for retention
- left channel
purge_after:
type: string
format: date-time
description: Only present when archived for retention
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'404': '404':

View File

@@ -481,6 +481,12 @@ type ChannelStore interface {
// Returns rows affected (0 = not found or not owner). // Returns rows affected (0 = not found or not owner).
DeleteByOwner(ctx context.Context, channelID, userID string) (int64, error) DeleteByOwner(ctx context.Context, channelID, userID string) (int64, error)
// ArchiveForRetention archives a channel and sets a purge_after timestamp.
ArchiveForRetention(ctx context.Context, channelID string, purgeAfter time.Time) error
// ListPurgeable returns IDs of channels whose purge_after has passed.
ListPurgeable(ctx context.Context) ([]string, error)
// MarkRead updates last_read_at and last_read_message_id for a user. // MarkRead updates last_read_at and last_read_message_id for a user.
MarkRead(ctx context.Context, channelID, userID string) error MarkRead(ctx context.Context, channelID, userID string) error
@@ -1165,7 +1171,7 @@ type PersonaGroupStore interface {
type FolderStore interface { type FolderStore interface {
List(ctx context.Context, userID string) ([]models.Folder, error) List(ctx context.Context, userID string) ([]models.Folder, error)
Create(ctx context.Context, f *models.Folder) error Create(ctx context.Context, f *models.Folder) error
Update(ctx context.Context, folderID, userID string, name string, sortOrder *int) (int64, error) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int, parentID **string) (int64, error)
Delete(ctx context.Context, folderID, userID string) (int64, error) Delete(ctx context.Context, folderID, userID string) (int64, error)
// UnassignChannels removes folder_id from all channels in this folder. // UnassignChannels removes folder_id from all channels in this folder.
UnassignChannels(ctx context.Context, folderID, userID string) error UnassignChannels(ctx context.Context, folderID, userID string) error

View File

@@ -339,9 +339,13 @@ func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelPart
func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) { func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) {
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, participant_type, participant_id, role, SELECT cp.id, cp.channel_id, cp.participant_type, cp.participant_id, cp.role,
display_name, avatar_url, joined_at COALESCE(NULLIF(cp.display_name,''), NULLIF(u.display_name,''), u.username) AS display_name,
FROM channel_participants WHERE channel_id = $1 ORDER BY joined_at`, channelID) COALESCE(NULLIF(cp.avatar_url,''), u.avatar_url) AS avatar_url,
cp.joined_at
FROM channel_participants cp
LEFT JOIN users u ON cp.participant_type = 'user' AND cp.participant_id = u.id
WHERE cp.channel_id = $1 ORDER BY cp.joined_at`, channelID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -515,6 +519,32 @@ func (s *ChannelStore) DeleteByOwner(ctx context.Context, channelID, userID stri
return result.RowsAffected() return result.RowsAffected()
} }
func (s *ChannelStore) ArchiveForRetention(ctx context.Context, channelID string, purgeAfter time.Time) error {
_, err := DB.ExecContext(ctx, `
UPDATE channels SET is_archived = true, purge_after = $2, updated_at = NOW()
WHERE id = $1
`, channelID, purgeAfter)
return err
}
func (s *ChannelStore) ListPurgeable(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id FROM channels WHERE purge_after IS NOT NULL AND purge_after <= NOW()
`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
ids = append(ids, id)
}
}
return ids, rows.Err()
}
func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error { func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error {
// Update last_read_at // Update last_read_at
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `

View File

@@ -39,14 +39,27 @@ func (s *FolderStore) List(ctx context.Context, userID string) ([]models.Folder,
func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error { func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error {
return DB.QueryRowContext(ctx, ` return DB.QueryRowContext(ctx, `
INSERT INTO folders (user_id, name, sort_order) INSERT INTO folders (user_id, name, parent_id, sort_order)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
RETURNING id, name, parent_id, sort_order, created_at, updated_at RETURNING id, name, parent_id, sort_order, created_at, updated_at
`, f.UserID, f.Name, f.SortOrder).Scan( `, f.UserID, f.Name, f.ParentID, f.SortOrder).Scan(
&f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt) &f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt)
} }
func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int) (int64, error) { func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int, parentID **string) (int64, error) {
if parentID != nil {
res, err := DB.ExecContext(ctx, `
UPDATE folders
SET name = COALESCE(NULLIF($3, ''), name),
sort_order = COALESCE($4, sort_order),
parent_id = $5
WHERE id = $1 AND user_id = $2
`, folderID, userID, name, sortOrder, *parentID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
res, err := DB.ExecContext(ctx, ` res, err := DB.ExecContext(ctx, `
UPDATE folders UPDATE folders
SET name = COALESCE(NULLIF($3, ''), name), SET name = COALESCE(NULLIF($3, ''), name),

View File

@@ -256,7 +256,7 @@ func (s *MessageStore) ListWithSenderInfo(ctx context.Context, channelID string,
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id, SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id,
m.sibling_index, m.participant_type, m.participant_id, m.sibling_index, m.participant_type, m.participant_id,
CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username) CASE WHEN m.participant_type = 'user' THEN COALESCE(NULLIF(u.display_name, ''), u.username)
WHEN m.participant_type = 'persona' THEN p.name WHEN m.participant_type = 'persona' THEN p.name
ELSE NULL END AS sender_name, ELSE NULL END AS sender_name,
CASE WHEN m.participant_type = 'user' THEN u.avatar_url CASE WHEN m.participant_type = 'user' THEN u.avatar_url

View File

@@ -338,9 +338,13 @@ func (s *ChannelStore) AddParticipant(ctx context.Context, p *models.ChannelPart
func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) { func (s *ChannelStore) ListParticipants(ctx context.Context, channelID string) ([]models.ChannelParticipant, error) {
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, participant_type, participant_id, role, SELECT cp.id, cp.channel_id, cp.participant_type, cp.participant_id, cp.role,
display_name, avatar_url, joined_at COALESCE(NULLIF(cp.display_name,''), NULLIF(u.display_name,''), u.username) AS display_name,
FROM channel_participants WHERE channel_id = ? ORDER BY joined_at`, channelID) COALESCE(NULLIF(cp.avatar_url,''), u.avatar_url) AS avatar_url,
cp.joined_at
FROM channel_participants cp
LEFT JOIN users u ON cp.participant_type = 'user' AND cp.participant_id = u.id
WHERE cp.channel_id = ? ORDER BY cp.joined_at`, channelID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -518,6 +522,32 @@ func (s *ChannelStore) DeleteByOwner(ctx context.Context, channelID, userID stri
return result.RowsAffected() return result.RowsAffected()
} }
func (s *ChannelStore) ArchiveForRetention(ctx context.Context, channelID string, purgeAfter time.Time) error {
_, err := DB.ExecContext(ctx, `
UPDATE channels SET is_archived = 1, purge_after = ?, updated_at = datetime('now')
WHERE id = ?
`, purgeAfter.UTC().Format("2006-01-02T15:04:05Z"), channelID)
return err
}
func (s *ChannelStore) ListPurgeable(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id FROM channels WHERE purge_after IS NOT NULL AND purge_after <= datetime('now')
`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
ids = append(ids, id)
}
}
return ids, rows.Err()
}
func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error { func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error {
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
UPDATE channel_participants UPDATE channel_participants

View File

@@ -41,9 +41,9 @@ func (s *FolderStore) List(ctx context.Context, userID string) ([]models.Folder,
func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error { func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error {
f.ID = store.NewID() f.ID = store.NewID()
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO folders (id, user_id, name, sort_order) INSERT INTO folders (id, user_id, name, parent_id, sort_order)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`, f.ID, f.UserID, f.Name, f.SortOrder) `, f.ID, f.UserID, f.Name, f.ParentID, f.SortOrder)
if err != nil { if err != nil {
return err return err
} }
@@ -53,7 +53,20 @@ func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error {
`, f.ID).Scan(&f.Name, &f.ParentID, &f.SortOrder, st(&f.CreatedAt), st(&f.UpdatedAt)) `, f.ID).Scan(&f.Name, &f.ParentID, &f.SortOrder, st(&f.CreatedAt), st(&f.UpdatedAt))
} }
func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int) (int64, error) { func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int, parentID **string) (int64, error) {
if parentID != nil {
res, err := DB.ExecContext(ctx, `
UPDATE folders
SET name = COALESCE(NULLIF(?, ''), name),
sort_order = COALESCE(?, sort_order),
parent_id = ?
WHERE id = ? AND user_id = ?
`, name, sortOrder, *parentID, folderID, userID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
res, err := DB.ExecContext(ctx, ` res, err := DB.ExecContext(ctx, `
UPDATE folders UPDATE folders
SET name = COALESCE(NULLIF(?, ''), name), SET name = COALESCE(NULLIF(?, ''), name),

View File

@@ -260,7 +260,7 @@ func (s *MessageStore) ListWithSenderInfo(ctx context.Context, channelID string,
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id, SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id,
m.sibling_index, m.participant_type, m.participant_id, m.sibling_index, m.participant_type, m.participant_id,
CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username) CASE WHEN m.participant_type = 'user' THEN COALESCE(NULLIF(u.display_name, ''), u.username)
WHEN m.participant_type = 'persona' THEN p.name WHEN m.participant_type = 'persona' THEN p.name
ELSE NULL END AS sender_name, ELSE NULL END AS sender_name,
CASE WHEN m.participant_type = 'user' THEN u.avatar_url CASE WHEN m.participant_type = 'user' THEN u.avatar_url

View File

@@ -1,3 +1,25 @@
package main package main
const Version = "0.37.13" import (
"os"
"strings"
)
// Version is the application version.
// In Docker builds, injected via ldflags from the VERSION file.
// In local dev, read from ../VERSION at startup.
var Version = "dev"
func init() {
if Version == "dev" {
if b, err := os.ReadFile("../VERSION"); err == nil {
if v := strings.TrimSpace(string(b)); v != "" {
Version = v
}
} else if b, err := os.ReadFile("VERSION"); err == nil {
if v := strings.TrimSpace(string(b)); v != "" {
Version = v
}
}
}
}

View File

@@ -472,3 +472,125 @@ select option { background: var(--bg-surface); color: var(--text); }
.sw-tabs-content { flex: 1; min-height: 0; overflow: hidden; } .sw-tabs-content { flex: 1; min-height: 0; overflow: hidden; }
.sw-tab-panel { height: 100%; overflow-y: auto; } .sw-tab-panel { height: 100%; overflow-y: auto; }
/* ── Responsive: Mobile ───────────────────── */
@media (max-width: 768px) {
/* Flyout items: larger touch targets */
.sw-menu-flyout .flyout-item {
padding: 10px 12px;
min-height: 44px;
}
/* Flyout: constrain to viewport */
.sw-menu-flyout {
max-width: calc(100vw - 16px);
}
/* Dialog/modal: full width on mobile, max with margins */
.sw-dialog__content {
width: calc(100vw - 32px);
max-width: 100%;
max-height: calc(100vh - 64px);
}
/* Toast: full width, centered */
.sw-toast {
max-width: calc(100vw - 32px);
}
}
/* ── User Picker (autocomplete) ──────────── */
.sw-user-picker {
position: relative;
}
.sw-user-picker__input {
width: 100%;
padding: 8px 10px;
border-radius: var(--radius, 6px);
border: 1px solid var(--border, #2a2a2e);
background: var(--bg, #0e0e10);
color: var(--text, #eee);
font-size: 0.85em;
}
.sw-user-picker__input:focus {
outline: none;
border-color: var(--accent, #b38a4e);
}
.sw-user-picker__spinner {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
border: 2px solid var(--border, #2a2a2e);
border-top-color: var(--accent, #b38a4e);
border-radius: 50%;
animation: sw-spin 0.6s linear infinite;
}
@keyframes sw-spin { to { transform: translateY(-50%) rotate(360deg); } }
.sw-user-picker__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: var(--bg-elevated, #2a2a2e);
border: 1px solid var(--border, #2a2a2e);
border-radius: var(--radius, 6px);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
z-index: 300;
max-height: 240px;
overflow-y: auto;
}
.sw-user-picker__option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
transition: background 0.1s;
}
.sw-user-picker__option:hover,
.sw-user-picker__option--active {
background: var(--bg-hover, rgba(255,255,255,0.06));
}
.sw-user-picker__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent, #b38a4e);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7em;
font-weight: 600;
flex-shrink: 0;
}
.sw-user-picker__label {
display: flex;
flex-direction: column;
min-width: 0;
}
.sw-user-picker__name {
font-size: 0.85em;
color: var(--text, #eee);
}
.sw-user-picker__handle {
font-size: 0.75em;
color: var(--text-muted, #888);
}

View File

@@ -98,11 +98,17 @@
font-family: inherit; font-family: inherit;
padding: 4px 6px; padding: 4px 6px;
cursor: pointer; cursor: pointer;
max-width: 140px; min-width: 100px;
max-width: 160px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.sw-model-selector__select:disabled {
opacity: 0.5;
cursor: default;
}
.sw-model-selector__select:focus { .sw-model-selector__select:focus {
border-color: var(--accent, #b38a4e); border-color: var(--accent, #b38a4e);
outline: none; outline: none;
@@ -148,6 +154,72 @@
word-wrap: break-word; word-wrap: break-word;
} }
/* Other user messages — left-aligned bubble */
.sw-chat-msg--other {
padding: 8px 16px;
}
.sw-chat-msg--other .sw-chat-msg__content {
background: var(--bg-2, #1a1a1e);
color: var(--text, #eee);
padding: 8px 14px;
border-radius: 12px 12px 12px 2px;
max-width: 85%;
word-wrap: break-word;
}
.sw-chat-msg__sender {
font-size: 11px;
font-weight: 600;
color: var(--text-3, #555);
margin-bottom: 2px;
padding-left: 2px;
}
/* ── @Mention highlights in rendered messages ─ */
.sw-mention {
color: var(--accent, #6366f1);
background: rgba(var(--accent-rgb, 99, 102, 241), 0.1);
border-radius: 3px;
padding: 0 3px;
font-weight: 500;
}
/* ── Typing Indicator ─────────────────────── */
.sw-typing-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 16px;
font-size: 12px;
color: var(--text-3, #888);
min-height: 22px;
}
.sw-typing-indicator__dots {
display: flex;
gap: 3px;
}
.sw-typing-indicator__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--text-3, #888);
animation: sw-typing-bounce 1.4s infinite ease-in-out both;
}
.sw-typing-indicator__dot:nth-child(1) { animation-delay: 0s; }
.sw-typing-indicator__dot:nth-child(2) { animation-delay: 0.16s; }
.sw-typing-indicator__dot:nth-child(3) { animation-delay: 0.32s; }
@keyframes sw-typing-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* Assistant messages — left-aligned */ /* Assistant messages — left-aligned */
.sw-chat-msg--assistant { .sw-chat-msg--assistant {
padding: 8px 16px; padding: 8px 16px;
@@ -527,31 +599,22 @@
font-size: 12px; font-size: 12px;
} }
/* ── Markdown Toolbar ─────────────────────── */ /* ── CM6 Chat Input ──────────────────────── */
.sw-md-toolbar { .sw-msg-input__cm-container {
display: flex; flex: 1;
gap: 1px; min-width: 0;
padding: 4px 12px 2px;
flex-shrink: 0;
} }
.sw-md-toolbar__btn { .sw-msg-input__cm-container .cm-editor {
background: none; font-size: 14px;
border: none; line-height: 1.5;
color: var(--text-3, #555); max-height: 160px;
font-size: 11px; overflow-y: auto;
font-weight: 600;
padding: 3px 7px;
border-radius: 4px;
cursor: pointer;
transition: color 0.15s, background 0.15s;
line-height: 1;
} }
.sw-md-toolbar__btn:hover { .sw-msg-input__cm-container .cm-editor.cm-focused {
color: var(--text, #eee); outline: none;
background: var(--bg, #0e0e10);
} }
/* ── Error Banner ─────────────────────────── */ /* ── Error Banner ─────────────────────────── */
@@ -750,3 +813,35 @@
vertical-align: middle; vertical-align: middle;
accent-color: var(--accent, #b38a4e); accent-color: var(--accent, #b38a4e);
} }
/* ── Thinking / Reasoning Blocks ────────── */
.sw-chat-msg__thinking {
margin-bottom: 8px;
border-left: 3px solid var(--border, rgba(255,255,255,0.1));
border-radius: 4px;
font-size: 0.85em;
color: var(--text-muted, #888);
}
.sw-chat-msg__thinking-toggle {
cursor: pointer;
padding: 6px 10px;
user-select: none;
font-weight: 500;
opacity: 0.7;
}
.sw-chat-msg__thinking-toggle:hover { opacity: 1; }
.sw-chat-msg__thinking[open] .sw-chat-msg__thinking-toggle {
border-bottom: 1px solid var(--border, rgba(255,255,255,0.06));
margin-bottom: 4px;
}
.sw-chat-msg__thinking-content {
padding: 6px 10px 10px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: break-word;
}

View File

@@ -41,7 +41,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex: 1; width: 100%;
padding: 8px 12px; padding: 8px 12px;
background: var(--accent-dim, rgba(179, 138, 78, 0.1)); background: var(--accent-dim, rgba(179, 138, 78, 0.1));
border: 1px solid var(--accent, #b38a4e); border: 1px solid var(--accent, #b38a4e);
@@ -53,6 +53,7 @@
transition: background 0.15s; transition: background 0.15s;
} }
.sw-chat-surface__new-folder-btn,
.sw-chat-surface__collapse-btn { .sw-chat-surface__collapse-btn {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -68,6 +69,7 @@
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
} }
.sw-chat-surface__new-folder-btn:hover,
.sw-chat-surface__collapse-btn:hover { .sw-chat-surface__collapse-btn:hover {
color: var(--text, #eee); color: var(--text, #eee);
background: var(--bg, #0e0e10); background: var(--bg, #0e0e10);
@@ -77,6 +79,52 @@
background: rgba(179, 138, 78, 0.2); background: rgba(179, 138, 78, 0.2);
} }
.sw-chat-surface__new-menu-wrap {
position: relative;
flex: 1;
min-width: 0;
}
.sw-chat-surface__new-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--surface, #1a1a1e);
border: 1px solid var(--border, #2a2a2e);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
z-index: 130;
padding: 4px;
display: flex;
flex-direction: column;
}
.sw-chat-surface__new-menu button {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: none;
border: none;
border-radius: 6px;
color: var(--text, #eee);
font-size: 12px;
font-family: inherit;
cursor: pointer;
text-align: left;
}
.sw-chat-surface__new-menu button:hover {
background: var(--bg-hover, rgba(255, 255, 255, 0.06));
}
.sw-chat-surface__new-menu-icon {
width: 18px;
text-align: center;
flex-shrink: 0;
}
/* ── Search ───────────────────────────────── */ /* ── Search ───────────────────────────────── */
.sw-chat-surface__search-wrap { .sw-chat-surface__search-wrap {
@@ -95,9 +143,9 @@
display: flex; display: flex;
} }
.sw-chat-surface__search { input.sw-chat-surface__search {
width: 100%; width: 100%;
padding: 6px 8px 6px 30px; padding: 6px 8px 6px 34px;
background: var(--bg, #0e0e10); background: var(--bg, #0e0e10);
border: 1px solid var(--border, #2a2a2e); border: 1px solid var(--border, #2a2a2e);
border-radius: 6px; border-radius: 6px;
@@ -108,7 +156,7 @@
box-sizing: border-box; box-sizing: border-box;
} }
.sw-chat-surface__search:focus { input.sw-chat-surface__search:focus {
border-color: var(--accent, #b38a4e); border-color: var(--accent, #b38a4e);
} }
@@ -222,7 +270,7 @@
} }
.sw-chat-surface__item-menu { .sw-chat-surface__item-menu {
display: none; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 22px; width: 22px;
@@ -234,10 +282,14 @@
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
padding: 0; padding: 0;
opacity: 0;
pointer-events: none;
} }
.sw-chat-surface__item:hover .sw-chat-surface__item-menu { .sw-chat-surface__item:hover .sw-chat-surface__item-menu,
display: flex; .sw-chat-surface__folder-header:hover .sw-chat-surface__item-menu {
opacity: 1;
pointer-events: auto;
} }
.sw-chat-surface__item-menu:hover { .sw-chat-surface__item-menu:hover {
@@ -261,6 +313,29 @@
.sw-chat-surface__folder { .sw-chat-surface__folder {
margin: 2px 0; margin: 2px 0;
transition: background 0.15s;
}
.sw-chat-surface__folder--drag-over {
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
border-radius: 6px;
}
.sw-chat-surface__root-drop {
margin: 4px 8px;
padding: 10px;
border: 2px dashed var(--border, #2a2a2e);
border-radius: 6px;
text-align: center;
font-size: 11px;
color: var(--text-3, #555);
transition: border-color 0.15s, background 0.15s;
}
.sw-chat-surface__root-drop--active {
border-color: var(--accent, #b38a4e);
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
color: var(--text-2, #999);
} }
.sw-chat-surface__folder-header { .sw-chat-surface__folder-header {
@@ -270,6 +345,25 @@
padding: 4px 12px; padding: 4px 12px;
color: var(--text-3, #555); color: var(--text-3, #555);
font-size: 11px; font-size: 11px;
cursor: pointer;
border-radius: 6px;
transition: background 0.15s;
}
.sw-chat-surface__folder-header:hover {
background: var(--bg, #0e0e10);
}
/* Draggable items */
.sw-chat-surface__item[draggable="true"],
.sw-chat-surface__folder[draggable="true"] {
cursor: grab;
}
.sw-chat-surface__item[draggable="true"]:active,
.sw-chat-surface__folder[draggable="true"]:active {
cursor: grabbing;
opacity: 0.6;
} }
.sw-chat-surface__folder-name { .sw-chat-surface__folder-name {
@@ -380,6 +474,180 @@
background: var(--bg, #0e0e10); background: var(--bg, #0e0e10);
} }
/* ── Header Buttons (members, settings) ───── */
.sw-chat-surface__header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-3, #555);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.sw-chat-surface__header-btn:hover {
color: var(--text, #eee);
background: var(--bg, #0e0e10);
}
/* ── Channel Panel Shared ─────────────────── */
.sw-panel-loading,
.sw-panel-empty {
padding: 24px 16px;
text-align: center;
color: var(--text-muted, #888);
font-size: 0.9em;
}
/* ── Members Panel ────────────────────────── */
.sw-members-list {
padding: 8px 0;
}
.sw-members-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
transition: background 0.12s;
}
.sw-members-row:hover {
background: var(--bg, rgba(255,255,255,0.03));
}
.sw-members-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent, #b38a4e);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75em;
font-weight: 600;
flex-shrink: 0;
}
.sw-members-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.sw-members-name {
font-size: 0.9em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sw-members-type {
font-size: 0.75em;
color: var(--text-muted, #888);
}
.sw-members-role {
font-size: 0.8em;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-secondary, #151517);
color: var(--text, #eee);
border: 1px solid var(--border, #2a2a2e);
}
.sw-members-remove {
background: none;
border: none;
color: var(--text-muted, #888);
cursor: pointer;
font-size: 1.1em;
padding: 2px 6px;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
}
.sw-members-remove:hover {
color: #e55;
background: rgba(238,85,85,0.1);
}
.sw-members-add {
display: flex;
gap: 6px;
padding: 12px 16px;
border-top: 1px solid var(--border, #2a2a2e);
}
.sw-members-add input {
flex: 1;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--border, #2a2a2e);
background: var(--bg, #0e0e10);
color: var(--text, #eee);
font-size: 0.85em;
}
/* ── Settings Panel ───────────────────────── */
.sw-settings-form {
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.sw-settings-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.sw-settings-field label {
font-size: 0.8em;
font-weight: 500;
color: var(--text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sw-settings-field input,
.sw-settings-field textarea,
.sw-settings-field select {
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border, #2a2a2e);
background: var(--bg, #0e0e10);
color: var(--text, #eee);
font-size: 0.9em;
resize: vertical;
}
.sw-settings-info {
padding: 10px 0;
border-top: 1px solid var(--border, rgba(255,255,255,0.06));
font-size: 0.82em;
color: var(--text-muted, #888);
display: flex;
flex-direction: column;
gap: 4px;
}
.sw-settings-save {
align-self: flex-start;
}
/* ── Tools Button ─────────────────────────── */ /* ── Tools Button ─────────────────────────── */
.sw-chat-surface__tools-btn { .sw-chat-surface__tools-btn {
@@ -595,14 +863,169 @@
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 280px; width: 280px;
z-index: 20; z-index: 120;
} }
.sw-chat-surface__sidebar-overlay { .sw-chat-surface__sidebar-overlay {
display: block; display: block;
z-index: 110;
} }
.sw-chat-surface__collapse-btn { .sw-chat-surface__collapse-btn {
display: none; display: none;
} }
/* Ensure touch targets are >= 44px */
.sw-chat-surface__item {
min-height: 44px;
}
.sw-chat-surface__folder-header {
min-height: 40px;
}
/* Prevent iOS zoom on search */
input.sw-chat-surface__search {
font-size: 16px;
}
/* Context menu: constrain to viewport */
.sw-chat-surface__context-menu {
max-width: calc(100vw - 16px);
right: 8px;
left: auto !important;
}
/* Header buttons: tighter on mobile */
.sw-chat-surface__sidebar-header {
gap: 4px;
}
.sw-chat-surface__new-chat-btn {
font-size: 12px;
padding: 6px 10px;
}
}
/* ── Dashboard (no chat selected) ─────────── */
.sw-chat-dashboard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px 24px;
gap: 32px;
overflow-y: auto;
}
.sw-chat-dashboard__welcome {
text-align: center;
}
.sw-chat-dashboard__title {
font-size: 20px;
font-weight: 600;
color: var(--text, #eee);
margin: 0 0 8px;
}
.sw-chat-dashboard__hint {
font-size: 13px;
color: var(--text-3, #555);
margin: 0;
}
.sw-chat-dashboard__actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
max-width: 360px;
width: 100%;
}
.sw-chat-dashboard__card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px 12px;
background: var(--surface, #1a1a1e);
border: 1px solid var(--border, #2a2a2e);
border-radius: 10px;
color: var(--text, #eee);
font-family: inherit;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.sw-chat-dashboard__card:hover {
border-color: var(--accent, #b38a4e);
background: var(--accent-dim, rgba(179, 138, 78, 0.08));
}
.sw-chat-dashboard__card-icon {
font-size: 24px;
line-height: 1;
}
.sw-chat-dashboard__card-label {
font-size: 12px;
font-weight: 500;
}
.sw-chat-dashboard__recent {
max-width: 360px;
width: 100%;
}
.sw-chat-dashboard__section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-3, #555);
margin: 0 0 8px;
}
.sw-chat-dashboard__recent-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.sw-chat-dashboard__recent-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
color: var(--text-2, #999);
}
.sw-chat-dashboard__recent-icon {
width: 16px;
text-align: center;
flex-shrink: 0;
font-size: 11px;
}
.sw-chat-dashboard__recent-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 768px) {
.sw-chat-dashboard {
padding: 24px 16px;
}
.sw-chat-dashboard__actions {
gap: 8px;
}
.sw-chat-dashboard__card {
padding: 16px 8px;
}
} }

View File

@@ -353,14 +353,25 @@
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 280px; width: 280px;
z-index: 20; z-index: 120;
} }
.sw-notes-surface__sidebar-overlay { .sw-notes-surface__sidebar-overlay {
display: block; display: block;
z-index: 110;
} }
.sw-notes-surface__collapse-btn { .sw-notes-surface__collapse-btn {
display: none; display: none;
} }
/* Touch targets */
.sw-notes-surface__item {
min-height: 44px;
}
/* Prevent iOS zoom */
.sw-notes-surface__search {
font-size: 16px;
}
} }

View File

@@ -193,6 +193,11 @@
.sw-menu__arrow { font-size: 0.6em; color: var(--text-3); } .sw-menu__arrow { font-size: 0.6em; color: var(--text-3); }
.sw-menu__divider { height: 1px; background: var(--border); margin: 4px 0; } .sw-menu__divider { height: 1px; background: var(--border); margin: 4px 0; }
@media (max-width: 768px) {
.sw-menu { max-width: calc(100vw - 16px); }
.sw-menu__item { min-height: 44px; padding: 0.5rem 0.75rem; }
}
/* ── Drawer ────────────────────────────────── */ /* ── Drawer ────────────────────────────────── */
.sw-drawer-overlay { .sw-drawer-overlay {

View File

@@ -10,6 +10,11 @@
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
background: var(--bg); background: var(--bg);
/* Safe-area insets for notched devices */
padding-top: env(safe-area-inset-top, 0);
padding-bottom: env(safe-area-inset-bottom, 0);
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
} }
/* ── Banner (fixed top / bottom — identical component) ── */ /* ── Banner (fixed top / bottom — identical component) ── */
@@ -137,3 +142,184 @@
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
} }
/* ── Notification Bell ───────────────────── */
.sw-notification-bell {
position: relative;
}
.sw-notification-bell__trigger {
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: 32px;
height: 32px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-3, #555);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.sw-notification-bell__trigger:hover {
color: var(--text, #eee);
background: var(--bg, #0e0e10);
}
.sw-notification-bell__badge {
position: absolute;
top: 2px;
right: 2px;
background: var(--danger, #ef4444);
color: #fff;
font-size: 9px;
font-weight: 700;
min-width: 14px;
height: 14px;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0 3px;
}
.sw-notification-bell__panel {
position: absolute;
top: 100%;
right: 0;
margin-top: 6px;
width: 320px;
max-height: 420px;
background: var(--bg-secondary, #151517);
border: 1px solid var(--border, #2a2a2e);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
z-index: 200;
overflow: hidden;
display: flex;
flex-direction: column;
}
.sw-notification-bell__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--border, #2a2a2e);
font-size: 13px;
font-weight: 600;
color: var(--text, #eee);
}
.sw-notification-bell__mark-all {
background: none;
border: none;
color: var(--accent, #b38a4e);
font-size: 11px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
}
.sw-notification-bell__mark-all:hover {
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
}
.sw-notification-bell__list {
overflow-y: auto;
flex: 1;
}
.sw-notification-bell__empty {
padding: 24px 14px;
text-align: center;
color: var(--text-3, #555);
font-size: 12px;
}
.sw-notification-bell__item {
padding: 10px 14px;
border-bottom: 1px solid var(--border, #2a2a2e);
cursor: pointer;
transition: background 0.15s;
}
.sw-notification-bell__item:last-child {
border-bottom: none;
}
.sw-notification-bell__item:hover {
background: var(--bg, #0e0e10);
}
.sw-notification-bell__item--unread {
background: var(--accent-dim, rgba(179, 138, 78, 0.05));
}
.sw-notification-bell__item--unread::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent, #b38a4e);
margin-right: 8px;
vertical-align: middle;
}
.sw-notification-bell__item-text {
font-size: 12px;
color: var(--text, #eee);
display: inline;
}
.sw-notification-bell__item-body {
font-size: 11px;
color: var(--text-3, #555);
margin-top: 3px;
line-height: 1.4;
}
.sw-notification-bell__item-time {
font-size: 10px;
color: var(--text-3, #555);
margin-top: 3px;
display: flex;
align-items: center;
justify-content: space-between;
}
.sw-notification-bell__item--navigable {
cursor: pointer;
}
.sw-notification-bell__item-action {
font-size: 10px;
color: var(--accent, #4a9eff);
font-weight: 600;
opacity: 0;
transition: opacity 0.15s;
}
.sw-notification-bell__item:hover .sw-notification-bell__item-action {
opacity: 1;
}
/* ── Notification Bell — Mobile ──────────── */
@media (max-width: 768px) {
.sw-notification-bell__panel {
width: calc(100vw - 24px);
max-width: 320px;
right: -8px;
}
.sw-notification-bell__trigger {
min-width: 44px;
min-height: 44px;
}
}

View File

@@ -95,3 +95,18 @@
width: 200px; width: 200px;
margin-bottom: 0; margin-bottom: 0;
} }
/* ── Responsive: Mobile ───────────────────── */
@media (max-width: 768px) {
/* User menu button: ensure 44px touch target */
.user-menu-wrap .user-btn {
padding: 8px;
min-height: 44px;
}
/* Flyout: open upward and constrain width */
.user-menu-wrap .sw-menu-flyout {
max-width: calc(100vw - 16px);
}
}

View File

@@ -47,10 +47,13 @@
--warning-dim: rgba(234,179,8,0.2); --warning-dim: rgba(234,179,8,0.2);
/* ── Surfaces ────────────────────────────── */ /* ── Surfaces ────────────────────────────── */
--bg-primary: #0e0e10;
--bg-secondary: #151517;
--overlay: rgba(0,0,0,0.55); --overlay: rgba(0,0,0,0.55);
--glass: rgba(255,255,255,0.03); --glass: rgba(255,255,255,0.03);
--input-bg: #1a1a1f; --input-bg: #1a1a1f;
--user-bubble: rgba(108,159,255,0.08); --user-bubble: rgba(108,159,255,0.08);
--danger-bg: rgba(239,68,68,0.12);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5); --shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
/* ── Layout ──────────────────────────────── */ /* ── Layout ──────────────────────────────── */
@@ -105,10 +108,13 @@
--success-dim: rgba(22,163,74,0.09); --success-dim: rgba(22,163,74,0.09);
--warning-dim: rgba(202,138,4,0.09); --warning-dim: rgba(202,138,4,0.09);
--bg-primary: #f7f7fa;
--bg-secondary: #ecedf2;
--overlay: rgba(0,0,0,0.25); --overlay: rgba(0,0,0,0.25);
--glass: rgba(0,0,0,0.02); --glass: rgba(0,0,0,0.02);
--input-bg: #f2f3f6; --input-bg: #f2f3f6;
--user-bubble: rgba(74,124,219,0.06); --user-bubble: rgba(74,124,219,0.06);
--danger-bg: #fef2f2;
--shadow-lg: 0 8px 32px rgba(0,0,0,0.1); --shadow-lg: 0 8px 32px rgba(0,0,0,0.1);
color-scheme: light; color-scheme: light;

View File

@@ -147,29 +147,36 @@ function tripleBacktickHandler() {
} }
/** /**
* Keybinding: Ctrl/Cmd+E wraps selection in inline code backticks, * Wrap the selection with `before`/`after` markers (or insert an empty
* or inserts a pair and places cursor between them. * pair and place the cursor between them). Used for bold, italic, code.
*/ */
function inlineCodeKeymap() { function _wrapWith(view, before, after) {
return keymap.of([{ const { from, to } = view.state.selection.main;
key: 'Mod-e', if (from !== to) {
run: (view) => { const text = view.state.doc.sliceString(from, to);
const { from, to } = view.state.selection.main; view.dispatch({
if (from !== to) { changes: { from, to, insert: before + text + after },
const text = view.state.doc.sliceString(from, to); selection: { anchor: from + before.length, head: to + before.length },
view.dispatch({ });
changes: { from, to, insert: '`' + text + '`' }, } else {
selection: { anchor: from + 1, head: to + 1 }, view.dispatch({
}); changes: { from, to: from, insert: before + after },
} else { selection: { anchor: from + before.length },
view.dispatch({ });
changes: { from, to: from, insert: '``' }, }
selection: { anchor: from + 1 }, return true;
}); }
}
return true; /**
}, * Keybindings: Ctrl/Cmd+B (bold), Ctrl/Cmd+I (italic), Ctrl/Cmd+E (inline code).
}]); * Wraps selection or inserts an empty pair with cursor between markers.
*/
function formattingKeymap() {
return keymap.of([
{ key: 'Mod-b', run: (view) => _wrapWith(view, '**', '**') },
{ key: 'Mod-i', run: (view) => _wrapWith(view, '_', '_') },
{ key: 'Mod-e', run: (view) => _wrapWith(view, '`', '`') },
]);
} }
// ── Chat Input Factory ────────────────────── // ── Chat Input Factory ──────────────────────
@@ -193,6 +200,7 @@ export function chatInput(target, opts = {}) {
onChange = null, onChange = null,
maxHeight = 200, maxHeight = 200,
darkMode = document.documentElement.getAttribute('data-theme') !== 'light', darkMode = document.documentElement.getAttribute('data-theme') !== 'light',
extensions: extraExtensions = [],
} = opts; } = opts;
// Build extensions // Build extensions
@@ -201,9 +209,9 @@ export function chatInput(target, opts = {}) {
markdown({ base: markdownLanguage }), markdown({ base: markdownLanguage }),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }), syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
// Code block shortcuts // Markdown shortcuts
tripleBacktickHandler(), // ``` → fenced code block tripleBacktickHandler(), // ``` → fenced code block
inlineCodeKeymap(), // Ctrl/Cmd+E → inline code formattingKeymap(), // Ctrl/Cmd+B, I, E → bold, italic, code
// Code block visual decorations (WYSIWYG style) // Code block visual decorations (WYSIWYG style)
codeBlockDeco, codeBlockDeco,
@@ -275,6 +283,11 @@ export function chatInput(target, opts = {}) {
// Single-line display mode: wrap long lines // Single-line display mode: wrap long lines
extensions.push(EditorView.lineWrapping); extensions.push(EditorView.lineWrapping);
// Extra extensions (e.g. @mention autocomplete)
if (extraExtensions.length) {
extensions.push(...extraExtensions);
}
const view = new EditorView({ const view = new EditorView({
state: EditorState.create({ doc: '', extensions }), state: EditorState.create({ doc: '', extensions }),
parent: target, parent: target,

View File

@@ -31,6 +31,7 @@ import { emacs } from '@replit/codemirror-emacs';
import { codeEditor } from './code-editor.mjs'; import { codeEditor } from './code-editor.mjs';
import { chatInput } from './chat-input.mjs'; import { chatInput } from './chat-input.mjs';
import { noteEditor } from './note-editor.mjs'; import { noteEditor } from './note-editor.mjs';
import { mentionExtension } from './mention.mjs';
// ── Expose on window ──────────────────────── // ── Expose on window ────────────────────────
@@ -54,6 +55,10 @@ window.CM = {
// CM.noteEditor(container, { value, darkMode, onChange, onLink, linkCompleter }) // CM.noteEditor(container, { value, darkMode, onChange, onLink, linkCompleter })
noteEditor, noteEditor,
// Extension: @mention autocomplete + decoration for chat input
// CM.mentionExtension({ completer: async (query) => [{ label, handle, type }] })
mentionExtension,
// Bundled language modes (for reference / dynamic use) // Bundled language modes (for reference / dynamic use)
languages: { languages: {
javascript, json, markdown, sql, html, css, yaml, go, python, rust, javascript, json, markdown, sql, html, css, yaml, go, python, rust,

156
src/editor/mention.mjs Normal file
View File

@@ -0,0 +1,156 @@
// ==========================================
// Chat Switchboard — CM6 @Mention Extension
// ==========================================
// Provides:
// 1. Autocomplete triggered by @ for personas/users
// 2. ViewPlugin that decorates @handle as styled marks
// 3. Theme for mention styling in the editor
//
// Modeled on wikilink.mjs — same pattern adapted for
// @mention tokens instead of [[wikilink]] syntax.
//
// Usage:
// import { mentionExtension } from './mention.mjs';
// const ext = mentionExtension({
// completer: async (query) => [{ label, handle, type }],
// });
// ==========================================
import {
ViewPlugin, Decoration, EditorView,
} from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';
import { autocompletion } from '@codemirror/autocomplete';
// ── Mention Regex ───────────────────────────
// Matches @handle tokens: @ preceded by start-of-string or whitespace,
// followed by one or more word chars or hyphens.
const MENTION_RE = /(?:^|(?<=\s))@([\w-]+)/g;
// ── ViewPlugin: Decorate @mentions ──────────
function mentionDecoPlugin() {
return ViewPlugin.fromClass(class {
decorations;
constructor(view) {
this.decorations = this.build(view);
}
update(update) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = this.build(update.view);
}
}
build(view) {
const builder = new RangeSetBuilder();
const doc = view.state.doc;
const text = doc.toString();
const sel = view.state.selection.main;
let match;
MENTION_RE.lastIndex = 0;
while ((match = MENTION_RE.exec(text)) !== null) {
// match[0] includes any leading whitespace captured by lookbehind,
// but lookbehind is zero-width so match.index points to @
const atIdx = text.indexOf('@', match.index);
const from = atIdx;
const to = from + 1 + match[1].length; // @handle
// Don't decorate if cursor is inside the mention (let user edit)
if (sel.from >= from && sel.from <= to) continue;
builder.add(from, to, Decoration.mark({ class: 'cm-mention' }));
}
return builder.finish();
}
}, {
decorations: (v) => v.decorations,
});
}
// ── Autocomplete: @ trigger ─────────────────
function mentionAutocomplete(completer) {
return autocompletion({
override: [
async (ctx) => {
// Look for @ before the cursor (at line start or after whitespace)
const line = ctx.state.doc.lineAt(ctx.pos);
const textBefore = ctx.state.doc.sliceString(line.from, ctx.pos);
const triggerMatch = textBefore.match(/(?:^|\s)@([\w-]*)$/);
if (!triggerMatch) return null;
const query = triggerMatch[1];
// from = position of the first char after @
const from = ctx.pos - query.length;
if (!completer) return null;
try {
const results = await completer(query);
if (!results || results.length === 0) return null;
return {
from,
options: results.map(r => ({
label: r.handle,
displayLabel: r.label,
detail: r.type === 'all' ? 'all users' : r.type,
apply: (view, completion, from, to) => {
// Insert handle + trailing space
const insert = completion.label + ' ';
view.dispatch({
changes: { from, to, insert },
selection: { anchor: from + insert.length },
});
},
})),
};
} catch (e) {
console.warn('[mention] autocomplete error:', e);
return null;
}
},
],
activateOnTyping: true,
});
}
// ── Theme: Mention Styles ───────────────────
const mentionTheme = EditorView.baseTheme({
'.cm-mention': {
color: 'var(--accent, #6366f1)',
backgroundColor: 'rgba(var(--accent-rgb, 99, 102, 241), 0.1)',
borderRadius: '3px',
padding: '0 2px',
fontWeight: '500',
},
});
// ── Public: Combined Extension ──────────────
/**
* Create the @mention CM6 extension bundle.
*
* @param {Object} opts
* @param {Function} opts.completer - async (query) => [{ label, handle, type }]
* @returns {Extension[]} Array of CM6 extensions
*/
export function mentionExtension(opts = {}) {
const extensions = [
mentionDecoPlugin(),
mentionTheme,
];
if (opts.completer) {
extensions.push(mentionAutocomplete(opts.completer));
}
return extensions;
}

51
src/favicon-light.svg Normal file
View File

@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#f5f2ef"/>
<stop offset="100%" stop-color="#e8e4e0"/>
</linearGradient>
</defs>
<!-- Panel background -->
<rect x="4" y="4" width="56" height="56" rx="10" fill="url(#panel)" stroke="#c8c4c0" stroke-width="1.5"/>
<!-- Static jack holes (light rings) -->
<circle cx="22" cy="23" r="8" fill="#d0ccc8"/>
<circle cx="42" cy="23" r="8" fill="#d0ccc8"/>
<circle cx="42" cy="43" r="8" fill="#d0ccc8"/>
<circle cx="22" cy="43" r="8" fill="#d0ccc8"/>
<!--
Cascading color rotation (clockwise: TL → TR → BR → BL)
dur=12s, each cascade round=3s, fade=0.4s out + 0.4s in
Same animation as dark favicon, fade-through color = #d0ccc8
-->
<!-- TL: Blue → Purple → Green → Orange (offset 0s) -->
<circle cx="22" cy="23" r="5.5" fill="#2D7DD2">
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
keyTimes="0;0.0333;0.0667;0.25;0.2833;0.3167;0.5;0.5333;0.5667;0.75;0.7833;0.8167;1"
values="#2D7DD2;#d0ccc8;#9B59B6;#9B59B6;#d0ccc8;#2EAA4E;#2EAA4E;#d0ccc8;#E8852E;#E8852E;#d0ccc8;#2D7DD2;#2D7DD2"/>
</circle>
<!-- TR: Orange → Blue → Purple → Green (offset 0.4s) -->
<circle cx="42" cy="23" r="5.5" fill="#E8852E">
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
keyTimes="0;0.0333;0.0667;0.1;0.2833;0.3167;0.35;0.5333;0.5667;0.6;0.7833;0.8167;0.85;1"
values="#E8852E;#E8852E;#d0ccc8;#2D7DD2;#2D7DD2;#d0ccc8;#9B59B6;#9B59B6;#d0ccc8;#2EAA4E;#2EAA4E;#d0ccc8;#E8852E;#E8852E"/>
</circle>
<!-- BR: Purple → Green → Orange → Blue (offset 0.8s) -->
<circle cx="42" cy="43" r="5.5" fill="#9B59B6">
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
keyTimes="0;0.0667;0.1;0.1333;0.3167;0.35;0.3833;0.5667;0.6;0.6333;0.8167;0.85;0.8833;1"
values="#9B59B6;#9B59B6;#d0ccc8;#2EAA4E;#2EAA4E;#d0ccc8;#E8852E;#E8852E;#d0ccc8;#2D7DD2;#2D7DD2;#d0ccc8;#9B59B6;#9B59B6"/>
</circle>
<!-- BL: Green → Orange → Blue → Purple (offset 1.2s) -->
<circle cx="22" cy="43" r="5.5" fill="#2EAA4E">
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
keyTimes="0;0.1;0.1333;0.1667;0.35;0.3833;0.4167;0.6;0.6333;0.6667;0.85;0.8833;0.9167;1"
values="#2EAA4E;#2EAA4E;#d0ccc8;#E8852E;#E8852E;#d0ccc8;#2D7DD2;#2D7DD2;#d0ccc8;#9B59B6;#9B59B6;#d0ccc8;#2EAA4E;#2EAA4E"/>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -13,34 +13,32 @@ const { describe, it } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
// ── Admin /users response ──────────────────── // ── Admin /users response ────────────────────
// Bug: Frontend read resp.data, backend sends resp.users // Normalized: uses standard {data: [...], total: N} envelope.
// Fix: Read resp.users || resp.data // Has siblings → _unwrap does NOT strip. Frontend reads .data and .total.
describe('GET /admin/users response contract', () => { describe('GET /admin/users response contract', () => {
// This is the ACTUAL backend response shape from ListUsers handler: // ACTUAL backend response shape from ListUsers handler:
// c.JSON(200, gin.H{"users": users, "total": total}) // c.JSON(200, gin.H{"data": users, "total": total})
const backendResponse = { const backendResponse = {
users: [ data: [
{ id: 'u1', username: 'alice', email: 'alice@test.com', role: 'user', is_active: true }, { id: 'u1', username: 'alice', email: 'alice@test.com', role: 'user', is_active: true },
{ id: 'u2', username: 'admin', email: 'admin@test.com', role: 'admin', is_active: true }, { id: 'u2', username: 'admin', email: 'admin@test.com', role: 'admin', is_active: true },
], ],
total: 2, total: 2,
}; };
it('response has "users" key (not "data")', () => { it('response has "data" array', () => {
assert.ok(Array.isArray(backendResponse.users), 'response.users must be an array'); assert.ok(Array.isArray(backendResponse.data), 'response.data must be an array');
assert.equal(backendResponse.data, undefined, 'response.data must NOT exist');
}); });
it('frontend extraction reads .users with .data fallback', () => { it('frontend extraction reads .data (siblings present, _unwrap passes through)', () => {
// This mirrors the fixed loadMemberUserDropdown logic const users = backendResponse.data || [];
const users = backendResponse.users || backendResponse.data || [];
assert.equal(users.length, 2); assert.equal(users.length, 2);
assert.equal(users[0].id, 'u1'); assert.equal(users[0].id, 'u1');
}); });
it('each user has required fields for member dropdown', () => { it('each user has required fields for member dropdown', () => {
for (const u of backendResponse.users) { for (const u of backendResponse.data) {
assert.ok(u.id, 'user must have id'); assert.ok(u.id, 'user must have id');
assert.ok(u.username || u.email, 'user must have username or email'); assert.ok(u.username || u.email, 'user must have username or email');
assert.equal(typeof u.is_active, 'boolean', 'is_active must be boolean'); assert.equal(typeof u.is_active, 'boolean', 'is_active must be boolean');
@@ -49,7 +47,7 @@ describe('GET /admin/users response contract', () => {
it('total is numeric', () => { it('total is numeric', () => {
assert.equal(typeof backendResponse.total, 'number'); assert.equal(typeof backendResponse.total, 'number');
assert.ok(backendResponse.total >= backendResponse.users.length); assert.ok(backendResponse.total >= backendResponse.data.length);
}); });
}); });
@@ -319,9 +317,10 @@ describe('GET /teams/:id/members response contract', () => {
// All models regardless of visibility. // All models regardless of visibility.
describe('GET /admin/models response contract', () => { describe('GET /admin/models response contract', () => {
// This is the ACTUAL CatalogEntry shape from ListAll → ListModelConfigs handler. // ACTUAL CatalogEntry shape from ListAll → ListModelConfigs handler.
// Normalized: {data: [...]} sole key → _unwrap strips → frontend gets bare array.
const backendResponse = { const backendResponse = {
models: [ data: [
{ {
id: 'catalog-uuid-1', id: 'catalog-uuid-1',
provider_config_id: 'provider-uuid', provider_config_id: 'provider-uuid',
@@ -337,36 +336,31 @@ describe('GET /admin/models response contract', () => {
], ],
}; };
it('response has "models" array (not null)', () => { it('response has "data" array (not null)', () => {
assert.ok(Array.isArray(backendResponse.models), assert.ok(Array.isArray(backendResponse.data),
'CRITICAL: models must be [] not null — null causes frontend fallback chain to pick wrong object'); 'CRITICAL: data must be [] not null');
}); });
it('each model has id and model_id', () => { it('each model has id and model_id', () => {
for (const m of backendResponse.models) { for (const m of backendResponse.data) {
assert.ok(m.id, 'must have id (UUID) for visibility toggle onclick'); assert.ok(m.id, 'must have id (UUID) for visibility toggle onclick');
assert.ok(m.model_id, 'must have model_id for display'); assert.ok(m.model_id, 'must have model_id for display');
} }
}); });
it('each model has visibility', () => { it('each model has visibility', () => {
for (const m of backendResponse.models) { for (const m of backendResponse.data) {
assert.ok(['enabled', 'disabled', 'team'].includes(m.visibility), assert.ok(['enabled', 'disabled', 'team'].includes(m.visibility),
`visibility must be enabled/disabled/team, got: ${m.visibility}`); `visibility must be enabled/disabled/team, got: ${m.visibility}`);
} }
}); });
it('frontend extraction handles both null and empty array', () => { it('_unwrap strips sole-key {data:[]} → frontend gets bare array', () => {
// This is the exact fallback chain from loadAdminModels // After _unwrap, frontend receives the bare array, uses `resp || []`
const nullResp = { models: null }; const unwrapped = backendResponse.data; // simulates _unwrap
const list1 = nullResp.models || nullResp.data || nullResp || []; const list = unwrapped || [];
const arr1 = Array.isArray(list1) ? list1 : []; assert.ok(Array.isArray(list));
assert.equal(arr1.length, 0, 'null models must produce empty array'); assert.equal(list.length, 1);
const emptyResp = { models: [] };
const list2 = emptyResp.models || emptyResp.data || emptyResp || [];
const arr2 = Array.isArray(list2) ? list2 : [];
assert.equal(arr2.length, 0, 'empty models must produce empty array');
}); });
}); });
@@ -409,30 +403,30 @@ describe('POST /admin/models/fetch response contract', () => {
// ── Cross-endpoint consistency ─────────────── // ── Cross-endpoint consistency ───────────────
describe('Response shape consistency', () => { describe('Response shape consistency — all endpoints use {data:[...]} envelope', () => {
it('admin/users uses {users:[]} not {data:[]}', () => { it('admin/users uses {data:[], total:N} (siblings → not unwrapped)', () => {
// This is the contract the team member dropdown depends on const shape = { data: [], total: 0 };
const shape = { users: [], total: 0 }; assert.ok('data' in shape);
assert.ok('users' in shape, 'admin/users must use "users" key'); assert.ok('total' in shape);
}); });
it('teams/members uses {data:[]}', () => { it('teams/members uses {data:[]}', () => {
const shape = { data: [] }; const shape = { data: [] };
assert.ok('data' in shape, 'teams/members uses "data" key'); assert.ok('data' in shape);
}); });
it('models/enabled uses {data:[]}', () => { it('models/enabled uses {data:[], default_model:""} (siblings → not unwrapped)', () => {
const shape = { data: [], default_model: '' };
assert.ok('data' in shape);
});
it('admin/models uses {data:[]} (sole key → unwrapped)', () => {
const shape = { data: [] }; const shape = { data: [] };
assert.ok('data' in shape); assert.ok('data' in shape);
}); });
it('admin/models uses {models:[]}', () => { it('personas uses {data:[]} (sole key → unwrapped)', () => {
const shape = { models: [] }; const shape = { data: [] };
assert.ok('models' in shape, 'admin/models must use "models" key'); assert.ok('data' in shape);
});
it('personas uses {personas:[]}', () => {
const shape = { personas: [] };
assert.ok('personas' in shape);
}); });
}); });

View File

@@ -241,3 +241,82 @@ describe('init() profile gate', () => {
// Without guard: UI renders. With guard: splash shown. // Without guard: UI renders. With guard: splash shown.
}); });
}); });
// ── Boot-time 401 redirect suppression ──────
// v0.37.14: Stale cookie blank-login-page fix.
// When the login page boots the SDK with stale localStorage tokens,
// the REST client's 401 handler must NOT redirect to /login (we're
// already there). boot() handles 401 gracefully on its own.
describe('on401Failure redirect suppression', () => {
// Model of the _on401Failure guard logic
function make401Handler() {
let _booting = false;
let redirectedTo = null;
return {
set booting(v) { _booting = v; },
get booting() { return _booting; },
get redirectedTo() { return redirectedTo; },
reset() { redirectedTo = null; },
on401Failure(pathname, basePath) {
// Mirrors the real _on401Failure logic
if (_booting) return;
const loginPath = basePath + '/login';
const here = pathname.replace(/\/+$/, '');
if (here === loginPath) return;
redirectedTo = loginPath;
},
};
}
it('suppresses redirect during boot', () => {
const h = make401Handler();
h.booting = true;
h.on401Failure('/chat', '');
assert.equal(h.redirectedTo, null, 'must NOT redirect during boot');
});
it('allows redirect after boot completes', () => {
const h = make401Handler();
h.booting = true;
h.on401Failure('/chat', '');
assert.equal(h.redirectedTo, null);
h.booting = false;
h.on401Failure('/chat', '');
assert.equal(h.redirectedTo, '/login', 'must redirect after boot');
});
it('suppresses redirect when already on /login', () => {
const h = make401Handler();
h.on401Failure('/login', '');
assert.equal(h.redirectedTo, null, 'must NOT redirect to /login from /login');
});
it('suppresses redirect when on /login with trailing slash', () => {
const h = make401Handler();
h.on401Failure('/login/', '');
assert.equal(h.redirectedTo, null, 'must NOT redirect from /login/');
});
it('handles basePath correctly', () => {
const h = make401Handler();
h.on401Failure('/app/login', '/app');
assert.equal(h.redirectedTo, null, 'must NOT redirect from basePath + /login');
});
it('redirects from non-login paths normally', () => {
const h = make401Handler();
h.on401Failure('/chat', '');
assert.equal(h.redirectedTo, '/login');
});
it('redirects from non-login paths with basePath', () => {
const h = make401Handler();
h.on401Failure('/app/chat', '/app');
assert.equal(h.redirectedTo, '/app/login');
});
});

View File

@@ -0,0 +1,149 @@
// ==========================================
// Channel API Contract Tests
// ==========================================
// Validates channel listing, type filtering, and
// the sidebar's data flow. Catches the channel_type
// vs type parameter mismatch (v0.37.14.2 fix).
//
// Run: node --test src/js/__tests__/channel-contracts.test.js
// ==========================================
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
// ── Channel list response shape ──────────────
describe('GET /channels response contract', () => {
// Backend response from ListChannels handler:
// c.JSON(200, gin.H{"data": channels, "total": total, ...})
const backendResponse = {
data: [
{ id: 'c1', title: 'General', type: 'channel', folder_id: null },
{ id: 'c2', title: 'AI Chat', type: 'direct', folder_id: 'f1' },
{ id: 'c3', title: 'Dev Group', type: 'group', folder_id: null },
{ id: 'c4', title: 'DM: Alice', type: 'dm', folder_id: null },
],
total: 4,
page: 1,
per_page: 100,
};
it('response has "data" array', () => {
assert.ok(Array.isArray(backendResponse.data), 'response.data must be an array');
});
it('each channel has required fields', () => {
for (const ch of backendResponse.data) {
assert.ok(ch.id, 'channel must have id');
assert.ok(ch.type, 'channel must have type');
assert.ok(['direct', 'dm', 'group', 'channel', 'workflow'].includes(ch.type),
`unknown type: ${ch.type}`);
}
});
});
// ── Type filter parameter naming ──────────────
describe('Channel list type filter', () => {
// Simulates URL parameter building (like _qs in api-domains.js)
function buildQueryString(opts) {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(opts)) {
if (v != null) p.set(k, String(v));
}
const s = p.toString();
return s ? '?' + s : '';
}
it('should use "type" param (not "channel_type")', () => {
// The Go handler expects "type" or "types"
const qs = buildQueryString({ page: 1, per_page: 100, type: 'direct' });
assert.ok(qs.includes('type=direct'), `query should contain type=direct, got: ${qs}`);
assert.ok(!qs.includes('channel_type'), 'should NOT use channel_type param');
});
it('should use "types" for multi-type filter', () => {
const qs = buildQueryString({ page: 1, per_page: 200, types: 'direct,dm,group,channel' });
assert.ok(qs.includes('types=direct'), `query should contain types param, got: ${qs}`);
});
it('single type results should all match requested type', () => {
const channels = [
{ id: 'c1', type: 'direct' },
{ id: 'c2', type: 'direct' },
];
for (const ch of channels) {
assert.equal(ch.type, 'direct', `type filter leaked: ${ch.type}`);
}
});
it('multi-type results should only contain requested types', () => {
const channels = [
{ id: 'c1', type: 'direct' },
{ id: 'c2', type: 'group' },
{ id: 'c3', type: 'channel' },
{ id: 'c4', type: 'dm' },
];
const allowed = new Set(['direct', 'dm', 'group', 'channel']);
for (const ch of channels) {
assert.ok(allowed.has(ch.type), `unexpected type: ${ch.type}`);
}
});
});
// ── Sidebar merge deduplication ───────────────
describe('Sidebar channel merge', () => {
it('single request should not produce duplicates', () => {
// After fix: sidebar uses a single request with types=direct,dm,group,channel
const apiResponse = [
{ id: 'c1', title: 'Chat 1', type: 'direct' },
{ id: 'c2', title: 'General', type: 'channel' },
{ id: 'c3', title: 'DM: Bob', type: 'dm' },
];
const ids = apiResponse.map(ch => ch.id);
const uniqueIds = new Set(ids);
assert.equal(ids.length, uniqueIds.size, 'no duplicate ids expected');
});
it('_extract handles nested .data or flat array', () => {
// Mirrors use-sidebar.js _extract pattern
function extract(resp) {
if (!resp) return [];
if (Array.isArray(resp)) return resp;
if (resp.data && Array.isArray(resp.data)) return resp.data;
return [];
}
assert.deepEqual(extract({ data: [{ id: '1' }] }), [{ id: '1' }]);
assert.deepEqual(extract([{ id: '2' }]), [{ id: '2' }]);
assert.deepEqual(extract(null), []);
assert.deepEqual(extract({}), []);
});
});
// ── Channel type feature matrix ───────────────
describe('Channel type feature matrix', () => {
const AI_TYPES = new Set(['direct', 'workflow']);
it('direct type shows model selector', () => {
assert.ok(AI_TYPES.has('direct'));
});
it('workflow type shows model selector', () => {
assert.ok(AI_TYPES.has('workflow'));
});
it('dm type hides model selector', () => {
assert.ok(!AI_TYPES.has('dm'));
});
it('group type hides model selector', () => {
assert.ok(!AI_TYPES.has('group'));
});
it('channel type hides model selector', () => {
assert.ok(!AI_TYPES.has('channel'));
});
});

View File

@@ -78,60 +78,53 @@ describe('Policy evaluation logic', () => {
// ── Team member dropdown logic ─────────────── // ── Team member dropdown logic ───────────────
describe('Team member dropdown population', () => { describe('Team member dropdown population', () => {
function getAvailableUsers(usersResp, membersResp) { // admin/users returns {data:[...], total:N} — has siblings, _unwrap passes through.
const users = usersResp.users || usersResp.data || []; // teams/members returns {data:[...]} — sole key, _unwrap strips to bare array.
const existingIds = new Set((membersResp.data || []).map(m => m.user_id)); function getAvailableUsers(usersResp, membersArr) {
const users = usersResp.data || [];
const existingIds = new Set((membersArr || []).map(m => m.user_id));
return users.filter(u => u.is_active && !existingIds.has(u.id)); return users.filter(u => u.is_active && !existingIds.has(u.id));
} }
it('extracts users from {users:[]} response', () => { it('extracts users from {data:[...], total:N} response', () => {
const usersResp = { users: [ const usersResp = { data: [
{ id: 'u1', username: 'alice', is_active: true }, { id: 'u1', username: 'alice', is_active: true },
{ id: 'u2', username: 'bob', is_active: true }, { id: 'u2', username: 'bob', is_active: true },
]}; ], total: 2};
const membersResp = { data: [] }; const membersArr = [];
const available = getAvailableUsers(usersResp, membersResp); const available = getAvailableUsers(usersResp, membersArr);
assert.equal(available.length, 2); assert.equal(available.length, 2);
}); });
it('falls back to {data:[]} response (backward compat)', () => { it('excludes existing team members', () => {
const usersResp = { data: [ const usersResp = { data: [
{ id: 'u1', username: 'alice', is_active: true }, { id: 'u1', username: 'alice', is_active: true },
]};
const membersResp = { data: [] };
const available = getAvailableUsers(usersResp, membersResp);
assert.equal(available.length, 1);
});
it('excludes existing team members', () => {
const usersResp = { users: [
{ id: 'u1', username: 'alice', is_active: true },
{ id: 'u2', username: 'bob', is_active: true }, { id: 'u2', username: 'bob', is_active: true },
]}; ], total: 2};
const membersResp = { data: [{ user_id: 'u1', role: 'admin' }] }; const membersArr = [{ user_id: 'u1', role: 'admin' }];
const available = getAvailableUsers(usersResp, membersResp); const available = getAvailableUsers(usersResp, membersArr);
assert.equal(available.length, 1); assert.equal(available.length, 1);
assert.equal(available[0].id, 'u2'); assert.equal(available[0].id, 'u2');
}); });
it('excludes inactive users', () => { it('excludes inactive users', () => {
const usersResp = { users: [ const usersResp = { data: [
{ id: 'u1', username: 'alice', is_active: true }, { id: 'u1', username: 'alice', is_active: true },
{ id: 'u2', username: 'disabled', is_active: false }, { id: 'u2', username: 'disabled', is_active: false },
]}; ], total: 2};
const membersResp = { data: [] }; const membersArr = [];
const available = getAvailableUsers(usersResp, membersResp); const available = getAvailableUsers(usersResp, membersArr);
assert.equal(available.length, 1); assert.equal(available.length, 1);
assert.equal(available[0].id, 'u1'); assert.equal(available[0].id, 'u1');
}); });
it('returns empty for empty users response', () => { it('returns empty for empty users response', () => {
const available = getAvailableUsers({ users: [] }, { data: [] }); const available = getAvailableUsers({ data: [] }, []);
assert.equal(available.length, 0); assert.equal(available.length, 0);
}); });
it('handles completely missing response fields', () => { it('handles completely missing response fields', () => {
const available = getAvailableUsers({}, {}); const available = getAvailableUsers({}, []);
assert.equal(available.length, 0); assert.equal(available.length, 0);
}); });
}); });

View File

@@ -222,33 +222,17 @@ const DebugLog = {
url: location.href, url: location.href,
}; };
// API client state (redact tokens) // Preact SDK state (v0.37.14: replaces old API/App globals)
if (typeof API !== 'undefined') { if (window.sw) {
snap.api = { snap.sdk = {
hasAccessToken: !!API.accessToken, user: window.sw.auth?.user ? {
hasRefreshToken: !!API.refreshToken, username: window.sw.auth.user.username,
user: API.user ? { role: window.sw.auth.user.role,
username: API.user.username, display_name: window.sw.auth.user.display_name
role: API.user.role,
display_name: API.user.display_name
} : null, } : null,
isAuthed: API.isAuthed, isAuthed: !!window.sw.auth?.token,
isAdmin: API.isAdmin wsConnected: window.sw.events?.connected ?? false,
}; theme: window.sw.theme?.resolved ?? 'unknown',
}
// App state
if (typeof App !== 'undefined') {
snap.app = {
chatCount: App.chats?.length || 0,
activeConversation: App.activeConversation,
modelCount: App.models?.length || 0,
isGenerating: App.isGenerating,
settings: {
model: App.settings?.model || '?',
stream: App.settings?.stream,
showThinking: App.settings?.showThinking
}
}; };
} }
@@ -261,25 +245,6 @@ const DebugLog = {
snap.storageKeys = '(error reading)'; snap.storageKeys = '(error reading)';
} }
// EventBus state
if (typeof Events !== 'undefined') {
snap.eventBus = {
wsConnected: Events.connected,
subscriptions: Events.debug(),
queueLength: Events._wsQueue?.length || 0
};
}
// Extensions state
if (typeof Extensions !== 'undefined') {
snap.extensions = Extensions.debug();
}
// CM6 editor state
snap.cm6 = window.CM
? { version: window.CM.version, languages: Object.keys(window.CM.languages || {}) }
: { available: false };
return snap; return snap;
}, },
@@ -337,13 +302,13 @@ const DebugLog = {
this.log('DIAG', ` ❌ OPTIONS failed: ${err.message}`); this.log('DIAG', ` ❌ OPTIONS failed: ${err.message}`);
} }
// Test 3: Token validity // Test 3: Token validity via Preact SDK
if (typeof API !== 'undefined' && API.accessToken) { if (window.sw?.auth?.token) {
this.log('DIAG', 'Test 3: Token validation'); this.log('DIAG', 'Test 3: Token validation');
try { try {
const resp = await this._origFetch(baseUrl + '/api/v1/profile', { const resp = await this._origFetch(baseUrl + '/api/v1/profile', {
headers: { headers: {
'Authorization': `Bearer ${API.accessToken}`, 'Authorization': `Bearer ${window.sw.auth.token}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000)
@@ -359,29 +324,10 @@ const DebugLog = {
this.log('DIAG', 'Test 3: Skipped (no token)'); this.log('DIAG', 'Test 3: Skipped (no token)');
} }
// Test 4: WebSocket connectivity // Test 4: WebSocket connectivity via Preact SDK
this.log('DIAG', `Test 4: EventBus WebSocket = ${typeof Events !== 'undefined' ? (Events.connected ? 'connected' : 'disconnected') : 'N/A'}`); this.log('DIAG', `Test 4: SDK WebSocket = ${window.sw?.events?.connected ? 'connected' : 'disconnected'}`);
if (typeof Events !== 'undefined') {
this.log('DIAG', ` Subscriptions: ${JSON.stringify(Events.debug())}`);
this.log('DIAG', ` Queue depth: ${Events._wsQueue?.length || 0}`);
}
// Test 5: Extensions // Test 5: Service Worker & Cache
this.log('DIAG', 'Test 5: Browser extensions');
if (typeof Extensions !== 'undefined') {
const info = Extensions.debug();
const extNames = Object.keys(info.extensions);
this.log('DIAG', ` Loaded: ${extNames.length} extension(s)`);
for (const name of extNames) {
const ext = info.extensions[name];
this.log('DIAG', ` ${name}: ${ext.active ? '✅ active' : '❌ inactive'}, renderers: [${ext.renderers.join(', ')}]`);
}
this.log('DIAG', ` Total renderers: ${info.rendererCount}, tool handlers: ${Extensions._toolHandlers?.size || 0}`);
} else {
this.log('DIAG', ' Extensions not loaded');
}
// Test 6: Service Worker & Cache
this.log('DIAG', 'Test 6: Service Worker cache'); this.log('DIAG', 'Test 6: Service Worker cache');
try { try {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
@@ -652,8 +598,8 @@ async function clearDebugLog() {
function copyDebugLog() { function copyDebugLog() {
const text = DebugLog.exportText(); const text = DebugLog.exportText();
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text)
.then(() => { if (typeof showToast === 'function') showToast('📋 Debug log copied', 'success'); }) .then(() => { DebugLog.log('DEBUG', '📋 Debug log copied to clipboard'); })
.catch(() => { UI.toast('Copy failed', 'error'); }); .catch(() => { DebugLog.log('ERROR', 'Copy to clipboard failed'); });
} }
function exportDebugLog() { function exportDebugLog() {
@@ -712,12 +658,15 @@ async function purgeCache() {
// Initialize ASAP — before app.js init() so we capture everything // Initialize ASAP — before app.js init() so we capture everything
DebugLog.init(); DebugLog.init();
// ── Exports ───────────────────────────────── // ── Exports (v0.37.14: window.* replaces sb.register) ──
sb.ns('DebugLog', DebugLog); window.DebugLog = DebugLog;
sb.register('openDebugModal', openDebugModal); window.openDebugModal = openDebugModal;
sb.register('switchDebugTab', switchDebugTab); window.closeModal = closeModal;
sb.register('clearDebugLog', clearDebugLog); window.openModal = openModal;
sb.register('copyDebugLog', copyDebugLog); window.showConfirm = showConfirm;
sb.register('exportDebugLog', exportDebugLog); window.switchDebugTab = switchDebugTab;
sb.register('runDebugDiagnostics', runDebugDiagnostics); window.clearDebugLog = clearDebugLog;
sb.register('purgeCache', purgeCache); window.copyDebugLog = copyDebugLog;
window.exportDebugLog = exportDebugLog;
window.runDebugDiagnostics = runDebugDiagnostics;
window.purgeCache = purgeCache;

View File

@@ -1,361 +0,0 @@
// ==========================================
// Chat Switchboard EventBus
// ==========================================
// Labeled event bus with WebSocket bridge.
// Components subscribe by label (supports * wildcard).
// Events route: local-only, FE→BE, BE→FE, or both.
//
// Usage:
// Events.on('chat.message.*', (payload, meta) => { ... });
// Events.emit('chat.typing.abc123', { user: 'jeff' });
// Events.connect('/ws');
//
// Exports: window.Events
// ==========================================
const Events = {
_handlers: new Map(), // label → Set<{fn, once}>
_ws: null,
_wsUrl: null,
_wsReconnectMs: 1000,
_wsMaxReconnectMs: 30000,
_wsReconnectTimer: null,
_wsConnected: false,
_wsQueue: [], // buffered while disconnected
// Labels that should NOT cross the wire
_localOnly: new Set([
'ui.modal.open', 'ui.modal.close',
'ui.sidebar.toggle', 'ui.sidebar.collapse',
'ui.toast',
]),
// Labels the FE should never receive from BE
// (enforced server-side, this is defense-in-depth)
_serverOnly: new Set([
'plugin.hook.pre_completion',
'plugin.hook.post_completion',
'internal.',
'db.',
]),
// ── Subscribe ────────────────────────────
/**
* Subscribe to events matching a label pattern.
* Supports exact match and trailing wildcard:
* 'chat.message.abc123' — exact
* 'chat.message.*' — any chat.message.{id}
* 'chat.*' — any chat.{anything}
*
* @param {string} label - Event label or pattern
* @param {Function} fn - Handler(payload, meta)
* @returns {Function} unsubscribe function
*/
on(label, fn) {
if (!this._handlers.has(label)) this._handlers.set(label, new Set());
const entry = { fn, once: false };
this._handlers.get(label).add(entry);
return () => this._handlers.get(label)?.delete(entry);
},
/**
* Subscribe for a single event, then auto-unsubscribe.
*/
once(label, fn) {
if (!this._handlers.has(label)) this._handlers.set(label, new Set());
const entry = { fn, once: true };
this._handlers.get(label).add(entry);
return () => this._handlers.get(label)?.delete(entry);
},
/**
* Remove all handlers for a label, or a specific handler.
*/
off(label, fn) {
if (!fn) {
this._handlers.delete(label);
} else {
const set = this._handlers.get(label);
if (set) {
for (const entry of set) {
if (entry.fn === fn) { set.delete(entry); break; }
}
}
}
},
// ── Emit ─────────────────────────────────
/**
* Emit an event. Dispatches locally and sends via WebSocket
* unless the label is in _localOnly.
*
* @param {string} label - Event label
* @param {*} payload - Event data
* @param {object} opts - { localOnly: bool, room: string }
*/
emit(label, payload, opts = {}) {
const meta = {
event: label,
room: opts.room || null,
ts: Date.now(),
local: true,
};
// Local dispatch
this._dispatch(label, payload, meta);
// Send over WebSocket unless local-only
if (!opts.localOnly && !this._isLocalOnly(label)) {
this._send({ event: label, room: meta.room, payload, ts: meta.ts });
}
},
// ── Internal dispatch ────────────────────
_dispatch(label, payload, meta) {
// Check every registered pattern against this label
for (const [pattern, entries] of this._handlers) {
if (this._match(label, pattern)) {
for (const entry of entries) {
try {
entry.fn(payload, meta);
} catch (e) {
console.error(`[EventBus] Handler error for ${pattern}:`, e);
}
if (entry.once) entries.delete(entry);
}
}
}
},
/**
* Match a concrete label against a subscription pattern.
* 'chat.message.abc' matches:
* 'chat.message.abc' (exact)
* 'chat.message.*' (wildcard last segment)
* 'chat.*' (wildcard all after chat.)
* '*' (match everything)
*/
_match(label, pattern) {
if (pattern === '*') return true;
if (pattern === label) return true;
if (!pattern.endsWith('*')) return false;
const prefix = pattern.slice(0, -1); // 'chat.message.' from 'chat.message.*'
return label.startsWith(prefix);
},
_isLocalOnly(label) {
if (this._localOnly.has(label)) return true;
for (const prefix of this._localOnly) {
if (prefix.endsWith('.') && label.startsWith(prefix)) return true;
}
return false;
},
// ── WebSocket Bridge ─────────────────────
/**
* Connect to WebSocket endpoint. Auto-reconnects on drop.
* @param {string} url - WebSocket URL (e.g. '/ws' or 'wss://host/ws')
*/
connect(url) {
this._wsUrl = url;
this._wsReconnectMs = 1000;
this._wsRetries = 0;
this._wsMaxRetries = 5;
this._doConnect();
},
disconnect() {
if (this._wsReconnectTimer) clearTimeout(this._wsReconnectTimer);
this._wsReconnectTimer = null;
this._wsRetries = 0;
if (this._ws) {
this._ws.onclose = null; // prevent reconnect
this._ws.close();
this._ws = null;
}
this._wsConnected = false;
this._dispatch('ws.disconnected', {}, { event: 'ws.disconnected', ts: Date.now(), local: true });
},
async _doConnect() {
if (!this._wsUrl) return;
// Build full URL
let url = this._wsUrl;
if (url.startsWith('/')) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
url = `${proto}//${location.host}${url}`;
}
// Authenticate: prefer ticket exchange, fall back to legacy ?token=
const authParam = await this._acquireWsAuth();
if (!authParam) {
console.warn('[EventBus] No auth available — skipping WebSocket');
return;
}
url += (url.includes('?') ? '&' : '?') + authParam;
try {
this._ws = new WebSocket(url);
} catch (e) {
console.warn('[EventBus] WebSocket creation failed:', e);
this._scheduleReconnect();
return;
}
this._ws.onopen = () => {
console.log('[EventBus] WebSocket connected');
this._wsConnected = true;
this._wsReconnectMs = 1000;
this._wsRetries = 0;
// Flush queued events
while (this._wsQueue.length > 0) {
const msg = this._wsQueue.shift();
this._ws.send(JSON.stringify(msg));
}
this._dispatch('ws.connected', {}, { event: 'ws.connected', ts: Date.now(), local: true });
};
this._ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
// Server pong
if (msg.event === 'pong') return;
// Drop events that shouldn't reach the frontend
for (const prefix of this._serverOnly) {
if (msg.event.startsWith(prefix)) return;
}
const meta = {
event: msg.event,
room: msg.room || null,
ts: msg.ts || Date.now(),
local: false,
};
this._dispatch(msg.event, msg.payload, meta);
} catch (e) {
console.warn('[EventBus] Bad message:', evt.data);
}
};
this._ws.onclose = (evt) => {
this._wsConnected = false;
this._ws = null;
if (evt.code !== 1000) {
// Abnormal close — log with context
console.log(`[EventBus] WebSocket closed (code=${evt.code}, reason=${evt.reason || 'none'})`);
}
this._dispatch('ws.disconnected', { code: evt.code }, { event: 'ws.disconnected', ts: Date.now(), local: true });
this._scheduleReconnect();
};
this._ws.onerror = () => {
// onclose will fire after this — don't double-log
};
// Heartbeat
this._startHeartbeat();
},
/**
* Acquire WebSocket auth parameter.
* Preferred: POST /api/v1/ws/ticket → ?ticket=<opaque> (v0.28.8+)
* Fallback: JWT access token → ?token=<jwt> (deprecated)
* @returns {string|null} query parameter string or null if no auth
*/
async _acquireWsAuth() {
// Try ticket exchange first
try {
if (typeof API !== 'undefined' && API._post) {
const data = await API._post('/api/v1/ws/ticket', {});
if (data && data.ticket) {
return `ticket=${encodeURIComponent(data.ticket)}`;
}
}
} catch (e) {
// Ticket endpoint unavailable (pre-v0.28.8 server) or auth error.
// Fall through to legacy path silently.
console.debug('[EventBus] Ticket exchange failed, falling back to ?token=', e.message || e);
}
// Legacy fallback: JWT in query param
const token = (typeof API !== 'undefined' && API.accessToken) ? API.accessToken : null;
if (token) {
return `token=${encodeURIComponent(token)}`;
}
return null;
},
_scheduleReconnect() {
if (!this._wsUrl) return;
if (this._wsReconnectTimer) return;
this._wsRetries++;
if (this._wsRetries > this._wsMaxRetries) {
console.warn(`[EventBus] WebSocket failed after ${this._wsMaxRetries} attempts — giving up. Real-time features unavailable.`);
this._dispatch('ws.failed', { retries: this._wsRetries }, { event: 'ws.failed', ts: Date.now(), local: true });
return;
}
console.log(`[EventBus] Reconnecting in ${this._wsReconnectMs}ms (attempt ${this._wsRetries}/${this._wsMaxRetries})`);
this._wsReconnectTimer = setTimeout(() => {
this._wsReconnectTimer = null;
this._doConnect();
}, this._wsReconnectMs);
// Exponential backoff with cap
this._wsReconnectMs = Math.min(this._wsReconnectMs * 2, this._wsMaxReconnectMs);
},
_send(msg) {
if (this._wsConnected && this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify(msg));
} else {
// Buffer up to 100 events while disconnected
if (this._wsQueue.length < 100) this._wsQueue.push(msg);
}
},
_heartbeatInterval: null,
_startHeartbeat() {
if (this._heartbeatInterval) clearInterval(this._heartbeatInterval);
this._heartbeatInterval = setInterval(() => {
if (this._wsConnected) {
this._send({ event: 'ping', payload: {}, ts: Date.now() });
}
}, 30000);
},
// ── Utility ──────────────────────────────
/** Check if WebSocket is connected */
get connected() { return this._wsConnected; },
/** Remove all handlers (for cleanup / logout) */
clear() {
this._handlers.clear();
},
/** Debug: list all subscriptions */
debug() {
const subs = {};
for (const [label, entries] of this._handlers) {
subs[label] = entries.size;
}
return subs;
}
};
sb.ns('Events', Events);

View File

@@ -7,7 +7,7 @@
// //
// Features: // Features:
// - AsyncFunction wrapper (top-level await) // - AsyncFunction wrapper (top-level await)
// - Injected globals: API, Events, Extensions, DebugLog, Surfaces, Stores // - Injected globals: sw, DebugLog (v0.37.14: old globals removed)
// - Pretty-print results (collapsible JSON, red errors with stack) // - Pretty-print results (collapsible JSON, red errors with stack)
// - Command history: ↑/↓ with sessionStorage // - Command history: ↑/↓ with sessionStorage
// - Tab-completion on live object graphs // - Tab-completion on live object graphs
@@ -57,7 +57,7 @@ const REPL = {
const tab = document.querySelector('.debug-tab[data-tab="repl"]'); const tab = document.querySelector('.debug-tab[data-tab="repl"]');
if (!tab) return; if (!tab) return;
const isAdmin = typeof API !== 'undefined' && API.user?.role === 'admin'; const isAdmin = window.sw?.auth?.user?.role === 'admin';
const debugParam = new URLSearchParams(window.location.search).has('debug'); const debugParam = new URLSearchParams(window.location.search).has('debug');
if (!isAdmin && !debugParam) { if (!isAdmin && !debugParam) {
@@ -144,13 +144,9 @@ const REPL = {
_getGlobals() { _getGlobals() {
const globals = {}; const globals = {};
// Core app objects // Preact SDK (v0.37.14: replaces old API/Events/Extensions globals)
if (typeof API !== 'undefined') globals.API = API; if (window.sw) globals.sw = window.sw;
if (typeof Events !== 'undefined') globals.Events = Events;
if (typeof Extensions !== 'undefined') globals.Extensions = Extensions;
if (typeof DebugLog !== 'undefined') globals.DebugLog = DebugLog; if (typeof DebugLog !== 'undefined') globals.DebugLog = DebugLog;
if (typeof PanelRegistry !== 'undefined') globals.Panels = PanelRegistry;
if (typeof UI !== 'undefined') globals.UI = UI;
// Convenience aliases // Convenience aliases
globals.$ = (sel) => document.querySelector(sel); globals.$ = (sel) => document.querySelector(sel);
@@ -246,13 +242,6 @@ const REPL = {
const cursor = input.selectionStart; const cursor = input.selectionStart;
const text = input.value.substring(0, cursor); const text = input.value.substring(0, cursor);
// Event label completion: Events.on(' or Events.emit('
const eventMatch = text.match(/Events\.(on|once|emit|off)\s*\(\s*['"]([^'"]*)?$/);
if (eventMatch) {
this._completeEventLabels(input, cursor, eventMatch[2] || '');
return;
}
// Object property completion: obj.prop or obj.prop.sub // Object property completion: obj.prop or obj.prop.sub
const propMatch = text.match(/([\w$.]+)\.([\w]*)$/); const propMatch = text.match(/([\w$.]+)\.([\w]*)$/);
if (propMatch) { if (propMatch) {
@@ -267,25 +256,6 @@ const REPL = {
} }
}, },
_completeEventLabels(input, cursor, partial) {
// Gather known event labels from the Events handler map
if (typeof Events === 'undefined') return;
const labels = Array.from(Events._handlers?.keys() || []);
const matches = labels.filter(l => l.startsWith(partial)).sort();
if (matches.length === 0) return;
if (matches.length === 1) {
this._insertCompletion(input, cursor, partial, matches[0]);
} else {
this._appendInfo('Completions: ' + matches.join(', '));
// Complete common prefix
const common = this._commonPrefix(matches);
if (common.length > partial.length) {
this._insertCompletion(input, cursor, partial, common);
}
}
},
_completeProperty(input, cursor, objExpr, partial) { _completeProperty(input, cursor, objExpr, partial) {
try { try {
// Resolve the object in global scope // Resolve the object in global scope
@@ -538,7 +508,7 @@ const REPL = {
'Injected globals: ' + globals.join(', '), 'Injected globals: ' + globals.join(', '),
'', '',
'Top-level await is supported:', 'Top-level await is supported:',
' const r = await API._get("/api/v1/me")', ' const r = await sw.api.get("/api/v1/profile")',
'', '',
'Shortcuts:', 'Shortcuts:',
' $(sel) → document.querySelector(sel)', ' $(sel) → document.querySelector(sel)',
@@ -551,7 +521,7 @@ const REPL = {
}; };
// Initialized from app.js startApp() after auth is confirmed, // Initialized from app.js startApp() after auth is confirmed,
// ensuring API.user.role is available for admin gate check. // ensuring auth is available for admin gate check.
// ── Exports ───────────────────────────────── // ── Exports (v0.37.14: window.* replaces sb.ns) ──
sb.ns('REPL', REPL); window.REPL = REPL;

View File

@@ -1,133 +0,0 @@
// ==========================================
// Chat Switchboard Action Registry (sb.js)
// ==========================================
// Central registry for all dispatchable actions. Loaded first
// in base.html, before all other JS files.
//
// Files register their exports:
// sb.ns('UI', UI); // namespace object
// sb.register('deleteChat', fn); // standalone action
//
// Dispatch (from data-action attributes):
// sb.resolve('deleteChat') → fn
// sb.resolve('UI.copyMessage') → UI.copyMessage.bind(UI)
//
// Template bridge (Go onclick handlers):
// sb.call('deleteChat', id) → fn(id)
// sb.call('UI.toggleFolder', id) → UI.toggleFolder(id)
//
// Introspection:
// sb.list() → { actions: [...], namespaces: [...] }
//
// Extension hook (future):
// Extensions register actions into the same registry.
// Platform and extension actions are dispatched identically.
//
// Exports: window.sb
const sb = {
_actions: new Map(),
_namespaces: new Map(),
/**
* Register a standalone action function.
* Also sets window[name] for backward compatibility with cross-file
* direct calls. Template onclick handlers are fully migrated to sb.call()
* (Phase 3b). This side-effect is removed in Phase 4 when files convert
* to ES module imports.
*/
register(name, fn) {
if (typeof fn !== 'function') {
console.warn('[sb] register: not a function:', name);
return;
}
this._actions.set(name, fn);
window[name] = fn; // backward compat — remove when ES modules land
},
/**
* Register a namespace object (API, UI, App, etc.).
* Methods are accessible via dot notation: sb.resolve('UI.toast').
* Also sets window[name] for backward compatibility with cross-file
* direct calls (e.g. API.listProjects()). Removed in Phase 4.
*/
ns(name, obj) {
if (obj == null || typeof obj !== 'object') {
console.warn('[sb] ns: not an object:', name);
return;
}
this._namespaces.set(name, obj);
window[name] = obj; // backward compat
},
/**
* Resolve an action by name. Returns the function or null.
* Supports flat names ('deleteChat') and dot notation ('UI.copyMessage').
*/
resolve(name) {
// Flat action
const flat = this._actions.get(name);
if (flat) return flat;
// Dot notation: namespace.method
const dot = name.indexOf('.');
if (dot > 0) {
const nsName = name.slice(0, dot);
const method = name.slice(dot + 1);
const obj = this._namespaces.get(nsName);
if (obj && typeof obj[method] === 'function') {
return obj[method].bind(obj);
}
// Try nested: UI._deleteRoutingPolicy → UI['_deleteRoutingPolicy']
if (obj && typeof obj[method] !== 'undefined') {
return typeof obj[method] === 'function' ? obj[method].bind(obj) : null;
}
}
// Fallback: check window (catches un-migrated globals)
if (typeof window[name] === 'function') return window[name];
return null;
},
/**
* Call an action by name. Template bridge.
* Go templates use: onclick="sb.call('action', arg1, arg2)"
*/
call(name, ...args) {
const fn = this.resolve(name);
if (fn) return fn(...args);
console.warn('[sb] Unknown action:', name);
},
/**
* Call with event passthrough (for context menu positioning).
* Go templates use: onclick="sb.callEvent(event, 'showProjectMenu', id)"
*/
callEvent(event, name, ...args) {
event?.stopPropagation?.();
const fn = this.resolve(name);
if (fn) return fn(event, ...args);
console.warn('[sb] Unknown action:', name);
},
/**
* Introspection: list all registered actions and namespaces.
*/
list() {
return {
actions: [...this._actions.keys()].sort(),
namespaces: [...this._namespaces.keys()].sort(),
total: this._actions.size + this._namespaces.size,
};
},
/**
* Check if an action is registered.
*/
has(name) {
return this.resolve(name) !== null;
},
};
window.sb = sb;

View File

@@ -27,7 +27,7 @@ export function ChatHistory({ channelId, onSelect, onNew }) {
if (!window.sw?.api?.channels?.list) return; if (!window.sw?.api?.channels?.list) return;
window.sw.api.channels.list({ page: 1, per_page: 20, channel_type: 'direct' }) window.sw.api.channels.list({ page: 1, per_page: 20, channel_type: 'direct' })
.then(resp => { .then(resp => {
setChannels(resp?.data || resp || []); setChannels(resp || []);
}) })
.catch(() => {}); .catch(() => {});
} }

View File

@@ -25,8 +25,8 @@ import { ChatHistory } from './chat-history.js';
import { renderMarkdown } from './markdown.js'; import { renderMarkdown } from './markdown.js';
import { CodeBlock, extractCodeBlocks } from './code-block.js'; import { CodeBlock, extractCodeBlocks } from './code-block.js';
import { MessageActions } from './message-actions.js'; import { MessageActions } from './message-actions.js';
import { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
import { EmojiPicker } from './emoji-picker.js'; import { EmojiPicker } from './emoji-picker.js';
import { TypingIndicator } from './typing-indicator.js';
const { useRef, useEffect, useCallback } = window.hooks; const { useRef, useEffect, useCallback } = window.hooks;
const html = window.html; const html = window.html;
@@ -59,9 +59,11 @@ export function ChatPane(props) {
initialChannelId, initialChannelId,
getContext, getContext,
onChannelChange, onChannelChange,
externalModel: props.modelId,
}); });
// Expose imperative handle via handleRef prop // Expose imperative handle via handleRef prop
// Intentional: no deps — handle methods must reference latest chat closures
useEffect(() => { useEffect(() => {
if (handleRef) { if (handleRef) {
handleRef.current = { handleRef.current = {
@@ -73,6 +75,8 @@ export function ChatPane(props) {
clearInput() { inputHandle.current?.clear(); }, clearInput() { inputHandle.current?.clear(); },
clear() { chat.newChat(); }, clear() { chat.newChat(); },
stop() { chat.stop(); }, stop() { chat.stop(); },
setModel(id) { chat.setModel(id); },
getModel() { return chat.model; },
}; };
} }
}); });
@@ -103,18 +107,22 @@ export function ChatPane(props) {
<${MessageList} <${MessageList}
messages=${chat.messages} messages=${chat.messages}
streamContent=${chat.streamContent} streamContent=${chat.streamContent}
streamThinking=${chat.streamThinking}
streaming=${chat.streaming} streaming=${chat.streaming}
hasMore=${chat.hasMore} hasMore=${chat.hasMore}
loadingMore=${chat.loadingMore} loadingMore=${chat.loadingMore}
onLoadMore=${chat.loadMore} onLoadMore=${chat.loadMore}
onRegenerate=${chat.regenerate} onRegenerate=${chat.regenerate}
onDelete=${chat.deleteMessage} /> onDelete=${chat.deleteMessage} />
<${TypingIndicator} users=${chat.typingUsers} />
<${MessageInput} <${MessageInput}
handleRef=${inputHandle} handleRef=${inputHandle}
onSend=${chat.sendMessage} onSend=${chat.sendMessage}
onStop=${chat.stop} onStop=${chat.stop}
disabled=${chat.sending} disabled=${chat.sending}
streaming=${chat.streaming} /> streaming=${chat.streaming}
channelId=${chat.channelId}
onTyping=${chat.emitTyping} />
</div>`; </div>`;
} }
@@ -129,5 +137,5 @@ export { ChatHistory } from './chat-history.js';
export { renderMarkdown } from './markdown.js'; export { renderMarkdown } from './markdown.js';
export { CodeBlock, extractCodeBlocks } from './code-block.js'; export { CodeBlock, extractCodeBlocks } from './code-block.js';
export { MessageActions } from './message-actions.js'; export { MessageActions } from './message-actions.js';
export { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
export { EmojiPicker } from './emoji-picker.js'; export { EmojiPicker } from './emoji-picker.js';
export { TypingIndicator } from './typing-indicator.js';

View File

@@ -1,110 +0,0 @@
// ==========================================
// ChatPane Kit — MarkdownToolbar Component
// ==========================================
// Formatting toolbar for the message input.
// Wraps selection with markdown syntax. Independently importable.
//
// Usage:
// html`<${MarkdownToolbar} textareaRef=${ref} onInput=${fn} />`
const html = window.html;
/**
* Insert markdown wrapper around the textarea's selection.
*
* @param {HTMLTextAreaElement} ta
* @param {string} before — prefix (e.g. '**')
* @param {string} after — suffix (e.g. '**')
* @param {string} placeholder — text if nothing selected
* @param {Function} onInput — trigger resize after edit
*/
function _wrap(ta, before, after, placeholder, onInput) {
if (!ta) return;
ta.focus();
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = ta.value.slice(start, end) || placeholder;
const replacement = before + selected + after;
// Use execCommand for undo support, fallback to direct edit
const hasExecCommand = document.queryCommandSupported?.('insertText');
if (hasExecCommand) {
document.execCommand('insertText', false, replacement);
} else {
ta.value = ta.value.slice(0, start) + replacement + ta.value.slice(end);
}
// Select the inserted text (without wrappers) for quick overtype
const selStart = start + before.length;
const selEnd = selStart + selected.length;
ta.setSelectionRange(selStart, selEnd);
if (onInput) onInput();
}
/**
* Insert text at cursor (no wrapping).
*/
function _insert(ta, text, onInput) {
if (!ta) return;
ta.focus();
const start = ta.selectionStart;
const hasExecCommand = document.queryCommandSupported?.('insertText');
if (hasExecCommand) {
document.execCommand('insertText', false, text);
} else {
ta.value = ta.value.slice(0, start) + text + ta.value.slice(ta.selectionEnd);
}
const pos = start + text.length;
ta.setSelectionRange(pos, pos);
if (onInput) onInput();
}
const ACTIONS = [
{ key: 'bold', label: 'B', title: 'Bold (Ctrl+B)', fn: (ta, cb) => _wrap(ta, '**', '**', 'bold text', cb) },
{ key: 'italic', label: 'I', title: 'Italic (Ctrl+I)', fn: (ta, cb) => _wrap(ta, '_', '_', 'italic text', cb), style: 'font-style:italic' },
{ key: 'code', label: '<>', title: 'Inline code', fn: (ta, cb) => _wrap(ta, '`', '`', 'code', cb), style: 'font-family:var(--mono, monospace);font-size:11px' },
{ key: 'codeblk', label: '```',title: 'Code block', fn: (ta, cb) => _wrap(ta, '```\n', '\n```', 'code', cb), style: 'font-family:var(--mono, monospace);font-size:10px' },
{ key: 'link', label: '🔗', title: 'Link', fn: (ta, cb) => _wrap(ta, '[', '](url)', 'link text', cb) },
{ key: 'list', label: '•', title: 'Bullet list', fn: (ta, cb) => _insert(ta, '\n- ', cb) },
{ key: 'olist', label: '1.', title: 'Numbered list', fn: (ta, cb) => _insert(ta, '\n1. ', cb) },
{ key: 'heading', label: 'H', title: 'Heading', fn: (ta, cb) => _insert(ta, '\n## ', cb), style: 'font-weight:700' },
{ key: 'quote', label: '>', title: 'Blockquote', fn: (ta, cb) => _insert(ta, '\n> ', cb) },
];
/**
* @param {{ textareaRef: { current: HTMLTextAreaElement }, onInput?: () => void }} props
*/
export function MarkdownToolbar({ textareaRef, onInput }) {
return html`
<div class="sw-md-toolbar" role="toolbar" aria-label="Formatting">
${ACTIONS.map(a => html`
<button key=${a.key}
class="sw-md-toolbar__btn"
title=${a.title}
style=${a.style || ''}
onClick=${() => a.fn(textareaRef.current, onInput)}
tabindex="-1"
type="button">
${a.label}
</button>`)}
</div>`;
}
/**
* Handle keyboard shortcuts for formatting.
* Call from textarea onKeyDown handler.
*
* @param {KeyboardEvent} e
* @param {HTMLTextAreaElement} ta
* @param {Function} onInput
* @returns {boolean} true if handled
*/
export function handleFormatShortcut(e, ta, onInput) {
if (!(e.ctrlKey || e.metaKey)) return false;
switch (e.key) {
case 'b': e.preventDefault(); _wrap(ta, '**', '**', 'bold', onInput); return true;
case 'i': e.preventDefault(); _wrap(ta, '_', '_', 'italic', onInput); return true;
case 'e': e.preventDefault(); _wrap(ta, '`', '`', 'code', onInput); return true;
default: return false;
}
}

View File

@@ -4,6 +4,10 @@
// Wraps window.marked + window.DOMPurify with fallback. // Wraps window.marked + window.DOMPurify with fallback.
// Independently importable utility. // Independently importable utility.
// //
// Note: This module uses innerHTML via DOMPurify (sanitized) and imperative
// DOM patterns intentionally for rendering performance. This is a documented
// exception from the "no raw DOM" convention (Critical Review P3-4).
//
// v0.37.10: Task list rendering, code block language extraction. // v0.37.10: Task list rendering, code block language extraction.
// //
// Usage: // Usage:
@@ -33,21 +37,21 @@ function _ensureConfigured() {
gfm: true, gfm: true,
}); });
// Custom renderer for task list items // Custom renderer for task list items (marked v16 token-based API)
const renderer = new marked.Renderer(); marked.use({
const origListItem = renderer.listitem?.bind(renderer); renderer: {
renderer.listitem = function(text, task, checked) { listitem(token) {
if (task) { const body = this.parser.parse(token.tokens);
const checkbox = checked if (token.task) {
? '<input type="checkbox" checked disabled class="sw-task-checkbox" />' const checkbox = token.checked
: '<input type="checkbox" disabled class="sw-task-checkbox" />'; ? '<input type="checkbox" checked disabled class="sw-task-checkbox" />'
return '<li class="sw-task-item">' + checkbox + ' ' + text + '</li>\n'; : '<input type="checkbox" disabled class="sw-task-checkbox" />';
} return '<li class="sw-task-item">' + checkbox + ' ' + body + '</li>\n';
if (origListItem) return origListItem(text, task, checked); }
return '<li>' + text + '</li>\n'; return '<li>' + body + '</li>\n';
}; },
},
marked.use({ renderer }); });
} }
/** /**
@@ -69,6 +73,17 @@ export function renderMarkdown(text) {
result = _escapeHtml(text); result = _escapeHtml(text);
} }
// Highlight @mentions as styled spans (runs after sanitization — safe)
result = result.replace(/(^|[\s>])@([\w-]+)/g, '$1<span class="sw-mention">@$2</span>');
// Run pipe render filters (extensions can transform final HTML)
if (window.sw?.pipe?._runRender) {
const pipeCtx = window.sw.pipe._runRender({ html: result, raw: text });
if (pipeCtx !== null && pipeCtx !== undefined) {
result = pipeCtx.html;
}
}
_lastInput = text; _lastInput = text;
_lastOutput = result; _lastOutput = result;
return result; return result;

View File

@@ -38,6 +38,7 @@ function _relTime(isoDate) {
* @param {{ * @param {{
* role: string, * role: string,
* content: string, * content: string,
* thinking?: string,
* id?: string, * id?: string,
* created_at?: string, * created_at?: string,
* streaming?: boolean, * streaming?: boolean,
@@ -45,8 +46,11 @@ function _relTime(isoDate) {
* onDelete?: () => void, * onDelete?: () => void,
* }} props * }} props
*/ */
export function MessageBubble({ role, content, id, created_at, streaming, onRegenerate, onDelete }) { export function MessageBubble({ role, content, thinking, id, created_at, streaming, onRegenerate, onDelete, isMine, senderName }) {
const cls = 'sw-chat-msg sw-chat-msg--' + role // isMine: true = right-aligned (current user), false = left-aligned (other user/assistant)
const alignment = role === 'assistant' ? 'assistant'
: (isMine === false ? 'other' : 'user');
const cls = 'sw-chat-msg sw-chat-msg--' + alignment
+ (streaming ? ' sw-chat-msg--streaming' : ''); + (streaming ? ' sw-chat-msg--streaming' : '');
// Parse markdown and extract code blocks for enhanced rendering // Parse markdown and extract code blocks for enhanced rendering
@@ -55,10 +59,26 @@ export function MessageBubble({ role, content, id, created_at, streaming, onRege
return extractCodeBlocks(rendered); return extractCodeBlocks(rendered);
}, [content]); }, [content]);
const thinkingHtml = useMemo(() => {
if (!thinking) return null;
return renderMarkdown(thinking);
}, [thinking]);
const timeStr = _relTime(created_at); const timeStr = _relTime(created_at);
// While streaming, show thinking open if content hasn't started yet
const thinkingOpen = streaming && thinking && !content;
return html` return html`
<div class=${cls}> <div class=${cls}>
${senderName && alignment === 'other' && html`
<div class="sw-chat-msg__sender">${senderName}</div>`}
${thinkingHtml && html`
<details class="sw-chat-msg__thinking" open=${thinkingOpen || undefined}>
<summary class="sw-chat-msg__thinking-toggle">Thinking\u2026</summary>
<div class="sw-chat-msg__thinking-content"
dangerouslySetInnerHTML=${{ __html: thinkingHtml }} />
</details>`}
<div class="sw-chat-msg__content"> <div class="sw-chat-msg__content">
${segments.map((seg, i) => ${segments.map((seg, i) =>
seg.type === 'code' seg.type === 'code'

View File

@@ -1,18 +1,18 @@
// ========================================== // ==========================================
// ChatPane Kit — MessageInput Component // ChatPane Kit — MessageInput Component
// ========================================== // ==========================================
// Auto-resize textarea with Enter-to-send, markdown toolbar, // CM6-powered chat input with markdown highlighting,
// emoji picker, and stop button. // emoji picker, and stop button.
// Independently importable. // Independently importable.
// //
// v0.37.10: Added MarkdownToolbar, EmojiPicker, stop overlay, aria. // v0.37.10: Added MarkdownToolbar, EmojiPicker, stop overlay, aria.
// v0.37.14: Replaced textarea + toolbar with CM.chatInput().
// //
// Usage: // Usage:
// const inputHandle = useRef(null); // const inputHandle = useRef(null);
// html`<${MessageInput} onSend=${fn} onStop=${fn} disabled=${false} // html`<${MessageInput} onSend=${fn} onStop=${fn} disabled=${false}
// streaming=${false} handleRef=${inputHandle} />` // streaming=${false} handleRef=${inputHandle} />`
import { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
import { EmojiPicker } from './emoji-picker.js'; import { EmojiPicker } from './emoji-picker.js';
const { useRef, useState, useCallback, useEffect } = window.hooks; const { useRef, useState, useCallback, useEffect } = window.hooks;
@@ -40,6 +40,11 @@ const EMOJI_SVG = html`
<line x1="15" y1="9" x2="15.01" y2="9"/> <line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>`; </svg>`;
// Convert display name to @mention handle (mirrors Go HandleFromName)
function _toHandle(name) {
return (name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
/** /**
* @param {{ * @param {{
* onSend: (text: string) => void, * onSend: (text: string) => void,
@@ -47,12 +52,136 @@ const EMOJI_SVG = html`
* disabled?: boolean, * disabled?: boolean,
* streaming?: boolean, * streaming?: boolean,
* handleRef?: { current: any }, * handleRef?: { current: any },
* channelId?: string,
* }} props * }} props
*/ */
export function MessageInput({ onSend, onStop, disabled, streaming, handleRef }) { export function MessageInput({ onSend, onStop, disabled, streaming, handleRef, channelId, onTyping }) {
const taRef = useRef(null); const containerRef = useRef(null);
const editorRef = useRef(null);
const taRef = useRef(null); // fallback textarea
const [emojiOpen, setEmojiOpen] = useState(false); const [emojiOpen, setEmojiOpen] = useState(false);
const onSendRef = useRef(onSend);
onSendRef.current = onSend;
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const participantsCache = useRef({ channelId: null, list: null });
const onTypingRef = useRef(onTyping);
onTypingRef.current = onTyping;
const typingTimerRef = useRef(null);
const isTypingRef = useRef(false);
// ── CM6 editor lifecycle ────────────────────
useEffect(() => {
if (!window.CM?.chatInput || !containerRef.current) return;
// Build @mention completer bound to current channelId
const mentionCompleter = async (query) => {
if (!channelId || !window.sw?.api?.channels?.participants) return [];
// Fetch and cache participants per channel
if (participantsCache.current.channelId !== channelId) {
try {
const resp = await window.sw.api.channels.participants(channelId);
participantsCache.current = {
channelId,
list: (resp || []).map(p => ({
label: p.display_name || p.participant_id,
handle: _toHandle(p.display_name || p.participant_id),
type: p.participant_type || 'user',
})),
};
} catch (e) {
console.warn('[mention] Failed to fetch participants:', e);
return [];
}
}
const q = query.toLowerCase();
const results = participantsCache.current.list.filter(p =>
p.handle.startsWith(q) || p.label.toLowerCase().includes(q)
);
// Add @all option
if (!q || 'all'.startsWith(q)) {
results.unshift({ label: 'All users', handle: 'all', type: 'all' });
}
return results;
};
// Build extensions array with @mention support
const extraExtensions = window.CM.mentionExtension
? window.CM.mentionExtension({ completer: mentionCompleter })
: [];
const editor = window.CM.chatInput(containerRef.current, {
placeholder: 'Type a message\u2026',
maxHeight: 160,
extensions: extraExtensions,
onSubmit: (text) => {
const trimmed = text?.trim();
if (trimmed && !disabledRef.current) {
onSendRef.current(trimmed);
editor.setValue('');
// Stop typing on send
if (isTypingRef.current) {
isTypingRef.current = false;
onTypingRef.current?.(false);
}
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
}
},
onChange: () => {
// Emit typing start (debounced: stop after 3s of inactivity)
if (!isTypingRef.current) {
isTypingRef.current = true;
onTypingRef.current?.(true);
}
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
typingTimerRef.current = setTimeout(() => {
isTypingRef.current = false;
onTypingRef.current?.(false);
}, 3000);
},
});
editorRef.current = editor;
return () => {
editor.destroy();
editorRef.current = null;
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
if (isTypingRef.current) {
isTypingRef.current = false;
onTypingRef.current?.(false);
}
};
}, [channelId]);
// ── Imperative handle ───────────────────────
useEffect(() => {
if (!handleRef) return;
handleRef.current = {
getValue() {
if (editorRef.current) return editorRef.current.getValue();
return taRef.current?.value || '';
},
setValue(val) {
if (editorRef.current) { editorRef.current.setValue(val || ''); return; }
if (taRef.current) { taRef.current.value = val || ''; _autoResize(); }
},
focus() {
if (editorRef.current) { editorRef.current.focus(); return; }
taRef.current?.focus();
},
clear() {
if (editorRef.current) { editorRef.current.setValue(''); return; }
if (taRef.current) { taRef.current.value = ''; _autoResize(); }
},
};
return () => { handleRef.current = null; };
}, [handleRef]);
// ── Fallback textarea helpers ───────────────
function _autoResize() { function _autoResize() {
const ta = taRef.current; const ta = taRef.current;
if (!ta) return; if (!ta) return;
@@ -60,26 +189,9 @@ export function MessageInput({ onSend, onStop, disabled, streaming, handleRef })
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
} }
// Expose imperative handle via handleRef prop
useEffect(() => {
if (handleRef) {
handleRef.current = {
getValue() { return taRef.current?.value || ''; },
setValue(val) { if (taRef.current) { taRef.current.value = val; _autoResize(); } },
focus() { taRef.current?.focus(); },
clear() { if (taRef.current) { taRef.current.value = ''; _autoResize(); } },
getTextarea() { return taRef.current; },
};
}
return () => { if (handleRef) handleRef.current = null; };
}, [handleRef]);
const _onInput = useCallback(() => _autoResize(), []); const _onInput = useCallback(() => _autoResize(), []);
const _onKeyDown = useCallback((e) => { const _onKeyDown = useCallback((e) => {
// Formatting shortcuts (Ctrl+B, Ctrl+I, Ctrl+E)
if (handleFormatShortcut(e, taRef.current, _autoResize)) return;
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
const text = taRef.current?.value?.trim(); const text = taRef.current?.value?.trim();
@@ -91,14 +203,26 @@ export function MessageInput({ onSend, onStop, disabled, streaming, handleRef })
}, [onSend, disabled]); }, [onSend, disabled]);
const _onClickSend = useCallback(() => { const _onClickSend = useCallback(() => {
const text = taRef.current?.value?.trim(); const text = editorRef.current
? editorRef.current.getValue()?.trim()
: taRef.current?.value?.trim();
if (text && !disabled) { if (text && !disabled) {
onSend(text); onSend(text);
if (taRef.current) { taRef.current.value = ''; _autoResize(); } if (editorRef.current) editorRef.current.setValue('');
else if (taRef.current) { taRef.current.value = ''; _autoResize(); }
} }
}, [onSend, disabled]); }, [onSend, disabled]);
// ── Emoji ───────────────────────────────────
const _insertEmoji = useCallback((emoji) => { const _insertEmoji = useCallback((emoji) => {
if (editorRef.current) {
const view = editorRef.current.getView();
const pos = view.state.selection.main.head;
view.dispatch({ changes: { from: pos, insert: emoji } });
view.focus();
return;
}
// Fallback: textarea
const ta = taRef.current; const ta = taRef.current;
if (!ta) return; if (!ta) return;
ta.focus(); ta.focus();
@@ -117,20 +241,24 @@ export function MessageInput({ onSend, onStop, disabled, streaming, handleRef })
const _toggleEmoji = useCallback(() => setEmojiOpen(v => !v), []); const _toggleEmoji = useCallback(() => setEmojiOpen(v => !v), []);
const _closeEmoji = useCallback(() => setEmojiOpen(false), []); const _closeEmoji = useCallback(() => setEmojiOpen(false), []);
// ── Render ──────────────────────────────────
const useCM = !!window.CM?.chatInput;
return html` return html`
<div class="sw-chat-pane__input-bar"> <div class="sw-chat-pane__input-bar">
<${MarkdownToolbar} textareaRef=${taRef} onInput=${_autoResize} />
<div class="sw-msg-input__wrap"> <div class="sw-msg-input__wrap">
<textarea ${useCM
ref=${taRef} ? html`<div ref=${containerRef} class="sw-msg-input__cm-container" />`
class="sw-msg-input__textarea" : html`<textarea
placeholder="Type a message\u2026" ref=${taRef}
rows="1" class="sw-msg-input__textarea"
disabled=${disabled} placeholder="Type a message\u2026"
onInput=${_onInput} rows="1"
onKeyDown=${_onKeyDown} disabled=${disabled}
aria-label="Message input" onInput=${_onInput}
/> onKeyDown=${_onKeyDown}
aria-label="Message input"
/>`}
<div class="sw-msg-input__buttons"> <div class="sw-msg-input__buttons">
<div class="sw-msg-input__emoji-wrap"> <div class="sw-msg-input__emoji-wrap">
<button <button

View File

@@ -23,6 +23,7 @@ const SCROLL_THRESHOLD = 60;
* @param {{ * @param {{
* messages: Array<{role: string, content: string, id?: string, created_at?: string}>, * messages: Array<{role: string, content: string, id?: string, created_at?: string}>,
* streamContent?: string, * streamContent?: string,
* streamThinking?: string,
* streaming?: boolean, * streaming?: boolean,
* hasMore?: boolean, * hasMore?: boolean,
* loadingMore?: boolean, * loadingMore?: boolean,
@@ -33,7 +34,7 @@ const SCROLL_THRESHOLD = 60;
* }} props * }} props
*/ */
export function MessageList({ export function MessageList({
messages, streamContent, streaming, hasMore, loadingMore, messages, streamContent, streamThinking, streaming, hasMore, loadingMore,
onLoadMore, onRegenerate, onDelete, className, onLoadMore, onRegenerate, onDelete, className,
}) { }) {
const scrollRef = useRef(null); const scrollRef = useRef(null);
@@ -68,6 +69,7 @@ export function MessageList({
}, [messages.length]); }, [messages.length]);
const hasContent = messages.length > 0 || streaming; const hasContent = messages.length > 0 || streaming;
const myUserId = window.sw?.auth?.user?.id;
return html` return html`
<div class=${'sw-chat-pane__messages' + (className ? ' ' + className : '')} <div class=${'sw-chat-pane__messages' + (className ? ' ' + className : '')}
@@ -90,16 +92,25 @@ export function MessageList({
<p class="sw-chat-welcome__title">Start a conversation</p> <p class="sw-chat-welcome__title">Start a conversation</p>
<p class="sw-chat-welcome__hint">Type a message below to get started.</p> <p class="sw-chat-welcome__hint">Type a message below to get started.</p>
</div>`} </div>`}
${messages.map((m, i) => html` ${messages.map((m, i) => {
// Determine if this message was sent by the current user
const senderId = m.user_id || m.participant_id;
const isMine = m.role === 'user' && (!senderId || senderId === myUserId);
return html`
<${MessageBubble} <${MessageBubble}
key=${m.id || i} key=${m.id || i}
role=${m.role} role=${m.role}
content=${m.content} content=${m.content}
thinking=${m.thinking}
id=${m.id} id=${m.id}
created_at=${m.created_at} created_at=${m.created_at}
isMine=${isMine}
senderName=${m.role === 'user' && !isMine ? (m.display_name || m.sender_name || m.username || '') : ''}
onRegenerate=${m.role === 'assistant' && m.id && onRegenerate ? () => onRegenerate(m.id) : undefined} onRegenerate=${m.role === 'assistant' && m.id && onRegenerate ? () => onRegenerate(m.id) : undefined}
onDelete=${m.id && onDelete ? () => onDelete(m.id) : undefined} />`)} onDelete=${m.id && onDelete ? () => onDelete(m.id) : undefined} />`;
})}
${streaming && html` ${streaming && html`
<${MessageBubble} role="assistant" content=${streamContent} streaming=${true} />`} <${MessageBubble} role="assistant" content=${streamContent}
thinking=${streamThinking} streaming=${true} />`}
</div>`; </div>`;
} }

View File

@@ -21,14 +21,18 @@ export function ModelSelector({ value, onChange }) {
let cancelled = false; let cancelled = false;
window.sw.api.models.enabled().then(resp => { window.sw.api.models.enabled().then(resp => {
if (cancelled) return; if (cancelled) return;
const list = resp?.data || resp || []; const list = (resp.data || []).filter(m => !m.hidden);
setModels(list.filter(m => !m.hidden)); setModels(list);
// Auto-select first model if no value set
if (!value && list.length && onChange) {
const first = list[0];
const id = first.isPersona ? (first.personaId || first.id) : first.id;
onChange(id);
}
}).catch(() => {}); }).catch(() => {});
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
if (!models.length) return null;
function _onChange(e) { function _onChange(e) {
onChange(e.target.value); onChange(e.target.value);
} }
@@ -36,8 +40,11 @@ export function ModelSelector({ value, onChange }) {
return html` return html`
<select class="sw-model-selector__select" <select class="sw-model-selector__select"
title="Select model" title="Select model"
value=${value} value=${value || ''}
onChange=${_onChange}> onChange=${_onChange}
disabled=${!models.length}>
${!models.length && html`
<option value="" disabled selected>No models</option>`}
${models.map(m => { ${models.map(m => {
const id = m.isPersona ? (m.personaId || m.id) : m.id; const id = m.isPersona ? (m.personaId || m.id) : m.id;
const label = (m.isPersona ? '\ud83c\udfad ' : '') + (m.name || m.id); const label = (m.isPersona ? '\ud83c\udfad ' : '') + (m.name || m.id);

View File

@@ -0,0 +1,38 @@
// ==========================================
// ChatPane Kit — TypingIndicator Component
// ==========================================
// Shows "X is typing..." below the message list.
// Handles 1, 2, or 3+ typers.
//
// v0.37.14: Initial implementation.
//
// Usage:
// html`<${TypingIndicator} users=${[{id, name}]} />`
const html = window.html;
/**
* @param {{ users: Array<{id: string, name: string}> }} props
*/
export function TypingIndicator({ users }) {
if (!users || users.length === 0) return null;
let text;
if (users.length === 1) {
text = users[0].name + ' is typing';
} else if (users.length === 2) {
text = users[0].name + ' and ' + users[1].name + ' are typing';
} else {
text = users[0].name + ' and ' + (users.length - 1) + ' others are typing';
}
return html`
<div class="sw-typing-indicator" aria-live="polite">
<span class="sw-typing-indicator__dots">
<span class="sw-typing-indicator__dot" />
<span class="sw-typing-indicator__dot" />
<span class="sw-typing-indicator__dot" />
</span>
<span class="sw-typing-indicator__text">${text}</span>
</div>`;
}

View File

@@ -12,15 +12,35 @@
import { useStream } from './use-stream.js'; import { useStream } from './use-stream.js';
const { useState, useRef, useCallback } = window.hooks; const { useState, useRef, useCallback, useEffect } = window.hooks;
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
/** Extract <think>…</think> blocks from raw content.
* Returns { content, thinking } with tags stripped. */
function extractThinking(raw) {
if (!raw || !raw.includes('<think>')) return { content: raw, thinking: '' };
let thinking = '';
const content = raw.replace(/<think>([\s\S]*?)<\/think>/g, (_, t) => {
thinking += (thinking ? '\n' : '') + t.trim();
return '';
});
return { content: content.trim(), thinking };
}
/** Apply extractThinking to an array of message objects. */
function hydrateThinking(msgs) {
return msgs.map(m => {
const { content, thinking } = extractThinking(m.content);
return thinking ? { ...m, content, thinking } : m;
});
}
/** /**
* @param {{ initialChannelId?: string, getContext?: () => {path: string, content: string}|null, onChannelChange?: (id: string) => void }} opts * @param {{ initialChannelId?: string, getContext?: () => {path: string, content: string}|null, onChannelChange?: (id: string) => void }} opts
*/ */
export function useChat(opts = {}) { export function useChat(opts = {}) {
const { initialChannelId, getContext, onChannelChange } = opts; const { initialChannelId, getContext, onChannelChange, externalModel } = opts;
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [channelId, setChannelId] = useState(initialChannelId || null); const [channelId, setChannelId] = useState(initialChannelId || null);
@@ -32,15 +52,33 @@ export function useChat(opts = {}) {
const channelRef = useRef(channelId); const channelRef = useRef(channelId);
const pageRef = useRef(1); const pageRef = useRef(1);
const { streamContent, streaming, startStream, stopStream } = useStream(); const { streamContent, streamThinking, streaming, startStream, stopStream } = useStream();
// Keep ref in sync // Keep ref in sync
channelRef.current = channelId; channelRef.current = channelId;
// ── Init model from session ───────────── // Use external model (from parent surface) when provided
const effectiveModel = externalModel || model;
const modelRef = useRef(effectiveModel);
modelRef.current = effectiveModel;
// ── Init model from session or first enabled ─────────────
if (!model && window.sw?.auth?.session?.settings?.model) { if (!model && window.sw?.auth?.session?.settings?.model) {
setTimeout(() => setModel(window.sw.auth.session.settings.model), 0); setTimeout(() => setModel(window.sw.auth.session.settings.model), 0);
} }
// Auto-load first enabled model if none set
const modelInitRef = useRef(false);
if (!effectiveModel && !modelInitRef.current && window.sw?.api?.models?.enabled) {
modelInitRef.current = true;
window.sw.api.models.enabled().then(resp => {
const list = resp.data || [];
const visible = list.filter(m => !m.hidden);
if (visible.length && !model && !externalModel) {
const first = visible[0];
setModel(first.isPersona ? (first.personaId || first.id) : first.id);
}
}).catch(() => {});
}
// ── Send Message ──────────────────────── // ── Send Message ────────────────────────
const sendMessage = useCallback(async (text) => { const sendMessage = useCallback(async (text) => {
@@ -48,13 +86,27 @@ export function useChat(opts = {}) {
setSending(true); setSending(true);
setError(null); setError(null);
// Resolve model: use current ref value, or fetch first enabled model on the fly
let resolvedModel = modelRef.current;
if (!resolvedModel && window.sw?.api?.models?.enabled) {
try {
const resp = await window.sw.api.models.enabled();
const list = (resp.data || []).filter(m => !m.hidden);
if (list.length) {
const first = list[0];
resolvedModel = first.isPersona ? (first.personaId || first.id) : first.id;
setModel(resolvedModel);
}
} catch (_) {}
}
let cid = channelRef.current; let cid = channelRef.current;
// Create channel if needed // Create channel if needed
if (!cid) { if (!cid) {
try { try {
const title = text.slice(0, 50) + (text.length > 50 ? '\u2026' : ''); const title = text.slice(0, 50) + (text.length > 50 ? '\u2026' : '');
const resp = await window.sw.api.channels.create({ title, model_id: model, channel_type: 'direct' }); const resp = await window.sw.api.channels.create({ title, model_id: resolvedModel, channel_type: 'direct' });
cid = resp.id; cid = resp.id;
channelRef.current = cid; channelRef.current = cid;
setChannelId(cid); setChannelId(cid);
@@ -67,7 +119,8 @@ export function useChat(opts = {}) {
} }
// Append user message optimistically // Append user message optimistically
const userMsg = { role: 'user', content: text }; const myId = window.sw?.auth?.user?.id;
const userMsg = { id: 'tmp-' + Date.now(), role: 'user', content: text, user_id: myId };
setMessages(prev => [...prev, userMsg]); setMessages(prev => [...prev, userMsg]);
try { try {
@@ -80,9 +133,22 @@ export function useChat(opts = {}) {
} }
} }
const data = { channel_id: cid, content, model }; // Run pipe pre-send filters (extensions can transform or block)
if (window.sw?.pipe?._runPre) {
const pipeCtx = window.sw.pipe._runPre({ content, channel: { id: cid }, model: resolvedModel });
if (pipeCtx === null) { setSending(false); return; }
content = pipeCtx.content;
}
const data = { channel_id: cid, content, model: resolvedModel };
const resp = await window.sw.api.channels.complete(data); const resp = await window.sw.api.channels.complete(data);
await startStream(resp); // ai_mode off/mention_only returns JSON, not SSE — skip streaming
const ct = resp.headers.get('content-type') || '';
if (ct.includes('application/json')) {
// Message persisted server-side, no AI response expected
} else {
await startStream(resp);
}
} catch (e) { } catch (e) {
if (e.name !== 'AbortError') { if (e.name !== 'AbortError') {
setError('Error: ' + e.message); setError('Error: ' + e.message);
@@ -90,13 +156,15 @@ export function useChat(opts = {}) {
} }
setSending(false); setSending(false);
}, [sending, model, getContext, onChannelChange, startStream]); }, [sending, effectiveModel, getContext, onChannelChange, startStream]);
// Finalize: when streaming ends, capture the final content into messages // Finalize: when streaming ends, capture the final content into messages
const prevStreamingRef = useRef(false); const prevStreamingRef = useRef(false);
if (prevStreamingRef.current && !streaming && streamContent) { if (prevStreamingRef.current && !streaming && streamContent) {
prevStreamingRef.current = false; prevStreamingRef.current = false;
setMessages(prev => [...prev, { role: 'assistant', content: streamContent }]); const msg = { role: 'assistant', content: streamContent };
if (streamThinking) msg.thinking = streamThinking;
setMessages(prev => [...prev, msg]);
} }
prevStreamingRef.current = streaming; prevStreamingRef.current = streaming;
@@ -109,7 +177,7 @@ export function useChat(opts = {}) {
setError(null); setError(null);
try { try {
const resp = await window.sw.api.channels.regenerate(cid, msgId, { model }); const resp = await window.sw.api.channels.regenerate(cid, msgId, { model: modelRef.current });
// Remove the old assistant message being regenerated // Remove the old assistant message being regenerated
setMessages(prev => { setMessages(prev => {
const idx = prev.findIndex(m => m.id === msgId); const idx = prev.findIndex(m => m.id === msgId);
@@ -123,7 +191,7 @@ export function useChat(opts = {}) {
} }
setSending(false); setSending(false);
}, [sending, model, startStream]); }, [sending, effectiveModel, startStream]);
// ── Delete Message ────────────────────── // ── Delete Message ──────────────────────
const deleteMessage = useCallback(async (msgId) => { const deleteMessage = useCallback(async (msgId) => {
@@ -152,12 +220,24 @@ export function useChat(opts = {}) {
try { try {
const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: PAGE_SIZE }); const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: PAGE_SIZE });
const data = resp?.data || resp || []; const data = hydrateThinking(resp.data || resp || []);
setMessages(data); setMessages(data);
setHasMore(data.length >= PAGE_SIZE); setHasMore(data.length >= PAGE_SIZE);
} catch (e) { } catch (e) {
// Channel deleted/not found — clear selection
if ((e.status === 404 || e.message?.includes('404')) && onChannelChange) {
channelRef.current = null;
setChannelId(null);
onChannelChange(null);
return;
}
setError('Failed to load messages: ' + e.message); setError('Failed to load messages: ' + e.message);
} }
// Mark channel as read
if (window.sw?.api?.channels?.markRead) {
window.sw.api.channels.markRead(id).catch(() => {});
}
}, [onChannelChange]); }, [onChannelChange]);
// ── Load More (pagination) ────────────── // ── Load More (pagination) ──────────────
@@ -170,7 +250,7 @@ export function useChat(opts = {}) {
try { try {
const resp = await window.sw.api.channels.messages(cid, { page: nextPage, per_page: PAGE_SIZE }); const resp = await window.sw.api.channels.messages(cid, { page: nextPage, per_page: PAGE_SIZE });
const data = resp?.data || resp || []; const data = hydrateThinking(resp.data || resp || []);
if (data.length > 0) { if (data.length > 0) {
pageRef.current = nextPage; pageRef.current = nextPage;
// Prepend older messages // Prepend older messages
@@ -207,6 +287,116 @@ export function useChat(opts = {}) {
// ── Clear Error ───────────────────────── // ── Clear Error ─────────────────────────
const clearError = useCallback(() => setError(null), []); const clearError = useCallback(() => setError(null), []);
// ── Typing indicators ───────────────────
const [typingUsers, setTypingUsers] = useState([]); // [{ id, name }]
const typingTimers = useRef({}); // id → timeout
// ── WebSocket: listen for messages from other users ──
useEffect(() => {
if (!window.sw?.on) return;
const handler = (evt) => {
try {
const data = typeof evt.payload === 'string' ? JSON.parse(evt.payload) : (evt.payload || evt);
// Only append if it's for the active channel
if (data.channel_id !== channelRef.current) return;
// Skip messages from self (already added optimistically)
const myId = window.sw?.auth?.user?.id;
if (data.user_id && data.user_id === myId) return;
const { content: cleanContent, thinking } = extractThinking(data.content);
const msg = {
id: data.id,
role: data.role || 'user',
content: cleanContent,
user_id: data.user_id,
participant_id: data.participant_id || data.user_id,
participant_type: data.participant_type || 'user',
display_name: data.display_name,
created_at: data.created_at || new Date().toISOString(),
};
if (thinking) msg.thinking = thinking;
setMessages(prev => prev.some(m => m.id && m.id === msg.id) ? prev : [...prev, msg]);
// Clear typing for this user/persona when their message arrives
const pid = data.participant_id || data.user_id;
if (pid) _clearTyping(pid);
} catch (_) {}
};
// Typing start handler (persona chaining + user typing)
const typingStartHandler = (evt) => {
try {
const data = typeof evt.payload === 'string' ? JSON.parse(evt.payload) : (evt.payload || evt);
if (data.channel_id !== channelRef.current) return;
const pid = data.participant_id || data.user_id;
if (!pid) return;
const myId = window.sw?.auth?.user?.id;
if (pid === myId) return;
const name = data.display_name || pid;
setTypingUsers(prev => {
if (prev.some(t => t.id === pid)) return prev;
return [...prev, { id: pid, name }];
});
// Auto-clear after 10s (safety net)
if (typingTimers.current[pid]) clearTimeout(typingTimers.current[pid]);
typingTimers.current[pid] = setTimeout(() => _clearTyping(pid), 10000);
} catch (_) {}
};
// Typing stop handler
const typingStopHandler = (evt) => {
try {
const data = typeof evt.payload === 'string' ? JSON.parse(evt.payload) : (evt.payload || evt);
const pid = data.participant_id || data.user_id;
if (pid) _clearTyping(pid);
} catch (_) {}
};
// message.deleted handler (other clients' deletes)
const deleteHandler = (evt) => {
try {
const data = typeof evt.payload === 'string' ? JSON.parse(evt.payload) : (evt.payload || evt);
if (data.channel_id !== channelRef.current) return;
setMessages(prev => prev.filter(m => m.id !== data.id));
} catch (_) {}
};
window.sw.on('message.created', handler);
window.sw.on('message.deleted', deleteHandler);
window.sw.on('typing.start', typingStartHandler);
window.sw.on('typing.stop', typingStopHandler);
window.sw.on('chat.typing.start', typingStartHandler);
window.sw.on('chat.typing.stop', typingStopHandler);
return () => {
window.sw.off?.('message.created', handler);
window.sw.off?.('message.deleted', deleteHandler);
window.sw.off?.('typing.start', typingStartHandler);
window.sw.off?.('typing.stop', typingStopHandler);
window.sw.off?.('chat.typing.start', typingStartHandler);
window.sw.off?.('chat.typing.stop', typingStopHandler);
};
}, []);
function _clearTyping(pid) {
if (typingTimers.current[pid]) {
clearTimeout(typingTimers.current[pid]);
delete typingTimers.current[pid];
}
setTypingUsers(prev => prev.filter(t => t.id !== pid));
}
// Emit typing event (called from MessageInput via prop)
const emitTyping = useCallback((isTyping) => {
const cid = channelRef.current;
if (!cid || !window.sw?.emit) return;
const myId = window.sw?.auth?.user?.id;
const myName = window.sw?.auth?.user?.display_name || window.sw?.auth?.user?.username || '';
window.sw.emit(isTyping ? 'chat.typing.start' : 'chat.typing.stop', {
channel_id: cid,
participant_id: myId,
participant_type: 'user',
display_name: myName,
});
}, []);
return { return {
messages, messages,
channelId, channelId,
@@ -221,6 +411,7 @@ export function useChat(opts = {}) {
clearError, clearError,
// Streaming state (passthrough from useStream) // Streaming state (passthrough from useStream)
streamContent, streamContent,
streamThinking,
streaming, streaming,
// v0.37.10: new methods // v0.37.10: new methods
regenerate, regenerate,
@@ -228,5 +419,8 @@ export function useChat(opts = {}) {
loadMore, loadMore,
hasMore, hasMore,
loadingMore, loadingMore,
// v0.37.14: typing indicators
typingUsers,
emitTyping,
}; };
} }

View File

@@ -18,13 +18,16 @@ const { useState, useRef, useCallback } = window.hooks;
*/ */
export function useStream() { export function useStream() {
const [streamContent, setStreamContent] = useState(''); const [streamContent, setStreamContent] = useState('');
const [streamThinking, setStreamThinking] = useState('');
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
const contentRef = useRef(''); const contentRef = useRef('');
const thinkingRef = useRef('');
const abortRef = useRef(null); const abortRef = useRef(null);
const rafRef = useRef(null); const rafRef = useRef(null);
const _flush = useCallback(() => { const _flush = useCallback(() => {
setStreamContent(contentRef.current); setStreamContent(contentRef.current);
setStreamThinking(thinkingRef.current);
rafRef.current = null; rafRef.current = null;
}, []); }, []);
@@ -44,13 +47,16 @@ export function useStream() {
rafRef.current = null; rafRef.current = null;
} }
setStreamContent(contentRef.current); setStreamContent(contentRef.current);
setStreamThinking(thinkingRef.current);
setStreaming(false); setStreaming(false);
}, []); }, []);
const startStream = useCallback(async (response) => { const startStream = useCallback(async (response) => {
// Reset // Reset
contentRef.current = ''; contentRef.current = '';
thinkingRef.current = '';
setStreamContent(''); setStreamContent('');
setStreamThinking('');
setStreaming(true); setStreaming(true);
const controller = new AbortController(); const controller = new AbortController();
@@ -75,9 +81,20 @@ export function useStream() {
const data = line.slice(6); const data = line.slice(6);
if (data === '[DONE]') continue; if (data === '[DONE]') continue;
try { try {
const delta = JSON.parse(data).choices?.[0]?.delta?.content; const parsed = JSON.parse(data).choices?.[0]?.delta;
if (delta) { if (parsed?.content) {
contentRef.current += delta; let chunk = parsed.content;
// Run pipe stream filters (extensions can transform or suppress chunks)
if (window.sw?.pipe?._runStream) {
const pipeCtx = window.sw.pipe._runStream({ content: chunk });
if (pipeCtx === null) continue;
chunk = pipeCtx.content;
}
contentRef.current += chunk;
_scheduleFlush();
}
if (parsed?.reasoning_content) {
thinkingRef.current += parsed.reasoning_content;
_scheduleFlush(); _scheduleFlush();
} }
} catch (_) { /* skip malformed JSON */ } } catch (_) { /* skip malformed JSON */ }
@@ -95,9 +112,10 @@ export function useStream() {
rafRef.current = null; rafRef.current = null;
} }
setStreamContent(contentRef.current); setStreamContent(contentRef.current);
setStreamThinking(thinkingRef.current);
setStreaming(false); setStreaming(false);
abortRef.current = null; abortRef.current = null;
}, [_scheduleFlush]); }, [_scheduleFlush]);
return { streamContent, streaming, startStream, stopStream }; return { streamContent, streamThinking, streaming, startStream, stopStream };
} }

View File

@@ -101,7 +101,7 @@ export async function resolveTransclusions(container, onLinkClick) {
let note = cache[title.toLowerCase()]; let note = cache[title.toLowerCase()];
if (!note) { if (!note) {
const resp = await api.searchTitles(title, 1); const resp = await api.searchTitles(title, 1);
const match = (resp.data || resp || []).find( const match = (resp || []).find(
n => n.title.toLowerCase() === title.toLowerCase() n => n.title.toLowerCase() === title.toLowerCase()
); );
if (!match) { if (!match) {

View File

@@ -69,7 +69,7 @@ export function NoteEditor(props) {
if (!query || query.length < 1) return []; if (!query || query.length < 1) return [];
try { try {
const resp = await window.sw.api.notes.searchTitles(query, 8); const resp = await window.sw.api.notes.searchTitles(query, 8);
return (resp.data || resp || []).map(n => ({ label: n.title, id: n.id })); return (resp || []).map(n => ({ label: n.title, id: n.id }));
} catch { return []; } } catch { return []; }
}, },
}); });

View File

@@ -67,7 +67,7 @@ export function NoteReader(props) {
if (!api) return; if (!api) return;
// Get all note titles (limited) // Get all note titles (limited)
const resp = await api.list({ limit: 200, offset: 0 }); const resp = await api.list({ limit: 200, offset: 0 });
const allNotes = resp.data || resp || []; const allNotes = resp.data || [];
const content = note.content.toLowerCase(); const content = note.content.toLowerCase();
// Find titles mentioned in content but not wikilinked // Find titles mentioned in content but not wikilinked

Some files were not shown because too many files have changed in this diff Show More