eleven / messenger Public

Command panels: slash commands can open a transient, interactive results surface (#497)

* web: command panels — a transient results surface a slash command can open

A command can now answer with a PANEL: a temporary, interactive surface in
the chat you typed in — above the composer by default, or pinned above the
feed with position: "top" — alongside the existing private reply. It's the
/gif-picker shape: run a command, get tappable results in place, tap one and
the panel acts on the composer (insert text, stage attachments) and
dismisses itself. The user can always dismiss it too (its ✕, or Escape), and
opening another replaces it. Panels are client-only chrome: never sent,
never folded, gone when dismissed or when the pane closes.

- apps.js: ctx.panel(spec) / ctx.closePanel() in command + mention handlers,
  the injectable host (setCommandPanelHost), and the panel chrome CSS.
  ctx.panel returns false when the thread has no chat surface, so commands
  can fall back to ctx.reply.
- chat.js: the host — builds the panel in the pane skeleton, routes
  open/close per thread, wires Escape into the existing layer-peeling order,
  and hands renders a handle: close / refresh / insertText (caret-aware;
  forwarded as composerInsert to native shells in embed mode) / attachFiles
  (the same staging path the attach button uses).
- apps/emoji.js: the reference app — /emoji [search] opens a picker panel
  from the curated shortcode set; tapping an emoji inserts it and closes.
- Docs: authoring guide section + ctx table rows in apps/README.md; the
  command-doctrine bullet in CLAUDE.md now names panels as a sanctioned
  in-place response.
- Tests: searchEmoji + panel plumbing in test/apps.test.mjs; verified live
  in Chromium via the verify-skill recipe (12 assertions: open, grid, tap
  inserts + closes, Escape, ✕, no-match reply fallback).

Co-Authored-By: Claude <noreply@anthropic.com>

* mac: command panels — the native composer runs mini-app commands through a per-thread webview

The Mac feed renders chat natively, so it had no JS engine for mini-app
slash commands — /emoji (and future panel commands) just posted as literal
text. This gives every native thread a hidden COMMAND HOST: the same embed
page the app-thread webviews load, seeded with mode:"command", which is the
real web command engine bound to that thread. The shell forwards an
allowlisted slash command to it; a private reply comes back as an inline
notice, and a panel reveals the webview above the composer — the webview IS
the panel, drawn by the same web code the browser uses.

Web (the command-host embed mode):
- shell.js installCommandHost: window.lchatNative.runCommand/dismissPanel;
  posts {commandHostReady}, {panel: {title, height} | null}, {panelHeight},
  {notice}; height measured via scrollHeight so it works while the webview
  is still hidden (0-tall).
- apps.js setCommandPanelObserver/notifyCommandPanel — fired by the host's
  controller for every show/dismiss (command, ✕, Escape, app close), the one
  reliable visibility signal.
- style.css body.command-host: everything but .cmd-panel collapses; the
  panel body scrolls within whatever frame the shell grants.

Mac:
- CommandPanelWebView.swift: CommandHost (webview + "lchat" relay + a boot
  queue so the first command never races the page load), CommandHostCache
  (LRU, AppWebViewCache's shape), and the NSViewRepresentable panel.
- NativeFeedView: WebPanelCommands-owned drafts forward to the host and
  clear; the panel sits in-flow above the banners, sized by the page's
  measure (capped 340pt); Escape peels the panel before reply/edit;
  {composerInsert} appends to the draft (an /emoji tap); {notice} reuses the
  routeNotice inline slot; a "/" draft pre-warms the host. The typeahead now
  lists the command-host verbs.
- LChatCore WebPanelCommands: the forwarding allowlist (only commands that
  act in the current thread — the embed holds a single thread's key), with
  tests.

Verified: the mode:"command" page driven in Chromium with a stubbed webkit
bridge (13 assertions: ready post, collapse-to-panel, panel/height posts,
composerInsert + auto-dismiss on tap, dismissPanel, notice fallback); npm
test + go test green; LChatMac scheme builds; LChatCore 143 tests pass.
iOS follows in a separate change (its chat webview already renders panels;
it needs the composerInsert handler and Escape wiring).

Co-Authored-By: Claude <noreply@anthropic.com>

* web: /gif — Giphy search in a command panel, posted as an encrypted attachment

/gif [search] opens a command panel of Giphy results (in-panel refine box,
trending on a bare /gif); tapping one posts the GIF into the chat as an
ordinary encrypted image message from you — tap = send, like Slack's /giphy,
so the same flow works in the native shells' command-host webview where
there's no web composer to stage into.

The trust model (documented in CLAUDE.md next to unfurl's exception): all
Giphy traffic is CLIENT-side — the lchat server never sees the search, the
pick, or the bytes. The app itself never fetches (invariant #7 holds): the
host provides a scoped doorway, hostBridge.gif { search, bytes, upload } in
shell.js — bytes() accepts only https *.giphy.com URLs, upload() encrypts to
the thread key WITHOUT the image re-encode path so animation survives, and
the CSP widens connect-src to exactly https://*.giphy.com. The Giphy API key
is a public client identifier (an instance setting is a fine follow-up).

pickRendition (downsized → fixed_height → original, size-capped with the
byte fetch re-checking real length) and gifItems are pure + unit-tested.
WebPanelCommands gains /gif so the Mac forwards it and lists it in the
typeahead.

Verified live in Chromium against real Giphy, twice: the browser chat page
(11 assertions — tiles render under the new CSP, refine works, tap posts a
blob:-decrypted image message, panel dismisses, composer stays empty) and
the mode:"command" embed page the Mac webview loads (5 assertions — panel
posts, tap posts the message over the page's own socket, {panel: null}
follows). npm test + go test green.

Co-Authored-By: Claude <noreply@anthropic.com>

* tools: lchat-dev-mac — seed the native Mac app with the dev server's space

The Mac app's only product entry is "Sign in with Eleven" (the home vault),
which can't know about a local dev instance — and the join page's "Open in
the app" deep link (elevenmessenger://add) is deliberately unhandled
(joining lives on the web; a native join flow comes later), silently, with
no logging. So click-testing a branch build against tools/lchat-dev-up.mjs
had no way in.

This script does what the vault would: mints + claims a fresh add-device
token for the seeded "you" account (admin API + the same InviteCrypto
derivation the clients use) and writes a SavedInstance +  rail entry into
the app's stores, via InstancesStore's legacy file-read fallback. The file
carries the dev identity's private key in the clear — exactly why the app
never WRITES it — acceptable only for this throwaway localhost identity;
the header says so loudly. Re-run after the app's keychain adopts (and
deletes) the file.

Co-Authored-By: Claude <noreply@anthropic.com>

* mac: fix the panel deadlock in detached webviews; log the command chain

The command-host page is detached from the native view hierarchy until the
shell reveals it — and WebKit suspends requestAnimationFrame in unparented
webviews, so installCommandHost's rAF-deferred {panel} post never fired: the
panel couldn't announce itself, so the shell never attached the webview, so
rendering never resumed. (The Chromium contract drives ran in a visible tab,
which is why they passed.) The measure never needed a frame — scrollHeight
forces layout synchronously — so post immediately; the late re-measure keeps
riding a timer, which detached webviews do run.

Also instrument the whole chain with os.Logger (subsystem
com.elevenmessenger.mac, category "commands"): forward decision, page
load/fail, readiness + queue flush, every post (panel/insert/notice), eval
errors, and the page's own {error} — a silent drop anywhere in this chain is
now visible in `log stream`.

Co-Authored-By: Claude <noreply@anthropic.com>

* dev: ignore the dev server's db + blob store (unswept by tools/lchat-dev-up)

Three encrypted upload blobs from a local dev run slipped into the previous
commit via git add -A — remove them and ignore .lchat-dev.db* and
.lchat-dev-blobs/ alongside the already-ignored dev config.

Co-Authored-By: Claude <noreply@anthropic.com>

* gif: rendition-fallback on post + visible status; surface page errors natively

A native click-test post died silently: the picked rendition's download
failed (or busted the size cap), the catch put its message BELOW the result
grid — out of view in the height-capped panel — and the "Sending…" overlay
was all anyone saw. Server-side forensics showed the click never reached
upload; a headless WKWebView harness against the same seed then posted
fine, pinning the failure to the single-rendition fetch, not the machinery.

- gif.js: posting walks renditionCandidates (downsized → fixed_height →
  original, metadata-oversize dropped, deduped) so one bad URL falls back
  instead of failing the send; the status line moves ABOVE the grid where a
  scrolled panel can't hide it; the failure path logs which step died.
- shell.js: the gif doorway logs each step's failure + gets fetch timeouts;
  command-host pages forward window errors, unhandled rejections, and
  console.error as {error} posts — CommandHost writes them to the unified
  log, so a page-side death is now visible in `log show`.
- Mac: the chain logs move .info → .notice — info is memory-only in the
  unified log, which is why the first forensic `log show` came back empty.

Co-Authored-By: Claude <noreply@anthropic.com>

* web+mac: panels ease open/shut; apps opt out with animate: false

A panel now eases up into place on open and rolls down on dismissal — ✕,
Escape, and the app's own panel.close()/ctx.closePanel all take the same
path — honouring the OS reduce-motion setting, with the spec's
`animate: false` choosing instant instead. The command-host embed skips the
in-page ease (the panel IS the page there); the animate flag rides the
{panel} post and the Mac shell eases its frame reveal/collapse natively, so
there's exactly one animation on every surface.

A replace-while-animating starts clean (inline styles reset on show), a
second Escape mid-roll-up is a no-op, and a new panel opening during a
close wins over the stale teardown (captured-spec check).

Co-Authored-By: Claude <noreply@anthropic.com>

* mac: reveal the panel by height, not insertion — kills the fade + size jump

The panel webview now mounts PERMANENTLY at height 0 once the thread's
command host exists, and the reveal animates only the frame height. Three
birds: inserting the view on open was what produced SwiftUI's default
opacity fade (a height ease can't happen across an insertion); a mounted
webview always has the pane's true width, so the page's first height
measure is right and the post-reveal correction disappears; and rendering
callbacks stay live. What correction remains (fonts/images landing) now
eases instead of snapping. Hidden, the frame is 0 with hit-testing and
accessibility off.

docs: the authoring guide's command-panels section now demonstrates the
generic shape — panel buttons dispatching typed events via ctx.emit, the
host-doorway rule for privileged work (gif's pattern), the native
WebPanelCommands step (+ shipping checklist item), and the animate flag.

Co-Authored-By: Claude <noreply@anthropic.com>

* web+mac: a subtle top border + shadow lifts the panel off the feed

The web .cmd-panel keeps its hairline and gains a soft shadow cast toward
the feed (upward for the composer placement, downward for position:"top",
via the theme's --shadow so it follows light/dark). The command-host page
stays chromeless — on the Mac the native frame is the panel's chrome: a
rounded hairline stroke + shadow on the webview, both fading with
visibility so the parked 0-height frame leaves no ghost line above the
composer. iOS inherits the web styling through its embedded feed.

Co-Authored-By: Claude <noreply@anthropic.com>

* test: fix the panel-guards call count the animate assertion shifted

The animate-passthrough open added a third host call; the close assertions
still expected two. Caught by the gif-animation agent running the suite on
this base branch (and missed here by misreading a grep count as clean).

Co-Authored-By: Claude <noreply@anthropic.com>

* web: move the gif app out of the defaults — apps-disabled/ is the install-it-yourself shelf

/gif is no longer a built-in: gif.js moves to web/static/apps-disabled/, a
sibling folder shell.js never imports, kept as the reference for the
INSTALLABLE app paths — paste its source into the Admin console's custom
apps and it runs for every member with full privileges (its "../apps.js"
import resolves identically from /static/apps-custom/, which is why the
folder is a sibling, not a child). The host-side pieces an installed copy
needs — hostBridge.gif and the *.giphy.com CSP entry — stay in the client.
Member-app (sandboxed) uploads deliberately can't run it: no network, no
host doorways, no panel plumbing in the cage. See apps-disabled/README.md.

WebPanelCommands drops /gif (the native typeahead shouldn't advertise a
command that only exists where an admin installed the app — dynamic
forwarding for installed panel commands is an open follow-up), and the
/gif changelog entry goes with it (not a default feature anymore).

Co-Authored-By: Claude <noreply@anthropic.com>

* GIFs animate in the feed; click to pause/resume (web + native Mac) (#506)

* web: click a GIF to pause/resume it in the feed

Animated GIFs already play in the web feed; the click now belongs to a
play/pause toggle instead of the lightbox (the message menu keeps the other
actions). Pausing swaps the img src for a canvas-rendered still of the first
frame; resuming restores the blob URL. prefers-reduced-motion starts GIFs
frozen. Non-GIF images keep the zoom-in lightbox click.

Co-Authored-By: Claude <noreply@anthropic.com>

* mac: animated GIFs play in the native feed; click toggles pause

BlobImageLoader's NSImage(data:) already keeps every GIF frame, so the Mac
feed now hosts image/gif attachments (with >1 frame) in an NSImageView with
animates on, behind a click-through hitTest so the SwiftUI tap toggle,
right-click menu and drag-out keep working. Click = play/pause, matching the
web; stills (and single-frame GIFs) keep the lightbox click. Reduced motion
(NSWorkspace) starts GIFs paused. iOS native-beta feed keeps stills for now —
the default iOS feed is the embedded web page, which the web change covers.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* web: gif back as a default (bring-your-own key); command panels reach member apps

Two halves:

/gif returns to the default set, reworked to ask for the member's OWN free
Giphy API key on first launch instead of shipping a hard-coded one: the
panel shows a key form (link to developers.giphy.com) until a key is saved,
hostBridge.gif stores it in this device's localStorage (a public client
identifier, never sent to the lchat server), search answers "key"/"auth"
reason strings so the form reappears exactly when the key is the problem,
and a footer "API key…" link changes it later. WebPanelCommands lists /gif
again and the changelog entry returns with the new wording.

Member apps get the command-panel plumbing — the declarative form, since a
render closure can't cross the sandbox bridge: a command calls
ctx.panel({ title, data }) and the app declares a top-level
panel(data, el, panelCtx) hook. The host mounts a FRESH /app-sandbox frame
inside the real panel (same opaque-origin, no-network cage; header + ✕ stay
host-drawn), inits it with { panel: { data } }, and the frame runs the hook
with panelCtx = ctx + { close, insertText }. Everything crossing is
validated JSON: sanitizePanelSpec (clamped title, whitelisted
position/animate, 64 KB data cap — field-wise, so unset options don't trip
the whole-object cleaner), emits ride the existing sanitize + rate limit,
inserts and size reports are clamped, panel frames' state reports are
ignored (the app-thread's own frame stays the single writer), and a panel
can only open while one of the app's commands is live (resolved by command
seq — "a response, not an ambush" holds for member apps too). The trusted
world gains the same declarative form (panelSpecFor), so one file runs
identically compiled-in or pasted into App Studio.

apps-disabled/mood.js is that file: /mood opens a five-button feeling panel;
a tap emits mood.set and closes. Verified end-to-end in Chromium: deployed
through App Studio's real path, /mood typed in a chat, the button clicked
INSIDE the sandboxed panel frame, the panel dismissed over the bridge, and
exactly one encrypted event landed in the app-thread. Docs: a Command
panels section in building-member-apps.md, the trust-model addendum in
member-apps.md, and the declarative form in the authoring guide.

Co-Authored-By: Claude <noreply@anthropic.com>

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam f3e8368b852b8121d82d3cf821cc1579974c830d parent f68fb10
31 files changed, +2297 −35
  • .gitignore +2 −0
  • CLAUDE.md +15 −5
  • changelog/2026-07-09-command-panels.md +5 −0
  • changelog/2026-07-09-gif-click-to-pause.md +3 −0
  • changelog/2026-07-09-gif-command.md +8 −0
  • docs/building-member-apps.md +56 −0
  • docs/member-apps.md +19 −0
  • ios/LChatCore/Sources/LChatCore/AnimatedGIFView.swift +68 −0
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +36 −6
  • ios/LChatCore/Sources/LChatCore/Models.swift +1 −0
  • ios/LChatCore/Sources/LChatCore/SlashCommands.swift +24 −0
  • ios/LChatCore/Tests/LChatCoreTests/AnimatedGIFTests.swift +59 −0
  • ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift +22 −0
  • ios/LChatMac/CommandPanelWebView.swift +222 −0
  • ios/LChatMac/NativeChatView.swift +1 −0
  • ios/LChatMac/NativeFeedView.swift +127 −7
  • main.go +5 −1
  • test/apps.test.mjs +169 −0
  • test/sandbox.test.mjs +34 −1
  • tools/lchat-dev-mac.mjs +101 −0
  • web/static/apps-disabled/README.md +24 −0
  • web/static/apps-disabled/mood.js +91 −0
  • web/static/apps.js +95 −1
  • web/static/apps/README.md +125 −0
  • web/static/apps/chat.js +221 −3
  • web/static/apps/emoji.js +84 −0
  • web/static/apps/gif.js +300 −0
  • web/static/sandbox-frame.js +66 −0
  • web/static/sandbox.js +133 −11
  • web/static/shell.js +170 −0
  • web/static/style.css +11 −0
diff --git a/.gitignore b/.gitignore
index 9289367..5c585ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,8 @@
# the dev bot's keys/device token. (The db they point at is covered by *.db.)
.lchat-dev.json
.lchat-dev-bot.json
+.lchat-dev.db*
+.lchat-dev-blobs/
# macOS
.DS_Store
diff --git a/CLAUDE.md b/CLAUDE.md
index c8b6f85..8328961 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,10 +29,16 @@ nothing."** Every change must preserve that:
and MIME type ride *inside* the encrypted message payload, never as server
metadata.
-The single deliberate exception: **link previews** (`unfurl.go`) — the server
-fetches a posted URL to build an OpenGraph card, so it briefly sees URLs (not
-message text). This was an explicit, accepted trade-off and is easy to gate
-behind a setting later.
+Two deliberate exceptions, both easy to gate behind a setting later:
+
+- **Link previews** (`unfurl.go`) — the *server* fetches a posted URL to build
+ an OpenGraph card, so it briefly sees URLs (not message text).
+- **GIF search** (`apps/gif.js` + `hostBridge.gif` in `shell.js`) — the
+ *client* queries Giphy directly (CSP pins `connect-src` to `*.giphy.com`),
+ so Giphy sees the member's search terms and IP; the lchat server sees
+ nothing — the chosen GIF is downloaded client-side, encrypted to the thread
+ key, and posted as an ordinary opaque attachment. The Giphy API key in
+ `shell.js` is a public client identifier, not a secret.
Instance **branding** is plaintext by design and doesn't count against the rule —
it's admin-set, never user content. Three things: the **group name** (`config`
@@ -157,7 +163,11 @@ mode is being too clever.
needs to move you or change the UI, offer that as an **action chip** on the
response, so leaving the current context is always the user's tap, never the
command's side effect. (Personal commands that navigate in the app are a
- possible *later* addition — never the default.)
+ possible *later* addition — never the default.) A response may also be a
+ **command panel** — a temporary, interactive results surface in the same chat
+ (above the composer, or pinned above the feed) that the user or the app
+ dismisses: still in place, still transient, never a navigation (`ctx.panel`;
+ see `web/static/apps/README.md § Command panels`).
- **Apps can live *alongside* the chat — but never must.** A mini-app may present
as a second view onto the same thread (a Chat conversation *and* an Interface,
toggled in the header), or it may take over the thread's full surface. The
diff --git a/changelog/2026-07-09-command-panels.md b/changelog/2026-07-09-command-panels.md
new file mode 100644
index 0000000..b323bf7
--- /dev/null
+++ b/changelog/2026-07-09-command-panels.md
@@ -0,0 +1,5 @@
+🤖 Slash commands can now answer with a panel — a temporary surface that
+appears right above the message box with something to tap, then gets out of
+your way. First up: type `/emoji` (or `/emoji cat` to search) and a picker
+opens above the composer; tap an emoji and it drops straight into your
+message. Dismiss a panel any time with its ✕ or the Escape key.
diff --git a/changelog/2026-07-09-gif-click-to-pause.md b/changelog/2026-07-09-gif-click-to-pause.md
new file mode 100644
index 0000000..438e5b5
--- /dev/null
+++ b/changelog/2026-07-09-gif-click-to-pause.md
@@ -0,0 +1,3 @@
+🤖 GIFs in the chat now come with a pause button — sort of. Click (or tap) an
+animated GIF and it freezes in place; click again and it plays on. And if your
+device is set to reduce motion, GIFs arrive paused and only move when you ask.
diff --git a/changelog/2026-07-09-gif-command.md b/changelog/2026-07-09-gif-command.md
new file mode 100644
index 0000000..103c462
--- /dev/null
+++ b/changelog/2026-07-09-gif-command.md
@@ -0,0 +1,8 @@
+🤖 Type `/gif` in any chat to search Giphy without leaving the conversation —
+results appear in a panel above the message box, and tapping one sends it
+right into the chat as a picture from you. Add a search right in the command
+(`/gif high five`) or refine in the panel. The first time you use it, it asks
+for your own free Giphy API key (grab one at developers.giphy.com) — the key
+stays on your device, and your searches go to Giphy directly; our server
+never sees them. Your message and the GIF itself stay end-to-end encrypted
+like everything else.
diff --git a/docs/building-member-apps.md b/docs/building-member-apps.md
index 1c26eb3..4fe42be 100644
--- a/docs/building-member-apps.md
+++ b/docs/building-member-apps.md
@@ -268,6 +268,62 @@ view runs that command — so "buy milk" adds an item, exactly like typing into
To-do. It only applies in the app's own thread; when a real bot is in the thread,
plain text stays an ordinary message.
+## Command panels — a temporary surface where the member typed
+
+A command can answer with more than a reply: `ctx.panel(...)` opens a
+**panel** — a transient, interactive surface in the chat the member typed in,
+just above the composer, with a host-drawn header and ✕ (Escape works too).
+In the sandbox the form is **declarative** — a render closure can't cross the
+bridge — so it's two pieces:
+
+1. The command handler calls `ctx.panel({ title, data })`. `data` is plain
+ JSON handed to your hook (results, the typed args, whatever the panel
+ needs). Optional: `position: "top"` (pin above the feed),
+ `animate: false` (no open/close ease). Returns a Promise<boolean> — false
+ means no panel could open (fall back to `ctx.reply`).
+2. The app declares a top-level **`panel(data, el, panelCtx)`** hook — it runs
+ in a fresh sandboxed frame mounted inside the real panel, and draws into
+ `el` exactly like `render` does (same DOM rules, same theme variables, your
+ `styles` apply).
+
+`panelCtx` is the app `ctx` (emit / schedule / id / me / getState) plus:
+
+- `panelCtx.close()` — dismiss the panel (e.g. after a tap did its job).
+- `panelCtx.insertText(t)` — insert text at the composer's caret (clamped;
+ use it in response to a tap, never spontaneously).
+
+The whole shape, from `web/static/apps-disabled/mood.js` (the working
+example — paste it into App Studio to try):
+
+```js
+commands: {
+ "/mood": {
+ desc: "log how you're feeling",
+ run(args, ctx) {
+ ctx.panel({ title: "How are you feeling?", data: { note: args.trim() } });
+ },
+ },
+},
+panel(data, el, ctx) {
+ for (const mood of ["😄", "🙂", "😐"]) {
+ const b = document.createElement("button");
+ b.textContent = mood;
+ b.onclick = async () => {
+ await ctx.emit({ type: "mood.set", id: ctx.id(), mood, note: data.note });
+ ctx.close();
+ };
+ el.appendChild(b);
+ }
+},
+```
+
+Rules: a panel opens only from a command the member just ran (the host
+enforces it); one panel per app (opening another replaces it); the panel frame
+is the same no-network, no-keys cage as the app's own surface — `data` and
+everything you send back cross the bridge as validated, size-capped JSON. The
+declarative form also works for compiled-in apps, so a file written this way
+runs identically in both worlds.
+
## Companion chat — the thread reads as a conversation
By default a member app **takes over** its thread (the surface is the whole
diff --git a/docs/member-apps.md b/docs/member-apps.md
index bebcad7..554b50f 100644
--- a/docs/member-apps.md
+++ b/docs/member-apps.md
@@ -239,3 +239,22 @@ never enter the frame.
admin can delete any via the API today).
- In-place update of a running app without a reload (updates currently reload
to swap code).
+
+## 🤖 Command panels (member apps)
+
+A member app's command may open a command panel (`ctx.panel` — the transient
+surface above the composer). The trust posture is unchanged:
+
+- The panel BODY is a fresh `/app-sandbox` frame — the same opaque-origin,
+ no-network, no-keys cage as the app's own surface. The host never runs the
+ app's panel code in the page.
+- Everything crosses the bridge as validated JSON: the request
+ (`sanitizePanelSpec` — clamped title, 64 KB data cap), emits from the panel
+ frame (the standard sanitize + rate limit), composer inserts (length-capped),
+ and content-height reports (clamped).
+- A panel opens only while one of the app's commands is actually running (the
+ host resolves the opener by the command's sequence number), so a frame can't
+ pop panels spontaneously.
+- The panel HEADER and ✕ are host-drawn, outside the frame — dismissal can't
+ be trapped. A panel frame's `state` reports are ignored; the app-thread's
+ own frame stays the single writer.
diff --git a/ios/LChatCore/Sources/LChatCore/AnimatedGIFView.swift b/ios/LChatCore/Sources/LChatCore/AnimatedGIFView.swift
new file mode 100644
index 0000000..bb456b6
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/AnimatedGIFView.swift
@@ -0,0 +1,68 @@
+import SwiftUI
+
+// Animated-GIF playback for the native Mac feed. BlobImageLoader decodes an
+// attachment's decrypted bytes with NSImage(data:), which keeps every GIF
+// frame in its bitmap rep — so the cached image animates as-is inside an
+// NSImageView (`animates = true`), no second decode and no second cache.
+// SwiftUI's Image would flatten it to the first frame, which is exactly what
+// the paused state shows (matching the web's canvas-drawn still).
+
+/// Whether the system asks for reduced motion — animated GIFs then start
+/// paused (a click still plays them). The web twin is chat.js honouring
+/// prefers-reduced-motion in wireGifToggle.
+func gifsStartPaused() -> Bool {
+ #if canImport(AppKit) && !targetEnvironment(macCatalyst)
+ return NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
+ #else
+ // iOS renders GIFs as stills in the native beta feed (the default feed is
+ // the web page, which handles reduced motion itself), so nothing to gate.
+ return false
+ #endif
+}
+
+#if canImport(AppKit) && !targetEnvironment(macCatalyst)
+import AppKit
+
+extension NSImage {
+ /// Frames in the image's bitmap rep — >1 means it will actually animate.
+ /// A still that merely claims image/gif (single-frame GIF) reports 1 and
+ /// keeps the ordinary photo behaviour (lightbox on click).
+ var gifFrameCount: Int {
+ guard let rep = representations.first as? NSBitmapImageRep,
+ let n = rep.value(forProperty: .frameCount) as? Int else { return 1 }
+ return n
+ }
+}
+
+/// NSImageView host for an animated GIF; `playing` pauses/resumes (paused
+/// shows the first frame). Events pass straight through the AppKit view, so
+/// the SwiftUI container keeps the tap toggle, the right-click menu, and
+/// drag-out exactly as for still photos.
+struct AnimatedGIFView: NSViewRepresentable {
+ let image: NSImage
+ let playing: Bool
+
+ func makeNSView(context: Context) -> ClickThroughImageView {
+ let v = ClickThroughImageView()
+ v.imageScaling = .scaleProportionallyUpOrDown
+ v.image = image
+ v.animates = playing
+ // The SwiftUI .frame decides the size — the GIF's intrinsic size must
+ // not push the bubble wider than the reserved w/h box.
+ v.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+ v.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+ return v
+ }
+
+ func updateNSView(_ v: ClickThroughImageView, context: Context) {
+ if v.image !== image { v.image = image }
+ v.animates = playing
+ }
+
+ // NSImageView is an NSControl, so left it would swallow the clicks the
+ // bubble needs; returning nil routes every event to the SwiftUI layer.
+ final class ClickThroughImageView: NSImageView {
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
+ }
+}
+#endif
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
index f376616..274db5f 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
@@ -776,6 +776,8 @@ public struct MessageBubble: View {
@State private var image: PlatformImage?
// The thumbnail's live window frame — the lightbox zooms out from here.
@State private var globalFrame = CGRect.zero
+ // Animated GIFs only: reduced-motion users get them paused at first.
+ @State private var playing = !gifsStartPaused()
var body: some View {
let box = displaySize()
@@ -783,11 +785,17 @@ public struct MessageBubble: View {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(theme.bubbleThem.resolved(scheme))
if let image {
- Image(platform: image)
- .resizable()
- .scaledToFill()
- .frame(width: box.width, height: box.height)
- .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+ #if canImport(AppKit) && !targetEnvironment(macCatalyst)
+ if isAnimatedGIF(image) {
+ AnimatedGIFView(image: image, playing: playing)
+ .frame(width: box.width, height: box.height)
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+ } else {
+ still(image, box: box)
+ }
+ #else
+ still(image, box: box)
+ #endif
}
}
.frame(width: box.width, height: box.height)
@@ -798,7 +806,11 @@ public struct MessageBubble: View {
})
.contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.onTapGesture {
- if let image { onTap?(attachment, image, globalFrame) } // the web's zoom-in click
+ guard let image else { return }
+ // An animated GIF claims the click for play/pause (as on the
+ // web); everything else keeps the zoom-in lightbox click.
+ if isAnimatedGIF(image) { playing.toggle() }
+ else { onTap?(attachment, image, globalFrame) }
}
.task(id: attachment.id) {
guard image == nil, let source else { return }
@@ -811,6 +823,24 @@ public struct MessageBubble: View {
.help(attachment.name ?? "photo")
}
+ private func still(_ image: PlatformImage, box: CGSize) -> some View {
+ Image(platform: image)
+ .resizable()
+ .scaledToFill()
+ .frame(width: box.width, height: box.height)
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+ }
+
+ // A GIF that actually has frames to play — a single-frame "GIF" is
+ // just a photo and keeps photo behaviour (SwiftUI still + lightbox).
+ private func isAnimatedGIF(_ image: PlatformImage) -> Bool {
+ #if canImport(AppKit) && !targetEnvironment(macCatalyst)
+ return attachment.isGIF && image.gifFrameCount > 1
+ #else
+ return false // iOS native beta renders GIFs as stills for now
+ #endif
+ }
+
// chat.js: scale = min(260/w, 320/h, 1). Unknown dimensions (very old
// payloads) get a modest square placeholder rather than a layout jump.
private func displaySize() -> CGSize {
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index 3552ac9..717de0f 100644
--- a/ios/LChatCore/Sources/LChatCore/Models.swift
+++ b/ios/LChatCore/Sources/LChatCore/Models.swift
@@ -177,6 +177,7 @@ public struct Attachment: Codable, Hashable, Sendable {
public var dur: Double? // video duration, seconds
public var isImage: Bool { type?.hasPrefix("image/") ?? false }
public var isVideo: Bool { type?.hasPrefix("video/") ?? false }
+ public var isGIF: Bool { type == "image/gif" } // what /gif posts (gif.js)
}
// A group's encrypted name payload (threads.name_blob decrypts to this).
diff --git a/ios/LChatCore/Sources/LChatCore/SlashCommands.swift b/ios/LChatCore/Sources/LChatCore/SlashCommands.swift
index 3189312..afa83db 100644
--- a/ios/LChatCore/Sources/LChatCore/SlashCommands.swift
+++ b/ios/LChatCore/Sources/LChatCore/SlashCommands.swift
@@ -58,3 +58,27 @@ public enum SlashCommand: Equatable {
return ChatTheme.presets.first { $0.id == a }?.id
}
}
+
+// 🤖 Commands the native shell forwards to its per-thread COMMAND-HOST webview
+// (the web command engine — shell.js installCommandHost) instead of handling
+// natively or sending as a message. These are the web's panel-opening commands;
+// only ones that act in the CURRENT thread (or need no thread at all) belong
+// here — the embed page holds a single thread's key, so a command that routes
+// to another thread (/todo) can't resolve there. Keep name + desc matching the
+// web app's declaration (apps/emoji.js), like the botCommands mirror in
+// ComposerTypeahead.swift.
+public enum WebPanelCommands {
+ public static let catalog: [(name: String, desc: String)] = [
+ ("/emoji", "browse emoji — tap one to add it to your message"),
+ ("/gif", "find a GIF — tap one to send it here"),
+ ]
+
+ /// Whether composer text names a command the command host answers —
+ /// matched on the first token, case-insensitively, like SlashCommand.parse.
+ public static func owns(_ text: String) -> Bool {
+ let t = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard t.first == "/" else { return false }
+ let name = t.split(separator: " ", maxSplits: 1).first.map(String.init) ?? t
+ return catalog.contains { $0.name == name.lowercased() }
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/AnimatedGIFTests.swift b/ios/LChatCore/Tests/LChatCoreTests/AnimatedGIFTests.swift
new file mode 100644
index 0000000..af52fe3
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/AnimatedGIFTests.swift
@@ -0,0 +1,59 @@
+import XCTest
+@testable import LChatCore
+
+// The GIF gate MessageBubble's AttachmentImage animates behind: the payload
+// type says "GIF" (what /gif posts — see gif.js) and the decoded image really
+// has frames to play. Single-frame GIFs and mislabelled stills stay photos.
+final class AnimatedGIFTests: XCTestCase {
+ func testIsGIFMatchesExactType() {
+ XCTAssertTrue(Attachment(id: "a", type: "image/gif").isGIF)
+ XCTAssertFalse(Attachment(id: "a", type: "image/jpeg").isGIF)
+ XCTAssertFalse(Attachment(id: "a", type: "image/gif+xml").isGIF)
+ XCTAssertFalse(Attachment(id: "a", type: nil).isGIF)
+ }
+}
+
+#if canImport(AppKit) && !targetEnvironment(macCatalyst)
+import AppKit
+import ImageIO
+import UniformTypeIdentifiers
+
+final class GIFFrameCountTests: XCTestCase {
+ func testAnimatedGIFReportsItsFrames() throws {
+ let img = try XCTUnwrap(NSImage(data: makeImage(frames: 3, as: .gif)))
+ XCTAssertEqual(img.gifFrameCount, 3)
+ }
+
+ func testSingleFrameGIFReportsOne() throws {
+ let img = try XCTUnwrap(NSImage(data: makeImage(frames: 1, as: .gif)))
+ XCTAssertEqual(img.gifFrameCount, 1)
+ }
+
+ func testStillPNGReportsOne() throws {
+ let img = try XCTUnwrap(NSImage(data: makeImage(frames: 1, as: .png)))
+ XCTAssertEqual(img.gifFrameCount, 1)
+ }
+
+ // A tiny solid-colour image encoded with `frames` frames, via ImageIO —
+ // the same decoder family NSImage(data:) reads back through.
+ private func makeImage(frames: Int, as type: UTType) throws -> Data {
+ let data = NSMutableData()
+ let dest = try XCTUnwrap(CGImageDestinationCreateWithData(
+ data, type.identifier as CFString, frames, nil))
+ let frameProps = [kCGImagePropertyGIFDictionary:
+ [kCGImagePropertyGIFDelayTime: 0.1]] as CFDictionary
+ for i in 0..<frames {
+ let shade = CGFloat(i) / CGFloat(max(frames - 1, 1))
+ let ctx = try XCTUnwrap(CGContext(
+ data: nil, width: 4, height: 4, bitsPerComponent: 8, bytesPerRow: 0,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue))
+ ctx.setFillColor(CGColor(red: shade, green: 0, blue: 1 - shade, alpha: 1))
+ ctx.fill(CGRect(x: 0, y: 0, width: 4, height: 4))
+ CGImageDestinationAddImage(dest, try XCTUnwrap(ctx.makeImage()), frameProps)
+ }
+ XCTAssertTrue(CGImageDestinationFinalize(dest))
+ return data as Data
+ }
+}
+#endif
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift
index eb43c59..ed75489 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/SlashCommandsTests.swift
@@ -25,3 +25,25 @@ final class SlashCommandsTests: XCTestCase {
XCTAssertEqual(SlashCommand.parse("/ hey", meName: "P"), .sendAsMessage)
}
}
+
+// 🤖 WebPanelCommands — the commands the native shell forwards to its
+// per-thread command-host webview (the web command engine) instead of
+// handling natively or sending as a message.
+final class WebPanelCommandsTests: XCTestCase {
+ func testOwnsMatchesTheFirstToken() {
+ XCTAssertTrue(WebPanelCommands.owns("/emoji"))
+ XCTAssertTrue(WebPanelCommands.owns("/emoji cat"))
+ XCTAssertTrue(WebPanelCommands.owns(" /EMOJI heart ")) // trimmed + case-insensitive
+ XCTAssertTrue(WebPanelCommands.owns("/gif office party"))
+ }
+ func testOwnsRejectsEverythingElse() {
+ XCTAssertFalse(WebPanelCommands.owns("/emojis cat")) // whole-token match only
+ XCTAssertFalse(WebPanelCommands.owns("/me waves")) // native-handled
+ XCTAssertFalse(WebPanelCommands.owns("/todo buy milk"))// routes to another thread — never forwarded
+ XCTAssertFalse(WebPanelCommands.owns("emoji")) // no slash → plain text
+ XCTAssertFalse(WebPanelCommands.owns(""))
+ }
+ func testCatalogFeedsTheTypeahead() {
+ XCTAssertTrue(WebPanelCommands.catalog.contains { $0.name == "/emoji" && !$0.desc.isEmpty })
+ }
+}
diff --git a/ios/LChatMac/CommandPanelWebView.swift b/ios/LChatMac/CommandPanelWebView.swift
new file mode 100644
index 0000000..737a0eb
--- /dev/null
+++ b/ios/LChatMac/CommandPanelWebView.swift
@@ -0,0 +1,222 @@
+import LChatCore
+import SwiftUI
+import WebKit
+import os
+
+// Diagnostics for the whole command→panel chain (page load, readiness, every
+// post, run/dismiss evals). Watch with:
+// log stream --predicate 'subsystem == "com.elevenmessenger.mac" && category == "commands"'
+let cmdLog = Logger(subsystem: "com.elevenmessenger.mac", category: "commands")
+
+// 🤖 The command engine + panel surface for a NATIVE chat thread. The Mac feed
+// renders chat natively, so it has no JS to run mini-app slash commands — this
+// per-thread webview is that engine: the same embed page AppThreadWebView uses,
+// seeded with mode:"command" (shell.js installCommandHost), which collapses the
+// page to just a command's PANEL. The shell keeps it hidden, forwards a typed
+// command to window.lchatNative.runCommand, and reveals the webview above the
+// composer only while a panel is showing — the webview IS the panel. Posts back
+// on the "lchat" handler: {commandHostReady}, {panel: {title, height} | null},
+// {panelHeight}, {composerInsert} (a panel inserting into the native composer),
+// and {notice} (a command's private "only visible to you" reply).
+
+// What the command-host page tells the shell.
+enum CommandHostEvent {
+ case panel(title: String, height: CGFloat, animate: Bool) // reveal (or retitle) the panel
+ case panelClosed // collapse it (✕, Escape, app close)
+ case panelHeight(CGFloat) // late layout settled — resize
+ case composerInsert(String) // insert into the native composer
+ case notice(from: String, text: String) // a private reply — render inline
+}
+
+// One thread's command host: the webview, its readiness, and the queue of
+// commands typed before the page finished booting. Owned by CommandHostCache
+// (which outlives any one SwiftUI view); the feed view on screen points `sink`
+// at itself, exactly like the model-cache re-point pattern.
+@MainActor
+final class CommandHost: NSObject, WKScriptMessageHandler {
+ let web: WKWebView
+ var sink: ((CommandHostEvent) -> Void)?
+ private var ready = false
+ private var queued: [String] = []
+
+ init(instance: SavedInstance, thread: ChatThread, myUserId: String, instanceKeyB64: String?) {
+ var seed: [String: Any] = [
+ "token": instance.deviceToken,
+ "name": instance.screenName,
+ "myUserId": myUserId,
+ "threadId": thread.id,
+ "threadKey": thread.threadKeyB64,
+ "mode": "command",
+ ]
+ if let ik = instanceKeyB64 { seed["instanceKey"] = ik }
+ let cfg = WKWebViewConfiguration()
+ cfg.websiteDataStore = .default()
+ if let data = try? JSONSerialization.data(withJSONObject: seed),
+ let json = String(data: data, encoding: .utf8) {
+ cfg.userContentController.addUserScript(
+ WKUserScript(source: "window.__lchatEmbed = \(json);",
+ injectionTime: .atDocumentStart, forMainFrameOnly: true))
+ }
+ web = WKWebView(frame: .zero, configuration: cfg)
+ super.init()
+ // A weak proxy breaks the controller→handler→self→web retain cycle, so
+ // an evicted host actually deallocates (teardown also unregisters).
+ cfg.userContentController.add(WeakMessageHandler(self), name: "lchat")
+ web.navigationDelegate = self
+ web.setValue(false, forKey: "drawsBackground") // blend with the composer chrome
+ if let url = instance.baseURL {
+ cmdLog.notice("command host loading \(url.absoluteString, privacy: .public) for thread \(thread.id, privacy: .public)")
+ web.load(URLRequest(url: url))
+ } else {
+ cmdLog.error("command host has no base URL (origin \(instance.origin, privacy: .public))")
+ }
+ }
+
+ // Run a slash command through the page's engine. Booting queues the text —
+ // the page flushes on {commandHostReady} — so the first command typed in a
+ // thread never races the load.
+ func run(_ text: String) {
+ guard ready else {
+ cmdLog.notice("command queued until the page is ready: \(text, privacy: .public)")
+ queued.append(text)
+ return
+ }
+ guard let data = try? JSONSerialization.data(withJSONObject: [text]),
+ let arg = String(data: data, encoding: .utf8) else { return }
+ web.evaluateJavaScript(
+ "window.lchatNative && window.lchatNative.runCommand ? window.lchatNative.runCommand(\(arg)[0]) : false"
+ ) { result, error in
+ if let error {
+ cmdLog.error("runCommand eval failed: \(error.localizedDescription, privacy: .public)")
+ } else {
+ cmdLog.notice("runCommand handled=\((result as? Bool) ?? false) for \(text, privacy: .public)")
+ }
+ }
+ }
+
+ // Ask the page to dismiss its panel (Escape in the native composer). The
+ // actual collapse follows on the {panel: null} post, so the page's onClose
+ // hook always runs — never hide the frame directly.
+ func dismissPanel() {
+ web.evaluateJavaScript(
+ "window.lchatNative && window.lchatNative.dismissPanel && window.lchatNative.dismissPanel()",
+ completionHandler: nil)
+ }
+
+ func teardown() {
+ web.configuration.userContentController.removeScriptMessageHandler(forName: "lchat")
+ web.stopLoading()
+ sink = nil
+ }
+
+ // MARK: WKScriptMessageHandler
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
+ guard let dict = message.body as? [String: Any] else { return }
+ if dict["commandHostReady"] as? Bool == true {
+ cmdLog.notice("command host ready (\(self.queued.count) queued)")
+ ready = true
+ let pending = queued
+ queued = []
+ for text in pending { run(text) }
+ return
+ }
+ if dict.keys.contains("panel") {
+ if let p = dict["panel"] as? [String: Any] {
+ let h = (p["height"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 240
+ cmdLog.notice("panel open: \(p["title"] as? String ?? "", privacy: .public) height \(h)")
+ sink?(.panel(title: p["title"] as? String ?? "", height: h,
+ animate: (p["animate"] as? Bool) ?? true))
+ } else {
+ cmdLog.notice("panel closed")
+ sink?(.panelClosed)
+ }
+ return
+ }
+ if let h = dict["panelHeight"] as? NSNumber {
+ sink?(.panelHeight(CGFloat(truncating: h)))
+ return
+ }
+ if let t = dict["composerInsert"] as? String {
+ cmdLog.notice("composerInsert (\(t.count) chars)")
+ sink?(.composerInsert(t))
+ return
+ }
+ if let n = dict["notice"] as? [String: Any] {
+ cmdLog.notice("notice from \(n["from"] as? String ?? "", privacy: .public)")
+ sink?(.notice(from: n["from"] as? String ?? "", text: n["text"] as? String ?? ""))
+ return
+ }
+ // The page reporting a boot problem ({error} — bootEmbed's catch).
+ if let e = dict["error"] as? String {
+ cmdLog.error("command host page error: \(e, privacy: .public)")
+ }
+ }
+}
+
+// Load diagnostics: a page that never reaches didFinish explains a silent
+// panel better than anything else can.
+extension CommandHost: WKNavigationDelegate {
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ cmdLog.notice("command host page loaded")
+ }
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+ cmdLog.error("command host page failed: \(error.localizedDescription, privacy: .public)")
+ }
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
+ cmdLog.error("command host page failed to start: \(error.localizedDescription, privacy: .public)")
+ }
+}
+
+// WKUserContentController retains its handlers strongly; this proxy keeps the
+// host owning the controller without a cycle (the standard WKWebView pattern).
+private final class WeakMessageHandler: NSObject, WKScriptMessageHandler {
+ weak var target: WKScriptMessageHandler?
+ init(_ target: WKScriptMessageHandler) { self.target = target }
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
+ target?.userContentController(controller, didReceive: message)
+ }
+}
+
+// Keeps each thread's command host alive across thread switches, so a second
+// slash command in a revisited chat is instant. Same LRU shape as
+// AppWebViewCache; hosts are cheap to rebuild, so the cap stays small.
+@MainActor
+final class CommandHostCache {
+ static let shared = CommandHostCache()
+
+ private struct Entry {
+ let host: CommandHost
+ let seedKey: String
+ var lastUsed = Date()
+ }
+
+ private var entries: [String: Entry] = [:] // "instanceID|threadID"
+ private let maxEntries = 4
+
+ func host(for instance: SavedInstance, thread: ChatThread,
+ myUserId: String, instanceKeyB64: String?) -> CommandHost {
+ let key = instance.id + "|" + thread.id
+ if var e = entries[key], e.seedKey == thread.threadKeyB64 {
+ e.lastUsed = Date()
+ entries[key] = e
+ return e.host
+ }
+ entries.removeValue(forKey: key)?.host.teardown()
+ let host = CommandHost(instance: instance, thread: thread,
+ myUserId: myUserId, instanceKeyB64: instanceKeyB64)
+ entries[key] = Entry(host: host, seedKey: thread.threadKeyB64)
+ while entries.count > maxEntries,
+ let oldest = entries.filter({ $0.value.host.web.superview == nil })
+ .min(by: { $0.value.lastUsed < $1.value.lastUsed }) {
+ entries.removeValue(forKey: oldest.key)?.host.teardown()
+ }
+ return host
+ }
+}
+
+// The visible panel: just the host's webview, framed by the composer stack.
+struct CommandPanelWebView: NSViewRepresentable {
+ let host: CommandHost
+ func makeNSView(context: Context) -> WKWebView { host.web }
+ func updateNSView(_ web: WKWebView, context: Context) {}
+}
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 4a384d8..4a71803 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -461,6 +461,7 @@ struct NativeChatView: View {
isGroup: thread.kind == "group" || thread.members.count > 2,
isActive: isActive,
roster: store.roster,
+ instance: instance, instanceKeyB64: store.instanceKey(),
mentionAvatar: { [weak store] uid in
store.flatMap { s in
s.roster.first { $0.user_id == uid }.flatMap(s.avatarSource(for:))
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 4e5c9d6..3a953e8 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -46,6 +46,7 @@ struct NativeFeedView: View {
init(model: ChatFeedModel, thread: ChatThread, myUserId: String, isGroup: Bool,
isActive: Bool = true, roster: [RosterEntry],
+ instance: SavedInstance? = nil, instanceKeyB64: String? = nil,
mentionAvatar: @escaping (String) -> AvatarSource?) {
// The model arrives PARKED (NativeFeedModelCache) — folded messages and
// a live socket survive leaving the chat, so re-opening never flashes.
@@ -61,16 +62,25 @@ struct NativeFeedView: View {
// The app-thread's default command (To-do: plain text adds a to-do) —
// the web's defaultCommandFor, so every composer routes identically.
self.defaultCommand = AppDefaultCommand.spec(appID: thread.appId, hasBot: self.hasBot)
- // The thread's app-local verbs for the "/" typeahead (/add in To-do) —
- // shown bot or no bot, like the web's commandCatalog(threadId).
- self.appCommands = AppDefaultCommand.localCommands(appID: thread.appId)
+ // The thread's app-local verbs for the "/" typeahead (/add in To-do),
+ // then the command-host verbs (/emoji) — every native composer offers
+ // what the per-thread command webview can answer (see sendDraft).
+ self.appCommands = AppDefaultCommand.localCommands(appID: thread.appId) + WebPanelCommands.catalog
self.threadID = thread.id
+ // The command host needs the full thread (key) + instance to seed its
+ // webview; nil (a mount without an instance) just disables forwarding.
+ self.thread = thread
+ self.instance = instance
+ self.instanceKeyB64 = instanceKeyB64
}
private let defaultCommand: AppDefaultCommand.Spec?
private let appCommands: [(name: String, desc: String)]
private let threadID: String
+ private let thread: ChatThread
+ private let instance: SavedInstance?
+ private let instanceKeyB64: String?
// The companion app's start-of-history intro (chatIntro on the web),
// shown above the earliest message once all history is loaded.
@@ -90,6 +100,13 @@ struct NativeFeedView: View {
@State private var routeNotice: String?
@State private var routeNoticeGen = 0
@State private var dcRouting = false
+ // The thread's COMMAND HOST — the hidden per-thread webview that runs
+ // mini-app slash commands (/emoji) and shows their panels; the panel
+ // reveals the webview above the composer (see CommandPanelWebView.swift).
+ @State private var commandHost: CommandHost?
+ @State private var panelVisible = false
+ @State private var panelHeight: CGFloat = 240
+ @State private var panelAnimates = true // the open post's animate flag, reused on close
// The native composer's draft — cleared only when a send actually reaches
// the server (model.send returns false on a down socket, keeping the text).
@State private var draft = ""
@@ -674,6 +691,33 @@ struct NativeFeedView: View {
mentionAvatar: mentionAvatar) { pick($0, token: token) }
.padding(.horizontal, 10)
}
+ // A command's results panel (the command-host webview — the page
+ // draws its own header + ✕): in-flow above the banners, occupying
+ // layout like the web's .cmd-panel, sized by the page's measure.
+ // Mounted PERMANENTLY at height 0 once the host exists, for three
+ // reasons: revealing by height (not insertion) animates as a real
+ // ease-up instead of SwiftUI's insertion fade; the page always has
+ // the pane's true width, so its height measure is right the first
+ // time (no post-reveal jump); and an attached webview keeps its
+ // rendering callbacks live.
+ if let host = commandHost {
+ // The command-host page suppresses its own border/shadow (the
+ // webview is edge-to-edge there) — this frame is the panel's
+ // chrome: a hairline and a subtle lift, matching the web's
+ // .cmd-panel. Both fade with visibility so the parked 0-height
+ // frame leaves no ghost line above the composer.
+ CommandPanelWebView(host: host)
+ .frame(height: panelVisible ? panelHeight : 0)
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .strokeBorder(.primary.opacity(panelVisible ? 0.08 : 0), lineWidth: 1)
+ )
+ .shadow(color: .black.opacity(panelVisible ? 0.10 : 0), radius: 7, y: 2)
+ .padding(.horizontal, 8)
+ .allowsHitTesting(panelVisible)
+ .accessibilityHidden(!panelVisible)
+ }
if let replyTo {
composerBanner("Replying to \(replyTo.name ?? "")",
detail: replyTo.snippet) { cancelReply() }
@@ -724,6 +768,9 @@ struct NativeFeedView: View {
// The typing beats: alive while there's a draft, an immediate
// clear when it empties (edits don't announce themselves).
model.typingBeat(active: !text.isEmpty && editingID == nil)
+ // A "/" draft pre-warms the command host, so the page has booted
+ // by the time Enter runs the command (a cold page queues it).
+ if text.first == "/", commandHost == nil { ensureCommandHost() }
}
}
@@ -908,10 +955,20 @@ struct NativeFeedView: View {
return .ignored
}
.onKeyPress(.escape) {
- guard !taItems.isEmpty else { return .ignored }
- taToken = nil
- taItems = []
- return .handled
+ if !taItems.isEmpty {
+ taToken = nil
+ taItems = []
+ return .handled
+ }
+ // An open command panel is the most recently summoned
+ // layer — Escape peels it before reply/edit below (the
+ // page posts {panel: null} back, which collapses the
+ // frame, so the panel's onClose hook always runs).
+ if panelVisible {
+ commandHost?.dismissPanel()
+ return .handled
+ }
+ return .ignored
}
.focused($composerFocused)
// Regaining the app drops the caret into the message box, the
@@ -982,6 +1039,54 @@ struct NativeFeedView: View {
}
}
+ // The thread's command host, created on first use (and pre-warmed the
+ // moment a draft turns into a "/" — see onChange(of: draft)). Re-points
+ // `sink` at THIS mount every call, like the model-cache re-point pattern,
+ // so a revisited thread's cached host talks to the pane on screen.
+ @discardableResult
+ private func ensureCommandHost() -> CommandHost? {
+ guard let instance else { return nil }
+ let host = commandHost ?? CommandHostCache.shared.host(
+ for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64)
+ if commandHost == nil { commandHost = host }
+ host.sink = { event in
+ switch event {
+ case .panel(_, let height, let animate): // the web panel draws its own header
+ // The app's call (spec.animate): ease the reveal, or appear
+ // instantly. The web panel skips its in-page ease in command-
+ // host mode, so this is the ONE animation either way.
+ panelAnimates = animate
+ withAnimation(animate ? .easeOut(duration: 0.22) : nil) {
+ panelHeight = min(height, 340)
+ panelVisible = true
+ }
+ case .panelClosed:
+ withAnimation(panelAnimates ? .easeIn(duration: 0.2) : nil) {
+ panelVisible = false
+ }
+ case .panelHeight(let h):
+ // Late re-measures (fonts, images) ease too — an abrupt snap
+ // here read as a "jump" after the reveal.
+ if panelVisible {
+ withAnimation(panelAnimates ? .easeInOut(duration: 0.18) : nil) {
+ panelHeight = min(h, 340)
+ }
+ }
+ case .composerInsert(let text):
+ // A panel acting on the composer (an /emoji tap). Caret-aware
+ // splicing needs the NSTextView-backed field (the typeahead's
+ // known limitation) — append, the common caret-at-end case.
+ draft += text
+ composerFocused = true
+ case .notice(let from, let text):
+ // A command's private "only visible to you" reply — the same
+ // inline slot the default-command push-back uses.
+ setRouteNotice(from.isEmpty ? text : "\(from) — \(text)")
+ }
+ }
+ return host
+ }
+
private func sendDraft() {
// An edit replaces the message in place — no attachments/reply staging.
// cancelEdit (not inline clears) so an edit inside an open thread view
@@ -996,6 +1101,21 @@ struct NativeFeedView: View {
// theme, everything else falls through to a normal send so a bot
// answers it (matching the web).
if staged.isEmpty, replyTo == nil, draft.first == "/" {
+ // A command the web engine owns (/emoji — WebPanelCommands) runs in
+ // the thread's command-host webview: its reply posts back as an
+ // inline notice, a panel reveals the webview above the composer.
+ // Cleared immediately, like the web composer — runCommand consumes
+ // a command attempt synchronously (a booting page queues it).
+ if WebPanelCommands.owns(draft) {
+ if let host = ensureCommandHost() {
+ cmdLog.notice("forwarding to the command host: \(draft, privacy: .public)")
+ host.run(draft)
+ draft = "" // onChange(of: draft) ends the typing beat
+ } else {
+ cmdLog.error("no command host (no instance for this pane) — command dropped")
+ }
+ return
+ }
switch SlashCommand.parse(draft, meName: model.myScreenName) {
case .me(let line):
if model.sendAction(line) { draft = "" }
diff --git a/main.go b/main.go
index eef4b2e..3749f10 100644
--- a/main.go
+++ b/main.go
@@ -547,7 +547,11 @@ func (s *Server) servePage(name string) http.HandlerFunc {
// only holds ciphertext), so media-src must allow blob: or the
// <video> is refused. Without this it falls back to default-src 'self'.
"media-src 'self' blob:; "+
- "connect-src 'self'; "+
+ // *.giphy.com is the /gif app's CLIENT-side doorway (hostBridge.gif in
+ // shell.js): search + GIF download happen in the browser, so the server
+ // never sees the query or the pick — the deliberate, disclosed sibling
+ // of the unfurl trade-off. Keep connect-src pinned to named hosts.
+ "connect-src 'self' https://*.giphy.com; "+
// 'self' admits exactly one same-origin frame: /app-sandbox, the
// opaque-origin cage member apps run in (memberapps.go).
"frame-src 'self' https://www.youtube-nocookie.com; "+
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index a08a1a9..e540bf4 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -51,6 +51,9 @@ import {
parseHere,
runAppMention,
setThreadIsGroup,
+ setCommandPanelHost,
+ openCommandPanel,
+ closeCommandPanel,
} from "../web/static/apps.js";
import { app as todoApp } from "../web/static/apps/todo.js"; // registers the /todo app
import { app as pollApp, pollSummary } from "../web/static/apps/poll.js";
@@ -63,6 +66,8 @@ import { app as welcomeApp } from "../web/static/apps/welcome.js"; // the joiner
import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin)
import { app as chatApp, viewItems, threadItems } from "../web/static/apps/chat.js"; // the chat app
import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app (registers /vault)
+import { app as emojiApp, searchEmoji } from "../web/static/apps/emoji.js"; // registers /emoji (the command-panel reference app)
+import { app as gifApp, pickRendition, renditionCandidates, gifItems, GIF_MAX_BYTES } from "../web/static/apps/gif.js"; // registers /gif
// Each app's fold is synthesised from its `events` map by defineApp — the same
// (state, event) → state reducer the framework folds with. Test it directly.
@@ -1667,5 +1672,169 @@ await test("runAppMention: a bare mention gets the usage reply; plain text never
resetThread("grp4");
});
+// ---- command panels (ctx.panel) + the /emoji reference app -----------------
+
+await test("searchEmoji filters by name substring and collapses aliases", () => {
+ const cats = searchEmoji("cat");
+ assert.ok(cats.some((r) => r.ch === "🐱"), "cat matches");
+ assert.ok(cats.every((r) => r.name.includes("cat")), "only matching names");
+ // laughing/satisfied are aliases of one glyph — a bare search dedupes them.
+ const all = searchEmoji("");
+ assert.equal(all.filter((r) => r.ch === "😆").length, 1, "aliases collapse to one tile");
+ assert.ok(all.length > 100, "a bare query browses the whole set");
+ assert.deepEqual(searchEmoji("zzzznope"), [], "no match → empty");
+});
+
+await test("ctx.panel opens a command panel in the thread you TYPED in", async () => {
+ const opened = [];
+ setCommandPanelHost((threadId, spec) => {
+ opened.push({ threadId, spec });
+ return true;
+ });
+ const replies = [];
+ assert.equal(runCommand("/emoji cat", "chatX", (r) => replies.push(r)), true);
+ await tick();
+ assert.equal(opened.length, 1);
+ assert.equal(opened[0].threadId, "chatX", "the panel opens where you typed, not a routed app-thread");
+ assert.equal(typeof opened[0].spec.render, "function");
+ assert.match(opened[0].spec.title, /cat/);
+ assert.equal(opened[0].spec.sender.name, "Emoji", "runCommand injects the app persona as the panel's sender");
+ assert.equal(replies.length, 0, "the panel IS the answer — no reply too");
+});
+
+await test("a command falls back to a reply when no chat surface can draw a panel", async () => {
+ setCommandPanelHost(() => false); // the thread has no mounted chat pane
+ const replies = [];
+ assert.equal(runCommand("/emoji cat", "chatX", (r) => replies.push(r)), true);
+ await tick();
+ assert.equal(replies.length, 1, "ctx.panel returned false → the command replied instead");
+ assert.match(replies[0].text, /🐱/);
+});
+
+await test("openCommandPanel guards: no host / no render → false; close reaches the host", () => {
+ const calls = [];
+ setCommandPanelHost((threadId, spec) => (calls.push({ threadId, spec }), true));
+ assert.equal(openCommandPanel("t1", { title: "no render" }), false, "a panel must have render()");
+ assert.equal(calls.length, 0);
+ assert.equal(openCommandPanel("t1", { render() {} }), true);
+ // The spec passes through verbatim — animate/position are the host's to read.
+ assert.equal(openCommandPanel("t1", { render() {}, animate: false }), true);
+ assert.equal(calls[calls.length - 1].spec.animate, false);
+ closeCommandPanel("t1");
+ assert.equal(calls.length, 3); // two opens + the close
+ assert.equal(calls[2].spec, null, "close is a null spec to the same host");
+ setCommandPanelHost(null);
+ assert.equal(openCommandPanel("t1", { render() {} }), false, "no host → false (caller falls back to a reply)");
+});
+
+await test("gif: pickRendition prefers downsized, skips oversize, tolerates missing sizes", () => {
+ const images = {
+ downsized: { url: "https://media0.giphy.com/a.gif", width: "320", height: "240", size: "900000" },
+ fixed_height: { url: "https://media0.giphy.com/b.gif", width: "356", height: "200", size: "1500000" },
+ original: { url: "https://media0.giphy.com/c.gif", width: "480", height: "360", size: "9000000" },
+ };
+ assert.equal(pickRendition(images).url, "https://media0.giphy.com/a.gif");
+ // downsized too big → the next acceptable rendition wins
+ assert.equal(pickRendition({ ...images, downsized: { ...images.downsized, size: String(GIF_MAX_BYTES + 1) } }).url, "https://media0.giphy.com/b.gif");
+ // unknown size sails through — the byte fetch re-checks the real length
+ assert.equal(pickRendition({ downsized: { url: "https://media0.giphy.com/d.gif", width: "1", height: "1" } }).url, "https://media0.giphy.com/d.gif");
+ // nothing acceptable → null
+ assert.equal(pickRendition({ original: { url: "https://media0.giphy.com/c.gif", size: String(GIF_MAX_BYTES * 2) } }), null);
+ assert.equal(pickRendition(undefined), null);
+ // The post walks ALL acceptable renditions (fallback when one download
+ // fails), deduped by url, best first.
+ const cands = renditionCandidates(images);
+ assert.deepEqual(cands.map((c) => c.url.split("/").pop()), ["a.gif", "b.gif"], "oversize original dropped; order kept");
+ assert.deepEqual(renditionCandidates(undefined), []);
+});
+
+await test("gif: gifItems maps a Giphy page and drops unusable results", () => {
+ const data = [
+ { id: "g1", title: "Cat", images: {
+ fixed_height_small: { url: "https://media0.giphy.com/s.gif", width: "89", height: "100" },
+ downsized: { url: "https://media0.giphy.com/f.gif", width: "320", height: "240", size: "500000" },
+ } },
+ { id: "g2", title: "No preview", images: { downsized: { url: "https://media0.giphy.com/x.gif", size: "1" } } },
+ { id: "g3", title: "No postable rendition", images: { fixed_height_small: { url: "https://media0.giphy.com/y.gif" } } },
+ null,
+ ];
+ const items = gifItems(data);
+ assert.equal(items.length, 1); // g2 has no preview rendition, g3 nothing postable
+ assert.equal(items[0].id, "g1");
+ assert.equal(items[0].preview.url, "https://media0.giphy.com/s.gif");
+ assert.deepEqual(gifItems(null), []);
+});
+
+await test("gif: /gif opens a panel where you typed; no host doorway → a reply", async () => {
+ const opened = [];
+ setCommandPanelHost((threadId, spec) => (opened.push({ threadId, spec }), true));
+ setHostBridge({ gif: { search: async () => [], bytes: async () => null, upload: async () => null } });
+ const replies = [];
+ assert.equal(runCommand("/gif office party", "chatX", (r) => replies.push(r)), true);
+ await tick();
+ assert.equal(opened.length, 1);
+ assert.equal(opened[0].threadId, "chatX");
+ assert.match(opened[0].spec.title, /office party/);
+ assert.equal(replies.length, 0);
+ // Without the host doorway (e.g. a stripped-down embed) it explains itself.
+ setHostBridge(null);
+ assert.equal(runCommand("/gif cats", "chatX", (r) => replies.push(r)), true);
+ await tick();
+ assert.equal(replies.length, 1);
+ assert.match(replies[0].text, /isn't available/);
+ assert.equal(opened.length, 1, "no panel without the doorway");
+ setCommandPanelHost(null);
+ assert.equal(gifApp.personal, false);
+});
+
+await test("the declarative panel form: ctx.panel({data}) + a panel() hook synthesizes render", async () => {
+ const seen = [];
+ const fixture = defineApp({
+ name: "paneldemo",
+ title: "Panel Demo",
+ glyph: "▣",
+ personal: false,
+ listed: false,
+ initialState: () => ({}),
+ events: {},
+ commands: { "/paneldemo": (args, ctx) => ctx.panel({ title: "Demo", data: { args } }) },
+ panel(data, el, pctx) {
+ seen.push({ data, el, keys: Object.keys(pctx).sort() });
+ },
+ });
+ const opened = [];
+ setCommandPanelHost((threadId, spec) => (opened.push({ threadId, spec }), true));
+ assert.equal(runCommand("/paneldemo hello", "chatX"), true);
+ await tick();
+ assert.equal(opened.length, 1);
+ const spec = opened[0].spec;
+ assert.equal(typeof spec.render, "function", "the hook synthesizes the host render");
+ // The host invokes render(el, handle); the hook gets (data, el, panelCtx)
+ // with the handle's members plus the app ctx's emit/id/me/getState.
+ const el = {};
+ spec.render(el, { close() {}, insertText() {}, refresh() {}, attachFiles() {}, threadId: "chatX" });
+ assert.equal(seen.length, 1);
+ assert.deepEqual(seen[0].data, { args: "hello" });
+ assert.equal(seen[0].el, el);
+ for (const k of ["close", "insertText", "emit", "id", "me", "getState"]) {
+ assert.ok(seen[0].keys.includes(k), `panelCtx offers ${k}`);
+ }
+ assert.ok(fixture.panel, "the hook stays on the app object");
+ setCommandPanelHost(null);
+});
+
+await test("a no-match /emoji answers with a reply, and the app stays command-only", async () => {
+ setCommandPanelHost((threadId, spec) => true);
+ const replies = [];
+ assert.equal(runCommand("/emoji zzzznope", "chatX", (r) => replies.push(r)), true);
+ await tick();
+ assert.equal(replies.length, 1);
+ assert.match(replies[0].text, /No emoji match/);
+ // Command-only shape: no personal thread to route to, nothing to instantiate.
+ assert.equal(emojiApp.personal, false);
+ assert.equal(emojiApp.listed, false);
+ setCommandPanelHost(null);
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/test/sandbox.test.mjs b/test/sandbox.test.mjs
index fc82111..d274445 100644
--- a/test/sandbox.test.mjs
+++ b/test/sandbox.test.mjs
@@ -33,7 +33,7 @@ import {
normalizeCommands,
manifestOf,
} from "../web/static/sandbox-frame.js";
-import { validateManifest, cleanJSON, sanitizeAppEvent, sanitizeChat, registerMemberApp, resolveDark } from "../web/static/sandbox.js";
+import { validateManifest, cleanJSON, sanitizeAppEvent, sanitizeChat, sanitizePanelSpec, registerMemberApp, resolveDark } from "../web/static/sandbox.js";
import { tokenizeText as realTokenize, formatBytes as realBytes } from "../web/static/format.js";
// ---- SDK drift pins: the sandbox copies must behave exactly like apps.js ----
@@ -448,3 +448,36 @@ test("registerMemberApp refuses collisions and double registration", () => {
/prefixed/,
);
});
+
+// ---- command panels (the declarative sandbox form) --------------------------
+
+test("sanitizePanelSpec clamps the title, whitelists position/animate, caps data", () => {
+ const sp = sanitizePanelSpec({ title: "Hi", position: "top", animate: false, data: { a: 1 } });
+ assert.deepEqual(sp, { title: "Hi", data: { a: 1 }, position: "top", animate: false });
+ assert.equal(sanitizePanelSpec({ title: "x".repeat(200) }).title.length, 80);
+ // A hostile position/animate never passes through; missing data → null.
+ const loose = sanitizePanelSpec({ position: "overlay", animate: "yes" });
+ assert.equal(loose.position, undefined);
+ assert.equal(loose.animate, undefined);
+ assert.equal(loose.data, null);
+ // Un-cleanable or oversize data rejects the whole request.
+ assert.equal(sanitizePanelSpec({ data: { f: () => {} } }), null);
+ assert.equal(sanitizePanelSpec({ data: "y".repeat(70 * 1024) }), null);
+ assert.equal(sanitizePanelSpec("nope"), null);
+});
+
+test("evalMemberApp captures a panel hook; the mood example evaluates whole", async () => {
+ const { readFileSync } = await import("node:fs");
+ const src = readFileSync(new URL("../web/static/apps-disabled/mood.js", import.meta.url), "utf8");
+ const spec = evalMemberApp(src, "mood");
+ assert.equal(typeof spec.panel, "function", "the panel hook survives the sandbox transform");
+ assert.ok(spec.commands["/mood"], "the /mood command registers");
+ // The reducer folds a check-in and validates hostile input to a no-op.
+ const s1 = spec.reduce(undefined, { type: "mood.set", id: "a", mood: "🙂", note: "ok", name: "Ana", ts: 5 });
+ assert.equal(s1.items.length, 1);
+ assert.equal(spec.reduce(s1, { type: "mood.set", id: "b", mood: "<script>" }).items.length, 1, "unknown mood folds to a no-op");
+ // Its manifest extracts cleanly and validates for deploy.
+ const m = manifestOf(spec);
+ assert.equal(validateManifest(m), null);
+ assert.equal(m.chat, true);
+});
diff --git a/tools/lchat-dev-mac.mjs b/tools/lchat-dev-mac.mjs
new file mode 100644
index 0000000..2d68878
--- /dev/null
+++ b/tools/lchat-dev-mac.mjs
@@ -0,0 +1,101 @@
+#!/usr/bin/env node
+// lchat-dev-mac — seed the NATIVE Mac app with the dev server's space, for
+// click-testing a branch build against tools/lchat-dev-up.mjs.
+//
+// Why this exists: the Mac app's only product entry is "Sign in with Eleven"
+// (the home-server vault), which can't know about a local dev instance — and
+// the join page's "Open in the app" link is deliberately unhandled (joining
+// lives on the web; a native join flow comes later). So for dev builds this
+// script does what the vault would: it mints + claims a fresh add-device
+// token for the seeded "you" account and writes a SavedInstance the app can
+// load.
+//
+// It writes ~/Library/Application Support/lchat/instances.json — the store's
+// legacy READ fallback (InstancesStore in ios/LChatCore Keychain.swift). That
+// file holds the dev identity's PRIVATE KEY in the clear, which is exactly why
+// the app never WRITES it anymore — acceptable here only because this is a
+// throwaway localhost identity; never run this against a real space. The app
+// deletes the file on its first successful keychain save (migration), so
+// re-run the script if the space vanishes after the keychain adopts it.
+//
+// Usage: node tools/lchat-dev-mac.mjs (after tools/lchat-dev-up.mjs; quit
+// the Mac app first — it reads the stores at launch)
+import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
+import { homedir } from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { randomSecret, deriveInvite } from "../web/static/crypto.js";
+
+const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
+const cfgPath = path.join(root, ".lchat-dev.json");
+if (!existsSync(cfgPath)) {
+ console.error("No .lchat-dev.json — run tools/lchat-dev-up.mjs first.");
+ process.exit(1);
+}
+const cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
+
+// 1) Mint + claim a fresh add-device invite for the seeded "you" account.
+// wrapped is empty — we already hold the private key locally (dev config);
+// the claim only needs to yield a device token.
+const T = randomSecret();
+const inv = await deriveInvite(T);
+let r = await fetch(cfg.base + "/api/admin/key-invites", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: "Bearer " + cfg.adminToken },
+ body: JSON.stringify({ label: "mac-dev", user_id: cfg.you.userId, id: inv.id, wrapped: "", claim_hash: inv.claimHash }),
+});
+if (!r.ok) {
+ console.error("minting the device invite failed:", r.status, await r.text());
+ process.exit(1);
+}
+r = await fetch(`${cfg.base}/api/key-invite/${encodeURIComponent(inv.id)}/claim`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ claim_secret: inv.claimSecret }),
+});
+if (!r.ok) {
+ console.error("claiming the device invite failed:", r.status, await r.text());
+ process.exit(1);
+}
+const { token } = await r.json();
+
+// 2) Write the SavedInstance (Codable field names — ios/LChatCore Models.swift)
+// and a matching rail entry (SavedChat — ios/LChatMac MacInstance.swift),
+// merging with whatever is already there.
+const dir = path.join(homedir(), "Library/Application Support/lchat");
+mkdirSync(dir, { recursive: true });
+const instPath = path.join(dir, "instances.json");
+const serversPath = path.join(dir, "servers.json");
+
+const instance = {
+ origin: cfg.base,
+ deviceToken: token,
+ screenName: "You",
+ userPub: cfg.you.publicKey,
+ userPriv: cfg.you.privateKey,
+ myUserId: cfg.you.userId,
+ groupName: cfg.groupName || "Dev chat",
+};
+const readJSON = (p) => {
+ try {
+ return JSON.parse(readFileSync(p, "utf8"));
+ } catch {
+ return [];
+ }
+};
+const instances = readJSON(instPath).filter((i) => i && i.origin !== instance.origin);
+instances.push(instance);
+writeFileSync(instPath, JSON.stringify(instances, null, 2) + "\n");
+
+const servers = readJSON(serversPath);
+if (!servers.some((s) => s && typeof s.url === "string" && s.url.startsWith(cfg.base))) {
+ servers.push({ url: cfg.base, name: "You", lastSeenId: 0 });
+ writeFileSync(serversPath, JSON.stringify(servers, null, 2) + "\n");
+}
+
+console.log("Seeded the Mac app with the dev space:");
+console.log(" origin: " + cfg.base);
+console.log(" token: " + token.slice(0, 8) + "… (fresh device token)");
+console.log(" stores: " + instPath);
+console.log(" " + serversPath);
+console.log("Launch the app — the space should be in the rail, chats fully native.");
diff --git a/web/static/apps-disabled/README.md b/web/static/apps-disabled/README.md
new file mode 100644
index 0000000..cb1afd7
--- /dev/null
+++ b/web/static/apps-disabled/README.md
@@ -0,0 +1,24 @@
+# 🤖 apps-disabled/ — reference apps that DON'T ship
+
+Apps in this folder are complete and tested but deliberately **not imported by
+`shell.js`**, so they aren't part of the default client. They exist to exercise
+the *installable*-app paths:
+
+- **Admin custom app**: paste a file's source into the Admin console's custom
+ apps section (PUT `/api/admin/apps/{name}`) and it loads for every member
+ with full app privileges — `ctx.panel`, `ctx.host` doorways, the lot.
+ Import paths here are written as `"../apps.js"` on purpose: custom apps are
+ served from `/static/apps-custom/`, the same depth as this folder, so a file
+ works unchanged in both places.
+- **Member app** (App Studio / bot-built): expect a sandboxed subset — member
+ apps run in an opaque-origin iframe with no network, no keys, and (today) no
+ command-panel plumbing, so `gif.js` specifically can NOT run there by design.
+
+Current residents:
+
+- `mood.js` — `/mood` opens a command panel of feeling buttons; a tap
+ dispatches a `mood.set` event and dismisses. THE member-app panel example:
+ written against the declarative `ctx.panel({ title, data })` +
+ `panel(data, el, panelCtx)` form, so it runs identically pasted into App
+ Studio (sandboxed) or compiled in. Sandbox-legal by construction — imports
+ only from `../apps.js`, no fetch, no host doorways.
diff --git a/web/static/apps-disabled/mood.js b/web/static/apps-disabled/mood.js
new file mode 100644
index 0000000..ea252ae
--- /dev/null
+++ b/web/static/apps-disabled/mood.js
@@ -0,0 +1,91 @@
+// Mood check-in: the COMMAND-PANEL example for member apps. `/mood [note]`
+// opens a panel of feeling buttons; tapping one dispatches a `mood.set` event
+// into your Mood thread (an action line in its chat, a row in its history)
+// and dismisses the panel. This is the whole "panel buttons dispatch events"
+// shape in ~80 lines, written against the DECLARATIVE panel form — the
+// command calls ctx.panel({ title, data }) and the top-level
+// panel(data, el, panelCtx) hook draws it — which is the one form the
+// member-app sandbox supports (a render closure can't cross its bridge), and
+// which works identically compiled-in. To try it as a member app: paste this
+// file into App Studio and deploy. Sandbox-legal by construction: imports
+// only from ../apps.js, no fetch, no host doorways.
+import { defineApp, addItem } from "../apps.js";
+
+const MOODS = ["😄", "🙂", "😐", "😮‍💨", "😖"];
+
+export const app = defineApp({
+ name: "mood",
+ title: "Mood",
+ glyph: "🌤️",
+ initialState: () => ({ items: [] }),
+
+ events: {
+ "mood.set": (s, e) =>
+ e.id && typeof e.mood === "string" && MOODS.includes(e.mood)
+ ? { items: addItem(s.items, { id: e.id, mood: e.mood, note: typeof e.note === "string" ? e.note.slice(0, 200) : "", by: e.name || "", ts: e.ts || 0 }) }
+ : s,
+ },
+
+ commands: {
+ "/mood": {
+ desc: "log how you're feeling",
+ args: "[note]",
+ run(args, ctx) {
+ ctx.panel({ title: "How are you feeling?", data: { note: (args || "").trim() } });
+ },
+ },
+ },
+
+ // Companion projection: a check-in reads as an action line in the chat view.
+ chatMessage(e) {
+ if (e.type !== "mood.set") return null;
+ return { text: `is feeling ${e.mood}${e.note ? " — " + e.note : ""}`, action: true };
+ },
+
+ // The panel: five buttons; a tap emits the event and closes. `data` is what
+ // the command passed (the note typed after /mood); panelCtx offers
+ // { emit, id, me, getState, close, insertText } in both worlds.
+ panel(data, el, ctx) {
+ const row = document.createElement("div");
+ row.className = "mood-row";
+ for (const mood of MOODS) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "mood-btn";
+ b.textContent = mood;
+ b.onclick = async () => {
+ await ctx.emit({ type: "mood.set", id: ctx.id(), mood, note: (data && data.note) || "" });
+ ctx.close();
+ };
+ row.appendChild(b);
+ }
+ el.appendChild(row);
+ },
+
+ styles: `
+.mood-row { display: flex; gap: 8px; padding: 6px 2px; }
+.mood-btn {
+ border: none; background: var(--bubble-them); border-radius: 10px;
+ font-size: 26px; line-height: 1; padding: 10px 12px; cursor: pointer;
+}
+.mood-btn:hover { outline: 2px solid var(--accent); }
+.mood-item { display: flex; gap: 10px; align-items: baseline; padding: 6px 2px; border-bottom: 1px solid var(--border); }
+.mood-note { color: var(--muted); font-size: 13px; }
+`,
+
+ // The app-thread surface: newest first, the history of check-ins.
+ render(state, el) {
+ const items = [...((state && state.items) || [])].sort((a, b) => b.ts - a.ts);
+ for (const it of items) {
+ const row = document.createElement("div");
+ row.className = "mood-item";
+ const glyph = document.createElement("span");
+ glyph.textContent = it.mood;
+ const note = document.createElement("span");
+ note.className = "mood-note";
+ note.textContent = it.note || new Date(it.ts).toLocaleString();
+ row.append(glyph, note);
+ el.appendChild(row);
+ }
+ },
+});
diff --git a/web/static/apps.js b/web/static/apps.js
index d2074ea..14de905 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -73,6 +73,29 @@ injectStyles(
display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0;
overflow-y: auto; padding: 16px; gap: 10px;
}
+/* A command's transient results panel (see openCommandPanel): in-flow chrome
+ above the composer (default) or above the feed (.cmd-panel--top), occupying
+ layout space — never floating over content (CLAUDE.md). */
+.cmd-panel {
+ flex: 0 0 auto; display: flex; flex-direction: column; min-height: 0;
+ background: var(--panel); border-top: 1px solid var(--border);
+ box-shadow: 0 -6px 18px var(--shadow); /* a subtle lift off the feed above */
+}
+.cmd-panel--top {
+ border-top: none; border-bottom: 1px solid var(--border);
+ box-shadow: 0 6px 18px var(--shadow); /* pinned at the top — cast downward */
+}
+.cmd-panel-head {
+ display: flex; align-items: center; gap: 6px; padding: 8px 14px 0;
+ font-size: 12px; color: var(--muted);
+}
+.cmd-panel-title { font-weight: 600; flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cmd-panel-x {
+ flex: 0 0 auto; border: none; background: none; color: var(--muted);
+ font-size: 16px; line-height: 1; padding: 2px 4px; cursor: pointer; border-radius: 6px;
+}
+.cmd-panel-x:hover { color: var(--ink); background: var(--bubble-them); }
+.cmd-panel-body { padding: 8px 14px 10px; max-height: 34vh; overflow-y: auto; }
/* An app's glyph avatar (sidebar + header) and the "app" badge on its row. */
.app-avatar { display: flex; align-items: center; justify-content: center; background: var(--bubble-them); font-size: 18px; }
.app-badge {
@@ -1062,6 +1085,10 @@ export function runAppMention(text, threadId, respond) {
if (noticeCalmsDown(r)) return; // the action line in the feed is the record
reply(r);
};
+ // Mentions get the same transient-panel doorway a slash command does — the
+ // mention is typed in this thread, so the panel opens right here.
+ ctx.panel = (s) => openCommandPanel(threadId, panelSpecFor(m.app, ctx, sender, s));
+ ctx.closePanel = () => closeCommandPanel(threadId);
Promise.resolve(spec.run(m.args, ctx)).catch((e) => console.error(e));
return true;
}
@@ -1077,6 +1104,66 @@ export function parseHere(args) {
return m ? { rest: (m[1] || "").trim() } : null;
}
+// ---- command panels: a transient surface a command opens ------------------
+//
+// A command can answer with more than a private reply: it can open a PANEL — a
+// temporary, interactive surface in the chat you TYPED in (just above the
+// composer by default, or pinned above the feed with position: "top"). It's
+// the richer sibling of the reply, still in place: /gif would show a grid of
+// results, tapping one attaches it and dismisses the panel. A panel is
+// client-only chrome — never sent, never folded, gone on dismissal or when the
+// pane unmounts — so it carries no events and no durable state. One panel per
+// thread: opening another replaces it. The USER dismisses it (its ✕, or
+// Escape) or the APP does (panel.close()). The chat app injects the host that
+// actually draws it; openCommandPanel returns false when the thread has no
+// mounted chat surface, so a command can fall back to a plain reply.
+let panelHost = null; // (threadId, spec|null) => boolean — the chat app injects it
+export function setCommandPanelHost(fn) {
+ panelHost = fn;
+}
+export function openCommandPanel(threadId, spec) {
+ if (!panelHost || !spec || typeof spec.render !== "function") return false;
+ return !!panelHost(threadId, spec);
+}
+export function closeCommandPanel(threadId) {
+ if (panelHost) panelHost(threadId, null);
+}
+// A host-side observer of panel visibility, fired by the HOST's controller for
+// every show and dismiss — command open, replacement, the ✕, Escape, and the
+// app's own panel.close() all land here, so it's the one reliable signal that
+// the panel's box appeared or went away. The command-host embed (the native
+// Mac shell's panel webview) uses it to tell the shell to reveal or collapse
+// the webview around the panel.
+let panelObserver = null; // (threadId, spec|null) => void
+export function setCommandPanelObserver(fn) {
+ panelObserver = fn;
+}
+export function notifyCommandPanel(threadId, spec) {
+ if (!panelObserver) return;
+ try {
+ panelObserver(threadId, spec || null);
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+// panelSpecFor resolves a command's panel request to the host spec. Two forms:
+// a `render` closure (the trusted-world original), or the DECLARATIVE form —
+// ctx.panel({ title, data }) plus a top-level `panel(data, el, panelCtx)` hook
+// on the app. The declarative form is the one the member-app sandbox supports
+// (a closure can't cross its bridge), and it works identically here, so one
+// app file runs compiled-in or uploaded. panelCtx = the host handle (close /
+// insertText / …) plus the app ctx's emit/id/me/getState.
+function panelSpecFor(app, ctx, sender, spec) {
+ const s = { sender, ...spec };
+ if (typeof s.render !== "function" && app && typeof app.panel === "function") {
+ const data = s.data === undefined ? null : s.data;
+ s.render = (el, handle) =>
+ app.panel(data, el, { ...handle, emit: ctx.emit, id: ctx.id, me: ctx.me, getState: ctx.getState });
+ }
+ return s;
+}
+
// /me is unlike every other command: it BROADCASTS a real message (an
// action-style line, `{text, action:true}`) rather than answering privately,
// so the composer (chat.js) handles it directly, before runCommand ever sees
@@ -1185,6 +1272,12 @@ export function runCommand(text, activeThreadId, respond) {
if (target === activeThreadId && r && r.short) return;
reply({ sender, ...r });
};
+ // ctx.panel opens a transient results surface where you TYPED (like
+ // ctx.reply, it's bound to the active thread, not the routed target) —
+ // see the command-panels section above. Returns false when this thread
+ // can't draw one, so the command can answer with a reply instead.
+ ctx.panel = (spec) => openCommandPanel(activeThreadId, panelSpecFor(app, ctx, sender, spec));
+ ctx.closePanel = () => closeCommandPanel(activeThreadId);
await app.commands[cmd.name].run(args, ctx);
})();
return true;
@@ -1286,7 +1379,8 @@ function runNoticeAction(a) {
}
// Whether animations are allowed (honours the OS "reduce motion" setting).
-function motionOK() {
+// Exported for surface hosts (the chat app's command panel eases open/shut).
+export function motionOK() {
try {
return !(typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches);
} catch {
diff --git a/web/static/apps/README.md b/web/static/apps/README.md
index f22b065..aa2c46f 100644
--- a/web/static/apps/README.md
+++ b/web/static/apps/README.md
@@ -359,6 +359,127 @@ render.) Three rules keep this honest:
A default-command thread is text-only: the composer hides its attach affordances
(there's no one to read a file), and they return if a bot joins.
+### Command panels — a transient results surface
+
+A command can answer with more than a private reply: `ctx.panel(spec)` opens a
+**panel** — a temporary, interactive surface in the chat you *typed* in, sitting
+just above the composer (or pinned above the feed with `position: "top"`). It's
+still the "answer in place" doctrine: nothing navigates, nothing overlays what
+you're reading (the panel takes layout space), and leaving is always the user's
+tap. Two working references: `apps/emoji.js` (`/emoji cat` opens a picker; a
+tap drops the emoji into your message via `panel.insertText`) and
+`apps/gif.js` (`/gif high five` shows Giphy results — it asks for the member's
+own free Giphy key on first launch, stored device-side; a tap posts the GIF
+into the chat as an ordinary encrypted attachment and closes the panel — its
+network + upload go through the host's scoped `ctx.host.gif` doorway, because
+apps never fetch).
+
+```js
+"/emoji": {
+ run(args, ctx) {
+ const results = searchEmoji(args);
+ if (!results.length) return ctx.reply({ text: "No matches." });
+ const opened = ctx.panel({
+ title: `Emoji matching “${args}”`, // header line, next to the app's glyph
+ // position: "top", // pin above the feed instead (default: above the composer)
+ // animate: false, // appear/vanish instantly (default: ease up on open, roll down on dismiss)
+ render(el, panel) { // build DOM into el — textContent for anything user-typed
+ for (const r of results) {
+ const b = document.createElement("button");
+ b.textContent = r.ch;
+ b.onclick = () => { panel.insertText(r.ch); panel.close(); };
+ el.appendChild(b);
+ }
+ },
+ onClose() {}, // optional: told when the panel is replaced or dismissed
+ });
+ if (!opened) ctx.reply({ text: "…" }); // no chat surface here → answer with a reply
+ },
+},
+```
+
+The `panel` handle a render gets:
+
+| member | what it does |
+| ------ | ------------ |
+| `panel.close()` | Dismiss the panel (the app deciding it's done). |
+| `panel.refresh()` | Re-run `render` (e.g. async results arrived — render a spinner first, refresh when ready). |
+| `panel.insertText(t)` | Insert `t` at the caret of the composer the panel sits above (forwarded to the native composer in the embedded shell). |
+| `panel.attachFiles(files)` | Stage files on that composer — the same encrypt-and-upload path the attach button uses. |
+| `panel.threadId` | The thread the panel is showing in (where you typed). |
+
+**A panel is generic UI, not a picker widget.** `render` gets a bare container
+and full control: build any DOM you like, own its clicks, and — because the
+handler's `ctx` is your app's real context — a button can dispatch a typed
+event straight into the thread's encrypted log, where it folds like any other
+app event:
+
+```js
+"/lunch": (args, ctx) => {
+ ctx.panel({
+ title: "Start the lunch poll?",
+ render(el, panel) {
+ for (const q of ["Pizza or noodles?", "Eat out or order in?"]) {
+ const b = document.createElement("button");
+ b.textContent = q;
+ b.onclick = async () => {
+ // An ordinary app event — encrypted, attributed, folded by every
+ // member. The panel was just the UI that chose it.
+ await ctx.emit({ type: "poll.create", id: ctx.id(), q, options: ["👍", "👎"] });
+ panel.close();
+ };
+ el.appendChild(b);
+ }
+ },
+ });
+},
+```
+
+`/gif` composes the same pieces the other way round: its tiles call a
+privileged **host doorway** (`ctx.host.gif` — search/download/upload) and then
+`ctx.emit` a chat message with the uploaded attachment. If your panel needs
+the network or another privileged capability, don't reach for `fetch` (apps
+never fetch — Invariants #7): add a narrow, named doorway to `setHostBridge`
+in `shell.js`, the way `gif` and the connected services do, and route any new
+external host through the server's CSP deliberately (CLAUDE.md documents the
+Giphy exception).
+
+**The declarative form (and member apps).** Instead of a `render` closure you
+may declare a top-level **`panel(data, el, panelCtx)`** hook on the app and
+call `ctx.panel({ title, data })` — the host synthesizes the render, handing
+your hook the JSON `data` plus a panelCtx of `{ close, insertText, emit, id,
+me, getState }`. This is the ONLY form the member-app sandbox supports (a
+closure can't cross its bridge — the hook runs in a fresh sandboxed frame
+mounted inside the panel), and it works identically compiled-in, so one file
+runs in both worlds: see `apps-disabled/mood.js` and
+`docs/building-member-apps.md § Command panels`.
+
+**On the native apps.** The Mac app runs panel commands through a hidden
+per-thread *command-host* webview — your panel code runs there unchanged
+(`insertText` reaches the native composer; `ctx.emit` posts for real). One
+extra step: add your command to `WebPanelCommands.catalog`
+(`ios/LChatCore/Sources/LChatCore/SlashCommands.swift`) so the native composer
+forwards it and lists it in the typeahead. Only commands that act in the
+CURRENT thread (or need no thread) belong there — the native page holds a
+single thread's key, so a command that routes elsewhere (/todo) can't resolve
+in it.
+
+Rules that keep panels honest:
+
+- **A panel is client-only chrome.** Never sent, never folded, gone on dismissal
+ or when the pane closes — state that matters goes through events, as ever.
+- **One per thread.** Opening another replaces it (the outgoing one's `onClose`
+ fires). The user can always dismiss it — its ✕, or Escape.
+- **It's a response, not an ambush.** Only open one from a command the user just
+ ran (`ctx.panel` exists only in command/mention handlers) — never spontaneously.
+- **`ctx.panel` returns `false`** when the thread has no mounted chat surface to
+ draw in — fall back to `ctx.reply`.
+- **It eases open and shut by default** (and honours reduce-motion); pass
+ `animate: false` for an instant panel. On the Mac the native shell drives the
+ one animation, from the same flag.
+- The usual surface rules apply: build DOM nodes, `textContent` for user
+ content, theme variables for styling (ship the CSS in your app's `styles`).
+
## The events map & the reducer
Each handler is `(state, event, meta) => newState`. Rules:
@@ -442,6 +563,8 @@ server.
| `ctx.threadId` | anywhere | The thread the app is acting in. |
| `ctx.interval(fn,ms)` | render only | A ticker the framework stops for you on redraw / reset. |
| `ctx.onCleanup(fn)` | render only | Run `fn` before the next redraw or on thread reset. |
+| `ctx.panel(spec)` | commands only | Open a transient results panel where the command was typed (see "Command panels"). Returns `false` when the thread can't draw one. |
+| `ctx.closePanel()` | commands only | Dismiss that thread's open command panel. |
`ctx.interval` is how `remind.js` keeps its relative labels live without leaking
a timer — never call `setInterval` directly in a render, or you'll leak one per
@@ -814,5 +937,7 @@ Keep any non-trivial parsing pure and exported (like `parseReminder` in
- [ ] `import "/static/apps/<name>.js";` added in `web/static/shell.js`.
- [ ] `node --check web/static/apps/<name>.js` passes.
- [ ] Reducer / parser tests added to `test/apps.test.mjs`; `npm test` green.
+- [ ] A panel-opening command? Add it to `WebPanelCommands.catalog`
+ (`ios/LChatCore/.../SlashCommands.swift`) so the native apps forward it.
- [ ] A user-facing changelog entry in `changelog/<date>-<slug>.md`.
- [ ] No trailing whitespace; user content rendered with `textContent`.
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index e6acc9d..d058ebf 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -22,7 +22,7 @@
//
// This is the DEFAULT chat experience for group/DM threads; a device can
// switch back per-device (⋮ menu → "Use original chat" — see chat.js).
-import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor, appChatReadOnly } from "../apps.js";
+import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js";
import { blockTokens, richTokens, replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js";
import { renderCard, CARD_CSS } from "../cards.js";
import { emojify } from "../emoji.js";
@@ -216,6 +216,19 @@ function typingNames(threadId, exceptName) {
return out;
}
+// Command panels: the transient results surface a slash command can open
+// (ctx.panel — see the command-panels section of apps.js). Each mounted pane
+// registers its controller in buildSkeleton; the one injected host routes an
+// open/close to the pane it belongs to. Returning false when the thread has no
+// mounted chat surface lets the command fall back to a plain reply.
+const cmdPanels = new Map(); // threadId → (spec|null) => void
+setCommandPanelHost((threadId, spec) => {
+ const show = cmdPanels.get(threadId);
+ if (!show) return false;
+ show(spec);
+ return true;
+});
+
// Which reactions are mine, keyed "sid|emoji" — seeded from the load-time mine
// flags and kept current by local toggles, exactly like the original feed's
// myReactions set (live reaction frames carry counts only).
@@ -825,7 +838,9 @@ function renderItem(m, ctx) {
for (const a of m.attachments) {
if (a.type && a.type.startsWith("image/")) {
const img = document.createElement("img");
- img.className = "msg-image";
+ // A GIF's click belongs to its play/pause toggle, not the lightbox —
+ // .msg-gif swaps the zoom-in cursor for a plain pointer.
+ img.className = a.type === "image/gif" ? "msg-image msg-gif" : "msg-image";
img.alt = a.name || "photo";
// Reserve the image's final box before the blob decrypts (see the original
// appendMessage note) so the feed doesn't jump as photos pop in.
@@ -838,7 +853,8 @@ function renderItem(m, ctx) {
host.loadBlob(ctx.threadId, a).then((url) => {
if (!url) return;
img.src = url;
- img.onclick = () => openLightbox(url, img);
+ if (a.type === "image/gif") wireGifToggle(img, url);
+ else img.onclick = () => openLightbox(url, img);
});
}
content.appendChild(img);
@@ -1832,6 +1848,40 @@ function attachmentBlobIds(m) {
return ids;
}
+// An animated GIF plays in the feed, and a click pauses/resumes it in place —
+// the click is the toggle, not a lightbox opener (the message menu still holds
+// the other actions). Pausing swaps the img's src for a canvas-rendered still
+// of the GIF's first frame (the "default image" drawImage yields for animated
+// sources — no re-decode of the whole animation); resuming restores the blob
+// URL. prefers-reduced-motion starts the GIF frozen.
+function wireGifToggle(img, url) {
+ let still = null; // the frozen frame's data URL, rendered once on first pause
+ const freeze = () => {
+ if (!still) {
+ if (!img.naturalWidth) return; // not decoded yet — nothing to freeze
+ const c = document.createElement("canvas");
+ c.width = img.naturalWidth;
+ c.height = img.naturalHeight;
+ c.getContext("2d").drawImage(img, 0, 0);
+ still = c.toDataURL();
+ }
+ img.src = still;
+ img.classList.add("gif-paused");
+ };
+ img.onclick = () => {
+ if (img.classList.contains("gif-paused")) {
+ img.src = url;
+ img.classList.remove("gif-paused");
+ } else {
+ freeze();
+ }
+ };
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ if (img.complete && img.naturalWidth) freeze();
+ else img.addEventListener("load", freeze, { once: true });
+ }
+}
+
function openLightbox(url, source) {
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const back = document.createElement("div");
@@ -2739,6 +2789,160 @@ function buildSkeleton(el, ctx) {
fileInput.value = "";
stageFiles(files);
};
+
+ // ---- command panel: the transient results surface a command opens ----
+ // (ctx.panel — see the command-panels section of apps.js.) In-flow, never
+ // floating: it sits above the composer stack by default, or pinned above the
+ // feed with position: "top", taking layout space either way. One per thread —
+ // opening another replaces it; its ✕, Escape, or the app's panel.close()
+ // dismisses it. Client-only chrome: nothing here is sent or folded.
+ const panelBox = document.createElement("section");
+ panelBox.className = "cmd-panel hidden";
+ panelBox.setAttribute("role", "region");
+ let panelSpec = null;
+ const panelOpen = () => !!panelSpec;
+ const renderPanelBody = (spec, body) => {
+ body.replaceChildren();
+ // The handle a panel renders against: dismiss it, redraw it, and act on
+ // the composer it sits above — insert text at the caret, or stage
+ // attachments through the same path the attach button / paste / drop use.
+ const panel = {
+ threadId: ctx.threadId,
+ close: () => hidePanel(),
+ refresh: () => {
+ if (panelSpec === spec && body.isConnected) renderPanelBody(spec, body);
+ },
+ insertText: (t) => {
+ const text = String(t == null ? "" : t);
+ if (!text) return;
+ // Embedded: the native shell owns the visible composer — forward the
+ // insertion (a shell that doesn't handle composerInsert ignores it).
+ if (inEmbed) return host.native({ composerInsert: text });
+ const start = input.selectionStart == null ? input.value.length : input.selectionStart;
+ const end = input.selectionEnd == null ? start : input.selectionEnd;
+ input.value = input.value.slice(0, start) + text + input.value.slice(end);
+ input.selectionStart = input.selectionEnd = start + text.length;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ input.focus();
+ },
+ attachFiles: (files) => stageFiles([...(files || [])]),
+ };
+ try {
+ spec.render(body, panel);
+ } catch (e) {
+ // A panel must never take down the pane — contain the error in place.
+ console.error(`command panel (${(spec.sender && spec.sender.name) || "app"}) render failed:`, e);
+ body.replaceChildren();
+ const p = document.createElement("p");
+ p.textContent = "This panel hit an error and couldn't load.";
+ body.appendChild(p);
+ }
+ };
+ // Whether the panel eases open/shut: the app's call (animate: false opts
+ // out), gated on reduced-motion — and skipped in the command-host embed,
+ // where the panel is the whole page and the NATIVE shell animates the
+ // frame instead (an in-page ease inside a just-revealed frame reads as a
+ // flash, not a slide).
+ const panelEases = (spec) =>
+ spec && spec.animate !== false && motionOK() && !document.body.classList.contains("command-host");
+ const clearPanelEase = () => {
+ panelBox.style.removeProperty("height");
+ panelBox.style.removeProperty("overflow");
+ panelBox.style.removeProperty("transition");
+ };
+ const showPanel = (spec) => {
+ const prev = panelSpec;
+ panelSpec = spec && typeof spec.render === "function" ? spec : null;
+ clearPanelEase(); // a mid-animation replace starts clean
+ // Replacing (or closing) a panel tells the outgoing one — best-effort.
+ if (prev && prev.onClose) {
+ try {
+ prev.onClose();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ panelBox.replaceChildren();
+ panelBox.classList.toggle("hidden", !panelSpec);
+ panelBox.classList.toggle("cmd-panel--top", !!(panelSpec && panelSpec.position === "top"));
+ if (!panelSpec) {
+ notifyCommandPanel(ctx.threadId, null);
+ return;
+ }
+ // Both placements are in-flow: "top" pins it above the feed (under the
+ // shared-app surface); the default sits at the bottom of the pane, above
+ // the composer stack (before the zero-height .composer-float anchor).
+ root.insertBefore(panelBox, panelSpec.position === "top" ? feedWrap : float);
+ const head = document.createElement("div");
+ head.className = "cmd-panel-head";
+ const s = panelSpec.sender || {};
+ if (s.glyph) {
+ const g = document.createElement("span");
+ g.textContent = s.glyph;
+ head.appendChild(g);
+ }
+ const title = document.createElement("span");
+ title.className = "cmd-panel-title";
+ title.textContent = panelSpec.title || s.name || "";
+ const x = document.createElement("button");
+ x.type = "button";
+ x.className = "cmd-panel-x";
+ x.setAttribute("aria-label", "Dismiss");
+ x.innerHTML = icon("x");
+ x.onclick = () => hidePanel();
+ head.append(title, x);
+ const body = document.createElement("div");
+ body.className = "cmd-panel-body";
+ panelBox.append(head, body);
+ renderPanelBody(panelSpec, body);
+ notifyCommandPanel(ctx.threadId, panelSpec);
+ // Ease up from nothing to the panel's natural height (see panelEases).
+ if (panelEases(panelSpec)) {
+ const h = panelBox.offsetHeight;
+ if (h) {
+ panelBox.style.overflow = "hidden";
+ panelBox.style.height = "0px";
+ void panelBox.offsetHeight; // commit the start height
+ panelBox.style.transition = "height 0.22s ease";
+ panelBox.style.height = h + "px";
+ panelBox.addEventListener("transitionend", clearPanelEase, { once: true });
+ setTimeout(clearPanelEase, 350); // backstop if transitionend never fires
+ }
+ }
+ // A bottom panel takes its space from the feed — keep the newest in view.
+ if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true));
+ };
+ // Dismissal rolls the panel down before tearing it out, unless the app (or
+ // the OS) asked for instant. panelClosing keeps a second Escape (or the ✕)
+ // from re-arming the roll-up mid-flight; a NEW panel opening mid-animation
+ // wins — showPanel resets the inline styles, and the captured spec check
+ // keeps the stale finish() from tearing the newcomer down.
+ let panelClosing = false;
+ const hidePanel = () => {
+ if (!panelSpec || panelClosing) return;
+ if (!panelEases(panelSpec) || !panelBox.isConnected) return showPanel(null);
+ panelClosing = true;
+ const closing = panelSpec;
+ let finished = false;
+ const finish = () => {
+ if (finished) return;
+ finished = true;
+ panelClosing = false;
+ clearPanelEase();
+ if (panelSpec === closing) showPanel(null);
+ };
+ const startH = panelBox.offsetHeight;
+ if (!startH) return finish();
+ panelBox.style.overflow = "hidden";
+ panelBox.style.height = startH + "px";
+ void panelBox.offsetHeight;
+ panelBox.style.transition = "height 0.2s ease";
+ panelBox.style.height = "0px";
+ panelBox.addEventListener("transitionend", finish, { once: true });
+ setTimeout(finish, 320);
+ };
+ // Programmatic closes (ctx.closePanel) take the same eased path as ✕/Escape.
+ cmdPanels.set(ctx.threadId, (spec) => (spec ? showPanel(spec) : hidePanel()));
input.addEventListener("paste", (e) => {
const files = [...((e.clipboardData && e.clipboardData.files) || [])];
if (files.length) {
@@ -3009,6 +3213,12 @@ function buildSkeleton(el, ctx) {
setSendQueued(false); // un-queue first; a second Escape cancels reply/edit
return;
}
+ // An open command panel is the most recently summoned layer — Escape peels
+ // it before the thread view / reply / edit below.
+ if (e.key === "Escape" && panelOpen()) {
+ hidePanel();
+ return;
+ }
// An open thread view closes on Escape (dropping its staged reply) — unless
// an edit is in flight, which the branch below cancels first (cancelEdit
// re-stages the thread reply, so a second Escape then closes the view).
@@ -3400,6 +3610,14 @@ function buildSkeleton(el, ctx) {
const m = latest(false);
if (m) composers.get(ctx.threadId).startReply(m);
}
+ // Escape dismisses an open command panel when the composer isn't focused
+ // (the focused case is in input.onkeydown), with the same layer checks as
+ // the thread-view close below — one Escape peels one layer, never two.
+ if (e.key === "Escape" && panelOpen() && !e.defaultPrevented && document.activeElement !== input && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
+ e.preventDefault();
+ hidePanel();
+ return;
+ }
// Escape closes an open thread view when the composer isn't focused (the
// focused case is handled in input.onkeydown). Skipped when a layer that
// owns Escape is up — the picker preventDefaults before us (capture phase),
diff --git a/web/static/apps/emoji.js b/web/static/apps/emoji.js
new file mode 100644
index 0000000..fe7deb4
--- /dev/null
+++ b/web/static/apps/emoji.js
@@ -0,0 +1,84 @@
+// Emoji browser: `/emoji [search]` opens a COMMAND PANEL (the transient
+// results surface above the composer — see apps.js) with the matching emoji
+// from the curated shortcode set; tapping one drops it into your message and
+// dismisses the panel. The reference implementation for panels: no events, no
+// state, no thread of its own — the command and its temporary surface are the
+// whole app.
+import { defineApp } from "../apps.js";
+import { EMOJI } from "../emoji.js";
+
+// searchEmoji filters the shortcode set by a substring of the name and
+// collapses aliases (laughing/satisfied are one glyph) to one tile each,
+// keeping the first name as the label. Pure, so it's unit-testable in Node.
+export function searchEmoji(query) {
+ const q = (query || "").trim().toLowerCase();
+ const seen = new Set();
+ const out = [];
+ for (const [name, ch] of Object.entries(EMOJI)) {
+ if (q && !name.includes(q)) continue;
+ if (seen.has(ch)) continue;
+ seen.add(ch);
+ out.push({ name, ch });
+ }
+ return out;
+}
+
+export const app = defineApp({
+ name: "emoji",
+ title: "Emoji",
+ glyph: "😊",
+ // Command-only: no personal thread to route to (the command acts where you
+ // typed it) and nothing to instantiate from the Apps menu.
+ personal: false,
+ listed: false,
+ initialState: () => ({}),
+ events: {},
+
+ commands: {
+ "/emoji": {
+ desc: "browse emoji — tap one to add it to your message",
+ args: "[search]",
+ run(args, ctx) {
+ const q = (args || "").trim();
+ const results = searchEmoji(q);
+ if (!results.length) {
+ ctx.reply({ text: `No emoji match “${q}”. Try \`/emoji cat\`, or a bare \`/emoji\` to browse them all.` });
+ return;
+ }
+ const opened = ctx.panel({
+ title: q ? `Emoji matching “${q}”` : "Emoji — tap to add",
+ render(el, panel) {
+ const grid = document.createElement("div");
+ grid.className = "emoji-grid";
+ for (const r of results) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "emoji-grid-btn";
+ b.textContent = r.ch;
+ b.title = ":" + r.name + ":";
+ b.setAttribute("aria-label", r.name.replace(/_/g, " "));
+ b.onclick = () => {
+ panel.insertText(r.ch);
+ panel.close();
+ };
+ grid.appendChild(b);
+ }
+ el.appendChild(grid);
+ },
+ });
+ // No chat surface to draw a panel in (shouldn't happen where a
+ // composer exists, but stay honest): answer with a reply instead.
+ if (!opened) ctx.reply({ text: results.slice(0, 30).map((r) => r.ch).join(" ") });
+ },
+ },
+ },
+
+ styles: `
+.emoji-grid { display: flex; flex-wrap: wrap; gap: 2px; }
+.emoji-grid-btn {
+ border: none; background: none; font-size: 22px; line-height: 1;
+ padding: 6px; border-radius: 8px; cursor: pointer;
+}
+.emoji-grid-btn:hover { background: var(--bubble-them); }
+`,
+});
diff --git a/web/static/apps/gif.js b/web/static/apps/gif.js
new file mode 100644
index 0000000..b2cdd90
--- /dev/null
+++ b/web/static/apps/gif.js
@@ -0,0 +1,300 @@
+// GIF search: `/gif [search]` opens a COMMAND PANEL of Giphy results — tap one
+// and it posts into the chat as a normal encrypted image message, from you.
+// The app never fetches: all Giphy traffic goes through the host's scoped
+// doorway (ctx.host.gif — search, bytes, upload), and what lands in the thread
+// is an ordinary attachment blob, encrypted client-side to the thread key. The
+// server never sees the search, the pick, or the bytes; Giphy sees the query
+// from YOUR client (the one deliberate trade-off — see CLAUDE.md, next to
+// unfurl's), authenticated with YOUR OWN free Giphy key — asked for on first
+// launch, kept in this device's localStorage, never sent to the lchat server.
+// Posting is direct — tap = send, like Slack's /giphy — so the same
+// flow works in the native shells' command-host webview, where there is no web
+// composer to stage into.
+import { defineApp } from "../apps.js";
+
+// The largest GIF we'll post. Giphy's "downsized" rendition targets ~2 MB;
+// this cap is the backstop when only bigger renditions exist.
+export const GIF_MAX_BYTES = 8 * 1024 * 1024;
+
+// renditionCandidates lists the POSTABLE renditions of a Giphy result, best
+// first: downsized → fixed_height → original, skipping metadata-oversize ones
+// (sizes arrive as strings, sometimes "0"/absent — the byte fetch re-checks
+// the real length, so an unknown size stays in). The panel walks the list on
+// post, so one failing URL doesn't kill the send. Pure, unit-testable.
+export function renditionCandidates(images, maxBytes = GIF_MAX_BYTES) {
+ const out = [];
+ for (const name of ["downsized", "fixed_height", "original"]) {
+ const r = images && images[name];
+ if (!r || !r.url) continue;
+ const size = parseInt(r.size, 10) || 0;
+ if (size > maxBytes) continue;
+ if (out.some((c) => c.url === r.url)) continue;
+ out.push({ url: r.url, w: parseInt(r.width, 10) || 0, h: parseInt(r.height, 10) || 0 });
+ }
+ return out;
+}
+
+// The best single choice — candidates' head (kept for gifItems' usability gate).
+export function pickRendition(images, maxBytes = GIF_MAX_BYTES) {
+ return renditionCandidates(images, maxBytes)[0] || null;
+}
+
+// gifItems maps a raw Giphy data[] page to what the panel renders: a preview
+// (small, fixed-height for an even grid) plus the full images map for
+// pickRendition at post time. Results without a usable preview or post
+// rendition are dropped. Pure, testable.
+export function gifItems(data) {
+ const out = [];
+ for (const g of data || []) {
+ if (!g || !g.images) continue;
+ const p = g.images.fixed_height_small || g.images.fixed_height;
+ if (!p || !p.url || !pickRendition(g.images)) continue;
+ out.push({
+ id: g.id || "",
+ title: g.title || "GIF",
+ preview: { url: p.url, w: parseInt(p.width, 10) || 0, h: parseInt(p.height, 10) || 0 },
+ images: g.images,
+ });
+ }
+ return out;
+}
+
+export const app = defineApp({
+ name: "gif",
+ title: "GIFs",
+ glyph: "🎞️",
+ // Command-only, like /emoji: no personal thread, nothing to instantiate —
+ // the command acts in the chat you typed it in.
+ personal: false,
+ listed: false,
+ initialState: () => ({}),
+ events: {},
+
+ commands: {
+ "/gif": {
+ desc: "find a GIF — tap one to send it here",
+ args: "[search]",
+ run(args, ctx) {
+ const gifHost = ctx.host && ctx.host.gif;
+ if (!gifHost) {
+ ctx.reply({ text: "GIF search isn't available here." });
+ return;
+ }
+ const q = (args || "").trim();
+ const opened = ctx.panel({
+ title: q ? `GIFs matching “${q}”` : "GIFs — tap one to send it",
+ render(el, panel) {
+ buildGifPanel(el, panel, ctx, gifHost, q);
+ },
+ });
+ if (!opened) ctx.reply({ text: "Open a chat to use /gif." });
+ },
+ },
+ },
+
+ styles: `
+.gif-search {
+ width: 100%; box-sizing: border-box; margin: 0 0 8px;
+ padding: 6px 10px; font: inherit; font-size: 13px; color: var(--ink);
+ background: var(--bubble-them); border: 1px solid var(--border); border-radius: 8px;
+}
+.gif-search:focus { outline: none; border-color: var(--accent); }
+.gif-grid { display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; }
+.gif-tile {
+ border: none; background: var(--bubble-them); padding: 0; cursor: pointer;
+ border-radius: 8px; overflow: hidden; height: 100px; position: relative;
+}
+.gif-tile img { display: block; height: 100px; width: auto; }
+.gif-tile:hover { outline: 2px solid var(--accent); }
+.gif-tile--busy::after {
+ content: "Sending…"; position: absolute; inset: 0; display: flex;
+ align-items: center; justify-content: center; font-size: 12px; font-weight: 600;
+ color: #fff; background: rgba(0, 0, 0, 0.55);
+}
+.gif-status { font-size: 12px; color: var(--muted); padding: 6px 2px; }
+.gif-credit { font-size: 10px; color: var(--muted); text-align: right; padding-top: 6px; }
+.gif-keylink { border: none; background: none; padding: 0; font: inherit; color: var(--link); cursor: pointer; }
+.gif-keyform { display: flex; flex-direction: column; gap: 8px; max-width: 46ch; padding: 4px 0 8px; }
+.gif-keyintro { margin: 0; font-size: 13px; color: var(--ink); line-height: 1.45; }
+.gif-keyform a { font-size: 12px; color: var(--link); }
+.gif-keyrow { display: flex; gap: 6px; }
+.gif-keyrow .gif-search { margin: 0; flex: 1 1 auto; }
+.gif-keysave {
+ flex: 0 0 auto; border: none; border-radius: 8px; padding: 6px 14px;
+ font: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
+ background: var(--accent); color: var(--accent-ink, #fff);
+}
+`,
+});
+
+// The panel: a refine-search box, the result grid, a status line, and the
+// attribution Giphy's terms ask for. All Giphy I/O rides the host doorway.
+function buildGifPanel(el, panel, ctx, gifHost, initialQuery) {
+ let posting = false;
+ let searchSeq = 0;
+
+ const search = document.createElement("input");
+ search.className = "gif-search";
+ search.type = "search";
+ search.placeholder = "Search GIFs…";
+ search.value = initialQuery;
+
+ const grid = document.createElement("div");
+ grid.className = "gif-grid";
+ const status = document.createElement("div");
+ status.className = "gif-status";
+ const credit = document.createElement("div");
+ credit.className = "gif-credit";
+ credit.append("Powered by GIPHY · ");
+ const keyLink = document.createElement("button");
+ keyLink.type = "button";
+ keyLink.className = "gif-keylink";
+ keyLink.textContent = "API key…";
+ keyLink.onclick = () => showKeyForm("");
+ credit.appendChild(keyLink);
+ // Status sits ABOVE the grid: the panel scrolls, and an error appended
+ // below a tall grid would land out of view exactly when it matters.
+ el.append(search, status, grid, credit);
+
+ const setStatus = (text) => {
+ status.textContent = text || "";
+ status.style.display = text ? "" : "none";
+ };
+
+ // First-launch setup (and the recovery path for a rejected key): /gif uses
+ // YOUR OWN free Giphy key, stored on this device only — never sent to the
+ // lchat server. The form replaces the grid until a key is saved.
+ const showKeyForm = (problem) => {
+ grid.replaceChildren();
+ setStatus("");
+ const box = document.createElement("div");
+ box.className = "gif-keyform";
+ const intro = document.createElement("p");
+ intro.className = "gif-keyintro";
+ intro.textContent = problem ||
+ "GIF search uses your own (free) Giphy API key — it stays on this device and is only ever sent to Giphy.";
+ const how = document.createElement("a");
+ how.href = "https://developers.giphy.com/dashboard/";
+ how.target = "_blank";
+ how.rel = "noopener noreferrer";
+ how.textContent = "Get a key at developers.giphy.com";
+ const row = document.createElement("div");
+ row.className = "gif-keyrow";
+ const input = document.createElement("input");
+ input.className = "gif-search";
+ input.type = "text";
+ input.placeholder = "Paste your Giphy API key";
+ input.autocomplete = "off";
+ input.spellcheck = false;
+ const save = document.createElement("button");
+ save.type = "button";
+ save.className = "gif-keysave";
+ save.textContent = "Save";
+ const submit = () => {
+ const key = gifHost.setKey(input.value);
+ if (!key) return;
+ box.remove();
+ load(search.value.trim());
+ };
+ save.onclick = submit;
+ input.onkeydown = (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ submit();
+ }
+ };
+ row.append(input, save);
+ box.append(intro, how, row);
+ grid.appendChild(box);
+ input.focus();
+ };
+
+ const post = async (item, tile) => {
+ if (posting) return;
+ posting = true;
+ tile.classList.add("gif-tile--busy");
+ setStatus("");
+ try {
+ // Walk the acceptable renditions: a single oversize/failed download
+ // falls back to the next size instead of failing the whole send.
+ let r = null;
+ let bytes = null;
+ for (const cand of renditionCandidates(item.images)) {
+ bytes = await gifHost.bytes(cand.url, GIF_MAX_BYTES);
+ if (bytes) {
+ r = cand;
+ break;
+ }
+ }
+ if (!bytes) throw new Error("fetch");
+ const name = (item.title || "gif").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "gif";
+ const att = await gifHost.upload(ctx.threadId, bytes, { name: name + ".gif", type: "image/gif", w: r.w, h: r.h });
+ if (!att) throw new Error("upload");
+ const sent = await ctx.emit({ text: "", attachments: [att] });
+ if (!sent) throw new Error("send");
+ panel.close(); // posted — the message in the feed is the result
+ } catch (e) {
+ console.error("gif post failed at", (e && e.message) || e);
+ tile.classList.remove("gif-tile--busy");
+ posting = false;
+ setStatus("Couldn't send that one — try another.");
+ }
+ };
+
+ const show = (items) => {
+ grid.replaceChildren();
+ for (const item of items) {
+ const tile = document.createElement("button");
+ tile.type = "button";
+ tile.className = "gif-tile";
+ tile.setAttribute("aria-label", "Send: " + item.title);
+ const img = document.createElement("img");
+ img.src = item.preview.url; // https-only Giphy CDN — allowed by img-src
+ img.alt = item.title;
+ img.loading = "lazy";
+ tile.appendChild(img);
+ tile.onclick = () => post(item, tile);
+ grid.appendChild(tile);
+ }
+ };
+
+ const load = async (q) => {
+ const seq = ++searchSeq;
+ setStatus("Searching…");
+ const data = await gifHost.search(q);
+ if (seq !== searchSeq) return; // a newer search superseded this one
+ if (data === "key") {
+ show([]);
+ return showKeyForm("");
+ }
+ if (data === "auth") {
+ show([]);
+ return showKeyForm("Giphy rejected that API key — check it and save again.");
+ }
+ if (!data) {
+ show([]);
+ setStatus("Couldn't reach Giphy — check your connection and try again.");
+ return;
+ }
+ const items = gifItems(data);
+ show(items);
+ setStatus(items.length ? "" : `Nothing for “${q}”. Try another search.`);
+ };
+
+ // Refining re-searches (debounced); Enter searches immediately. Escape
+ // bubbles to the pane's handler, which closes the panel.
+ let debounce = 0;
+ search.oninput = () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => load(search.value.trim()), 350);
+ };
+ search.onkeydown = (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ clearTimeout(debounce);
+ load(search.value.trim());
+ }
+ };
+
+ load(initialQuery);
+}
diff --git a/web/static/sandbox-frame.js b/web/static/sandbox-frame.js
index 9732d64..83e654e 100644
--- a/web/static/sandbox-frame.js
+++ b/web/static/sandbox-frame.js
@@ -32,7 +32,20 @@
// { type: "unschedule", seq, sid }
// { type: "ephemeral", event }
// { type: "command-reply", seq, spec }
+// { type: "panel-open", seq, cmdSeq, spec } (a command asking for a panel)
+// { type: "panel-size", h } (panel frame → fit my content)
+// { type: "panel-close" } (panel frame dismissing itself)
+// { type: "panel-insert", text } (panel frame → composer insert)
// { type: "error", message }
+//
+// PANEL frames: a member app's command can open a command panel (the
+// transient surface above the composer — apps/README.md § Command panels).
+// A render closure can't cross the bridge, so the sandbox form is
+// declarative: the command calls ctx.panel({ title, data }) and the app
+// declares a top-level panel(data, el, panelCtx) hook. The host mounts a
+// FRESH sandboxed frame inside the real panel and initialises it with
+// { panel: { data } } — this runtime then calls the hook instead of starting
+// the render/fold loop. panelCtx = ctx + { close, insertText }.
// The pure text helpers from ../format.js — the SAME module the trusted apps
// use, so a member app's linkifying/formatting behaves identically. In Node
@@ -537,6 +550,24 @@ function bootFrame() {
replySpec = { text: String(r.text || ""), short: r.short != null ? String(r.short) : undefined };
}
},
+ // Open a command panel (declarative — see the header). The host mounts
+ // a fresh panel frame that runs the app's panel(data, el, panelCtx)
+ // hook; everything crossing the bridge is plain JSON. Resolves whether
+ // a panel actually opened (false: no hook, no chat surface, rejected).
+ panel: (p) => {
+ if (!spec || typeof spec.panel !== "function") {
+ console.warn("ctx.panel needs a top-level panel(data, el, panelCtx) hook in the sandbox");
+ return Promise.resolve(false);
+ }
+ const c = p && typeof p === "object" ? p : {};
+ // Unset fields are OMITTED (an `undefined` property would trip the
+ // host's whole-object JSON validation).
+ const out = { data: c.data === undefined ? null : c.data };
+ if (c.title !== undefined) out.title = c.title;
+ if (c.position !== undefined) out.position = c.position;
+ if (c.animate !== undefined) out.animate = c.animate;
+ return rpc("panel-open", { cmdSeq: m.seq, spec: out }).then((v) => !!v);
+ },
});
try {
await handler.run(String(m.args || ""), cctx);
@@ -635,6 +666,41 @@ function bootFrame() {
}
chatLines = Array.isArray(seed.chat) ? seed.chat.slice(-CHAT_CAP) : [];
for (const it of seed.pend || []) if (it && it.e) fold(it.e, it.meta);
+
+ // PANEL mode: this frame IS a command panel's body. Run the app's panel
+ // hook against the seeded state and keep the host sized to the content —
+ // no render/fold loop and no state reports (the app-thread's own frame
+ // owns those; the host ignores a panel frame's "state" anyway).
+ if (m.panel) {
+ if (state === undefined) {
+ try {
+ state = spec.reduce(undefined, { type: "@@lchat/init" }, {});
+ } catch {
+ state = null;
+ }
+ }
+ if (typeof spec.panel !== "function") {
+ showFatal("this app has no panel(data, el, panelCtx) hook");
+ return;
+ }
+ const pctx = Object.assign({}, ctx, {
+ close: () => send({ type: "panel-close" }),
+ insertText: (t) => send({ type: "panel-insert", text: String(t == null ? "" : t).slice(0, 2000) }),
+ });
+ try {
+ spec.panel(m.panel.data === undefined ? null : m.panel.data, root, pctx);
+ } catch (e) {
+ showFatal((e && e.message) || String(e));
+ return;
+ }
+ const postSize = () => {
+ const h = Math.max(document.body.scrollHeight, root.scrollHeight || 0);
+ send({ type: "panel-size", h: Math.min(600, Math.max(24, h)) });
+ };
+ if (typeof ResizeObserver !== "undefined") new ResizeObserver(postSize).observe(document.body);
+ postSize();
+ return;
+ }
// Materialise initialState even with zero events to fold: the reducer only
// seeds on the first fold, so a freshly-deployed app opened before any event
// would otherwise render with state === undefined — and a render that reads
diff --git a/web/static/sandbox.js b/web/static/sandbox.js
index 17e60ba..64722a4 100644
--- a/web/static/sandbox.js
+++ b/web/static/sandbox.js
@@ -183,6 +183,28 @@ export function sanitizeAppEvent(appName, ownedTypes, event) {
return e;
}
+// sanitizePanelSpec admits a command panel request from a frame (the
+// declarative ctx.panel form — see sandbox-frame.js's header): clamped title,
+// whitelisted position/animate, and `data` (the JSON handed to the app's
+// panel hook) cleaned and size-capped. Returns the cleaned spec or null.
+export function sanitizePanelSpec(spec) {
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) return null;
+ // Field-wise, not cleanJSON on the whole object: an app that leaves
+ // position/animate unset ships them as `undefined` properties, which
+ // cleanJSON (rightly) rejects wholesale — only `data` is arbitrary JSON.
+ const data = spec.data === undefined || spec.data === null ? null : cleanJSON(spec.data);
+ if (data === undefined) return null;
+ const out = { title: String(spec.title || "").slice(0, 80), data };
+ if (spec.position === "top") out.position = "top";
+ if (spec.animate === false) out.animate = false;
+ try {
+ if (JSON.stringify(data).length > 64 * 1024) return null;
+ } catch {
+ return null;
+ }
+ return out;
+}
+
// ---- the proxy app ---------------------------------------------------------
// One shared stylesheet for every sandbox mount (injected once).
@@ -269,16 +291,19 @@ function initMount(rec) {
const state = rec.ctx.getState();
const seed = seedFrom(state);
for (const it of seed.pend) if (it && it.meta && it.meta.id != null) rec.sent.add(it.meta.id);
+ const init = {
+ type: "lchat-sandbox-init",
+ source: rec.app.memberApp.source,
+ manifest: rec.app.memberApp.manifest,
+ threadId: rec.ctx.threadId,
+ me: { uid: rec.ctx.me, name: rec.ctx.meName },
+ theme: themeSnapshot(),
+ seed,
+ };
+ // A panel frame boots straight into the app's panel hook (sandbox-frame.js).
+ if (rec.panelFrame) init.panel = { data: rec.panelData === undefined ? null : rec.panelData };
rec.iframe.contentWindow.postMessage(
- {
- type: "lchat-sandbox-init",
- source: rec.app.memberApp.source,
- manifest: rec.app.memberApp.manifest,
- threadId: rec.ctx.threadId,
- me: { uid: rec.ctx.me, name: rec.ctx.meName },
- theme: themeSnapshot(),
- seed,
- },
+ init,
// The frame's origin is opaque, so "*" is the only addressable target;
// the transferred port scopes the channel to exactly this frame.
"*",
@@ -308,6 +333,7 @@ function onFrameMessage(rec, ev) {
const ctx = rec.ctx;
switch (m.type) {
case "state": {
+ if (rec.panelFrame) return; // the app-thread's own frame owns state
const upTo = m.upTo;
if (typeof upTo !== "number" || !Number.isFinite(upTo) || upTo < 0) return;
const state = cleanJSON(m.state);
@@ -372,12 +398,103 @@ function onFrameMessage(rec, ev) {
Promise.resolve(ctx.unschedule(sid)).then((ok) => reply(rec, m.seq, !!ok));
break;
}
+ case "panel-open": {
+ // A COMMAND asking for a panel (declarative ctx.panel). Gated to a
+ // still-live command invocation — the host ctx (with its panel opener,
+ // bound to where the member typed) is looked up by the command's seq,
+ // so a frame can't pop panels outside a command it was just asked to
+ // run ("a response, not an ambush", for member apps too).
+ const hostCtx = rec.cmdCtx && rec.cmdCtx.get(m.cmdSeq);
+ const spec = hostCtx && typeof hostCtx.panel === "function" ? sanitizePanelSpec(m.spec) : null;
+ if (!spec) {
+ reply(rec, m.seq, false);
+ return;
+ }
+ const opened = hostCtx.panel({
+ title: spec.title,
+ position: spec.position,
+ animate: spec.animate,
+ render: (el, handle) => mountPanelFrame(app, hostCtx.threadId, spec.data, el, handle),
+ onClose: () => teardownPanelMount(app.name),
+ });
+ reply(rec, m.seq, !!opened);
+ break;
+ }
+ case "panel-size": {
+ // The panel frame fitting its content: clamp and size the iframe; the
+ // host panel's own body scrolls beyond the cap.
+ if (!rec.panelFrame) return;
+ const h = typeof m.h === "number" && Number.isFinite(m.h) ? Math.min(600, Math.max(24, m.h)) : 0;
+ if (h) rec.iframe.style.height = h + "px";
+ break;
+ }
+ case "panel-close": {
+ if (rec.panelFrame && rec.panelHandle) rec.panelHandle.close();
+ break;
+ }
+ case "panel-insert": {
+ // A panel acting on the composer it sits above (clamped; the same
+ // doorway a trusted panel's insertText uses). Should follow a user tap.
+ if (rec.panelFrame && rec.panelHandle && typeof m.text === "string") {
+ rec.panelHandle.insertText(m.text.slice(0, 2000));
+ }
+ break;
+ }
case "error":
note(rec, `App error: ${String(m.message || "unknown").slice(0, 200)}`, true);
break;
}
}
+// ---- command panels for member apps ----------------------------------------
+
+// One live panel frame per app (opening another replaces it, like the host
+// panel itself). The frame is just another mount: its emits/schedules ride the
+// same sanitized, rate-limited bridge; only `state` reports are ignored.
+const panelMounts = new Map(); // app name → mount record
+
+function mountPanelFrame(app, threadId, data, el, handle) {
+ teardownPanelMount(app.name);
+ injectSandboxStyles();
+ const iframe = document.createElement("iframe");
+ iframe.className = "sandbox-frame sandbox-panel-frame";
+ iframe.setAttribute("sandbox", "allow-scripts");
+ iframe.setAttribute("allow", "");
+ iframe.referrerPolicy = "no-referrer";
+ iframe.src = "/app-sandbox";
+ iframe.style.cssText = "width:100%;border:0;display:block;height:96px";
+ const rec = {
+ iframe,
+ el,
+ app,
+ ctx: memberCtx(threadId, app),
+ port: null,
+ sent: new Set(),
+ emits: [],
+ panelFrame: true,
+ panelHandle: handle,
+ panelData: data,
+ };
+ mounts.set(iframe, rec);
+ panelMounts.set(app.name, rec);
+ iframe.addEventListener("load", () => {
+ rec.port = null;
+ initMount(rec);
+ });
+ el.appendChild(iframe);
+}
+
+function teardownPanelMount(name) {
+ const rec = panelMounts.get(name);
+ if (!rec) return;
+ panelMounts.delete(name);
+ // Small grace so a click's in-flight emit still crosses before removal.
+ setTimeout(() => {
+ mounts.delete(rec.iframe);
+ if (rec.iframe.parentNode) rec.iframe.remove();
+ }, 2000);
+}
+
function reply(rec, seq, value) {
if (rec.port && typeof seq === "number") rec.port.postMessage({ type: "rpc-reply", seq, value });
}
@@ -541,7 +658,7 @@ async function runMemberCommand(app, hostCtx, cmdName, args) {
// must still be torn down, or it leaks a stranded hidden iframe.
try {
if (!rec.port) return;
- const spec = await sendCommand(rec, cmdName, args);
+ const spec = await sendCommand(rec, cmdName, args, hostCtx);
if (spec && hostCtx.reply) hostCtx.reply(sanitizeReply(spec, app, hostCtx));
} finally {
teardownTransient(rec);
@@ -549,9 +666,14 @@ async function runMemberCommand(app, hostCtx, cmdName, args) {
}
let cmdSeq = 0;
-function sendCommand(rec, cmd, args) {
+function sendCommand(rec, cmd, args, hostCtx) {
if (!rec.cmdWaiters) rec.cmdWaiters = new Map();
+ if (!rec.cmdCtx) rec.cmdCtx = new Map();
const seq = ++cmdSeq;
+ // The host ctx stays addressable past the command's reply: a handler may
+ // reply first and open its panel after an await. The window is bounded.
+ rec.cmdCtx.set(seq, hostCtx);
+ setTimeout(() => rec.cmdCtx.delete(seq), 20000);
return new Promise((resolve) => {
rec.cmdWaiters.set(seq, resolve);
rec.port.postMessage({ type: "command", seq, cmd, args: String(args || "") });
diff --git a/web/static/shell.js b/web/static/shell.js
index d4029ed..9479235 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -91,6 +91,8 @@ import {
messageActionsFor,
renderInline as renderAppLine,
appStateFor,
+ setCommandPanelObserver,
+ closeCommandPanel,
} from "/static/apps.js";
// Register the mini-apps (side-effect imports). Each is a single self-contained
// file (JS + its own CSS). Personal apps get their own sidebar thread; shared
@@ -104,6 +106,8 @@ import "/static/apps/board.js";
import "/static/apps/connect.js";
import "/static/apps/dnsimple.js";
import "/static/apps/github.js";
+import "/static/apps/emoji.js"; // /emoji — the command-panel reference app
+import "/static/apps/gif.js"; // /gif — Giphy search in a command panel (bring-your-own key; posts via ctx.host.gif)
import { startTour } from "/static/apps/tutorial.js"; // also registers the /tutorial floor command
import { startSetup } from "/static/apps/setup.js"; // the first-run setup conversation (also registers its app)
import { startWelcome } from "/static/apps/welcome.js"; // the joiner welcome conversation (also registers its app)
@@ -764,6 +768,9 @@ function hideLoading() {
// that (feed render, send, reactions, edits, typing, attachments) is reused as-is.
async function bootEmbed() {
document.body.classList.add("native-embed");
+ // Command-host pages collapse to just the panel (style.css .command-host
+ // rules) — classed before the first render so the feed never flashes.
+ if (embed.mode === "command") document.body.classList.add("command-host");
try {
// The native shell can land straight in a custom app's thread, so the
// embed path needs registration complete before its render too.
@@ -811,6 +818,10 @@ async function bootEmbed() {
await openThread(tid);
connectWS();
installNativeBridge();
+ // Command-host mode (the Mac shell's panel webview): this page exists to
+ // RUN slash commands and show their panels — the native feed renders the
+ // chat itself. See installCommandHost.
+ if (embed.mode === "command") installCommandHost(tid);
startHeartbeat();
// The pane has real content now — the native shell drops its loading
// veil on this (didFinish fires long before the app has painted).
@@ -924,6 +935,70 @@ function installNativeBridge() {
};
}
+// ---- command host (embed.mode === "command") -------------------------------
+//
+// The native Mac shell renders chat NATIVELY, so it has no JS command engine —
+// this page is that engine, one per thread: the shell keeps it hidden, forwards
+// a typed slash command to window.lchatNative.runCommand, and reveals the
+// webview only while a panel is showing (body.command-host CSS collapses
+// everything except the .cmd-panel, so the webview IS the panel). Private
+// replies post back as {notice} for the shell to render inline; panel
+// visibility and height post as {panel}; a panel's insertText already posts
+// {composerInsert} (chat.js) for the shell's own composer.
+function installCommandHost(tid) {
+ // Content height for the shell's frame. scrollHeight is the content EXTENT,
+ // immune to the max-height clamp and to the webview being 0-tall while
+ // hidden — the one measurement that works before the shell reveals it.
+ const measure = () => {
+ const body = document.querySelector(".cmd-panel-body");
+ const head = document.querySelector(".cmd-panel-head");
+ return Math.min(600, (body ? body.scrollHeight : 200) + (head ? head.offsetHeight : 26) + 12);
+ };
+ const postPanel = (spec) => {
+ if (!spec) return nativePost({ panel: null });
+ // Measure synchronously — scrollHeight forces layout, and this page is
+ // DETACHED from the native view hierarchy until the shell reveals it, so
+ // a requestAnimationFrame here never fires (WebKit suspends rendering
+ // callbacks for unparented webviews) and the panel would deadlock hidden.
+ // Timers still run detached, so the late re-measure (fonts, images, and
+ // the real width once the shell attaches the frame) uses setTimeout.
+ nativePost({
+ panel: {
+ title: spec.title || (spec.sender && spec.sender.name) || "",
+ height: measure(),
+ // The app's animation choice (animate: false = appear instantly) —
+ // in command-host mode the NATIVE shell owns the reveal, so the flag
+ // rides the post instead of easing in-page (see panelEases, chat.js).
+ animate: spec.animate !== false,
+ },
+ });
+ setTimeout(() => nativePost({ panelHeight: measure() }), 350);
+ };
+ setCommandPanelObserver((threadId, spec) => {
+ if (threadId === tid) postPanel(spec);
+ });
+ // The shell can't see this page's console, so surface page-side failures as
+ // {error} posts — they land in the native unified log (CommandHost).
+ window.addEventListener("error", (e) => nativePost({ error: "page: " + (e.message || "error") }));
+ window.addEventListener("unhandledrejection", (e) =>
+ nativePost({ error: "page: " + ((e.reason && (e.reason.message || e.reason)) || "unhandled rejection") }));
+ const origConsoleError = console.error.bind(console);
+ console.error = (...args) => {
+ origConsoleError(...args);
+ try {
+ nativePost({ error: "console: " + args.map((a) => (a && a.message) || String(a)).join(" ").slice(0, 300) });
+ } catch {}
+ };
+ // Returns whether the text was a command this engine answered (panel or
+ // reply) — false lets the shell send it as a normal message (bot commands).
+ window.lchatNative.runCommand = (text) =>
+ runCommand(text, tid, (r) =>
+ nativePost({ notice: { from: (r && r.sender && r.sender.name) || "", text: (r && r.text) || "" } }));
+ // Escape in the native composer (and the shell's own chrome) dismisses.
+ window.lchatNative.dismissPanel = () => closeCommandPanel(tid);
+ nativePost({ commandHostReady: true });
+}
+
// ---- data ---------------------------------------------------------------
function ensurePubkey() {
@@ -2857,8 +2932,103 @@ setHostBridge({
}
},
},
+ // The /gif app's scoped Giphy doorway (apps never fetch — the host does, and
+ // only to *.giphy.com, which the CSP pins). This is the client-side sibling
+ // of the unfurl exception: the SERVER never sees the search, the pick, or
+ // the bytes — Giphy sees the query from the member's own client. The API
+ // key is the member's own (the panel asks on first use — free at
+ // developers.giphy.com) and lives in THIS device's localStorage: it's a
+ // public client identifier, not a secret, and it never touches the server.
+ gif: {
+ hasKey() {
+ try {
+ return !!localStorage.getItem(GIPHY_KEY_STORE);
+ } catch {
+ return false;
+ }
+ },
+ // Store (or clear, with "") the member's key. Returns the trimmed value.
+ setKey(k) {
+ const key = String(k || "").trim();
+ try {
+ if (key) localStorage.setItem(GIPHY_KEY_STORE, key);
+ else localStorage.removeItem(GIPHY_KEY_STORE);
+ } catch {}
+ return key;
+ },
+ // Search returns the raw data[] page — or a reason string: "key" (no key
+ // stored yet), "auth" (Giphy rejected the key), null (network/other), so
+ // the panel can ask for a key exactly when one is the problem.
+ async search(q, limit = 24) {
+ let key = "";
+ try {
+ key = localStorage.getItem(GIPHY_KEY_STORE) || "";
+ } catch {}
+ if (!key) return "key";
+ const base = "https://api.giphy.com/v1/gifs/";
+ const url = q
+ ? `${base}search?api_key=${encodeURIComponent(key)}&q=${encodeURIComponent(q)}&limit=${limit}&rating=pg-13`
+ : `${base}trending?api_key=${encodeURIComponent(key)}&limit=${limit}&rating=pg-13`;
+ try {
+ const r = await fetch(url, { signal: AbortSignal.timeout(15000) });
+ if (r.status === 401 || r.status === 403) return "auth";
+ if (!r.ok) return null;
+ return (await r.json()).data || [];
+ } catch (e) {
+ console.error("gif search failed:", e);
+ return null;
+ }
+ },
+ // Download one rendition's bytes — Giphy hosts only, size-capped (checked
+ // again after download; Content-Length can be absent). Failures log the
+ // step, so a native shell's unified log shows WHERE a post died.
+ async bytes(url, maxBytes) {
+ try {
+ const u = new URL(url);
+ if (u.protocol !== "https:" || !(u.hostname === "giphy.com" || u.hostname.endsWith(".giphy.com"))) {
+ console.error("gif bytes refused non-giphy url");
+ return null;
+ }
+ const r = await fetch(u, { signal: AbortSignal.timeout(30000) });
+ if (!r.ok) {
+ console.error("gif bytes fetch not ok:", r.status);
+ return null;
+ }
+ const buf = new Uint8Array(await r.arrayBuffer());
+ if (maxBytes && buf.length > maxBytes) {
+ console.error("gif bytes over cap:", buf.length);
+ return null;
+ }
+ return buf;
+ } catch (e) {
+ console.error("gif bytes failed:", e);
+ return null;
+ }
+ },
+ // Encrypt + upload bytes AS-IS to the thread's blob store (no image
+ // re-encode — an animated GIF must survive; the caller already bounded the
+ // size), returning the attachment metadata that rides inside the payload.
+ async upload(threadId, bytes, meta = {}) {
+ const key = threadKeys[threadId];
+ if (!key || !bytes || !bytes.length || bytes.length > MAX_UPLOAD) return null;
+ try {
+ const enc = await encryptBytes(key, bytes);
+ const id = await uploadEncrypted(threadId, enc);
+ const att = { id, name: meta.name || "gif.gif", type: meta.type || "image/gif", size: bytes.length };
+ if (meta.w) att.w = meta.w;
+ if (meta.h) att.h = meta.h;
+ return att;
+ } catch (e) {
+ console.error("gif upload failed:", e);
+ return null;
+ }
+ },
+ },
});
+// Where the member's own Giphy key lives (see the hostBridge.gif note above).
+const GIPHY_KEY_STORE = "lchat-giphy-key";
+
// The OAuth popup lands on /oauth/<service>/callback, which postMessages the
// one-time code back here; we hold the matching verifier. Complete on receipt.
window.addEventListener("message", (e) => {
diff --git a/web/static/style.css b/web/static/style.css
index c4eebe1..631e988 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -607,6 +607,8 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
background: var(--bubble-them);
cursor: zoom-in;
}
+/* a GIF's click toggles play/pause, so the zoom-in cursor would lie */
+.msg-image.msg-gif { cursor: pointer; }
/* full-screen image viewer */
.lightbox {
@@ -2188,3 +2190,12 @@ html.native .thread-head {
flex: none;
height: calc(16px + var(--native-bottom-inset, 0px));
}
+
+/* Command-host embed (embed.mode === "command"): the page the native Mac shell
+ keeps beside each thread purely to run slash commands — everything except a
+ command's panel collapses, so the webview the shell reveals IS the panel.
+ The shell owns the frame (and caps the height via the posted measure). */
+body.command-host .chatapp-root > :not(.cmd-panel) { display: none !important; }
+body.command-host .cmd-panel { border: none; box-shadow: none; } /* the native frame draws the border + shadow */
+/* The shell grants the frame; the body scrolls within it (100vh = the frame). */
+body.command-host .cmd-panel-body { max-height: calc(100vh - 40px); }