diff --git a/apps/desktop/src/app/chat/group-member-strip.tsx b/apps/desktop/src/app/chat/group-member-strip.tsx
new file mode 100644
index 0000000000000..97c506b7493c5
--- /dev/null
+++ b/apps/desktop/src/app/chat/group-member-strip.tsx
@@ -0,0 +1,63 @@
+/**
+ * Member strip: the room's roster, rendered as ProfileGlyphs beside the
+ * session's own ProfileTag once a chat has become a group. Invisible until
+ * then — a plain chat pays one store read and renders nothing.
+ *
+ * Each glyph carries the per-member actions on its context menu (mute /
+ * remove), mirroring ProfileSquare in the rail — the square/glyph IS the home
+ * for per-profile actions everywhere in the app.
+ */
+
+import { useStore } from '@nanostores/react'
+import type { FC } from 'react'
+
+import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
+import { ProfileGlyph } from '@/components/ui/profile-glyph'
+import { Tip } from '@/components/ui/tooltip'
+import { resolveProfileColor } from '@/lib/profile-color'
+import { cn } from '@/lib/utils'
+import { $groupChats, removeGroupMember, setMemberMuted } from '@/store/group-chat'
+import { $profileColors } from '@/store/profile'
+
+export const GroupMemberStrip: FC<{ sessionId: null | string }> = ({ sessionId }) => {
+ const groups = useStore($groupChats)
+ const colors = useStore($profileColors)
+
+ const groupId = sessionId ? `session:${sessionId}` : null
+ const group = groupId ? groups[groupId] : null
+ const members = group ? group.members.filter(member => !member.host) : []
+
+ if (!groupId || members.length === 0) {
+ return null
+ }
+
+ return (
+
+ {members.map(member => (
+
+
+
+
+
+
+
+
+
+ setMemberMuted(groupId, member.profile, !member.muted)}>
+ {member.muted ? 'Unmute' : 'Mute'}
+
+ removeGroupMember(groupId, member.profile)}>
+ Remove from chat
+
+
+
+ ))}
+
+ )
+}
diff --git a/apps/desktop/src/app/chat/group-typing-line.tsx b/apps/desktop/src/app/chat/group-typing-line.tsx
new file mode 100644
index 0000000000000..07f1ead8f53d0
--- /dev/null
+++ b/apps/desktop/src/app/chat/group-typing-line.tsx
@@ -0,0 +1,47 @@
+/**
+ * Working-members line for a normal chat that has become a room: "builder is
+ * typing…" dots above the composer, tinted per profile. Renders nothing for
+ * plain (non-group) sessions — the overwhelmingly common case costs one
+ * store read.
+ */
+
+import { useStore } from '@nanostores/react'
+import type { FC } from 'react'
+
+import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
+import { $groupTyping } from '@/store/group-chat'
+import { $profileColors } from '@/store/profile'
+
+export const GroupTypingLine: FC<{ sessionId: null | string }> = ({ sessionId }) => {
+ const typing = useStore($groupTyping)
+ const overrides = useStore($profileColors)
+
+ const working = sessionId ? (typing[`session:${sessionId}`] ?? []) : []
+
+ if (working.length === 0) {
+ return null
+ }
+
+ return (
+
+ {working.map(profile => {
+ const color = resolveProfileColor(profile, overrides) ?? 'var(--ui-text-secondary)'
+
+ return (
+
+ {profile}
+
+
+
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 1c2636b4f66e8..be9d444a586a1 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -56,6 +56,8 @@ import { requestComposerInsert } from './composer/focus'
import { droppedFileInlineRefs } from './composer/inline-refs'
import { useComposerScope } from './composer/scope'
import type { ChatBarState } from './composer/types'
+import { GroupMemberStrip } from './group-member-strip'
+import { GroupTypingLine } from './group-typing-line'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
import { ProfileTag } from './profile-tag'
@@ -149,6 +151,7 @@ function ChatHeader({
}}
>
{showProfileTag && }
+
}>
+
profile.name)
+
const [pickerOpen, setPickerOpen] = useState(false)
const { enabled: reactionsEnabled, react, reactions: shownReactions } = useMessageReactions(messageId, 'user')
@@ -229,6 +243,37 @@ export const UserMessage: FC<{
)
}
+ // Group-chat fan-in: `[name]: …` from a KNOWN profile is that member
+ // speaking, not the user — left-aligned, name-labeled, rail-tinted. Wire
+ // role stays `user` (that's the cache-safe transport), only the costume
+ // changes. Checked after hooks for the same reason as the branch above.
+ const memberMatch = MEMBER_MESSAGE_RE.exec(messageText.trim())
+ const memberName = memberMatch ? memberMatch[1]!.toLowerCase() : null
+ const isMemberMessage =
+ memberName !== null && memberProfiles.some(profile => profile.toLowerCase() === memberName)
+
+ if (isMemberMessage && memberMatch) {
+ const color = resolveProfileColor(memberMatch[1]!, colorOverrides) ?? 'var(--ui-text-secondary)'
+
+ return (
+
+
+ {memberMatch[1]}
+
+
+
+
+
+ )
+ }
+
const hasBody = messageText.trim().length > 0
const isLatestUser = messageId === latestUserId
const showStop = !readOnly && isLatestUser && threadRunning && Boolean(onCancel)