feat(groups): 群聊前端#761
Conversation
Builds the /groups UI against the group API on
feature/unified-chat-single-agent-runtime-group-chat: group and session rails,
the message stream, @-mention autocomplete, members, announcement, group
workspace and per-agent group memory.
Mentions are sent as a structured `mentions` array, never parsed out of the
text, so only a name picked from the dropdown can wake an agent — matching the
backend contract. The group workspace reuses the existing FileBrowser through an
adapter that reads a file's version token before writing, so a concurrent edit is
rejected rather than silently overwritten.
Transport is isolated in useGroupRealtime: WebSocket push, cursor backfill on
reconnect, polling as a stopgap. Two of those cannot land yet — there is no
`WS /ws/group/{id}` and no `after` cursor on the message list — so the hook
settles into polling and backfills by paging backward. When the backend ships
them, only that file changes.
docs/group-chat/frontend-realtime-contract.md records the four backend gaps this
found, each reproduced against a live backend: invites reject (type, ref_id) and
nothing exposes participant_id (blocking), the group workspace cannot hold
uploaded files, no group WebSocket, and no `after` cursor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 936b2e86ac
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const current = await groupApi.workspaceFile(groupId, path); | ||
| expected = current.exists ? current.version_token : null; |
There was a problem hiding this comment.
Use the loaded workspace token when saving
When two members edit the same workspace file, this reads the latest token immediately before saving, so a stale editor will overwrite any changes that landed after it loaded the file; the backend only rejects writes made after this fresh read, not edits made during the user's editing session. Store the version_token returned by read() and pass that original token into saveWorkspaceFile so intervening updates are rejected instead of silently clobbered.
Useful? React with 👍 / 👎.
| const intake = await groupApi.sendMessage(groupId, sessionId, { | ||
| content, | ||
| mentions: mentionParticipantIds.map((participant_id) => ({ participant_id })), | ||
| message_id: crypto.randomUUID(), | ||
| }); |
There was a problem hiding this comment.
Reuse the same message_id when retrying sends
If the POST reaches the backend but the client sees a network error, the composer keeps the draft so the user can send it again, but this line generates a new UUID for the retry. The backend idempotency check only deduplicates identical message_ids, so this failure mode creates duplicate group messages; generate and keep the id with the pending draft until it succeeds.
Useful? React with 👍 / 👎.
| // A mention the user deleted from the text should not wake its agent. | ||
| const live = picked.filter((member) => content.includes(`@${member.display_name}`)); |
There was a problem hiding this comment.
Track selected mention tokens instead of matching by name
Filtering live mentions with content.includes('@' + display_name) can send stale or unintended participants: for example, if @Ann was picked and later only @Ann Lee remains, @Ann Lee still contains @Ann, and duplicate display names have the same problem. In those cases the backend receives extra structured mention tokens and may wake the wrong agent, so this should track the inserted token/range or a less ambiguous marker rather than re-matching by display text.
Useful? React with 👍 / 👎.
| const startPolling = () => { | ||
| if (disposed || pollTimer) return; | ||
| setStatus('polling'); | ||
| void catchUp(); | ||
| pollTimer = setInterval(() => void catchUp(), POLL_INTERVAL_MS); |
There was a problem hiding this comment.
Refresh session summaries during polling fallback
With the current backend lacking /ws/group/{id}, the fallback polling path only calls catchUp(), which fetches messages for the active session. Messages arriving in other sessions never invoke onGroupActivity, so their unread badges and ordering stay stale until some unrelated refetch; the polling tick should also refresh the group session list or otherwise signal group activity.
Useful? React with 👍 / 👎.
…ndaries Group chat now uses REST as the authoritative write/history path, group-scoped WebSocket hints, and cursor backfill for recovery. The integration also aligns member identity contracts, workspace binary transport, transaction-bound storage compensation, and runtime tool transactions across the PR #761 frontend and unified runtime backend. Constraint: WebSocket and Redis delivery are best-effort; REST-confirmed cursor and current membership remain authoritative Constraint: Workspace uploads are capped at 50 MiB and group/path mutations require a Redis lock through commit or rollback Rejected: Advancing recovery from WebSocket observations | out-of-order or dropped hints can skip messages Rejected: Publishing before database commit | clients could observe rolled-back messages Rejected: Permanent polling | it hides realtime failures and adds avoidable latency Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve callback-aware transaction boundaries, membership lock ordering, current-member delivery checks, and version-conditioned upload compensation Tested: 135 focused backend tests, 122 expanded group/runtime tests, Ruff changed-file checks, compileall, alembic head/import checks, git diff --check, frontend TypeScript and Vite production build Not-tested: Browser E2E and live 3010 acceptance are pending deployment; hard process crash between storage write and compensation remains a durable-outbox risk
…ndaries Group chat now uses REST as the authoritative write/history path, group-scoped WebSocket hints, and cursor backfill for recovery. The integration also aligns member identity contracts, workspace binary transport, transaction-bound storage compensation, and runtime tool transactions across the PR #761 frontend and unified runtime backend. Constraint: WebSocket and Redis delivery are best-effort; REST-confirmed cursor and current membership remain authoritative Constraint: Workspace uploads are capped at 50 MiB and group/path mutations require a Redis lock through commit or rollback Rejected: Advancing recovery from WebSocket observations | out-of-order or dropped hints can skip messages Rejected: Publishing before database commit | clients could observe rolled-back messages Rejected: Permanent polling | it hides realtime failures and adds avoidable latency Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve callback-aware transaction boundaries, membership lock ordering, current-member delivery checks, and version-conditioned upload compensation Tested: 135 focused backend tests, 122 expanded group/runtime tests, Ruff changed-file checks, compileall, alembic head/import checks, git diff --check, frontend TypeScript and Vite production build Not-tested: Browser E2E and live 3010 acceptance are pending deployment; hard process crash between storage write and compensation remains a durable-outbox risk
…ndaries Group chat now uses REST as the authoritative write/history path, group-scoped WebSocket hints, and cursor backfill for recovery. The integration also aligns member identity contracts, workspace binary transport, transaction-bound storage compensation, and runtime tool transactions across the PR #761 frontend and unified runtime backend. Constraint: WebSocket and Redis delivery are best-effort; REST-confirmed cursor and current membership remain authoritative Constraint: Workspace uploads are capped at 50 MiB and group/path mutations require a Redis lock through commit or rollback Rejected: Advancing recovery from WebSocket observations | out-of-order or dropped hints can skip messages Rejected: Publishing before database commit | clients could observe rolled-back messages Rejected: Permanent polling | it hides realtime failures and adds avoidable latency Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve callback-aware transaction boundaries, membership lock ordering, current-member delivery checks, and version-conditioned upload compensation Tested: 135 focused backend tests, 122 expanded group/runtime tests, Ruff changed-file checks, compileall, alembic head/import checks, git diff --check, frontend TypeScript and Vite production build Not-tested: Browser E2E and live 3010 acceptance are pending deployment; hard process crash between storage write and compensation remains a durable-outbox risk
Follow-up UI work on the group chat frontend: - Nav: merge the group and session rails into one tree — groups are expandable nodes with their sessions nested; the group row shows name + unread roll-up + last-activity time (aggregated client-side since the list endpoint carries neither); active group expanded by default; empty groups show a "new session" guidance row. - Header: breadcrumb — group name (small, clickable, ⋯ menu) over the session title. Side panel gains a group-name + gear top bar so it reads as group-scoped. - Group settings modal (entries: tree ⋯, panel gear, breadcrumb ⋯): rename group, edit description, member management, dissolve. Panel is now view-and-invite only. Capabilities are shown by role, never surfaced-then-denied (rename/description/announcement/invite/create session for all human members; remove-member/dissolve/delete-session for managers) — matches the backend manager_only flags. No ownership transfer. - Delete group + change the session delete affordance to a trash icon. - Group memory tab reuses the FileBrowser work-list, matching the single-agent Mind tab. - Composer: adopt the single-agent .chat-composer shell and fix the IME bug where Enter mid-composition sent raw pinyin (guard on isComposing / keyCode 229). - Fix: cancelling the remove-member confirm no longer closes the settings modal (backdrop only closes on a direct click). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3c5f498bbd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const commit = async () => { | ||
| setBusy(true); | ||
| try { | ||
| await save(draft, data?.version_token ?? null); |
There was a problem hiding this comment.
Save with the token from the loaded draft
When a member starts editing an announcement or memory file and this query refetches in the background after another member saves, data?.version_token is updated even though the dirty draft is intentionally preserved. Saving then sends the newer token with the stale draft, so the backend accepts the write and clobbers the intervening edit; keep the version token captured with the draft that is being edited instead of reading it from live query data at commit time.
Useful? React with 👍 / 👎.
| onClick={() => { | ||
| if (!isActiveGroup) navigate(`/groups/${group.id}`); | ||
| setShowSettings(true); |
There was a problem hiding this comment.
Open settings for the selected group only
When viewing group A and clicking the settings button on group B in the tree, this opens the modal before navigation has switched activeGroup; the modal can mount with A, and after the route changes to B its useState(group.name)/description values are not reset. Pressing Save then PATCHes group B with group A's stale name/description, so either key/reset the modal on group.id or store the clicked group as the settings target.
Useful? React with 👍 / 👎.
| useEffect(() => { | ||
| if (!agentRefId && agents.length > 0) setAgentRefId(agents[0].participant_ref_id); | ||
| }, [agents, agentRefId]); |
There was a problem hiding this comment.
Reset the memory agent when the group changes
With the side panel left on the Memory tab, switching from one group to another keeps the previous group's agentRefId because this effect only initializes when it is empty. If the new group has different agents, the FileBrowser calls /groups/{newGroupId}/agents/{oldAgentId}/memory, producing a 404 or showing no memory until the user manually picks an agent; reset the selection whenever the current id is not in the new agents list.
Useful? React with 👍 / 👎.
- Sidebar Groups nav entry shows a total-unread badge (useGroupUnread sums every group's session unread, sharing the groups page's query cache). Per-group and per-session unread counts already render in the tree. - Rename a session inline via a pencil on the row: the title becomes an editable field in place (any member can rename; only managers delete). Creating a group or session stays a modal. - Unread badges use text-primary/bg-primary like the app's existing unread badge, not accent-primary/accent-text (same-hue, renders the number invisible). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add a draggable left-edge handle to the member panel; width is clamped 240-520px and persisted to localStorage across sessions. - Replace the breadcrumb ⋯ menu and connection-status text with a header subtitle showing the group's agent/member counts. - Swap the panel toggle to sidebar collapse/expand icons with a state-aware tooltip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
基于
feature/unified-chat-single-agent-runtime-group-chat(c5db89a2)开发,只动前端,未改任何后端代码。新增
/groups页面(侧边栏「群聊」入口),未改动AgentDetailPage。内容
types/group.tsapp/api/groups.py的 schema 定义的类型services/groupApi.ts/api/groups全部接口的客户端hooks/useGroupRealtime.tspages/groups/i18n/{zh,en}.json实现要点:
content是纯文本,被 @ 的对象放在mentions数组单独传。只有从下拉框选中的才进数组——手打的@名字是纯文本,不会唤醒 Agent。与后端「只以结构化 mention token 为准」一致。message_id,重发不产生重复消息。FileBrowser(通过FileBrowserApi适配器接入,未重写)。写入前先读一次拿version_token再提交——后端在expected_version_token为 null 时是无条件覆盖,不带 token 会让并发编辑互相冲掉。after游标就绪后,只改useGroupRealtime.ts一个文件,页面代码不动。需要后端补的四个缺口
详见本 PR 带的
docs/group-chat/frontend-realtime-contract.md。每一条都在真实后端上实测复现过(本地起了该分支的 postgres + redis + backend),不是推测:participant_id(participant_type, ref_id)→ 422。而没有任何接口暴露participant_id,Agent 的 participant 行还是懒创建的FileBrowser,上传能力只能先关掉WS /ws/group/{id}→ 403。前端已降级为轮询after游标after=→ 被忽略,返回最新一页。前端改用从头部反向翻页补拉1 是硬阻塞:不解决的话,群里除建群人外加不进任何人和 Agent,@ 唤醒和群 memory 都无法验证,群聊端到端跑不通。
建议改法(改动面最小,前端已按此实现):让
POST /groups/{id}/members额外接受(participant_type, ref_id),服务端内部调get_or_create_participant解析(懒创建正好在这里发生)。这样前端用现成的GET /agents/和GET /org/users就能构建候选列表,后端不需要新增查询接口。顺带发现(与群聊无关,但影响整个分支)
GET /api/notifications/unread-count→ 500,relation "notifications" does not exist。分支上缺 notifications 表的迁移,全站通知栏都会报错。一个 cursor 陷阱
cursor 形如
2026-07-14T06:16:04.577660+00:00|<uuid>,是微秒精度。JS 的Date只到毫秒,直接Date.parse比较会让同一毫秒内的两条消息判为相等、错序,进而在补拉时漏掉边界消息。本 PR 的compareCursor已处理(毫秒相等时回退到原始时间戳字符串比较)。如果后续要动 cursor 格式,请留意这一点。验证
git am到该分支基线:无冲突tsc --noEmit:通过npm run build:通过(GroupsPage为独立 lazy chunk)before翻页 → @ 补全 → 群公告读写落盘 → 群 workspace 目录与文件读写,全部通过🤖 Generated with Claude Code