feat(messaging-ui): Slack-style threaded-replies side panel

Replies (messages with parentInteractionId) now open in a right-hand ThreadPane
instead of inline quoting:
- extracted a shared Composer (draft + @mention autocomplete + attach) and
  MessageItem (bubble + mentions + attachment + reactions + reply affordance)
- Thread shows top-level messages only; a message with replies gets a
  '💬 N replies' link, and hovering shows 💬 'Reply in thread'
- ThreadPane renders the root + its replies + a composer that posts back with
  parentInteractionId; Messenger becomes 3-column (list | thread | pane),
  pane closes on conversation switch
- no contract/adapter/backend change (parentInteractionId was already wired)
- thread-pane render test (open → reply → parent shows the count); 58 green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 00:29:54 +05:30
parent 7a0fb2ca97
commit fa93173b1f
8 changed files with 452 additions and 188 deletions
@@ -4,6 +4,7 @@ import { useAdapter } from '../provider';
import { ConversationList } from './conversation-list';
import { ChannelBrowser } from './channel-browser';
import { Thread } from './thread';
import { ThreadPane } from './thread-pane';
/**
* The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread,
@@ -17,6 +18,7 @@ export function Messenger() {
const [selected, setSelected] = useState<string | null>(null);
const [browsing, setBrowsing] = useState(false);
const [activeRoot, setActiveRoot] = useState<string | null>(null); // open thread pane's root message
useEffect(() => {
if (browsing) return;
@@ -24,6 +26,9 @@ export function Messenger() {
setSelected(conversations[0]?.threadId ?? null);
}, [conversations, selected, browsing]);
// Switching conversations (or into browse) closes any open thread pane.
useEffect(() => setActiveRoot(null), [selected, browsing]);
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
@@ -73,9 +78,13 @@ export function Messenger() {
}}
/>
) : (
<Thread threadId={selected} />
<Thread threadId={selected} activeRootId={activeRoot} onOpenThread={setActiveRoot} />
)}
</section>
{!browsing && selected && activeRoot ? (
<ThreadPane threadId={selected} rootId={activeRoot} onClose={() => setActiveRoot(null)} />
) : null}
</div>
);
}