mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(composer): collapse typing agents and humans into one indicator group
- ChannelComposerActivityRow.tsx: partition the channel's working set by signal source — observer-backed agents keep the interactive activity pills, while typing-fallback-only agents (no observer turn, nothing to hover or open) are diverted out of the pill strip and merged with the human typers into ONE combined TypingIndicatorRow group with an overlapping avatar set; channel-agent roster names are overlaid onto the profile lookup so roster-only agents never fall back to truncated pubkeys - Fix row layout: the pill strip wrapper and typing row were both flex-1, splitting the row 50/50 and stranding the typing group at the halfway mark — the strip now sizes to content (still shrinkable) and the typing group takes the remainder; row gap set to gap-3, and the typing row's base px-4/sm:px-6 padding is zeroed next to pills (composer-edge alignment padding only applies when the group leads the row) - TypingIndicatorRow.tsx: 4+ typers label now reads "X, Y, and N more are typing..." per spec (was "N others"); new optional labelClassName prop so the composer row can match the pills' font-semibold weight without changing the thread panel's indicator - BotActivityBar.tsx: revert the passive typing pill variant and "is typing…" label — typing-only agents never reach the pill anymore; pill avatar bumped 18px → 20px with a uniform 3px surround (pl-0.75, h-7 minus 1px borders) and asymmetric pr-2 for label breathing room - debug/README.md: document that the harness Typing toggle now renders through the combined typing indicator group Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -2,7 +2,6 @@ import * as React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
|
||||
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
|
||||
import {
|
||||
getAgentTranscript,
|
||||
subscribeAgentObserverStore,
|
||||
@@ -163,10 +162,9 @@ function useStripHoverPopover(): StripHoverPopover {
|
||||
* `composerLiveActivity` preview flag off, the hover popover keeps the
|
||||
* legacy "View activity" item instead.
|
||||
*
|
||||
* Typing-fallback-only agents (working source "typing", no observer turn)
|
||||
* render a passive, borderless, width-uncapped status instead: no hover
|
||||
* card, no click-through, not focusable — there is no transcript or session
|
||||
* behind the signal to open.
|
||||
* Only observer-backed agents reach this pill: typing-fallback-only agents
|
||||
* are diverted into the combined typing indicator group by
|
||||
* ChannelComposerActivityRow before the strip renders.
|
||||
*/
|
||||
function BotActivityAgentPill({
|
||||
agent,
|
||||
@@ -197,10 +195,6 @@ function BotActivityAgentPill({
|
||||
const pillKey = agent.pubkey.toLowerCase();
|
||||
const open = hover.activePubkey === pillKey;
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
// Typing-fallback-only agents (no observer turn) render borderless and
|
||||
// uncapped — the basic "is typing" presentation.
|
||||
const typingOnly =
|
||||
useAgentWorking(agent.pubkey, channelId).source === "typing";
|
||||
const transcript = useAgentTranscript(true, agent.pubkey);
|
||||
const now = useNow(PILL_LABEL_TICK_MS);
|
||||
const headline = React.useMemo(
|
||||
@@ -209,11 +203,8 @@ function BotActivityAgentPill({
|
||||
);
|
||||
const activeId = headline?.id ?? GENERIC_LABEL_ID;
|
||||
// No fresh action headline (see deriveActivityPillLabel) — decay to the
|
||||
// agent-named generic label. Typing-fallback-only agents read
|
||||
// "is typing…" (matching the human indicator's vocabulary); observer-backed
|
||||
// agents read "is working…".
|
||||
const activeLabel =
|
||||
headline?.label ?? `${agent.name} is ${typingOnly ? "typing" : "working"}…`;
|
||||
// agent-named generic working label.
|
||||
const activeLabel = headline?.label ?? `${agent.name} is working…`;
|
||||
// The rendered label lags the derived one while the pill is moving: the
|
||||
// push-up ticker plays after the slot settles (or immediately when idle).
|
||||
// Keyed by transcript item id, not label text — a NEW action swaps, while
|
||||
@@ -261,7 +252,7 @@ function BotActivityAgentPill({
|
||||
<>
|
||||
<UserAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
className="!h-[18px] !w-[18px] shrink-0 text-3xs"
|
||||
className="!h-5 !w-5 shrink-0 text-3xs"
|
||||
displayName={agent.name}
|
||||
size="xs"
|
||||
/>
|
||||
@@ -288,20 +279,6 @@ function BotActivityAgentPill({
|
||||
</>
|
||||
);
|
||||
|
||||
// Typing-fallback-only: a passive status readout — no hover card, no
|
||||
// click-through, not focusable. There is no observer turn (and so no
|
||||
// transcript or session) behind it to open.
|
||||
if (typingOnly) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex h-7 min-w-0 items-center gap-1.5 rounded-full bg-background px-2 text-xs font-semibold leading-none text-muted-foreground"
|
||||
data-testid="bot-activity-composer-typing"
|
||||
>
|
||||
{pillContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onOpenChange={(nextOpen) => {
|
||||
@@ -316,7 +293,7 @@ function BotActivityAgentPill({
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-label={`${agent.name} is working. View activity.`}
|
||||
className="inline-flex h-7 min-w-0 max-w-50 items-center gap-1.5 rounded-full border border-border/60 bg-background px-2 text-xs font-semibold leading-none text-muted-foreground shadow-xs transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary"
|
||||
className="inline-flex h-7 min-w-0 max-w-50 items-center gap-1.5 rounded-full border border-border/60 bg-background pl-0.75 pr-2 text-xs font-semibold leading-none text-muted-foreground shadow-xs transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary"
|
||||
data-testid="bot-activity-composer-trigger"
|
||||
onBlur={hover.scheduleClose}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
getAgentWorkingState,
|
||||
subscribeAgentWorkingSignal,
|
||||
useChannelWorkingAgentPubkeys,
|
||||
} from "@/features/agents/agentWorkingSignal";
|
||||
import {
|
||||
BotActivityComposerAction,
|
||||
type BotActivityAgent,
|
||||
@@ -6,11 +12,18 @@ import {
|
||||
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
/**
|
||||
* Status strip anchored directly below the message composer: the inline
|
||||
* "agents working" trigger plus the typing indicator.
|
||||
*
|
||||
* The working set splits by signal source: observer-backed agents get the
|
||||
* interactive activity pills, while typing-fallback-only agents (no observer
|
||||
* turn — nothing to hover or open) merge with the human typers into ONE
|
||||
* combined typing indicator group ("X and Y are typing…") with an
|
||||
* overlapping avatar set.
|
||||
*
|
||||
* The row has a FIXED height (not min-h): it must not grow when the inline
|
||||
* bot-activity button (h-7) mounts, or the bottom-anchored composer above it
|
||||
* visibly bumps up. 34px (h-8.5) = 28px button + 6px bottom padding, the
|
||||
@@ -34,37 +47,106 @@ export function ChannelComposerActivityRow({
|
||||
profiles?: UserProfileLookup;
|
||||
typingPubkeys: string[];
|
||||
}) {
|
||||
const channelId = channel?.id ?? null;
|
||||
// Unified working set for the composer bar: observer-derived turns primary,
|
||||
// bot typing fallback (both folded together by agentWorkingSignal). This is
|
||||
// what makes the bar show for an agent whose observer stream is live but
|
||||
// whose typing signal never arrives — and vice versa.
|
||||
const workingBotPubkeys = useChannelWorkingAgentPubkeys(channel?.id ?? null);
|
||||
const workingBotPubkeys = useChannelWorkingAgentPubkeys(channelId);
|
||||
|
||||
// Typing-fallback-only pubkeys (channel-scoped working source "typing").
|
||||
// Snapshot is a joined string so useSyncExternalStore only re-renders when
|
||||
// the partition actually changes, not on every signal write.
|
||||
const getAgentTypingSnapshot = React.useCallback(
|
||||
() =>
|
||||
workingBotPubkeys
|
||||
.filter(
|
||||
(pubkey) =>
|
||||
getAgentWorkingState(pubkey, channelId).source === "typing",
|
||||
)
|
||||
.join(","),
|
||||
[channelId, workingBotPubkeys],
|
||||
);
|
||||
const agentTypingKey = React.useSyncExternalStore(
|
||||
subscribeAgentWorkingSignal,
|
||||
getAgentTypingSnapshot,
|
||||
);
|
||||
const agentTypingPubkeys = React.useMemo(
|
||||
() => (agentTypingKey === "" ? [] : agentTypingKey.split(",")),
|
||||
[agentTypingKey],
|
||||
);
|
||||
const observerWorkingPubkeys = React.useMemo(() => {
|
||||
const typingSet = new Set(agentTypingPubkeys);
|
||||
return workingBotPubkeys.filter((pubkey) => !typingSet.has(pubkey));
|
||||
}, [agentTypingPubkeys, workingBotPubkeys]);
|
||||
|
||||
// Humans and typing-fallback agents share one indicator group.
|
||||
const combinedTypingPubkeys = React.useMemo(
|
||||
() => [...typingPubkeys, ...agentTypingPubkeys],
|
||||
[agentTypingPubkeys, typingPubkeys],
|
||||
);
|
||||
|
||||
// The channel-agent roster carries names for agents that have no profile
|
||||
// entry (e.g. relay-roster-only agents); overlay them so the typing label
|
||||
// never falls back to a truncated pubkey.
|
||||
const typingProfiles = React.useMemo(() => {
|
||||
if (agentTypingPubkeys.length === 0 || agents.length === 0) {
|
||||
return profiles;
|
||||
}
|
||||
const merged: UserProfileLookup = { ...profiles };
|
||||
for (const agent of agents) {
|
||||
const key = agent.pubkey.toLowerCase();
|
||||
merged[key] = {
|
||||
...merged[key],
|
||||
displayName: merged[key]?.displayName || agent.name,
|
||||
avatarUrl: merged[key]?.avatarUrl ?? null,
|
||||
nip05Handle: merged[key]?.nip05Handle ?? null,
|
||||
isAgent: true,
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
}, [agentTypingPubkeys.length, agents, profiles]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-8.5 overflow-visible bg-background px-5 pb-1.5 pt-0"
|
||||
data-testid="channel-composer-activity-row"
|
||||
>
|
||||
<div className="flex h-full w-full items-center gap-2 overflow-visible">
|
||||
{workingBotPubkeys.length > 0 ? (
|
||||
<div className="flex min-w-0 flex-1 overflow-visible">
|
||||
<div className="flex h-full w-full items-center gap-3 overflow-visible">
|
||||
{/* The pill strip sizes to its content (shrinkable when the row gets
|
||||
tight); the typing group takes whatever is left so it sits
|
||||
directly after the pills instead of splitting the row 50/50. */}
|
||||
{observerWorkingPubkeys.length > 0 ? (
|
||||
<div className="flex min-w-0 overflow-visible">
|
||||
<BotActivityComposerAction
|
||||
agents={agents}
|
||||
channelId={channel?.id ?? null}
|
||||
channelId={channelId}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
profiles={profiles}
|
||||
workingBotPubkeys={workingBotPubkeys}
|
||||
workingBotPubkeys={observerWorkingPubkeys}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{typingPubkeys.length > 0 ? (
|
||||
{combinedTypingPubkeys.length > 0 ? (
|
||||
<TypingIndicatorRow
|
||||
channel={channel}
|
||||
className="min-w-0 flex-1 py-0 pl-[calc(0.75rem+1px)] pr-0 sm:pl-[calc(1rem+1px)]"
|
||||
className={cn(
|
||||
"min-w-0 flex-1 py-0 pr-0",
|
||||
// Composer-edge alignment only when the typing group leads the
|
||||
// row; next to pills the row's gap is the whole spacing (the
|
||||
// base variant's px-4/sm:px-6 must be zeroed, not just left
|
||||
// unoverridden).
|
||||
observerWorkingPubkeys.length === 0
|
||||
? "pl-[calc(0.75rem+1px)] sm:pl-[calc(1rem+1px)]"
|
||||
: "pl-0 sm:pl-0",
|
||||
)}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={typingPubkeys}
|
||||
// Match the activity pills' label weight so the two read as one
|
||||
// strip.
|
||||
labelClassName="font-semibold"
|
||||
profiles={typingProfiles}
|
||||
typingPubkeys={combinedTypingPubkeys}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,8 @@ type TypingIndicatorRowProps = {
|
||||
channel: Channel | null;
|
||||
className?: string;
|
||||
currentPubkey?: string;
|
||||
/** Extra classes for the "… is typing" label (e.g. weight overrides). */
|
||||
labelClassName?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
typingPubkeys: string[];
|
||||
variant?: "default" | "activity";
|
||||
@@ -48,13 +50,14 @@ function formatTypingLabel(names: string[]) {
|
||||
return `${names[0]}, ${names[1]}, and ${names[2]} are typing...`;
|
||||
}
|
||||
|
||||
return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing...`;
|
||||
return `${names[0]}, ${names[1]}, and ${names.length - 2} more are typing...`;
|
||||
}
|
||||
|
||||
export function TypingIndicatorRow({
|
||||
channel,
|
||||
className,
|
||||
currentPubkey,
|
||||
labelClassName,
|
||||
profiles,
|
||||
typingPubkeys,
|
||||
variant = "default",
|
||||
@@ -129,6 +132,7 @@ export function TypingIndicatorRow({
|
||||
isActivityVariant
|
||||
? "text-2xs font-medium leading-3"
|
||||
: "text-xs font-medium leading-4",
|
||||
labelClassName,
|
||||
)}
|
||||
data-testid="message-typing-indicator-label"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user