diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b119bb..bff7554 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,68 @@
All notable changes to Switchboard Core are documented here.
+## v0.5.2 β Chat Surface
+
+### Added
+
+- **`chat` surface package**: Full messaging UI built on `chat-core` library.
+ Route `/s/chat`, icon `π¬`, depends on `chat-core`.
+- **Conversation list sidebar**: Conversations sorted by recent activity, last
+ message preview, relative timestamps, unread count badges. "New" button
+ for conversation creation.
+- **Message thread**: Paginated message history with cursor-based load-more on
+ scroll-to-top. Participant avatars, display names, timestamps. Scroll-to-
+ bottom on new messages.
+- **Compose bar**: Auto-resizing textarea, Enter-to-send, Shift+Enter for
+ newline. Typing indicator broadcast on keypress (3s debounce).
+- **Participant sidebar**: Collapsible right panel with participant list, online
+ status via presence hub, role badges. Add participant via `sw.ui.Dialog` +
+ `sw.ui.UserPicker`. Remove participant with confirmation.
+- **Create conversation dialog**: Group or Direct Message type selector. Multi-
+ user picker with chip display for group, single picker for DM. Auto-title
+ from participant names.
+- **Typing indicators**: Realtime broadcast via `POST /typing/:id` Starlark
+ endpoint. Frontend subscribes to `conversation:{id}` channel, shows
+ "X is typing..." with 4s auto-expire. Handles multiple typers.
+- **Read receipts**: Auto mark-read on conversation focus and new incoming
+ messages. Unread counts in conversation list, cleared on selection.
+- **Message editing**: Inline edit mode on own messages with textarea. Save on
+ Enter, cancel on Escape. "(edited)" label on edited messages. Realtime
+ propagation to other participants.
+- **Message deletion**: Confirm dialog before delete. "This message was deleted"
+ placeholder. Admin can delete any message. Realtime propagation.
+
+### Fixed
+
+- **Stale token login deadlock**: REST client's 401βrefreshβretry loop caused
+ a deadlock when the refresh endpoint itself returned 401 (stale DB). Auth
+ endpoints (`/auth/login`, `/auth/refresh`, `/auth/register`) are now excluded
+ from the retry loop. Defensive 8s timeout on `auth.boot()` ensures login
+ page always renders even on unexpected hangs.
+- **Bundled package permissions**: Bundled packages were installed with
+ `pending_review` status and `granted=0` permissions, requiring manual admin
+ approval. Bundled installer now auto-grants all declared permissions and
+ sets status to `active` on install.
+- **Creator display name**: `chat-core.create()` now accepts
+ `creator_display_name` so the conversation creator shows a readable name
+ in the participant sidebar instead of a raw UUID.
+- **Chat dark mode theming**: Replaced non-existent CSS variable names with
+ actual theme tokens from `variables.css`. Fixes invisible text and wrong
+ backgrounds in dark mode.
+- **Dialog dropdown clipping**: UserPicker autocomplete dropdown in dialogs
+ (New Conversation, Add Participant) no longer clipped by `overflow: auto`
+ on `.sw-dialog__body`.
+- **User menu at scaled UI**: Menu primitive now divides anchor coordinates
+ and viewport dimensions by `sw.shell.getScale()` to compensate for CSS
+ `transform: scale()` on `#surfaceInner`. Fixes menu clipping at 110%+.
+
+### Changed
+
+- **Named Docker volume**: `docker-compose.yml` switched from bind mount
+ (`./data:/data`) to named volume (`sb_data:/data`). Fixes volume mount
+ failures when Docker daemon runs on a remote host (DinD, k8s pod).
+ Deterministic wipe via `docker compose down -v`.
+
## v0.5.1 β Chat Core Library
### Added
diff --git a/ROADMAP.md b/ROADMAP.md
index 0959089..907a9e7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -358,19 +358,19 @@ participation is post-MVP.
| Admin filter Dropdown | β
| Replaced button group with themed `Dropdown` primitive for type filter. Keyboard nav, consistent theming via CSS vars. |
| Unicode security gate | β
| `ScanSource()` detects variation selectors, bidi overrides, zero-width chars, tag characters. `Verdict()` blocks GlassWorm payloads and Trojan Source (CVE-2021-42574). Two enforcement points: install (422) + Starlark exec. 23 new tests. |
-### v0.5.2 β Chat Surface (planned)
+### v0.5.2 β Chat Surface
| Step | Status | Description |
|------|--------|-------------|
-| Conversation list | | Sidebar: conversation list with last message preview, unread badge, timestamp. Create conversation button. |
-| Message thread | | Main pane: message history with participant avatars, timestamps, content. Virtual scroll for long threads. |
-| Compose bar | | Text input with Enter-to-send, Shift+Enter for newline. Markdown support via existing inline renderer. |
-| Participant sidebar | | Collapsible right panel: participant list with online status (via kernel presence hub). Add/remove participants using `sw.ui.Dialog`. |
-| 1:1 and group | | Direct messages (two participants) and group conversations. Type field on conversation. |
-| Typing indicators | | `realtime.publish("conversation:{id}", "typing", {participant_id})` on keypress with debounce. Surface shows "X is typingβ¦" with auto-expire. |
-| Read receipts | | Mark-read on scroll/focus via `chat.mark_read()`. Unread count in conversation list. |
-| Message editing | | Edit own messages. `edited_at` timestamp displayed. Edit via `PUT /messages/:id`. |
-| Message deletion | | Soft-delete own messages. Replaced with "message deleted" placeholder. |
+| Conversation list | done | Sidebar: conversation list with last message preview, unread badge, timestamp. Create conversation button. |
+| Message thread | done | Main pane: message history with participant avatars, timestamps, content. Scroll-to-load-more for long threads. |
+| Compose bar | done | Text input with Enter-to-send, Shift+Enter for newline. Auto-resize textarea. |
+| Participant sidebar | done | Collapsible right panel: participant list with online status (via kernel presence hub). Add/remove participants using `sw.ui.Dialog`. |
+| 1:1 and group | done | Direct messages (two participants) and group conversations. Type field on conversation. |
+| Typing indicators | done | `realtime.publish("conversation:{id}", "typing", {participant_id})` on keypress with debounce. Surface shows "X is typingβ¦" with auto-expire. |
+| Read receipts | done | Mark-read on scroll/focus via `chat.mark_read()`. Unread count in conversation list. |
+| Message editing | done | Edit own messages. `edited_at` timestamp displayed. Edit via `PUT /messages/:id`. |
+| Message deletion | done | Soft-delete own messages. Replaced with "message deleted" placeholder. |
### v0.5.3 β Chat Polish + Integration Testing (planned)
@@ -386,6 +386,7 @@ participation is post-MVP.
| Item | Description |
|------|-------------|
| "Requires Review" filter | Dropdown filter option or dedicated button for `pending_review` packages. Useful when the package list grows long. |
+| Extreme UI scale polish | At 150%+ scale: user menu items overflow viewport (need scroll), topbar elements clipped. Ensure all surfaces and menus are usable at scale extremes (80%β200%). |
### v0.5.4 β Package Updates (planned)
diff --git a/VERSION b/VERSION
index 4b9fcbe..cb0c939 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.5.1
+0.5.2
diff --git a/docker-compose.yml b/docker-compose.yml
index be37571..bb1419f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,8 +7,8 @@
# docker compose up --build
# open http://localhost:3000
#
-# Data persists in ./data/ between restarts.
-# To reset: rm -rf ./data/
+# Data persists in the `sb_data` named volume between restarts.
+# To reset: docker compose down -v
#
# For Postgres / multi-replica / k8s deployment see k8s/ and Dockerfile.
@@ -37,7 +37,10 @@ services:
# Dev seed users β ignored if ENVIRONMENT=production
SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user}
volumes:
- - ./data:/data
+ - sb_data:/data
ports:
- "3000:80"
restart: unless-stopped
+
+volumes:
+ sb_data:
diff --git a/packages/chat-core/script.star b/packages/chat-core/script.star
index 3bc9845..5b0be12 100644
--- a/packages/chat-core/script.star
+++ b/packages/chat-core/script.star
@@ -66,7 +66,7 @@ def _now():
# Exported API (lib.require("chat-core"))
# βββββββββββββββββββββββββββββββββββββββββββββββ
-def create(title, type="group", participants=None, creator_id=""):
+def create(title, type="group", participants=None, creator_id="", creator_display_name=""):
"""Create a conversation and add initial participants.
Args:
@@ -74,6 +74,7 @@ def create(title, type="group", participants=None, creator_id=""):
type: "direct" or "group" (default "group")
participants: list of dicts with {id, type?, display_name?, role?}
creator_id: user ID of the creator (added as admin)
+ creator_display_name: display name of the creator
Returns:
dict with conversation data
@@ -95,7 +96,7 @@ def create(title, type="group", participants=None, creator_id=""):
"conversation_id": cid,
"participant_id": _str(creator_id),
"participant_type": "user",
- "display_name": "",
+ "display_name": _str(creator_display_name),
"role": "admin",
"joined_at": "",
})
@@ -430,8 +431,9 @@ def _handle_create_conversation(req, user_id):
title = _str(body.get("title", ""))
conv_type = _str(body.get("type", "group"))
participants_list = body.get("participants", [])
+ creator_name = _str(body.get("creator_display_name", ""))
- conv = create(title, conv_type, participants_list, user_id)
+ conv = create(title, conv_type, participants_list, user_id, creator_name)
return _resp(201, conv)
diff --git a/packages/chat/css/main.css b/packages/chat/css/main.css
new file mode 100644
index 0000000..a678f2e
--- /dev/null
+++ b/packages/chat/css/main.css
@@ -0,0 +1,538 @@
+/* βββββββββββββββββββββββββββββββββββββββββββ
+ Chat Surface β Styles (v0.1.0)
+ Uses variables.css theme tokens:
+ --bg, --bg-surface, --bg-raised, --bg-hover, --bg-secondary
+ --text, --text-2, --text-3
+ --accent, --accent-dim, --border, --border-light
+ --input-bg, --danger, --danger-bg, --success
+ βββββββββββββββββββββββββββββββββββββββββββ */
+
+/* ββ Layout βββββββββββββββββββββββββββββββ */
+
+.chat-app {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ overflow: hidden;
+ background: var(--bg);
+ color: var(--text);
+}
+
+.chat-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100vh;
+}
+
+.chat-body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+}
+
+.chat-main {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+}
+
+/* ββ Topbar extras ββββββββββββββββββββββββ */
+
+.chat-topbar__thread-title {
+ font-weight: 600;
+ font-size: 14px;
+ margin-right: 8px;
+ color: var(--text-2);
+}
+
+/* ββ Sidebar ββββββββββββββββββββββββββββββ */
+
+.chat-sidebar {
+ width: 280px;
+ min-width: 280px;
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-secondary);
+}
+
+.chat-sidebar__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.chat-sidebar__title {
+ font-weight: 600;
+ font-size: 14px;
+}
+
+.chat-sidebar__list {
+ flex: 1;
+ overflow-y: auto;
+}
+
+.chat-sidebar__empty {
+ padding: 24px 16px;
+ text-align: center;
+ color: var(--text-3);
+ font-size: 13px;
+}
+
+.chat-sidebar__item {
+ padding: 10px 16px;
+ cursor: pointer;
+ border-bottom: 1px solid var(--border-light);
+ transition: background 0.1s;
+}
+
+.chat-sidebar__item:hover {
+ background: var(--bg-hover);
+}
+
+.chat-sidebar__item--active {
+ background: var(--accent-dim);
+}
+
+.chat-sidebar__item-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-bottom: 2px;
+}
+
+.chat-sidebar__item-title {
+ font-weight: 600;
+ font-size: 13px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+ margin-right: 8px;
+}
+
+.chat-sidebar__item-time {
+ font-size: 11px;
+ color: var(--text-3);
+ white-space: nowrap;
+}
+
+.chat-sidebar__item-bottom {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.chat-sidebar__item-preview {
+ font-size: 12px;
+ color: var(--text-2);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+}
+
+.chat-sidebar__badge {
+ background: var(--accent);
+ color: var(--text-on-color);
+ font-size: 11px;
+ font-weight: 600;
+ min-width: 18px;
+ height: 18px;
+ border-radius: 9px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 5px;
+ flex-shrink: 0;
+}
+
+/* ββ Message Thread βββββββββββββββββββββββ */
+
+.chat-thread {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.chat-thread--empty {
+ align-items: center;
+ justify-content: center;
+ color: var(--text-3);
+}
+
+.chat-thread__messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.chat-thread__loading {
+ display: flex;
+ justify-content: center;
+ padding: 24px;
+}
+
+.chat-thread__load-more {
+ align-self: center;
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 4px 12px;
+ font-size: 12px;
+ color: var(--text-2);
+ cursor: pointer;
+ margin-bottom: 8px;
+}
+
+.chat-thread__load-more:hover {
+ background: var(--bg-hover);
+}
+
+.chat-thread__typing {
+ padding: 4px 16px 8px;
+ font-size: 12px;
+ color: var(--text-3);
+ font-style: italic;
+}
+
+/* ββ Message Bubble βββββββββββββββββββββββ */
+
+.chat-msg {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 4px 0;
+ position: relative;
+}
+
+.chat-msg--own {
+ flex-direction: row-reverse;
+}
+
+.chat-msg--system {
+ justify-content: center;
+ padding: 2px 0;
+}
+
+.chat-msg--system span {
+ font-size: 12px;
+ color: var(--text-3);
+ font-style: italic;
+}
+
+.chat-msg--deleted {
+ justify-content: center;
+ padding: 2px 0;
+}
+
+.chat-msg--deleted em {
+ font-size: 12px;
+ color: var(--text-3);
+}
+
+.chat-msg__body {
+ max-width: 65%;
+ background: var(--bg-raised);
+ border-radius: 12px;
+ padding: 8px 12px;
+}
+
+.chat-msg--own .chat-msg__body {
+ background: var(--accent);
+ color: var(--text-on-color);
+}
+
+.chat-msg__name {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-2);
+ display: block;
+ margin-bottom: 2px;
+}
+
+.chat-msg__content {
+ font-size: 14px;
+ line-height: 1.4;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.chat-msg__meta {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ margin-top: 2px;
+}
+
+.chat-msg__time {
+ font-size: 10px;
+ color: var(--text-3);
+}
+
+.chat-msg--own .chat-msg__time {
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.chat-msg__edited {
+ font-size: 10px;
+ color: var(--text-3);
+ font-style: italic;
+}
+
+.chat-msg--own .chat-msg__edited {
+ color: rgba(255, 255, 255, 0.7);
+}
+
+/* ββ Message Actions ββββββββββββββββββββββ */
+
+.chat-msg__actions {
+ display: flex;
+ gap: 2px;
+ position: absolute;
+ top: 0;
+ right: 0;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ box-shadow: var(--shadow-lg);
+ padding: 2px;
+}
+
+.chat-msg--own .chat-msg__actions {
+ right: auto;
+ left: 0;
+}
+
+.chat-msg__action {
+ background: none;
+ border: none;
+ padding: 4px 6px;
+ cursor: pointer;
+ border-radius: 4px;
+ font-size: 14px;
+ line-height: 1;
+ color: var(--text-2);
+}
+
+.chat-msg__action:hover {
+ background: var(--bg-hover);
+}
+
+.chat-msg__action--danger:hover {
+ background: var(--danger-bg);
+ color: var(--danger);
+}
+
+/* ββ Message Edit βββββββββββββββββββββββββ */
+
+.chat-msg__edit {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.chat-msg__edit-input {
+ width: 100%;
+ min-width: 200px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 6px 8px;
+ font-size: 14px;
+ font-family: inherit;
+ resize: vertical;
+ background: var(--input-bg);
+ color: var(--text);
+}
+
+.chat-msg__edit-actions {
+ display: flex;
+ gap: 6px;
+ justify-content: flex-end;
+}
+
+/* ββ Compose Bar ββββββββββββββββββββββββββ */
+
+.chat-compose {
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+ padding: 12px 16px;
+ border-top: 1px solid var(--border);
+ background: var(--bg);
+}
+
+.chat-compose__input {
+ flex: 1;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 8px 12px;
+ font-size: 14px;
+ font-family: inherit;
+ resize: none;
+ line-height: 1.4;
+ max-height: 160px;
+ background: var(--input-bg);
+ color: var(--text);
+}
+
+.chat-compose__input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px var(--accent-dim);
+}
+
+/* ββ Participant Sidebar ββββββββββββββββββ */
+
+.chat-participants {
+ width: 240px;
+ min-width: 240px;
+ border-left: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-secondary);
+}
+
+.chat-participants__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--border);
+ font-weight: 600;
+ font-size: 13px;
+}
+
+.chat-participants__list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px 0;
+}
+
+.chat-participants__item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 16px;
+}
+
+.chat-participants__name {
+ flex: 1;
+ font-size: 13px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.chat-participants__badge {
+ font-size: 10px;
+ color: var(--accent);
+ font-weight: 600;
+ margin-left: 4px;
+}
+
+.chat-participants__status {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--text-3);
+ flex-shrink: 0;
+}
+
+.chat-participants__status--online {
+ background: var(--success);
+}
+
+.chat-participants__remove {
+ background: none;
+ border: none;
+ color: var(--text-3);
+ cursor: pointer;
+ font-size: 16px;
+ padding: 0 4px;
+ line-height: 1;
+}
+
+.chat-participants__remove:hover {
+ color: var(--danger);
+}
+
+/* ββ New Conversation Dialog ββββββββββββββ */
+
+.chat-new {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 320px;
+}
+
+.chat-new__type {
+ display: flex;
+ gap: 16px;
+}
+
+.chat-new__type label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ cursor: pointer;
+}
+
+.chat-new__title {
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 8px 10px;
+ font-size: 14px;
+ font-family: inherit;
+ background: var(--input-bg);
+ color: var(--text);
+}
+
+.chat-new__selected {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.chat-new__chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: var(--accent-dim);
+ color: var(--accent);
+ font-size: 12px;
+ font-weight: 500;
+ padding: 3px 8px;
+ border-radius: 12px;
+}
+
+.chat-new__chip button {
+ background: none;
+ border: none;
+ color: inherit;
+ cursor: pointer;
+ font-size: 14px;
+ padding: 0;
+ line-height: 1;
+}
+
+/* ββ Dialog UserPicker overflow fix βββββββ */
+/* Allow the autocomplete dropdown to overflow the dialog body.
+ Applies to both New Conversation and Add Participant dialogs. */
+
+.sw-dialog__body:has(.sw-user-picker) {
+ overflow: visible;
+}
+
+.sw-dialog:has(.sw-user-picker) {
+ overflow: visible;
+}
diff --git a/packages/chat/js/main.js b/packages/chat/js/main.js
new file mode 100644
index 0000000..3633a98
--- /dev/null
+++ b/packages/chat/js/main.js
@@ -0,0 +1,772 @@
+/**
+ * Chat β Surface Entry Point (v0.1.0)
+ *
+ * Messaging surface built on chat-core library:
+ * sw.api.ext('chat-core') β conversation/message CRUD
+ * sw.api.ext('chat') β typing indicators
+ * sw.realtime β live events
+ * sw.ui.* β primitive components
+ * sw.shell.Topbar β navigation bar
+ */
+(async function () {
+ 'use strict';
+
+ var mount = document.getElementById('extension-mount');
+ if (!mount) return;
+
+ var base = window.__BASE__ || '';
+ var ver = window.__VERSION__ || '0';
+
+ // ββ Boot SDK βββββββββββββββββββββββββββββββ
+ try {
+ 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();
+ } catch (e) {
+ mount.innerHTML = '
SDK boot failed: ' + e.message + '
';
+ return;
+ }
+
+ var { html } = window;
+ var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
+ var { render } = preact;
+
+ // ββ SDK modules ββββββββββββββββββββββββββββ
+ var api = sw.api.ext('chat-core');
+ var chatApi = sw.api.ext('chat');
+ var { Button, Spinner, Avatar, Dialog, Tabs } = sw.ui;
+ var Topbar = sw.shell.Topbar;
+
+ // Import UserPicker directly (not in sw.ui index)
+ var { UserPicker } = await import(base + '/js/sw/primitives/user-picker.js?v=' + ver);
+
+ // ββ Helpers ββββββββββββββββββββββββββββββββ
+ var _timers = {};
+ function debounce(key, fn, ms) {
+ clearTimeout(_timers[key]);
+ _timers[key] = setTimeout(fn, ms);
+ }
+
+ function timeAgo(ts) {
+ if (!ts) return '';
+ var d = new Date(ts);
+ var now = Date.now();
+ var diff = Math.floor((now - d.getTime()) / 1000);
+ if (diff < 60) return 'now';
+ if (diff < 3600) return Math.floor(diff / 60) + 'm';
+ if (diff < 86400) return Math.floor(diff / 3600) + 'h';
+ if (diff < 604800) return Math.floor(diff / 86400) + 'd';
+ return d.toLocaleDateString();
+ }
+
+ function truncate(str, len) {
+ if (!str) return '';
+ return str.length > len ? str.slice(0, len) + '\u2026' : str;
+ }
+
+ function currentUserId() {
+ return sw.auth?.user?.id || '';
+ }
+
+ function currentDisplayName() {
+ return sw.auth?.user?.display_name || sw.auth?.user?.username || '';
+ }
+
+
+ // βββββββββββββββββββββββββββββββββββββββββββ
+ // ConversationList β left sidebar
+ // βββββββββββββββββββββββββββββββββββββββββββ
+
+ function ConversationList({ selected, onSelect, onNew, conversations, unread }) {
+ return html`
+ `;
+ }
+
+
+ // βββββββββββββββββββββββββββββββββββββββββββ
+ // MessageBubble β single message
+ // βββββββββββββββββββββββββββββββββββββββββββ
+
+ function MessageBubble({ msg, isOwn, onEdit, onDelete }) {
+ var [editing, setEditing] = useState(false);
+ var [editText, setEditText] = useState('');
+ var [hover, setHover] = useState(false);
+
+ if (msg._deleted) {
+ return html`
+
+ This message was deleted
+
`;
+ }
+
+ if (msg.content_type === 'system') {
+ return html`
+
+ ${msg.content}
+
`;
+ }
+
+ function startEdit() {
+ setEditText(msg.content);
+ setEditing(true);
+ }
+
+ function cancelEdit() {
+ setEditing(false);
+ setEditText('');
+ }
+
+ function saveEdit() {
+ if (editText.trim() && editText !== msg.content) {
+ onEdit(msg.id, editText.trim());
+ }
+ setEditing(false);
+ }
+
+ function onEditKeyDown(e) {
+ if (e.key === 'Escape') cancelEdit();
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveEdit(); }
+ }
+
+ return html`
+ setHover(true)}
+ onMouseLeave=${() => setHover(false)}>
+ ${!isOwn && html`
+ <${Avatar} name=${msg._display_name || msg.participant_id} size="sm" />`}
+
+ ${!isOwn && html`
${msg._display_name || msg.participant_id}`}
+ ${editing ? html`
+
+ ` : html`
+
${msg.content}
`}
+
+ ${timeAgo(msg.created_at)}
+ ${msg.edited_at && html`(edited)`}
+
+
+ ${hover && isOwn && !editing && html`
+
+
+
+
`}
+
`;
+ }
+
+
+ // βββββββββββββββββββββββββββββββββββββββββββ
+ // MessageThread β center pane
+ // βββββββββββββββββββββββββββββββββββββββββββ
+
+ function MessageThread({ conversationId, participants }) {
+ var [messages, setMessages] = useState([]);
+ var [loading, setLoading] = useState(false);
+ var [hasMore, setHasMore] = useState(false);
+ var [nextCursor, setNextCursor] = useState('');
+ var [typingUsers, setTypingUsers] = useState({});
+ var bottomRef = useRef(null);
+ var listRef = useRef(null);
+ var userId = currentUserId();
+
+ // Build participant lookup
+ var partMap = useMemo(() => {
+ var m = {};
+ (participants || []).forEach(p => { m[p.participant_id] = p.display_name || p.participant_id; });
+ return m;
+ }, [participants]);
+
+ // Enrich messages with display names
+ function enrichMessages(msgs) {
+ return msgs.map(m => ({ ...m, _display_name: partMap[m.participant_id] || m.participant_id }));
+ }
+
+ // Load initial messages
+ useEffect(() => {
+ if (!conversationId) { setMessages([]); return; }
+ setLoading(true);
+ setMessages([]);
+ setNextCursor('');
+ setHasMore(false);
+ api.get('/messages/' + conversationId + '?limit=50').then(res => {
+ var data = res || {};
+ var msgs = (data.messages || []).reverse();
+ setMessages(enrichMessages(msgs));
+ setHasMore(!!data.has_more);
+ setNextCursor(data.next_cursor || '');
+ setLoading(false);
+ setTimeout(() => scrollToBottom(), 50);
+ }).catch(() => setLoading(false));
+ }, [conversationId, partMap]);
+
+ // Load older messages
+ function loadMore() {
+ if (!hasMore || !nextCursor || loading) return;
+ setLoading(true);
+ api.get('/messages/' + conversationId + '?limit=50&cursor=' + encodeURIComponent(nextCursor)).then(res => {
+ var data = res || {};
+ var older = (data.messages || []).reverse();
+ setMessages(prev => [...enrichMessages(older), ...prev]);
+ setHasMore(!!data.has_more);
+ setNextCursor(data.next_cursor || '');
+ setLoading(false);
+ }).catch(() => setLoading(false));
+ }
+
+ function scrollToBottom() {
+ if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'auto' });
+ }
+
+ // Scroll listener for load-more
+ useEffect(() => {
+ var el = listRef.current;
+ if (!el) return;
+ function onScroll() {
+ if (el.scrollTop < 80 && hasMore && !loading) loadMore();
+ }
+ el.addEventListener('scroll', onScroll);
+ return () => el.removeEventListener('scroll', onScroll);
+ }, [hasMore, loading, nextCursor, conversationId]);
+
+ // Realtime: new messages, edits, deletes
+ useEffect(() => {
+ if (!conversationId) return;
+ var channel = 'conversation:' + conversationId;
+
+ var unsubs = [
+ sw.realtime.subscribe(channel, 'message', (payload) => {
+ var msg = { ...payload, _display_name: partMap[payload.participant_id] || payload.participant_id };
+ setMessages(prev => [...prev, msg]);
+ setTimeout(() => scrollToBottom(), 50);
+ // Auto mark read if from someone else
+ if (payload.participant_id !== userId && payload.id) {
+ api.post('/read/' + conversationId, { last_read_message_id: payload.id }).catch(() => {});
+ }
+ }),
+ sw.realtime.subscribe(channel, 'message.edited', (payload) => {
+ setMessages(prev => prev.map(m =>
+ m.id === payload.id ? { ...m, content: payload.content, edited_at: 'true' } : m
+ ));
+ }),
+ sw.realtime.subscribe(channel, 'message.deleted', (payload) => {
+ setMessages(prev => prev.map(m =>
+ m.id === payload.id ? { ...m, _deleted: true, content: '' } : m
+ ));
+ }),
+ ];
+
+ // Typing indicators
+ var typingTimers = {};
+ unsubs.push(sw.realtime.subscribe(channel, 'typing', (payload) => {
+ var pid = payload.participant_id;
+ if (pid === userId) return;
+ setTypingUsers(prev => ({ ...prev, [pid]: payload.display_name || pid }));
+ clearTimeout(typingTimers[pid]);
+ typingTimers[pid] = setTimeout(() => {
+ setTypingUsers(prev => {
+ var next = { ...prev };
+ delete next[pid];
+ return next;
+ });
+ }, 4000);
+ }));
+
+ return () => {
+ unsubs.forEach(fn => fn());
+ Object.values(typingTimers).forEach(clearTimeout);
+ setTypingUsers({});
+ };
+ }, [conversationId, partMap, userId]);
+
+ // Mark read on first load
+ useEffect(() => {
+ if (!conversationId || messages.length === 0) return;
+ var last = messages[messages.length - 1];
+ if (last && last.id) {
+ api.post('/read/' + conversationId, { last_read_message_id: last.id }).catch(() => {});
+ }
+ }, [conversationId, messages.length > 0]);
+
+ // Edit message
+ function handleEdit(msgId, newContent) {
+ api.put('/messages/' + conversationId + '/' + msgId, { content: newContent }).then(() => {
+ setMessages(prev => prev.map(m =>
+ m.id === msgId ? { ...m, content: newContent, edited_at: 'true' } : m
+ ));
+ }).catch(() => {});
+ }
+
+ // Delete message
+ async function handleDelete(msgId) {
+ var ok = await sw.ui.confirm('Delete this message?', { destructive: true });
+ if (!ok) return;
+ api.del('/messages/' + conversationId + '/' + msgId).then(() => {
+ setMessages(prev => prev.map(m =>
+ m.id === msgId ? { ...m, _deleted: true, content: '' } : m
+ ));
+ }).catch(() => {});
+ }
+
+ // Typing indicator text
+ var typingNames = Object.values(typingUsers);
+ var typingText = '';
+ if (typingNames.length === 1) typingText = typingNames[0] + ' is typing\u2026';
+ else if (typingNames.length === 2) typingText = typingNames.join(' and ') + ' are typing\u2026';
+ else if (typingNames.length > 2) typingText = 'Several people are typing\u2026';
+
+ if (!conversationId) {
+ return html`
+
+
Select a conversation or start a new one
+
`;
+ }
+
+ return html`
+
+
+ ${loading && messages.length === 0 && html`
<${Spinner} />
`}
+ ${hasMore && html`
+
`}
+ ${messages.map(m => html`
+ <${MessageBubble}
+ key=${m.id}
+ msg=${m}
+ isOwn=${m.participant_id === userId}
+ onEdit=${handleEdit}
+ onDelete=${handleDelete}
+ />`)}
+
+
+ ${typingText && html`
${typingText}
`}
+
`;
+ }
+
+
+ // βββββββββββββββββββββββββββββββββββββββββββ
+ // ComposeBar β message input
+ // βββββββββββββββββββββββββββββββββββββββββββ
+
+ function ComposeBar({ conversationId }) {
+ var [text, setText] = useState('');
+ var [sending, setSending] = useState(false);
+ var textareaRef = useRef(null);
+
+ // Reset on conversation change
+ useEffect(() => { setText(''); }, [conversationId]);
+
+ // Auto-focus
+ useEffect(() => {
+ if (textareaRef.current) textareaRef.current.focus();
+ }, [conversationId]);
+
+ // Auto-resize
+ function autoResize(el) {
+ if (!el) return;
+ el.style.height = 'auto';
+ el.style.height = Math.min(el.scrollHeight, 160) + 'px';
+ }
+
+ function handleInput(e) {
+ setText(e.target.value);
+ autoResize(e.target);
+ // Typing indicator (debounced)
+ debounce('typing', () => {
+ chatApi.post('/typing/' + conversationId, {
+ display_name: currentDisplayName(),
+ }).catch(() => {});
+ }, 3000);
+ }
+
+ async function handleSend() {
+ var content = text.trim();
+ if (!content || sending) return;
+ setSending(true);
+ try {
+ await api.post('/messages/' + conversationId, { content: content, content_type: 'text' });
+ setText('');
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto';
+ textareaRef.current.focus();
+ }
+ } catch (e) {
+ console.error('[chat] send failed:', e);
+ }
+ setSending(false);
+ }
+
+ function onKeyDown(e) {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ }
+
+ if (!conversationId) return null;
+
+ return html`
+
+
+ <${Button} onClick=${handleSend} disabled=${!text.trim() || sending}>Send/>
+
`;
+ }
+
+
+ // βββββββββββββββββββββββββββββββββββββββββββ
+ // ParticipantSidebar β right panel
+ // βββββββββββββββββββββββββββββββββββββββββββ
+
+ function ParticipantSidebar({ conversationId, participants, onRefresh, isAdmin }) {
+ var [addOpen, setAddOpen] = useState(false);
+ var [presence, setPresence] = useState({});
+
+ // Query presence
+ useEffect(() => {
+ if (!participants || participants.length === 0) return;
+ var ids = participants.map(p => p.participant_id).join(',');
+ sw.api.get('/api/v1/presence?users=' + ids).then(res => {
+ setPresence(res || {});
+ }).catch(() => {});
+ }, [participants]);
+
+ async function addUser(user) {
+ setAddOpen(false);
+ await api.post('/participants/' + conversationId, {
+ participant_id: user.id,
+ display_name: user.display_name || user.username,
+ }).catch(() => {});
+ onRefresh();
+ }
+
+ async function removeUser(pid) {
+ var ok = await sw.ui.confirm('Remove this participant?', { destructive: true });
+ if (!ok) return;
+ await api.del('/participants/' + conversationId + '/' + pid).catch(() => {});
+ onRefresh();
+ }
+
+ return html`
+
+
+
+ ${(participants || []).map(p => html`
+
+ <${Avatar} name=${p.display_name || p.participant_id} size="sm" />
+
+ ${p.display_name || p.participant_id}
+ ${p.role === 'admin' && html`admin`}
+
+
+ ${isAdmin && p.participant_id !== currentUserId() && html`
+ `}
+
`)}
+
+
+ <${Dialog} open=${addOpen} title="Add Participant" onClose=${() => setAddOpen(false)}>
+ <${UserPicker} onSelect=${addUser} placeholder="Search users\u2026" autoFocus />
+ />
+
`;
+ }
+
+
+ // βββββββββββββββββββββββββββββββββββββββββββ
+ // NewConversationDialog
+ // βββββββββββββββββββββββββββββββββββββββββββ
+
+ function NewConversationDialog({ open, onClose, onCreated }) {
+ var [type, setType] = useState('group');
+ var [title, setTitle] = useState('');
+ var [selected, setSelected] = useState([]);
+ var [creating, setCreating] = useState(false);
+
+ function reset() {
+ setType('group');
+ setTitle('');
+ setSelected([]);
+ setCreating(false);
+ }
+
+ function addUser(user) {
+ // Avoid duplicates
+ if (selected.find(u => u.id === user.id)) return;
+ // For DM, only allow one user
+ if (type === 'direct' && selected.length >= 1) {
+ setSelected([user]);
+ return;
+ }
+ setSelected(prev => [...prev, user]);
+ }
+
+ function removeSelected(id) {
+ setSelected(prev => prev.filter(u => u.id !== id));
+ }
+
+ async function handleCreate() {
+ if (selected.length === 0) return;
+ setCreating(true);
+ try {
+ var convTitle = type === 'direct'
+ ? ''
+ : (title.trim() || selected.map(u => u.display_name || u.username).join(', '));
+ var participants = selected.map(u => ({
+ id: u.id,
+ display_name: u.display_name || u.username,
+ }));
+ var res = await api.post('/conversations', {
+ title: convTitle,
+ type: type,
+ participants: participants,
+ creator_display_name: currentDisplayName(),
+ });
+ reset();
+ onClose();
+ onCreated(res.id || res.data?.id);
+ } catch (e) {
+ console.error('[chat] create failed:', e);
+ }
+ setCreating(false);
+ }
+
+ var actions = [
+ { label: 'Cancel', variant: 'secondary', onClick: () => { reset(); onClose(); } },
+ { label: 'Create', variant: 'primary', onClick: handleCreate, disabled: creating || selected.length === 0 },
+ ];
+
+ return html`
+ <${Dialog} open=${open} title="New Conversation" onClose=${() => { reset(); onClose(); }} actions=${actions}>
+
+ />`;
+ }
+
+
+ // βββββββββββββββββββββββββββββββββββββββββββ
+ // ChatApp β root component
+ // βββββββββββββββββββββββββββββββββββββββββββ
+
+ function ChatApp() {
+ var [conversations, setConversations] = useState([]);
+ var [unread, setUnread] = useState({});
+ var [selectedId, setSelectedId] = useState(null);
+ var [participants, setParticipants] = useState([]);
+ var [showParticipants, setShowParticipants] = useState(false);
+ var [showNew, setShowNew] = useState(false);
+ var [loading, setLoading] = useState(true);
+ var userId = currentUserId();
+
+ // Load conversations
+ // Note: SDK auto-unwraps {data: [...]} envelopes β res is the array directly
+ function loadConversations() {
+ return api.get('/conversations').then(res => {
+ setConversations(Array.isArray(res) ? res : (res && res.data) || []);
+ }).catch(() => {});
+ }
+
+ // Load unread
+ // Note: SDK returns {data: {id: count}} β NOT auto-unwrapped (data is object, not array)
+ function loadUnread() {
+ return api.get('/unread').then(res => {
+ setUnread((res && res.data) || (typeof res === 'object' && !Array.isArray(res) ? res : {}));
+ }).catch(() => {});
+ }
+
+ // Initial load
+ useEffect(() => {
+ Promise.all([loadConversations(), loadUnread()]).then(() => setLoading(false));
+ }, []);
+
+ // Load participants when conversation changes
+ useEffect(() => {
+ if (!selectedId) { setParticipants([]); return; }
+ api.get('/participants/' + selectedId).then(res => {
+ setParticipants(Array.isArray(res) ? res : (res && res.data) || []);
+ }).catch(() => {});
+ }, [selectedId]);
+
+ // Realtime: refresh conversation list on activity
+ useEffect(() => {
+ // Subscribe to all active conversations
+ var unsubs = [];
+ conversations.forEach(c => {
+ unsubs.push(sw.realtime.subscribe('conversation:' + c.id, 'message', () => {
+ loadConversations();
+ loadUnread();
+ }));
+ unsubs.push(sw.realtime.subscribe('conversation:' + c.id, 'participant.added', () => {
+ loadConversations();
+ if (c.id === selectedId) {
+ api.get('/participants/' + selectedId).then(res => {
+ setParticipants(Array.isArray(res) ? res : (res && res.data) || []);
+ }).catch(() => {});
+ }
+ }));
+ unsubs.push(sw.realtime.subscribe('conversation:' + c.id, 'participant.removed', () => {
+ loadConversations();
+ if (c.id === selectedId) {
+ api.get('/participants/' + selectedId).then(res => {
+ setParticipants(Array.isArray(res) ? res : (res && res.data) || []);
+ }).catch(() => {});
+ }
+ }));
+ });
+ return () => unsubs.forEach(fn => fn());
+ }, [conversations.map(c => c.id).join(','), selectedId]);
+
+ // Select conversation
+ function selectConversation(id) {
+ setSelectedId(id);
+ setShowParticipants(false);
+ // Mark read
+ setUnread(prev => { var next = { ...prev }; delete next[id]; return next; });
+ }
+
+ // After creating a new conversation
+ function onCreated(id) {
+ loadConversations().then(() => {
+ if (id) setSelectedId(id);
+ });
+ }
+
+ // Is current user admin in selected conversation?
+ var isAdmin = useMemo(() => {
+ return participants.some(p => p.participant_id === userId && p.role === 'admin');
+ }, [participants, userId]);
+
+ // Refresh participants
+ function refreshParticipants() {
+ if (!selectedId) return;
+ api.get('/participants/' + selectedId).then(res => {
+ setParticipants(Array.isArray(res) ? res : (res && res.data) || []);
+ }).catch(() => {});
+ }
+
+ // Conversation title for topbar
+ var selectedConv = conversations.find(c => c.id === selectedId);
+ var threadTitle = selectedConv ? (selectedConv.title || 'Direct Message') : '';
+
+ if (loading) {
+ return html`<${Spinner} />
`;
+ }
+
+ return html`
+
+ <${Topbar} title="Chat">
+ ${selectedId && html`
+
${threadTitle}
+ <${Button} size="sm" variant="secondary"
+ onClick=${() => setShowParticipants(!showParticipants)}>
+ ${showParticipants ? 'Hide' : 'People'}
+ />`}
+ />
+
+ <${ConversationList}
+ selected=${selectedId}
+ onSelect=${selectConversation}
+ onNew=${() => setShowNew(true)}
+ conversations=${conversations}
+ unread=${unread} />
+
+ <${MessageThread}
+ conversationId=${selectedId}
+ participants=${participants} />
+ <${ComposeBar} conversationId=${selectedId} />
+
+ ${showParticipants && selectedId && html`
+ <${ParticipantSidebar}
+ conversationId=${selectedId}
+ participants=${participants}
+ onRefresh=${refreshParticipants}
+ isAdmin=${isAdmin} />`}
+
+ <${NewConversationDialog}
+ open=${showNew}
+ onClose=${() => setShowNew(false)}
+ onCreated=${onCreated} />
+
`;
+ }
+
+
+ // ββ Mount ββββββββββββββββββββββββββββββββββ
+ render(html`<${ChatApp} />`, mount);
+
+})();
diff --git a/packages/chat/manifest.json b/packages/chat/manifest.json
new file mode 100644
index 0000000..8968c74
--- /dev/null
+++ b/packages/chat/manifest.json
@@ -0,0 +1,30 @@
+{
+ "id": "chat",
+ "title": "Chat",
+ "type": "full",
+ "tier": "starlark",
+ "route": "/s/chat",
+ "auth": "authenticated",
+ "layout": "single",
+ "version": "0.1.0",
+ "icon": "\ud83d\udcac",
+ "description": "Chat surface β conversations, messaging, typing indicators, read receipts.",
+ "author": "switchboard",
+
+ "permissions": ["db.write", "realtime.publish"],
+
+ "depends": ["chat-core"],
+
+ "api_routes": [
+ {"method": "POST", "path": "/typing/*"}
+ ],
+
+ "settings": {
+ "enter_to_send": {
+ "type": "boolean",
+ "label": "Enter to Send",
+ "description": "Press Enter to send messages (Shift+Enter for newline). When off, Enter inserts a newline.",
+ "default": true
+ }
+ }
+}
diff --git a/packages/chat/script.star b/packages/chat/script.star
new file mode 100644
index 0000000..66e1d41
--- /dev/null
+++ b/packages/chat/script.star
@@ -0,0 +1,39 @@
+# Chat Surface β Starlark Backend (v0.1.0)
+#
+# Thin surface layer on top of chat-core library.
+# Only handles typing indicator broadcast β all CRUD is
+# delegated to chat-core's own API routes.
+#
+# Modules: json, realtime
+
+def _resp(status, data):
+ return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
+
+
+def on_request(req):
+ path = req["path"]
+ method = req["method"]
+ user_id = req.get("user_id", "")
+
+ # POST /typing/:conversation_id β broadcast typing indicator
+ if method == "POST" and path.startswith("/typing/"):
+ cid = path[len("/typing/"):]
+ if not cid:
+ return _resp(400, {"error": "conversation_id required"})
+
+ body = json.decode(req.get("body", "{}"))
+ display_name = str(body.get("display_name", ""))
+
+ realtime.publish(
+ "conversation:" + cid,
+ "typing",
+ {
+ "participant_id": user_id,
+ "display_name": display_name,
+ "conversation_id": cid,
+ },
+ )
+
+ return _resp(200, {"ok": True})
+
+ return _resp(404, {"error": "not found"})
diff --git a/server/handlers/packages_bundled.go b/server/handlers/packages_bundled.go
index 5e9367f..df25ce7 100644
--- a/server/handlers/packages_bundled.go
+++ b/server/handlers/packages_bundled.go
@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"strings"
+ "time"
"github.com/gin-gonic/gin"
@@ -191,7 +192,21 @@ func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, run
// Sync permissions β use a minimal gin.Context for the functions that need it
fakeCtx := newBackgroundGinContext()
- SyncManifestPermissions(fakeCtx, stores, pkgID, manifest)
+ declaredPerms := SyncManifestPermissions(fakeCtx, stores, pkgID, manifest)
+
+ // Bundled packages are trusted β auto-grant all declared permissions
+ // and restore active status (SyncManifestPermissions sets pending_review).
+ // Use direct SQL with NULL granted_by to satisfy FK constraint on users(id).
+ if len(declaredPerms) > 0 {
+ now := time.Now().UTC().Format(time.RFC3339)
+ _, err := database.DB.ExecContext(ctx,
+ `UPDATE extension_permissions SET granted = 1, granted_by = NULL, granted_at = ? WHERE package_id = ? AND granted = 0`,
+ now, pkgID)
+ if err != nil {
+ log.Printf("[bundled] Failed to auto-grant permissions for %s: %v", pkgID, err)
+ }
+ _ = stores.Packages.SetStatus(ctx, pkgID, "active")
+ }
// Sync triggers from manifest
SyncManifestTriggers(ctx, stores, triggers.GlobalEngine(), pkgID, manifest)
diff --git a/src/js/sw/primitives/menu.js b/src/js/sw/primitives/menu.js
index b939c46..b7cba2f 100644
--- a/src/js/sw/primitives/menu.js
+++ b/src/js/sw/primitives/menu.js
@@ -17,13 +17,20 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
const selectableItems = items.filter(i => !i.divider && !i.disabled);
// Position relative to anchor
+ // When the surface uses CSS transform: scale(), getBoundingClientRect()
+ // returns scaled coordinates but position:fixed uses viewport coordinates.
+ // Dividing by the scale factor converts back to true viewport pixels.
useEffect(() => {
if (!open || !anchor) return;
- const rect = typeof anchor.getBoundingClientRect === 'function'
+ const scale = window.sw?.shell?.getScale?.() || 1;
+ const raw = typeof anchor.getBoundingClientRect === 'function'
? anchor.getBoundingClientRect()
: anchor;
- const vw = window.innerWidth;
- const vh = window.innerHeight;
+ const rect = scale !== 1
+ ? { top: raw.top / scale, bottom: raw.bottom / scale, left: raw.left / scale, right: raw.right / scale }
+ : raw;
+ const vw = window.innerWidth / scale;
+ const vh = window.innerHeight / scale;
let top, left;
const [vDir, hDir] = direction.split('-');
diff --git a/src/js/sw/sdk/auth.js b/src/js/sw/sdk/auth.js
index 92779c2..0127f98 100644
--- a/src/js/sw/sdk/auth.js
+++ b/src/js/sw/sdk/auth.js
@@ -209,13 +209,26 @@ export function createAuth() {
// Suppress _on401Failure redirect during boot β boot handles 401 itself.
_booting = true;
+ // Defensive timeout: boot MUST complete within 8s.
+ // If stale tokens cause a network hang or unexpected deadlock,
+ // clear tokens and let the login page render.
+ let timedOut = false;
+ const bootTimeout = setTimeout(() => {
+ timedOut = true;
+ console.warn('[sw.auth] Boot timed out β clearing stale tokens');
+ _clearTokens();
+ _booting = false;
+ }, 8000);
+
// Fetch permissions (validates the access token)
try {
await _fetchPermissions();
} catch (e) {
+ if (timedOut) return;
if (e.status === 401) {
// Token expired β try refresh
const ok = await auth.refresh();
+ if (timedOut) return;
if (ok) {
try { await _fetchPermissions(); }
catch (_) { _clearTokens(); }
@@ -226,6 +239,7 @@ export function createAuth() {
console.warn('[sw.auth] Boot: permissions fetch failed (keeping tokens):', e.message);
}
} finally {
+ clearTimeout(bootTimeout);
_booting = false;
}
diff --git a/src/js/sw/sdk/rest-client.js b/src/js/sw/sdk/rest-client.js
index 1dc47f3..89c5e9f 100644
--- a/src/js/sw/sdk/rest-client.js
+++ b/src/js/sw/sdk/rest-client.js
@@ -50,6 +50,12 @@ async function _buildError(resp, method, path) {
*/
export function createRestClient(getToken, onRefresh, on401Failure) {
+ // Auth endpoints must never trigger the 401βrefreshβretry loop.
+ // Doing so causes a deadlock: refresh() calls POST /auth/refresh via
+ // _request, which 401s and calls onRefresh(), which returns the same
+ // unresolved refresh promise β both await each other forever.
+ const _authPaths = ['/api/v1/auth/login', '/api/v1/auth/refresh', '/api/v1/auth/register'];
+
async function _request(path, method, body, opts) {
const _opts = opts || {};
const headers = { 'Content-Type': 'application/json' };
@@ -62,8 +68,9 @@ export function createRestClient(getToken, onRefresh, on401Failure) {
let resp = await fetch(BASE + path, fetchOpts);
- // 401 β try refresh once
- if (resp.status === 401) {
+ // 401 β try refresh once (skip for auth endpoints to prevent deadlock)
+ const isAuthPath = _authPaths.some(p => path === p || path.startsWith(p + '?'));
+ if (resp.status === 401 && !isAuthPath && !_opts.skipRefresh) {
const refreshed = await onRefresh();
if (refreshed) {
// Retry with new token