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:
parent
7583a41d9b
commit
6026247ad7
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 dock⇄float never remounts the editor. */}
|
||||
{showChatBar && (
|
||||
<Suspense fallback={<ChatBarFallback />}>
|
||||
<GroupTypingLine sessionId={activeSessionId} />
|
||||
<ChatBar
|
||||
busy={busy}
|
||||
cwd={currentCwd}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue