feat(channels): Phase 1 — multi-participant chat alongside the issue board (RFC #1920)#1954
Open
prellr wants to merge 9 commits intomultica-ai:mainfrom
Open
feat(channels): Phase 1 — multi-participant chat alongside the issue board (RFC #1920)#1954prellr wants to merge 9 commits intomultica-ai:mainfrom
prellr wants to merge 9 commits intomultica-ai:mainfrom
Conversation
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>
|
@prellr is attempting to deploy a commit to the IndexLabs Team on Vercel. A member of the Team first needs to authorize it. |
Author
|
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_enableddefaultsFALSE); 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:
server/migrations/065_channels.{up,down}.sqlchannel,channel_membership,channel_message+workspace.channels_enabled(default FALSE) +workspace.channel_retention_days+ partial indexes + generatedtsvectorserver/pkg/db/queries/channel.sql,channel_message.sql,workspace.sqlchannels_enabled/ retention with paired*_setflagsserver/internal/service/channel/{types,service,message_service}.go*db.Queries+TxStarter. Typed errors, idempotentGetOrCreateDMwith deterministicDMName(workspace, participants),CanActorAccessdistinguishing not-found / archived / not-a-memberserver/internal/handler/channel.go,channel_message.gotype='channel_mention', refs indetailsJSONB)server/cmd/server/router.goRequireWorkspaceMembergroup, mirroring/api/commentsstyleserver/pkg/protocol/events.gochannel:*constantspackages/core/{types,api,channels,realtime,paths,platform}/packages/views/channels/components/*.tsxChannelsPage,ChannelList,ChannelHeader,ChannelMessageList,MessageRow,ChannelComposer,ChannelCreateDialog,NewDMDialog,MembersPanelapps/web/app/[workspaceSlug]/(dashboard)/channels/{,[id]}/page.tsx<ChannelsPage>packages/views/layout/app-sidebar.tsxChannelsentry with render-time flag filter (FLAG_GATEDmap)packages/views/settings/components/workspace-tab.tsxapps/docs/content/docs/channels.{mdx,zh.mdx}+meta.{,zh}.jsonPhased rollout (this is PR 1 of 5)
Each phase is independently shippable:
@-mention triggers a task; agent posts back via existing endpointsPhase 1 deliberately renders
@agentmentions 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:
kindandvisibilitycolumns (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.channel_membership/channel_messagetouser(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.metadataJSONB onchannelandchannel_messageso later features (attachment lists, formatting hints, integration metadata) live without column additions.tsvectorcolumn for Phase 5 full-text search. Postgres maintains the GIN index automatically.channel.name(dm-<sha256>) computed from sorted participants + workspace id, soGetOrCreateDMis idempotent and the unique constraint on(workspace_id, kind, name)doubles as a concurrent-create guard.reactiontable. Phase 4 will either addchannel_message_reaction(mirroringcomment_reaction/issue_reaction) or refactor those into a polymorphicreaction(target_type, target_id, …). We didn't preempt that decision here.065_backfill_onboarded_atand065_project_resourceslanded upstream. The migration runner versions by full filename so all three coexist; happy to renumber to068_channelsif 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:
scope_authorizer.goparallel 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.MarkReadURL shortened from spec — spec proposedPATCH /api/channels/{id}/members/{type}/{memberId}/read; we usePOST /api/channels/{id}/readagainst the calling actor. Admin-marking-someone-else's-read isn't a Phase 1 use case and the shorter URL avoids the awkward shape.detailsJSONB —inbox_itemdoesn't have a polymorphicsource_type/source_id(the spec assumed it did). Channel mention entries usetype='channel_mention', leaveissue_idNULL, and stash{channel_id, message_id, channel_name}in the existingdetailscolumn. No new migration required.*db.Queries + TxStarter— not*realtime.Hub/*events.BuslikeTaskService. Spec was explicit that services must NOT publish WS for sidecar portability, so handlers publish.util.ParseMentions()— notmention/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:
channels_enabledstay opt-in, or be on by default once merged?notification_preference(migration 064) exists; should Channels reuse it directly, or maintain the per-membershipnotification_levelfield added in 065?Test plan
DMNamedeterminism, conflict detection, partial Update with retention clear (server/internal/service/channel/service_test.go)channel:created, validation rejects empty/uppercase/unknown-visibility, private invisible to non-member agent, archive hides from list, GetOrCreateDM idempotent, message create publisheschannel:messageand writes inbox row, MarkChannelRead advances cursor, AddMember/RemoveMember cycle (server/internal/handler/channel_test.go)pnpm testsuite green (241 tests across 7 packages);pnpm typecheckgreen@membermentions (inbox row landed), toggledchannels_enabledfrom Settings (sidebar entry hides/appears), members panel add/remove, archive flow with confirm dialogWhat's NOT in this PR (deliberately)
archived_at = NULLvia SQL)Risk + rollback
channels_enableddefaultsFALSE, so merging this PR has zero user-visible effect until an admin opts in.065_channels.down.sqldrops the three tables and the two new workspace columns. Tested locally.🤖 Generated with Claude Code