Skip to content

Promote develop to main: workbench scenarios, lens foundation, iOS parity#276

Merged
haowei2000 merged 17 commits into
mainfrom
develop
Jul 20, 2026
Merged

Promote develop to main: workbench scenarios, lens foundation, iOS parity#276
haowei2000 merged 17 commits into
mainfrom
develop

Conversation

@haowei2000

Copy link
Copy Markdown
Collaborator

Summary

Promotion of developmain (17 commits since #270).

Workbench & plugins

  • Official scenario templates (research-lab, task-board, team-ops) + parsed data on fs.read; new migration 0052_workbench_template_origin.sql
  • Plugin hot reload, protocol inspector, and a real build template
  • Re-inlined SDK v2 in official bundles; Pages gallery updated

iOS

  • Offline-first message cache (SwiftData) with memory-pressure eviction
  • @-mention picker in the composer (web parity)
  • Read-only Workbench file browser
  • Per-channel ChatModel retention for instant channel re-entry

Performance

  • Instant channel re-entry via per-channel cache + persistent WS

Docs

  • Declarative lens spec + gateway write-back ADR
  • Dropped Expo from the arch docs — native Swift is the mobile client, Android frozen
  • macOS download link points at /releases

Test plan

  • cargo test / cargo clippy (server)
  • npm run typecheck / npm run test (frontend)
  • Verify migration 0052 applies from an empty DB

🤖 Generated with Claude Code

haowei2000 and others added 17 commits July 19, 2026 19:41
GitHub's /releases/latest skips pre-releases, and the desktop builds are
published as pre-releases (unsigned, no notarization). So the "latest"
redirect resolves to connector-v0.1.28 — the connector, not the desktop app:

  $ curl -o /dev/null -w '%{redirect_url}' .../releases/latest
  https://github.com/ElePerson/Cheers/releases/tag/connector-v0.1.28

Every download entry added in #267 pointed there. Send them to the releases
list instead, and say which tag to pick, since that page interleaves
connector-v* and desktop-v* builds.

The connector install snippets in website/connector*.html keep using
/releases/latest/download/… — the connector releases are not pre-releases,
so that redirect resolves correctly for them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs: point the macOS download at /releases, not /releases/latest
…emplate for plugins

Writing a renderer plugin worked, but iterating on one did not. Three gaps, all
pure developer experience — the protocol, manifest and sandbox are untouched.

- Hot reload: "Watch file" keeps the picked .html handle and polls its mtime, so
  saving in your editor reloads the plugin in place instead of re-dragging it.
  File System Access API, so the button only appears on Chromium; drag-and-drop
  stays the universal path.

- Debugging: an opaque-origin sandbox swallows console output and uncaught
  errors, so a broken renderer showed up as a blank iframe with nothing in the
  host console. Session-loaded (temporary) plugins now get a "Dev" protocol
  inspector listing every cheers:* message in both directions, plus a new
  plugin -> host cheers:log that the SDK uses to forward uncaught errors and
  unhandled rejections. Installed plugins stay silent — this is a development
  affordance, not telemetry. Unknown to older hosts, which ignore it.

- Framework builds: the guide claimed "bundled framework code" was fine but gave
  no way to produce the required single file. Adds a React + TypeScript template
  built by vite-plugin-singlefile; verified to emit one 199 KB index.html with
  the embedded manifest intact, well under the 2 MiB cap.

Also fixes a documentation bug: the Chinese design original claimed cheers:render
is re-sent when the file changes externally. It is not — the code has exactly two
triggers (cheers:ready, and a conflicted save), which the English spec already
stated. That contract is now prominent in both, because "my board didn't refresh"
is correct behaviour and plugin authors have to design around it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eezes

Supersedes decision 2 of the 2026-07-06 mobile strategy (consolidate iOS and
Android onto one Expo codebase, then delete apps/ios and apps/android).

The decision did not survive contact with what we actually did. In the 13 days
after it was accepted the Expo scaffold was never started — there is still zero
Expo/React Native code in the repo — while apps/ios took three commits. The push
design was already revised on 2026-07-18 to direct APNs + relay precisely because
the shipping client is the native SwiftUI app. A document that contradicts the
work for two weeks is not a plan.

New direction:
- iOS = native SwiftUI (apps/ios), a long-term client invested in as such.
- Android = frozen: fixes only, no new features; Android users get the PWA.
- apps/mobile/ (Expo) will not be created.

The original cost argument was 2x maintenance for Swift *and* Kotlin. Freezing
Android removes that multiplier without a rewrite: one native client plus the PWA
is a smaller surface than either two native clients or a from-scratch port.

Accepted costs, recorded so they are not relitigated later: no native Android
feature velocity, and no React-team leverage on mobile.

The 2026-07-06 analysis is retained unedited — decision 1 (landing the three
branches) was carried out and is still accurate history. MOBILE_APP_DESIGN keeps
its product/UX and push sections, which remain valid; its Expo-specific mechanics
are banner-marked as superseded and will be replaced section by section rather
than deleted in one sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Workbench menu entry resolved to a placeholder sheet — an icon, a title and
a blurb. It now browses the channel's file workspace for real.

The web Workbench renders a file through a bound renderer (a built-in lens or a
sandboxed HTML plugin) and falls back to "Raw". iOS has no sandbox to run plugin
bundles in, so this ships the Raw half: the tree, plus exact file bytes. Native
lenses are the follow-up; editing (fs.write + the if_version optimistic lock)
comes after that.

No new transport or server work — fs.ls / fs.read ride the resource_req RPC that
ChatSocket already implements for the ViewBoards.

Two things the on-device pass caught that the code review would not have:

- fs.ls returns a FLAT, recursive list of full paths and emits no is_dir rows at
  all, so the first cut rendered draft/paper.md as a root-level "paper.md" and
  lost every folder. The hierarchy is now derived client-side from the "/" in
  each path, mirroring the web buildTree, so one listing feeds the whole browser
  and drilling down costs no round trip. FsEntry deliberately no longer exposes a
  basename helper — reading only the last segment is what caused this.

- Long lines were silently clipped with no way to scroll to them: a
  maxWidth: .infinity inside a horizontal ScrollView pins content to the viewport.
  Truncating JSON invisibly is the worst kind of wrong, so the Text now takes its
  natural width.

Refresh is a header button, not pull-to-refresh: inside a presentationDetents
sheet a downward pan is claimed by the sheet's own resize gesture, so .refreshable
never fires — verified, not assumed.

Untrusted-content posture follows ViewBoards: plain inert Text, no markup
rendering and no tappable links, so bot-authored content cannot act on a tap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nt WS

First channel entry (and every switch) paid a full cold reload: messages
were wiped and re-fetched, a brand-new WebSocket was handshaken per
channel, and a subscribe-ack race could re-fetch the whole first page a
second time. Discord/Telegram-style fixes, all client-side (the gateway
query path is already keyset-paginated and batched):

- chatCache.ts: per-channel in-memory LRU cache (messages + members).
  Re-entering a channel renders the cached window instantly, then a
  since_seq catch-up merges anything missed. Seeds are trimmed to the
  newest 100 rows (hasMore flips back on) and streaming flags cleared.
- useChatRealtime: the browser socket is now a module-level singleton
  shared across channel switches — switching sends unsubscribe/subscribe
  on the live connection instead of paying TCP/WS + auth per switch.
  Late frames are dropped by channel_id; an idle socket closes after a
  grace period; logout closes it immediately.
- ChannelView: the subscribe ack no longer races the initial history
  load into a duplicate since_seq=0 page fetch — the catch-up defers
  until the real seq cursor exists. Async responses landing after a
  channel switch are discarded instead of merged into the wrong channel.
- MessageList: a channel switch jumps straight to the bottom (the old
  auto-scroll only fired on message-count growth, which a cache seed
  doesn't trigger).

Verified against the kind cluster with headless Chrome: warm re-entry
renders in ~140ms with zero full-page refetches and zero new WS
connections across A→B→A→B switches; live delivery on the reused socket
still works after subscription churn. vitest 83/83.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The iOS client already had the other two web fixes by construction (one
app-wide socket with subscribe/unsubscribe per channel; catchUp guarded
by loadedOnce/highestSeq), but ChatsHomeView rebuilt ChatView — and with
it a fresh ChatModel — on every switch via .id(channelId), so each entry
cold-reloaded 50 messages + members.

- ChatModelStore (new): LRU (12 channels) of ChatModel instances hung
  off AppModel, cleared on sign-out. The open channel is always the most
  recently touched entry, so it can't be evicted.
- ChatView now receives the cached model instead of constructing one;
  the view still remounts per channel, the model survives.
- ChatModel: warm loadInitial path renders the in-memory history
  immediately, scrolls to bottom, marks read, refreshes members in the
  background, and heals via the existing since_seq catchUp (in-flight
  guarded — the subscribe ack requests the same catch-up). detach()
  trims retained history to the newest 100 rows (hasMoreBefore flips
  back on) so re-entry render cost stays bounded. channel DTO refreshes
  on reuse so renames still show.

xcodebuild (Debug, iOS Simulator) succeeds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The iOS app rendered incoming mentions but could never send one:
SendMessageRequest.mention_ids was never populated and the composer had
no "@" typeahead, so a message typed on iOS never triggered a bot.

- ComposerView: "@" opens a picker over the channel's members plus the
  group tokens (@all/@bots/@humans/@here), ranked bots -> groups ->
  people; tapping a row inserts "@Label " (caret assumed at end of
  draft - SwiftUI TextField exposes no caret position).
- ChatModel: builds the mention pool from channel members, tracks picked
  mentions, and on send keeps only tokens still present in the text,
  splitting them into mention_ids (members) and mention_names (group
  tokens), same contract as the web MessageComposer.
- DTOs: SendMessageRequest gains mention_names.

Verified live against the kind stack: @codex persisted the mention and
dispatched the bot; @Bots expanded server-side to the channel's bot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(ios): @-mention picker in the composer (web parity)
…eviction

Two platform-convention upgrades on top of the in-memory ChatModel cache:

- MessageStore (new, SwiftData): every visited channel's newest 200
  finalized messages persist across launches as full-DTO JSON blobs
  (schema-stable while the DTO evolves) keyed by unique msg_id, plus a
  per-channel hasMoreBefore flag so upward pagination works straight off
  a cold start. Cold loadInitial renders the persisted window first and
  the network refresh lands on top; if >1 page arrived while offline
  (fresh page not contiguous with the cached window) only the fresh page
  is kept so pagination stays gap-free. Writes are debounced (1 s) off
  upsert/loadOlder and flushed on detach; deleted messages are removed
  from disk so they can't resurrect; the store is wiped on sign-out.
  MessageDto becomes Codable (nested types already were).

  Gotcha found by a macOS SwiftData harness driving the real store: a
  trim fetch with fetchOffset run BEFORE saving saw every pending insert
  as overflow and deleted the batch it was meant to keep (SwiftData does
  not apply offset windows reliably to unsaved objects). save() now
  persists first, then trims by slicing in code.

- ChatModelStore responds to UIApplication.didReceiveMemoryWarning by
  shedding every cached model except the open channel's — safe to be
  aggressive now that evicted history reloads from disk, not network.

Verified: harness passes round-trip/ordering, unique upsert, 200-row
trim + hasMoreBefore, in-flight row skipping, delete, channel isolation,
removeAll, and cross-instance relaunch persistence. xcodebuild (Debug,
iOS Simulator) succeeds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflict in apps/ios/Sources/State/ChatModel.swift: #274 (iOS @-mention
picker) built its `mentionPool` inside the members-fetch block of
loadInitial, which this branch had extracted into refreshMembers().

Resolved by keeping this branch's loadInitial (offline-first seed +
contiguity check) and moving #274's mentionPool construction into
refreshMembers(). Strictly better than either side: refreshMembers()
also runs on warm re-entry, so a member who joined while the user was
away now appears in the mention picker without a cold reload — #274's
version only built the pool on a cold load.

