diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 21ab04f8d6..d685a96103 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -164,7 +164,7 @@ export function BotActivityComposerAction({ className={cn( "inline-flex items-center justify-center rounded-full border border-border/60 bg-background font-medium text-muted-foreground 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", isInline - ? "h-7 min-w-0 gap-2 overflow-visible border-transparent bg-transparent px-0 text-xs font-semibold leading-none shadow-none hover:border-transparent hover:bg-transparent data-[state=open]:border-transparent data-[state=open]:bg-transparent" + ? "min-w-0 gap-1.5 overflow-visible border-transparent bg-transparent px-0 text-xs font-normal leading-normal shadow-none hover:border-transparent hover:bg-transparent data-[state=open]:border-transparent data-[state=open]:bg-transparent" : "h-9 min-w-9 gap-1.5 px-2 text-xs", )} data-testid="bot-activity-composer-trigger" @@ -178,17 +178,16 @@ export function BotActivityComposerAction({ onMouseLeave={closeWithDelay} type="button" > - + {workingAgents.slice(0, 2).map((agent) => ( @@ -201,11 +200,15 @@ export function BotActivityComposerAction({ ) : null} {isInline ? ( - {visibleStatusLabel} + + {visibleStatusLabel} + ) : ( "working" )} diff --git a/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx new file mode 100644 index 0000000000..216b205fcc --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx @@ -0,0 +1,66 @@ +import type { ComponentProps } from "react"; + +import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; +import { ComposerActivityAccessory } from "@/features/messages/ui/ComposerActivityAccessory"; +import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; + +type ChannelComposerActivityAccessoryProps = { + agents: ComponentProps["agents"]; + channel: ComponentProps["channel"]; + currentPubkey: ComponentProps["currentPubkey"]; + onOpenAgentSession: ComponentProps< + typeof BotActivityComposerAction + >["onOpenAgentSession"]; + openAgentSessionPubkey: ComponentProps< + typeof BotActivityComposerAction + >["openAgentSessionPubkey"]; + profiles: ComponentProps["profiles"]; + typingPubkeys: string[]; + visible: boolean; + workingBotPubkeys: string[]; +}; + +export function ChannelComposerActivityAccessory({ + agents, + channel, + currentPubkey, + onOpenAgentSession, + openAgentSessionPubkey, + profiles, + typingPubkeys, + visible, + workingBotPubkeys, +}: ChannelComposerActivityAccessoryProps) { + return ( + +
+ {workingBotPubkeys.length > 0 ? ( +
+ +
+ ) : null} + {typingPubkeys.length > 0 ? ( + + ) : null} +
+
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 6fb7ff4ef3..92fa172ff5 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -3,6 +3,7 @@ import { Hash, LogIn } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; +import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { ComposerTimeoutBanner } from "@/features/moderation/ui/ComposerTimeoutBanner"; import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; @@ -25,7 +26,6 @@ import { buildVideoReviewContextForMessage, } from "@/features/messages/lib/videoReviewContext"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; -import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; @@ -40,6 +40,7 @@ import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewMod import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence"; import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; +import { ChannelComposerActivityAccessory } from "@/features/channels/ui/ChannelComposerActivityAccessory"; import { containsWelcomePersonaMention, WelcomeComposerBanner, @@ -218,6 +219,7 @@ export const ChannelPane = React.memo(function ChannelPane({ composerWrapperRef, `${activeChannelId}:${isSinglePanelView}:${hasMainComposerOverlay}`, "css-variable", + () => messageTimelineRef.current?.settleAtBottom() ?? false, ); const clearWelcomeComposerDismissTimer = React.useCallback(() => { if (welcomeComposerDismissTimerRef.current !== null) { @@ -408,26 +410,18 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel?.id ?? null, ); const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0; + const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity; const threadComposerBotTypingPubkeys = React.useMemo(() => { - if (!openThreadHeadId) { - return []; - } - - const pubkeys: string[] = []; - for (const entry of botTypingEntries) { - if (entry.threadHeadId !== openThreadHeadId) { - continue; - } - - if ( - !pubkeys.some( - (pubkey) => pubkey.toLowerCase() === entry.pubkey.toLowerCase(), - ) - ) { - pubkeys.push(entry.pubkey); - } - } - return pubkeys; + if (!openThreadHeadId) return []; + return botTypingEntries + .filter((entry) => entry.threadHeadId === openThreadHeadId) + .map((entry) => entry.pubkey) + .filter( + (pubkey, index, all) => + all.findIndex( + (candidate) => candidate.toLowerCase() === pubkey.toLowerCase(), + ) === index, + ); }, [botTypingEntries, openThreadHeadId]); const hasThreadComposerBotActivity = threadComposerBotTypingPubkeys.length > 0; @@ -733,7 +727,12 @@ export const ChannelPane = React.memo(function ChannelPane({ data-testid="channel-composer-overlay" ref={composerWrapperRef} > -
+
{timeoutState.active ? (
) : null} + -
-
- {hasComposerBotActivity ? ( -
- -
- ) : null} - {hasTypingActivity ? ( - - ) : null} -
-
+ {/* The activity accessory is anchored in the dock's reserved + bottom rail, so fading it cannot change the observed + overlay height or move the conversation. Its natural + content height remains responsive. */} +
)} @@ -898,7 +885,8 @@ export const ChannelPane = React.memo(function ChannelPane({ )} threadReplyUnreadCounts={threadReplyUnreadCounts} threadTypingPubkeys={threadTypingPubkeys} - toolbarExtraActions={ + activityAccessoryVisible={hasThreadComposerBotActivity} + activityAccessoryContent={ hasThreadComposerBotActivity ? ( + {visible ? ( + + {children} + + ) : null} + + ); +} diff --git a/desktop/src/features/messages/ui/ComposerDockBackdrop.tsx b/desktop/src/features/messages/ui/ComposerDockBackdrop.tsx new file mode 100644 index 0000000000..840be49bea --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerDockBackdrop.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/shared/lib/cn"; + +type ComposerDockBackdropProps = { + gutterClassName: string; +}; + +/** + * Owns the dock's stable backdrop blur separately from the resizing composer. + * An opaque rail mask covers the portion released for activity content. + */ +export function ComposerDockBackdrop({ + gutterClassName, +}: ComposerDockBackdropProps) { + return ( + <> +