Skip to content

Commit e09df37

Browse files
khaliqgantclaude
andcommitted
fix: wire useThread hook, fix picker toggle, TTL-based reaction overrides, mock user names
- Wire useThread hook into App.tsx ThreadPanel for non-channel view (API-backed pagination) - Pass isLoading/hasMore/onLoadMore props to ThreadPanel - Fix ReactionPicker click-outside excluding anchor button (prevents toggle conflict) - Use TTL-based (5s) reaction override expiry instead of clearing on every WS push - Mock routes use req.body.from || mockUser.displayName instead of hardcoded 'user' Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c79e961 commit e09df37

3 files changed

Lines changed: 55 additions & 36 deletions

File tree

packages/dashboard-server/src/mocks/routes.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -663,20 +663,21 @@ export function registerMockRoutes(app: Express, verbose: boolean): void {
663663
return;
664664
}
665665

666+
const agentName = req.body.from || mockUser.displayName;
666667
if (!message.reactions) message.reactions = [];
667668
const existing = message.reactions.find(r => r.emoji === emoji);
668669
if (existing) {
669-
if (!existing.agents.includes('user')) {
670-
existing.agents.push('user');
670+
if (!existing.agents.includes(agentName)) {
671+
existing.agents.push(agentName);
671672
existing.count++;
672673
}
673674
} else {
674-
message.reactions.push({ emoji, count: 1, agents: ['user'] });
675+
message.reactions.push({ emoji, count: 1, agents: [agentName] });
675676
}
676677

677678
res.status(201).json({
678679
ok: true,
679-
data: { id: `reaction-${Date.now()}`, message_id: id, emoji, agent_name: 'user', created_at: new Date().toISOString() },
680+
data: { id: `reaction-${Date.now()}`, message_id: id, emoji, agent_name: agentName, created_at: new Date().toISOString() },
680681
});
681682
});
682683

@@ -693,7 +694,7 @@ export function registerMockRoutes(app: Express, verbose: boolean): void {
693694
if (message.reactions) {
694695
const existing = message.reactions.find(r => r.emoji === emoji);
695696
if (existing) {
696-
existing.agents = existing.agents.filter(a => a !== 'user');
697+
existing.agents = existing.agents.filter(a => a !== mockUser.displayName);
697698
existing.count = existing.agents.length;
698699
if (existing.count === 0) {
699700
message.reactions = message.reactions.filter(r => r.emoji !== emoji);
@@ -753,13 +754,14 @@ export function registerMockRoutes(app: Express, verbose: boolean): void {
753754
return;
754755
}
755756

757+
const replyFrom = req.body.from || mockUser.displayName;
756758
const reply: Message = {
757759
id: `msg-reply-${Date.now()}`,
758-
from: 'user',
759-
to: parent.from === 'user' ? parent.to : parent.from,
760+
from: replyFrom,
761+
to: parent.from === replyFrom ? parent.to : parent.from,
760762
content: text,
761763
timestamp: new Date().toISOString(),
762-
thread: id as string,
764+
thread: id,
763765
};
764766

765767
mockMessages.push(reply);

packages/dashboard/src/components/App.tsx

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { UsageBanner } from './UsageBanner';
3838
import { useWebSocket, type DashboardData } from './hooks/useWebSocket';
3939
import { useAgents } from './hooks/useAgents';
4040
import { useMessages } from './hooks/useMessages';
41+
import { useThread } from './hooks/useThread';
4142
import { useOrchestrator } from './hooks/useOrchestrator';
4243
import { useTrajectory } from './hooks/useTrajectory';
4344
import { useRecentRepos } from './hooks/useRecentRepos';
@@ -232,19 +233,27 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
232233
}
233234
}, [wsError, wsData, restData]);
234235

235-
// Local reaction overrides for optimistic UI updates
236-
const [reactionOverrides, setReactionOverrides] = useState<Map<string, Reaction[]>>(new Map());
236+
// Local reaction overrides for optimistic UI updates (TTL-based expiry)
237+
const REACTION_OVERRIDE_TTL = 5000; // 5s — enough for API round-trip + WS echo
238+
const [reactionOverrides, setReactionOverrides] = useState<Map<string, { reactions: Reaction[]; timestamp: number }>>(new Map());
237239

238240
// Use WebSocket data if available, otherwise fall back to REST data
239241
// Merge in local reaction overrides
240242
const rawData = wsData || restData;
241243
const rawDataRef = useRef(rawData);
242244
rawDataRef.current = rawData;
243245

244-
// Clear stale reaction overrides when WebSocket delivers fresh data
246+
// Expire stale reaction overrides when WebSocket delivers fresh data
245247
useEffect(() => {
246248
if (rawData && reactionOverrides.size > 0) {
247-
setReactionOverrides(new Map());
249+
const now = Date.now();
250+
setReactionOverrides((prev) => {
251+
const next = new Map<string, { reactions: Reaction[]; timestamp: number }>();
252+
for (const [id, entry] of prev) {
253+
if (now - entry.timestamp < REACTION_OVERRIDE_TTL) next.set(id, entry);
254+
}
255+
return next.size === prev.size ? prev : next;
256+
});
248257
}
249258
// eslint-disable-next-line react-hooks/exhaustive-deps
250259
}, [rawData]);
@@ -254,8 +263,8 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
254263
return {
255264
...rawData,
256265
messages: rawData.messages.map((msg) => {
257-
const override = reactionOverrides.get(msg.id);
258-
return override ? { ...msg, reactions: override } : msg;
266+
const entry = reactionOverrides.get(msg.id);
267+
return entry ? { ...msg, reactions: entry.reactions } : msg;
259268
}),
260269
};
261270
}, [rawData, reactionOverrides]);
@@ -1011,6 +1020,12 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
10111020
senderName: currentUser?.displayName,
10121021
});
10131022

1023+
// Thread data (API-backed with client-side fallback)
1024+
const thread = useThread({
1025+
threadId: currentThread,
1026+
fallbackMessages: messages,
1027+
});
1028+
10141029
// Human context (DM inline view)
10151030
const currentHuman = useMemo(() => {
10161031
if (!currentChannel) return null;
@@ -2014,7 +2029,8 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
20142029
setReactionOverrides((prev) => {
20152030
const next = new Map(prev);
20162031
const msg = rawDataRef.current?.messages.find((m: Message) => m.id === messageId);
2017-
const current = prev.get(messageId) || msg?.reactions || [];
2032+
const prevEntry = prev.get(messageId);
2033+
const current = prevEntry?.reactions || msg?.reactions || [];
20182034
let updated: Reaction[];
20192035

20202036
if (hasReacted) {
@@ -2038,7 +2054,7 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
20382054
}
20392055
}
20402056

2041-
next.set(messageId, updated);
2057+
next.set(messageId, { reactions: updated, timestamp: Date.now() });
20422058
return next;
20432059
});
20442060

@@ -2967,7 +2983,6 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
29672983

29682984
{/* Thread Panel */}
29692985
{currentThread && (() => {
2970-
// Determine which message list to search based on view mode
29712986
const isChannelView = viewMode === 'channels';
29722987

29732988
// Helper to convert ChannelMessage to Message format for ThreadPanel
@@ -2984,9 +2999,14 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
29842999
});
29853000

29863001
let originalMessage: Message | null = null;
3002+
let replies: Message[] = [];
29873003
let isTopicThread = false;
3004+
let threadIsLoading = false;
3005+
let threadHasMore = false;
3006+
let threadLoadMore: (() => void) | undefined;
29883007

29893008
if (isChannelView) {
3009+
// Channel view: use inline filtering (useThread doesn't handle ChannelApiMessage)
29903010
const channelMsg = effectiveChannelMessages.find((m) => m.id === currentThread);
29913011
if (channelMsg) {
29923012
originalMessage = convertChannelMessage(channelMsg);
@@ -2999,42 +3019,36 @@ export function App({ wsUrl, orchestratorUrl, enableReactions = false }: AppProp
29993019
originalMessage = convertChannelMessage(threadMsgs[0]);
30003020
}
30013021
}
3022+
replies = effectiveChannelMessages
3023+
.filter((m) => m.threadId === currentThread)
3024+
.map(convertChannelMessage);
30023025
} else {
3003-
originalMessage = messages.find((m) => m.id === currentThread) ?? null;
3026+
// Non-channel view: use the useThread hook (API-backed with fallback)
3027+
originalMessage = thread.parentMessage;
3028+
replies = thread.replies;
30043029
isTopicThread = !originalMessage;
3005-
if (!originalMessage) {
3006-
const threadMsgs = messages
3007-
.filter((m) => m.thread === currentThread)
3008-
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
3009-
originalMessage = threadMsgs[0] ?? null;
3010-
}
3030+
threadIsLoading = thread.isLoading;
3031+
threadHasMore = thread.hasMore;
3032+
threadLoadMore = thread.loadMore;
30113033
}
30123034

3013-
// Get thread replies based on view mode
3014-
const replies: Message[] = isChannelView
3015-
? effectiveChannelMessages
3016-
.filter((m) => m.threadId === currentThread)
3017-
.map(convertChannelMessage)
3018-
: threadMessages(currentThread);
3019-
30203035
return (
30213036
<div className="w-full md:w-[400px] md:min-w-[320px] md:max-w-[500px] flex-shrink-0">
30223037
<ThreadPanel
30233038
originalMessage={originalMessage}
30243039
replies={replies}
30253040
onClose={() => setCurrentThread(null)}
30263041
showTimestamps={settings.display.showTimestamps}
3042+
isLoading={threadIsLoading}
3043+
hasMore={threadHasMore}
3044+
onLoadMore={threadLoadMore}
30273045
onReply={async (content) => {
30283046
if (isChannelView && selectedChannel) {
3029-
// For channels, send threaded message
30303047
await handleSendChannelMessage(content, currentThread);
30313048
return true;
30323049
}
3033-
// For topic threads, broadcast to all; for reply chains, reply to the other participant
30343050
let recipient = '*';
30353051
if (!isTopicThread && originalMessage) {
3036-
// If current user sent the original message, reply to the recipient
3037-
// If someone else sent it, reply to the sender
30383052
const isFromCurrentUser = originalMessage.from === 'Dashboard' ||
30393053
(currentUser && originalMessage.from === currentUser.displayName);
30403054
recipient = isFromCurrentUser

packages/dashboard/src/components/ReactionPicker.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ export function ReactionPicker({ onSelect, onClose, anchorRef }: ReactionPickerP
1919

2020
useEffect(() => {
2121
function handleClickOutside(e: MouseEvent) {
22-
if (ref.current && !ref.current.contains(e.target as Node)) {
22+
if (
23+
ref.current && !ref.current.contains(e.target as Node) &&
24+
!(anchorRef?.current?.contains(e.target as Node))
25+
) {
2326
onClose();
2427
}
2528
}

0 commit comments

Comments
 (0)