DTOs.swift, ChatView.swift and ComposerView.swift auto-merged.
xcodebuild (Debug, iOS Simulator) succeeds; SwiftData harness and
frontend vitest (83/83) still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rf-92401d

perf(chat): instant channel re-entry — per-channel message cache + persistent WebSocket
…asibility-0aa4f8

iOS Workbench (read-only), workbench plugin DX, and dropping Expo
Two documents that settle how workbench boards escape the HTML-plugin sandbox
and reach every client, native iOS included.

WORKBENCH_LENS_SPEC (draft): a lens = a YAML binding (path + field definitions)
x a widget (table / list / form, then chart / graph) x field types (text, enum,
locator, image, ...). Going YAML-only collapses the parser axis entirely — the
markdown convention parsers existed only because the substrate was plain text.
Coverage-checked against the actual source of all four official plugins; the
mismatches found (arbitrary UI like codemap; line- vs comment-preservation
semantics) are recorded, not papered over. Declarative lenses are data, not
code, which keeps them outside App Store guideline 4.7 — HTML plugins remain
the escape hatch on web/desktop.

WORKBENCH_WRITEBACK (accepted): the gateway owns both sides of the format.
fs.patch carries generic document ops (set / insert / remove / move) addressed
by path; fs.read grows a parsed `data` field. "YAML is a storage format; the
wire is JSON; the format never leaves the gateway." One implementation of the
subtle comment-preserving part instead of one per client — a divergence there
does not error, it silently mangles user files. Spiked, not guessed: yamlpath
(zizmor's tree-sitter crate) resolves paths to byte spans and we splice, which
beats the web's diff-based yamlDoc.ts on length-changed arrays because explicit
ops carry intent. All fixtures pass, including the YAML 1.1 boolean hazard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four scenarios now ship in the gateway binary and seed at startup, exactly like
official plugins (the seeder REUSES workbench_official::decide — admin deletion
sticks within a release, a higher manifest version re-seeds, an admin-claimed id
is never overwritten; API PUT refuses official ids the same way):

  cheers-task-board     Todo/Doing/Done kanban + backlog table (priority/status enums)
  cheers-code-project   plan kanban, issues table (severity + ref column),
                        progress chart, todo checklist, codemap/map.yaml seed
  cheers-research-lab   experiments table, metrics chart, submissions tracker
  cheers-team-ops       server inventory, assets/renewals, on-call board

All seed commented-YAML board files rendered by BUILT-IN lenses — CI enforces
both (a template may never depend on a plugin; every viewed file must be
seeded, so no official scenario can open onto an empty board). Each pins a
small conventions note telling the agent which file to keep updated: that pin
is what closes the human-agent loop on day one. The web lenses already accept
YAML, so these work today with no frontend change.

fs.read additionally returns `data`: the YAML parsed to JSON, server-side
(whole-file reads only; null when unparseable — the client falls back to the
editor, never errors). This is what lets clients with no YAML parser — native
iOS has none and a zero-dependency policy — render lenses at all, and it
resolves the YAML 1.1/1.2 boolean ambiguity once instead of per client parser
(serde_yaml is 1.2: a bare `no` stays a string; tested).

templates list() now reports origin so Settings can badge Official rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gallery

The official bundles' inlined SDK had drifted three revisions behind the
reference cheers-plugin-sdk.js:

- no open/compose — protocol abilities the reference implementations never
  demonstrated;
- no cheers:log / uncaught-error forwarding — an official plugin that broke
  showed a blank iframe with nothing in the Dev inspector;
- no one-save-in-flight guard — the old copy silently OVERWROTE pendingSave, so
  concurrent saves could adopt each other's results. A real bug, not a missing
  nicety.

checklist / kanban-md / frontmatter re-inline v2 and bump manifest version
1 -> 2 so the seeder re-installs on the next release (including rows an admin
deleted — that is the documented policy). cheers-table is deliberately NOT
updated: it duplicates builtin:table, which the new YAML scenario templates
already use, making it a deprecation candidate — no polish for what we are
about to retire.

Pages fixes:
- pages.yml assembles official scenario templates into downloads/templates/ —
  its OWN dir, because two docs examples share filenames with official
  templates (code-project, research-lab) and one dir would silently clobber;
- plugins.html gains an "Official scenario templates" section (4 cards);
- plugin-dev.html's protocol table was three messages behind the normative
  spec — cheers:open / cheers:compose / cheers:log rows added.

Verified by serving website/ locally: new section renders with all four cards,
the three protocol rows are present, both pages parse. cargo test: 179 pass
(every embedded bundle walks manifest validation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ens-foundation

Workbench: official scenarios, declarative-lens foundation, plugin SDK v2
@haowei2000
haowei2000 merged commit 94c5810 into main Jul 20, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant