Skip to content

feat(channels): Phase 1 — multi-participant chat alongside the issue board (RFC #1920)#1954

Open
prellr wants to merge 9 commits intomultica-ai:mainfrom
prellr:channels-phase-1
Open

feat(channels): Phase 1 — multi-participant chat alongside the issue board (RFC #1920)#1954
prellr wants to merge 9 commits intomultica-ai:mainfrom
prellr:channels-phase-1

Conversation

@prellr
Copy link
Copy Markdown

@prellr prellr commented Apr 30, 2026

Summary

Adds Channels — a multi-participant chat surface (public channels, private channels, DMs) alongside the issue board, with humans and agents as first-class members. Coexists with the existing 1:1 agent Chat and never modifies its tables, handlers, or views.

This PR is opt-in (workspace.channels_enabled defaults FALSE); when off, the sidebar entry hides and every Channels endpoint returns 404.

Discussion: #1920 (no maintainer comments yet — opening the Draft so you can react to the diff in PR-review form rather than skimming the description alone).

Diff at a glance

86 files changed, ~6.6k insertions across 8 feature commits + 1 merge:

Layer Where What
Schema server/migrations/065_channels.{up,down}.sql channel, channel_membership, channel_message + workspace.channels_enabled (default FALSE) + workspace.channel_retention_days + partial indexes + generated tsvector
Queries server/pkg/db/queries/channel.sql, channel_message.sql, workspace.sql CRUD, polymorphic membership, cursor-paginated list, batched retention sweep, workspace-PATCH gains channels_enabled / retention with paired *_set flags
Service server/internal/service/channel/{types,service,message_service}.go Sidecar-portable: depends only on *db.Queries + TxStarter. Typed errors, idempotent GetOrCreateDM with deterministic DMName(workspace, participants), CanActorAccess distinguishing not-found / archived / not-a-member
Handlers server/internal/handler/channel.go, channel_message.go 11 endpoints (CRUD + members + read + DMs + messages). Workspace-flag gate at start of every handler. Mention → inbox writes (type='channel_mention', refs in details JSONB)
Routes server/cmd/server/router.go Mounted under RequireWorkspaceMember group, mirroring /api/comments style
Events server/pkg/protocol/events.go Seven channel:* constants
Frontend core packages/core/{types,api,channels,realtime,paths,platform}/ TS types, API methods, query+mutation hooks, Zustand store (per-channel drafts), WS prefix invalidation, paths
Views packages/views/channels/components/*.tsx ChannelsPage, ChannelList, ChannelHeader, ChannelMessageList, MessageRow, ChannelComposer, ChannelCreateDialog, NewDMDialog, MembersPanel
Web apps/web/app/[workspaceSlug]/(dashboard)/channels/{,[id]}/page.tsx Two routes, both render <ChannelsPage>
Sidebar packages/views/layout/app-sidebar.tsx New Channels entry with render-time flag filter (FLAG_GATED map)
Settings packages/views/settings/components/workspace-tab.tsx "Channels" admin card with a Switch — admins toggle the flag from the UI, no SQL required
Docs apps/docs/content/docs/channels.{mdx,zh.mdx} + meta.{,zh}.json Full English doc + Chinese stub (TRANSLATION TODO marker), nav entries

Phased rollout (this is PR 1 of 5)

Each phase is independently shippable:

  1. Foundation ← this PR
  2. Retention — workspace + per-channel config, daily cleanup job
  3. Agents in channels@-mention triggers a task; agent posts back via existing endpoints
  4. Threads + reactions
  5. Edits, deletes, files, search

Phase 1 deliberately renders @agent mentions but does not trigger agent tasks — Phase 3 owns that.

Schema design choices we'd like sign-off on

These are intentional but the kind reviewers tend to push back on, flagging up front:

  • Open-string kind and visibility columns (no CHECK constraints). Lets future kinds (announcement channels, integration rooms) ship without migrations; today's values (channel/dm, public/private) are validated in the service layer.
  • No FK from channel_membership / channel_message to user(id) / agent(id). Polymorphic membership (member_type + member_id) is enforced in application code — Postgres can't express conditional FKs cleanly, and this leaves the door open to non-Multica actors.
  • metadata JSONB on channel and channel_message so later features (attachment lists, formatting hints, integration metadata) live without column additions.
  • Generated tsvector column for Phase 5 full-text search. Postgres maintains the GIN index automatically.
  • DMs use a deterministic hash for channel.name (dm-<sha256>) computed from sorted participants + workspace id, so GetOrCreateDM is idempotent and the unique constraint on (workspace_id, kind, name) doubles as a concurrent-create guard.
  • No new unified reaction table. Phase 4 will either add channel_message_reaction (mirroring comment_reaction / issue_reaction) or refactor those into a polymorphic reaction(target_type, target_id, …). We didn't preempt that decision here.
  • Migration number 065 collision — when this branch was created, 064 was the latest on main. While in flight, 065_backfill_onboarded_at and 065_project_resources landed upstream. The migration runner versions by full filename so all three coexist; happy to renumber to 068_channels if you'd prefer single-occupancy 0NN slots. Just say the word.

Spec deviations (with rationale)

The spec at the start of this work assumed a few things about the codebase that turned out to be different. Calling them out so reviewers don't waste time wondering:

  1. No scope_authorizer.go parallel auth system invented — this codebase uses JWT middleware + workspace role; introducing per-feature scopes for one feature would be a much larger architectural change. Channels gates on workspace role (admin for mutations, member for reads) plus the per-channel admin role for archive.
  2. MarkRead URL shortened from spec — spec proposed PATCH /api/channels/{id}/members/{type}/{memberId}/read; we use POST /api/channels/{id}/read against the calling actor. Admin-marking-someone-else's-read isn't a Phase 1 use case and the shorter URL avoids the awkward shape.
  3. Inbox writes use details JSONBinbox_item doesn't have a polymorphic source_type/source_id (the spec assumed it did). Channel mention entries use type='channel_mention', leave issue_id NULL, and stash {channel_id, message_id, channel_name} in the existing details column. No new migration required.
  4. Service constructor takes only *db.Queries + TxStarter — not *realtime.Hub / *events.Bus like TaskService. Spec was explicit that services must NOT publish WS for sidecar portability, so handlers publish.
  5. Mention parsing uses util.ParseMentions() — not mention/expand.go (which is for issue-ID auto-linking, not @mention parsing).

Open questions for maintainer review

Surfaced in #1920; restating here for in-PR discussion:

  1. Should the existing 1:1 agent Chat eventually be subsumed under Channels (as a special channel kind), or remain separate?
  2. Should channels_enabled stay opt-in, or be on by default once merged?
  3. Channel UI as a top-level sidebar item (current), or nested under another section?
  4. Notification preferences — notification_preference (migration 064) exists; should Channels reuse it directly, or maintain the per-membership notification_level field added in 065?
  5. Group DMs (3+ participants) — yes/no for v1?

Test plan

  • Service layer: 14 tests against real Postgres covering visibility filtering, idempotent member add, retention sweep, archive exclusion, MarkRead cursor, top-level vs threaded list, DMName determinism, conflict detection, partial Update with retention clear (server/internal/service/channel/service_test.go)
  • Handlers: 9 tests via httptest — flag-off endpoints 404, Create publishes channel:created, validation rejects empty/uppercase/unknown-visibility, private invisible to non-member agent, archive hides from list, GetOrCreateDM idempotent, message create publishes channel:message and writes inbox row, MarkChannelRead advances cursor, AddMember/RemoveMember cycle (server/internal/handler/channel_test.go)
  • TS: full pnpm test suite green (241 tests across 7 packages); pnpm typecheck green
  • Manual smoke: created public + private + DM channels, posted messages with @member mentions (inbox row landed), toggled channels_enabled from Settings (sidebar entry hides/appears), members panel add/remove, archive flow with confirm dialog
  • Migration up + down both run cleanly against fresh DB and against DB at 064

What's NOT in this PR (deliberately)

  • Phase 2-5 features (retention sweep job, agent triggering, threads, reactions, edits, deletes, files, search)
  • Channel-level retention override UI (Phase 2)
  • Restore-from-archive UI (deferred per spec — admins re-set archived_at = NULL via SQL)
  • Group DM support
  • A "leave channel" action for non-creators (deferred — currently only owners can leave via the remove-member affordance)

Risk + rollback

  • channels_enabled defaults FALSE, so merging this PR has zero user-visible effect until an admin opts in.
  • Migration 065_channels.down.sql drops the three tables and the two new workspace columns. Tested locally.
  • The service layer is unreferenced by any pre-existing code path; adding it can't regress existing flows.

🤖 Generated with Claude Code

prellr and others added 9 commits April 29, 2026 22:36
Lays the foundation for the Channels feature (multi-participant chat +
DMs, distinct from the existing 1:1 agent Chat). Schema and service are
sidecar-portable per the spec: handlers (next commit) own HTTP, auth,
WebSocket publishing, and task enqueuement; this package depends only
on db.

- migration 065 adds channel, channel_membership, channel_message, plus
  workspace.channels_enabled (FALSE by default) and channel_retention_days
- sqlc queries cover channel CRUD, polymorphic membership, cursor-paginated
  message listing, and the Phase 2 retention sweep
- service/channel/{service,message_service} expose typed errors
  (ErrNotFound/Forbidden/Conflict/Invalid/ChannelClosed), validate kind/
  visibility/name, enforce DMs-must-be-private, and provide an idempotent
  GetOrCreateDM with deterministic DMName(workspace, participants)
- 14 service tests against a real Postgres covering CRUD, visibility
  filtering (private hidden from non-members), archive exclusion, partial
  Update including retention clear, idempotent member add, MarkRead
  cursor, top-level vs threaded list, retention sweep, and DMName
  determinism
- WS event constants for channel:created/updated/archived/message/
  member_added/member_removed/read

Note: regenerating sqlc bumped the generator version comment from
v1.30.0 to v1.31.1 across all .sql.go files; the substantive changes
are channel.sql.go, channel_message.sql.go, and workspace.sql.go (the
two new columns).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the channels foundation (commit d5c5437) into the HTTP surface.
All endpoints early-return 404 when workspace.channels_enabled is FALSE
so the feature is invisible to anyone in a workspace that hasn't opted
in.

Endpoints (all under workspace-scoped middleware group):

  POST   /api/channels                Create channel
  GET    /api/channels                List visible to caller
  GET    /api/channels/{channelId}    Get
  PATCH  /api/channels/{channelId}    Partial update
  DELETE /api/channels/{channelId}    Soft archive
  POST   /api/channels/{channelId}/read              Mark read for caller
  GET    /api/channels/{channelId}/members           List members
  POST   /api/channels/{channelId}/members           Add member
  DELETE /api/channels/{channelId}/members/{type}/{id}   Remove member
  GET    /api/channels/{channelId}/messages          Cursor-paginated list
  POST   /api/channels/{channelId}/messages          Send (publishes WS,
                                                     fans @member mentions
                                                     out to inbox)
  POST   /api/dms                     Idempotent get-or-create DM

Spec deviation, deliberate: the spec proposed
`PATCH /api/channels/{id}/members/{type}/{memberId}/read` with the
read-state actor positioned in the URL. We use
`POST /api/channels/{id}/read` against the calling actor instead — admins
marking someone-else's read isn't a Phase 1 use case, and the shorter
URL avoids the awkward path shape.

Spec deviation, forced by schema: `inbox_item` doesn't have
source_type/source_id (the spec assumed it did). Channel mention inbox
entries use `type='channel_mention'`, leave issue_id NULL, and stash
{channel_id, message_id, channel_name} in the existing `details` JSONB
column. No new migration required.

Phase 1 mention semantics:
- @member mentions render as styled mentions and write inbox entries
- @agent mentions render but do NOT enqueue tasks (Phase 3 owns that)
- @ALL mentions render but produce no fan-out yet — pending design
- Self-mentions don't write inbox

Service-handler split honored: handlers own all platform integrations
(WS publish via h.publish, inbox writes via h.Queries.CreateInboxItem,
JSON serialization). The service layer at server/internal/service/channel
remains free of *realtime.Hub and *events.Bus, so it remains liftable
into a sidecar binary.

9 handler tests against real Postgres + real bus + real inbox table:
  - flag-off endpoints 404
  - Create happy path publishes EventChannelCreated
  - Create rejects empty/uppercase/unknown-visibility
  - Private channel invisible to non-member agent in list AND on direct GET
  - Archive hides from list
  - GetOrCreateDM idempotent across two calls
  - CreateChannelMessage publishes EventChannelMessage and writes inbox
    for @member mentions (poll for goroutine completion)
  - MarkChannelRead advances last_read_message_id and last_read_at
  - AddChannelMember + remove cycle: agent gains and loses access on the
    same private channel through the GET endpoint

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frontend foundation for the Channels feature. Mirrors the chat module's
shape (queries.ts / mutations.ts / store.ts / index.ts singleton).

- types/channel.ts: TS counterparts to db.Channel / db.ChannelMembership /
  db.ChannelMessage plus request shapes for the handler endpoints
- types/workspace.ts: adds channels_enabled and channel_retention_days
  to the Workspace type. Test fixtures in apps/web and packages/views
  updated to construct full Workspace objects.
- types/events.ts: WSEventType union extends with the seven channel:*
  events
- api/client.ts: thin wrappers over the eleven channel REST endpoints,
  matching chat method conventions
- channels/queries.ts: queryOptions for list/detail/members/messages
  with `enabled` gating so hooks no-op when the workspace flag is off
- channels/mutations.ts: create/update/archive, member add+remove,
  send (with optimistic insert + rollback), mark-read, GetOrCreateDM
- channels/store.ts: Zustand store for per-channel input drafts.
  Persists per workspace via storage adapter; rehydrates on workspace
  switch. Active channel intentionally lives in the URL, not the store.
- channels/index.ts: registerChannelsStore + useChannelsStore proxy
  singleton, mirroring the chat package
- platform/core-provider.tsx: constructs and registers the channels
  store at boot
- realtime/use-realtime-sync.ts: adds the `channel:` prefix to the
  refreshMap (workspace-scoped invalidation) plus an explicit
  `channel:message` handler for per-channel message-cache invalidation
- paths/paths.ts: paths.workspace(slug).channels() and .channelDetail(id)

Workspace type fixtures in three test files updated:
  - packages/core/paths/resolve.test.ts
  - packages/views/workspace/paths-hooks.test.tsx
  - apps/web/test/helpers.tsx

`pnpm typecheck` is green across all packages and apps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 frontend components for the Channels surface. The package is
imported as @multica/views/channels by both apps; no app-specific
imports leak in.

Layout: split-view where the left pane is a channel list (categorized
into Channels / Private / DMs) and the right pane shows the active
channel — header, scrollable message list, composer at the bottom.
The same component renders both /channels (no active channel,
right-pane empty state) and /channels/[id] (channel selected).

Components:
- channels-page.tsx: composes the split view; gates on
  workspace.channels_enabled with a polite empty state when off (the
  backend would 404 anyway; this just reads better than a broken UI)
- channel-list.tsx: left pane, three sections (public / private / DMs),
  uses AppLink + paths.workspace(slug).channelDetail(id) for navigation
- channel-header.tsx: name + description + members count, leading icon
  is `#` / lock / chat-bubble depending on kind+visibility
- channel-message-list.tsx: simple .map() of the latest 50 messages
  (server returns DESC; we reverse on render). Auto-scrolls to the
  bottom on new messages by tracking previous count.
  Phase 5+ will swap in TanStack Virtual if we hit pagination ceilings.
- message-row.tsx: avatar + author label + time + markdown body via
  packages/views/editor's ReadonlyContent. Soft-deleted rows render a
  "[message deleted]" placeholder so thread continuity isn't broken.
- channel-composer.tsx: bottom input. Reuses ContentEditor (mentions,
  markdown, file-drop, submit-on-Enter). Drafts persist per-channel
  via the channels store, so switching channels preserves what you
  were typing.
- channel-create-dialog.tsx: modal with name / display-name /
  description / public-or-private radio. Sanitizes the name
  client-side (lowercase, dashes for spaces) so well-meaning input
  like "General Chat" doesn't bounce off the server's validation. On
  success, navigates to the new channel via the navigation adapter.

Also exposes ./channels and the deep ./channels/queries+mutations
subpaths from packages/core's package.json so views can import them
directly.

`pnpm typecheck` is green across all packages and apps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- apps/web/app/[workspaceSlug]/(dashboard)/channels/page.tsx and
  channels/[id]/page.tsx — Next.js App Router routes that render
  ChannelsPage with the (optional) active channel id from the URL
- packages/views/layout/app-sidebar.tsx — adds the "Channels" entry to
  workspaceNav (between Autopilot and Agents) with a flag-gated render
  filter so it appears only when workspace.channels_enabled is TRUE.
  The filter is render-time so the static array stays a complete list
  for type-checking (FLAG_GATED map → easy place to add future gates)
- packages/views/editor/utils/link-handler.ts — adds "channels" to
  WORKSPACE_ROUTE_SEGMENTS so #channel and /channels/<id> markdown
  links rewrite correctly inside the editor
- packages/core/paths/consistency.test.ts — extends the parameterless-
  routes invariant test to include "channels" (the dual segment list
  must match paths.workspace's keys; this test catches drift)
- packages/views/package.json — exposes ./channels so apps/web can
  import @multica/views/channels
- apps/docs/content/docs/channels.mdx — full English doc covering kind
  semantics, creation flow, mention behaviour (with the v1 caveat that
  agent mentions render-only, no task triggering — that's Phase 3),
  retention, archiving, permission table, and API reference
- apps/docs/content/docs/channels.zh.mdx — Chinese stub with explicit
  TRANSLATION TODO marker, English content kept readable so navigation
  works on the zh locale until a translator picks it up
- apps/docs/content/docs/meta.json + meta.zh.json — both register
  "channels" under the "Collaborating with agents" group, between
  "chat" and "autopilots"

Phase 1 verification:
- pnpm typecheck: 6/6 packages green
- pnpm test: 134 TS tests pass (web/views/core)
- make test: every Go package green, including the new
  service/channel (14 tests) and handler channel tests (9 tests)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes that surfaced during the first end-to-end test:

1) The Workspace API never sent channels_enabled to the frontend, so
   the sidebar gate was always falsy and the Channels nav entry never
   appeared even when the flag was on. WorkspaceResponse now includes
   channels_enabled (bool) and channel_retention_days (*int32). The
   client TS type already had these fields, so no type changes — just
   the missing Go-side serialization.

2) Phase 1 had no UI path to create a real DM (the /api/dms endpoint
   existed, but only the channel-create dialog was reachable, which
   produces kind='channel' rows). This commit adds:

   - new-dm-dialog.tsx: a simple member/agent picker. Filters by name
     or email, excludes self and archived agents, calls
     useCreateOrFetchDM (idempotent on the participant set), navigates
     to the resulting channel on success
   - channel-list.tsx: refactored sections so each one (Channels,
     Private, Direct messages) has its own `+` affordance in the
     header, with the DM section's `+` opening NewDMDialog. DM rows
     resolve the "other participant" via useQueries against
     channelMembersOptions; the row label and avatar are pulled from
     the cached workspace members + agents lists. Self-DMs render as
     "Notes to self" — schema-supported but not surfaced in the
     picker.
   - channel-header.tsx: DMs now show the other participant's name +
     avatar in the header instead of the deterministic dm-<hash> name.
     Public/private channels still get the `#` / lock icon and the
     channel.display_name + description.
   - channels-page.tsx + index.ts: wired the new dialog state and
     export.

useQueries for batch member fetching is fine at the small N we expect
(a typical workspace has tens of DMs at most). If a workspace ever
sees hundreds, the right move is to denormalize participant info into
the channel response rather than fan out N more requests; flagged as
a follow-up rather than premature optimization here.

`pnpm typecheck` is green; UI smoke-tested in the dev stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the four biggest UX gaps from the first end-to-end test, all
necessary before this is reviewable upstream:

1) Admin can toggle channels_enabled from Settings — previously the
   only path was a manual SQL UPDATE, which is a non-starter for
   self-hosted users. workspace UpdateWorkspace SQL now distinguishes
   "leave alone" from "set" via paired *_set bool flags (same pattern
   the per-channel retention update already uses), so a name-only
   PATCH can't silently disable channels. UpdateWorkspaceRequest +
   the API client gain matching channels_enabled/_set and
   channel_retention_days/_set fields. WorkspaceTab grows a "Channels"
   card with a Switch that's optimistic + reconciled and gated to
   admins/owners.

2) Members panel — clicking the member-count button in the channel
   header opens a slide-over Sheet listing current members (resolved
   against cached workspace member + agent lists), with a search-
   filtered "Add to channel" picker at the bottom. For channel
   admins, each non-self / non-admin row gets a remove button. For
   DMs the entire add/remove section is hidden — DM membership is
   determined by the participant set at creation, and adding a third
   would silently turn it into a private channel.

3) Archive UI — admins see a "…" dropdown next to the members button
   with an Archive action. Archive opens a confirm dialog, calls
   useArchiveChannel, navigates back to /channels on success, and
   shows a toast. Wired to the existing DELETE endpoint; soft-archive
   only — no hard delete UI per the spec.

4) Mark-read on view — replaces the empty stub useEffect with a real
   implementation. Whenever the newest message id changes (mount,
   new arrival, channel switch), POST /channels/:id/read with that
   id. Tracks the last-sent id in a ref so re-renders with the same
   data are no-ops. Optimistic messages (id starts with "optimistic-")
   are skipped — only canonical server ids persist.

`isAdmin` resolves from the channel_membership row (role='admin').
Channel creators are auto-promoted to admin on Create per
ChannelService.Create, so this matches the spec's permission table
without a separate "is creator" branch.

`pnpm typecheck` and `pnpm test` are green. Go tests including
service/channel and handler suites all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small adjustments needed to clean up after the upstream merge:

1. ContentEditor `editable` prop removed in upstream commit 2817793
   (chat editor cleanup multica-ai#1919) — Tiptap mounts editable-state at
   construction so the prop was always silently broken. Migration
   guidance: wrap the editor in `pointer-events-none` + aria-disabled
   for "currently disabled". Applied in channel-composer.tsx so the
   composer reads as disabled in markup without monkey-patching the
   editor.

2. sqlc regen filled in `ForceFreshSession` scan target on
   ListQueuedClaimCandidatesByRuntime in agent.sql.go — upstream's
   migration 066 added the column but the generated file in
   commit b134568 was stale (regenerated against the merged schema
   here).

3. project_resource.sql.go version comment bumped from 1.30.0 to
   1.31.1 to match the rest of the generated tree.

Migrations applied cleanly:
  065_backfill_onboarded_at, 065_channels (already), 065_project_resources,
  066_force_fresh_session, 067_task_queue_claim_candidate_index.

`pnpm typecheck` and `pnpm test` both green across all 7 packages
(241 tests). Go test suite green except for two pre-existing
flaky upstream tests in cmd/server that share the integration-test
agent and fail when run as a group but pass in isolation:
  - TestRerunIssueSetsForceFreshSession
  - TestEnqueueTaskForIssueDoesNotForceFreshSession
The fixture (setupRerunTestFixture) doesn't insulate from siblings
that archive the agent. Unrelated to channels — flagged for an
upstream follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 30, 2026

@prellr is attempting to deploy a commit to the IndexLabs Team on Vercel.

A member of the Team first needs to authorize it.

@prellr
Copy link
Copy Markdown
Author

prellr commented Apr 30, 2026

FYI: phases 2 (retention sweep + UI) and 3 (agent triggering, server + daemon) are landed on stacked branches at prellr:channels-phase-2, channels-phase-3a, and channels-phase-3b. Happy to open them as Draft PRs at whatever cadence works for you — wanted to keep this PR clean in your queue rather than fan things out preemptively.

@prellr prellr marked this pull request as ready for review April 30, 2026 15:48
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