feat(desktop): group-chat presence UI — attributed member bubbles, typing pills, roster strip

[name]: fan-in messages from known profiles render as that member speaking
(left-aligned, name label, rail-tinted bubble) instead of a user bubble;
working members show tinted typing pills above the composer; the chat header
grows a roster strip of ProfileGlyphs with mute/remove on the context menu.
This commit is contained in:
Brooklyn Nicholson 2026-08-13 13:18:38 -05:00
parent 7583a41d9b
commit 6026247ad7
4 changed files with 159 additions and 0 deletions

View File

@ -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 (
<span className="inline-flex items-center gap-1" data-slot="group-member-strip">
{members.map(member => (
<ContextMenu key={member.profile}>
<ContextMenuTrigger asChild>
<span className={cn('inline-flex', member.muted && 'opacity-40')}>
<Tip label={member.muted ? `${member.profile} (muted)` : member.profile}>
<ProfileGlyph
aria-label={member.profile}
color={resolveProfileColor(member.profile, colors)}
isDefault={false}
name={member.profile}
role="img"
/>
</Tip>
</span>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => setMemberMuted(groupId, member.profile, !member.muted)}>
{member.muted ? 'Unmute' : 'Mute'}
</ContextMenuItem>
<ContextMenuItem onSelect={() => removeGroupMember(groupId, member.profile)}>
Remove from chat
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</span>
)
}

View File

@ -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 (
<div className="flex items-center gap-3 px-4 pb-1" data-slot="group-typing-line">
{working.map(profile => {
const color = resolveProfileColor(profile, overrides) ?? 'var(--ui-text-secondary)'
return (
<span
className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium"
key={profile}
style={{ backgroundColor: profileColorSoft(color, 12), color }}
>
{profile}
<span className="inline-flex gap-0.5">
<span className="size-1 animate-bounce rounded-full bg-current [animation-delay:0ms]" />
<span className="size-1 animate-bounce rounded-full bg-current [animation-delay:120ms]" />
<span className="size-1 animate-bounce rounded-full bg-current [animation-delay:240ms]" />
</span>
</span>
)
})}
</div>
)
}

View File

@ -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 && <ProfileTag className="pointer-events-auto mr-1.5" profile={activeStoredSession?.profile} />}
<GroupMemberStrip sessionId={selectedSessionId || activeSessionId} />
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
@ -605,6 +608,7 @@ export const ChatView = memo(function ChatView({
states stay mounted here, so dockfloat never remounts the editor. */}
{showChatBar && (
<Suspense fallback={<ChatBarFallback />}>
<GroupTypingLine sessionId={activeSessionId} />
<ChatBar
busy={busy}
cwd={currentCwd}

View File

@ -1,4 +1,5 @@
import { ActionBarPrimitive, BranchPickerPrimitive, MessagePrimitive, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useCallback, useRef, useState } from 'react'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
@ -12,10 +13,17 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { StopFilled } from '@/lib/icons'
import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { $profileColors, $profiles } from '@/store/profile'
import { notifyThreadEditOpen } from '@/store/thread-scroll'
import { isWatchWindow } from '@/store/windows'
/** Matches a group-chat fan-in message: `[name]: body`. The name must be a
* KNOWN profile before we re-attribute the bubble a human typing a literal
* `[foo]: bar` about some log format keeps their own bubble. */
const MEMBER_MESSAGE_RE = /^\[([\w.-]{1,64})\]:\s([\s\S]*)$/
/** True when the user has a live text highlight (drag-select / triple-click). */
export function hasTextSelection(): boolean {
const selection = window.getSelection()
@ -153,6 +161,12 @@ export const UserMessage: FC<{
return messageAttachmentRefs(custom.attachmentRefs)
})
// Group-chat attribution inputs (hooks — must run unconditionally). The
// profile list doubles as the allow-list for `[name]:` re-attribution.
const knownProfiles = useStore($profiles)
const colorOverrides = useStore($profileColors)
const memberProfiles = knownProfiles.map(profile => 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 (
<MessagePrimitive.Root
className="flex w-full min-w-0 flex-col items-start gap-0.5"
data-role="user"
data-slot="aui_member-message-root"
>
<span className="px-1 text-[11px] font-medium" style={{ color }}>
{memberMatch[1]}
</span>
<div
className="max-w-[85%] rounded-2xl px-3 py-1.5 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95"
style={{ backgroundColor: profileColorSoft(color, 10) }}
>
<UserMessageText className="wrap-anywhere" text={memberMatch[2] ?? ''} />
</div>
</MessagePrimitive.Root>
)
}
const hasBody = messageText.trim().length > 0
const isLatestUser = messageId === latestUserId
const showStop = !readOnly && isLatestUser && threadRunning && Boolean(onCancel)