Skip to content

Commit cb8118e

Browse files
tellahoCarl
authored andcommitted
fix(composer): partition thread agent typing by activity
Keep first-time thread-only agent typers in the combined typing group until channel-scoped observer activity provides a real session to preview. Preserve the truthful typing label after promotion and cover the full group-to-pill transition. Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
1 parent bd92fcc commit cb8118e

4 files changed

Lines changed: 193 additions & 37 deletions

File tree

desktop/src/features/channels/ui/BotActivityBar.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,13 @@ type BotActivityBarProps = {
3232
onOpenAgentSession: (pubkey: string, channelId?: string | null) => void;
3333
profiles?: UserProfileLookup;
3434
/**
35-
* Combined typing indicator (humans + typing-fallback agents), rendered as
36-
* the strip's trailing item — a sibling of the working pills, so it shares
37-
* the scroller, edge fades, and layout/enter/exit animations.
35+
* Agent pubkeys known to be typing in this strip's scope. Thread-only
36+
* typing is intentionally absent from the channel-wide working registry,
37+
* so callers pass it here to relabel an already pill-worthy agent without
38+
* leaking thread activity into channel-level surfaces.
3839
*/
40+
typingBotPubkeys?: string[];
41+
/** Combined human + typing-fallback agent indicator. */
3942
typingIndicator?: React.ReactNode;
4043
workingBotPubkeys: string[];
4144
};
@@ -236,6 +239,7 @@ function BotActivityAgentPill({
236239
onOpenAgentSession,
237240
pinWidth,
238241
profiles,
242+
typingBotPubkeys,
239243
}: {
240244
agent: BotActivityAgent;
241245
avatarUrl: string | null;
@@ -252,6 +256,7 @@ function BotActivityAgentPill({
252256
*/
253257
pinWidth: boolean;
254258
profiles?: UserProfileLookup;
259+
typingBotPubkeys?: ReadonlySet<string>;
255260
}) {
256261
const pillKey = agent.pubkey.toLowerCase();
257262
const open = hover.activePubkey === pillKey;
@@ -272,7 +277,8 @@ function BotActivityAgentPill({
272277
subscribeAgentWorkingSignal,
273278
() => getAgentChannelTypingSince(agent.pubkey, channelId),
274279
);
275-
const isTyping = typingSince !== null;
280+
const isTyping =
281+
typingBotPubkeys?.has(pillKey) === true || typingSince !== null;
276282
const activeId = isTyping
277283
? TYPING_LABEL_ID
278284
: (headline?.id ?? GENERIC_LABEL_ID);
@@ -578,6 +584,7 @@ export function BotActivityComposerAction({
578584
channelId = null,
579585
onOpenAgentSession,
580586
profiles,
587+
typingBotPubkeys = [],
581588
typingIndicator,
582589
workingBotPubkeys,
583590
}: BotActivityBarProps) {
@@ -607,6 +614,10 @@ export function BotActivityComposerAction({
607614

608615
return agents.filter((agent) => workingSet.has(agent.pubkey.toLowerCase()));
609616
}, [agents, workingBotPubkeys]);
617+
const typingBotSet = React.useMemo(
618+
() => new Set(typingBotPubkeys.map((pubkey) => pubkey.toLowerCase())),
619+
[typingBotPubkeys],
620+
);
610621

611622
// Turn-start pill order (earliest worker left-most, new agents append on
612623
// the right). Anchored to when each agent STARTED working — stable for the
@@ -758,6 +769,7 @@ export function BotActivityComposerAction({
758769
onOpenAgentSession={onOpenAgentSession}
759770
pinWidth={guardsOpenCard}
760771
profiles={profiles}
772+
typingBotPubkeys={typingBotSet}
761773
/>
762774
)}
763775
</AnimatedPillSlot>

desktop/src/features/channels/ui/ChannelPane.tsx

Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPre
4040
import { useCardMintJobs } from "@/features/agents/cardMintStore";
4141
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
4242
import { ChannelComposerActivityRow } from "@/features/channels/ui/ChannelComposerActivityRow";
43+
import { useThreadComposerActivity } from "@/features/channels/ui/useThreadComposerActivity";
4344
import { ComposerActivityAccessory } from "@/features/messages/ui/ComposerActivityAccessory";
4445
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
4546
import {
@@ -286,6 +287,7 @@ export const ChannelPane = React.memo(function ChannelPane({
286287
onEdit(target);
287288
return true;
288289
}, [findLastOwnEditable, messages, onEdit]);
290+
289291
const handleEditLastOwnThreadMessage = React.useCallback((): boolean => {
290292
if (!onEdit) return false;
291293
const scope: TimelineMessage[] = [];
@@ -296,7 +298,9 @@ export const ChannelPane = React.memo(function ChannelPane({
296298
onEdit(target);
297299
return true;
298300
}, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]);
301+
299302
const timeoutState = useTimeoutState();
303+
300304
// A moderation DM (1:1 with the relay identity) is read-only for the member;
301305
// only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` →
302306
// ordinary DM, composer enabled.
@@ -306,6 +310,7 @@ export const ChannelPane = React.memo(function ChannelPane({
306310
currentPubkey,
307311
relaySelfQuery.data,
308312
);
313+
309314
const isComposerDisabled =
310315
!activeChannel?.isMember ||
311316
activeChannel.archivedAt !== null ||
@@ -315,6 +320,7 @@ export const ChannelPane = React.memo(function ChannelPane({
315320
isSending;
316321
const knownAgentPubkeys = React.useMemo(() => {
317322
const pubkeys = new Set<string>();
323+
318324
for (const pubkey of agentPubkeys ?? []) {
319325
pubkeys.add(pubkey.toLowerCase());
320326
}
@@ -324,12 +330,14 @@ export const ChannelPane = React.memo(function ChannelPane({
324330
for (const agent of activityAgents) {
325331
pubkeys.add(agent.pubkey.toLowerCase());
326332
}
333+
327334
return pubkeys;
328335
}, [activityAgents, agentPubkeys, agentSessionAgents]);
329336
const completeWelcomeComposerBanner = React.useCallback(() => {
330337
if (!activeChannelId || !isActiveWelcomeChannel) {
331338
return;
332339
}
340+
333341
clearWelcomeComposerDismissTimer();
334342
completedWelcomeBannerChannelIdsRef.current.add(activeChannelId);
335343
setWelcomeComposerBannerState("complete");
@@ -361,8 +369,10 @@ export const ChannelPane = React.memo(function ChannelPane({
361369
isActiveWelcomeChannel &&
362370
(containsWelcomePersonaMention(content) ||
363371
mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys));
372+
364373
messageTimelineRef.current?.scrollToBottomOnNextUpdate();
365374
await onSendMessage(content, mentionPubkeys, mediaTags, channelId);
375+
366376
if (
367377
channelId &&
368378
channelId !== activeChannelId &&
@@ -371,6 +381,7 @@ export const ChannelPane = React.memo(function ChannelPane({
371381
) {
372382
await goChannel(channelId, { replace: true });
373383
}
384+
374385
if (shouldCompleteWelcomeBanner) {
375386
completeWelcomeComposerBanner();
376387
}
@@ -389,32 +400,24 @@ export const ChannelPane = React.memo(function ChannelPane({
389400
!isComposerDisabled &&
390401
!isMainDeferredEditPending &&
391402
!isSinglePanelView;
392-
// Working set for the composer bar (observer turns + bot-typing fallback,
393-
// folded by agentWorkingSignal); gates the dock's reserved bottom rail.
394403
const composerWorkingBotPubkeys = useChannelWorkingAgentPubkeys(
395404
activeChannel?.id ?? null,
396405
);
397-
// Background card mints surface in the same rail ("Minting card…" chip),
398-
// so they must also reserve the activity row.
399406
const hasCardMintActivity = useCardMintJobs().length > 0;
400407
const hasComposerBottomActivity =
401408
composerWorkingBotPubkeys.length > 0 ||
402409
typingPubkeys.length > 0 ||
403410
hasCardMintActivity;
404-
const threadComposerBotTypingPubkeys = React.useMemo(() => {
405-
if (!openThreadHeadId) return [];
406-
return botTypingEntries
407-
.filter((entry) => entry.threadHeadId === openThreadHeadId)
408-
.map((entry) => entry.pubkey)
409-
.filter(
410-
(pubkey, index, all) =>
411-
all.findIndex(
412-
(candidate) => candidate.toLowerCase() === pubkey.toLowerCase(),
413-
) === index,
414-
);
415-
}, [botTypingEntries, openThreadHeadId]);
416-
const hasThreadComposerBotActivity =
417-
threadComposerBotTypingPubkeys.length > 0;
411+
const {
412+
combinedTypingPubkeys: combinedThreadTypingPubkeys,
413+
hasActivity: hasThreadComposerActivity,
414+
pillBotPubkeys: threadPillBotPubkeys,
415+
} = useThreadComposerActivity({
416+
botTypingEntries,
417+
channelId: activeChannel?.id ?? null,
418+
threadHeadId: openThreadHeadId,
419+
typingPubkeys: threadTypingPubkeys,
420+
});
418421
const directMessageIntro = React.useMemo(
419422
() =>
420423
buildDirectMessageIntro({
@@ -424,6 +427,7 @@ export const ChannelPane = React.memo(function ChannelPane({
424427
}),
425428
[activeChannel, currentPubkey, profiles],
426429
);
430+
427431
const handleWelcomeAddAgent = React.useCallback(() => {
428432
onAddAgent?.({
429433
beforeSend: () =>
@@ -465,6 +469,7 @@ export const ChannelPane = React.memo(function ChannelPane({
465469
for (const message of threadAllMessages) {
466470
messagesById.set(message.id, message);
467471
}
472+
468473
return buildVideoReviewContextsByMessageId({
469474
channelId: activeChannel?.id ?? null,
470475
channelName: activeChannel?.name,
@@ -485,6 +490,7 @@ export const ChannelPane = React.memo(function ChannelPane({
485490
threadAllMessages,
486491
threadHeadMessage,
487492
]);
493+
488494
const isOverlay = useIsThreadPanelOverlay();
489495
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
490496
const threadViewMode = useThreadViewMode();
@@ -577,6 +583,7 @@ export const ChannelPane = React.memo(function ChannelPane({
577583
data-testid="channel-shared-header-backdrop"
578584
/>
579585
) : null}
586+
580587
{!isSinglePanelView ? (
581588
<section
582589
aria-label="Channel messages and composer"
@@ -781,9 +788,6 @@ export const ChannelPane = React.memo(function ChannelPane({
781788
}
782789
showTopBorder={false}
783790
/>
784-
{/* The accessory is anchored in the dock's reserved bottom
785-
rail, so fading it cannot change the observed overlay
786-
height or move the conversation. */}
787791
<ComposerActivityAccessory visible={hasComposerBottomActivity}>
788792
<ChannelComposerActivityRow
789793
agents={activityAgents}
@@ -802,6 +806,7 @@ export const ChannelPane = React.memo(function ChannelPane({
802806
) : null}
803807
</section>
804808
) : null}
809+
805810
{/*
806811
* `AnimatePresence` keeps the focus thread drawer mounted through its exit
807812
* animation — without it the drawer's own existence condition
@@ -878,33 +883,27 @@ export const ChannelPane = React.memo(function ChannelPane({
878883
threadHeadMessage.id,
879884
)}
880885
threadReplyUnreadCounts={threadReplyUnreadCounts}
881-
activityAccessoryVisible={
882-
hasThreadComposerBotActivity || threadTypingPubkeys.length > 0
883-
}
886+
activityAccessoryVisible={hasThreadComposerActivity}
884887
activityAccessoryContent={
885-
hasThreadComposerBotActivity ||
886-
threadTypingPubkeys.length > 0 ? (
888+
hasThreadComposerActivity ? (
887889
<BotActivityComposerAction
888890
agents={activityAgents}
889891
channelId={activeChannel?.id ?? null}
890892
onOpenAgentSession={onOpenAgentSession}
891893
profiles={profiles}
894+
typingBotPubkeys={threadPillBotPubkeys}
892895
typingIndicator={
893-
threadTypingPubkeys.length > 0 ? (
896+
combinedThreadTypingPubkeys.length > 0 ? (
894897
<TypingIndicatorRow
895898
channel={activeChannel}
896-
// The strip's slot owns spacing and the
897-
// typing-only inset; zero the base paddings and
898-
// let the row shrink so the lone-item slot can
899-
// ellipsize the label.
900899
className="min-w-0 shrink px-0 py-0 sm:px-0"
901900
currentPubkey={currentPubkey}
902901
profiles={profiles}
903-
typingPubkeys={threadTypingPubkeys}
902+
typingPubkeys={combinedThreadTypingPubkeys}
904903
/>
905904
) : null
906905
}
907-
workingBotPubkeys={threadComposerBotTypingPubkeys}
906+
workingBotPubkeys={threadPillBotPubkeys}
908907
/>
909908
) : null
910909
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import * as React from "react";
2+
import {
3+
getAgentTranscript,
4+
subscribeAgentObserverStore,
5+
} from "@/features/agents/observerRelayStore";
6+
import { partitionComposerWorkingAgents } from "@/features/channels/ui/composerLiveActivity";
7+
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
8+
9+
/** Partitions thread-scoped bot typers without leaking them into channel state. */
10+
export function useThreadComposerActivity({
11+
botTypingEntries,
12+
channelId,
13+
threadHeadId,
14+
typingPubkeys,
15+
}: {
16+
botTypingEntries: readonly TypingIndicatorEntry[];
17+
channelId: string | null;
18+
threadHeadId: string | null;
19+
typingPubkeys: readonly string[];
20+
}): {
21+
combinedTypingPubkeys: string[];
22+
hasActivity: boolean;
23+
pillBotPubkeys: string[];
24+
} {
25+
const botPubkeys = React.useMemo(() => {
26+
if (!threadHeadId) return [];
27+
return botTypingEntries
28+
.filter((entry) => entry.threadHeadId === threadHeadId)
29+
.map((entry) => entry.pubkey)
30+
.filter(
31+
(pubkey, index, all) =>
32+
all.findIndex(
33+
(candidate) => candidate.toLowerCase() === pubkey.toLowerCase(),
34+
) === index,
35+
);
36+
}, [botTypingEntries, threadHeadId]);
37+
const subscribe = React.useCallback(
38+
(onChange: () => void) => subscribeAgentObserverStore(onChange),
39+
[],
40+
);
41+
const getSnapshot = React.useCallback(() => {
42+
const partition = partitionComposerWorkingAgents({
43+
channelId,
44+
getTranscript: getAgentTranscript,
45+
// Thread typing stays out of the channel-wide working registry. A
46+
// channel-scoped transcript alone decides whether a real preview exists.
47+
getWorkingSource: () => "typing",
48+
pubkeys: botPubkeys,
49+
});
50+
return `${partition.pillPubkeys.join(",")}\n${partition.typingGroupPubkeys.join(",")}`;
51+
}, [botPubkeys, channelId]);
52+
const partitionKey = React.useSyncExternalStore(subscribe, getSnapshot);
53+
const [pillKey = "", typingKey = ""] = partitionKey.split("\n");
54+
const pillBotPubkeys = React.useMemo(
55+
() => (pillKey === "" ? [] : pillKey.split(",")),
56+
[pillKey],
57+
);
58+
const typingBotPubkeys = React.useMemo(
59+
() => (typingKey === "" ? [] : typingKey.split(",")),
60+
[typingKey],
61+
);
62+
const combinedTypingPubkeys = React.useMemo(
63+
() => [...typingPubkeys, ...typingBotPubkeys],
64+
[typingBotPubkeys, typingPubkeys],
65+
);
66+
return {
67+
combinedTypingPubkeys,
68+
hasActivity: pillBotPubkeys.length > 0 || combinedTypingPubkeys.length > 0,
69+
pillBotPubkeys,
70+
};
71+
}

0 commit comments

Comments
 (0)