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)