From 610dda1629336a6fd962928b4ff91e1998293931 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:15:02 -0700 Subject: [PATCH 1/4] fix: make composer activity layout responsive Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../features/channels/ui/BotActivityBar.tsx | 14 +-- .../ui/ChannelComposerActivityAccessory.tsx | 66 ++++++++++++ .../src/features/channels/ui/ChannelPane.tsx | 61 +++++------ .../messages/ui/ComposerActivityAccessory.tsx | 44 ++++++++ .../messages/ui/ComposerDockToolbar.tsx | 34 ++++++ .../features/messages/ui/MessageComposer.tsx | 102 ++---------------- .../messages/ui/MessageComposer.types.ts | 66 ++++++++++++ .../messages/ui/MessageThreadPanel.tsx | 28 +++-- .../messages/ui/TypingIndicatorRow.tsx | 4 +- .../src/shared/styles/globals/composer.css | 20 +++- 10 files changed, 291 insertions(+), 148 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx create mode 100644 desktop/src/features/messages/ui/ComposerActivityAccessory.tsx create mode 100644 desktop/src/features/messages/ui/ComposerDockToolbar.tsx create mode 100644 desktop/src/features/messages/ui/MessageComposer.types.ts diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 21ab04f8d6..41fca5a99a 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,15 +178,13 @@ export function BotActivityComposerAction({ onMouseLeave={closeWithDelay} type="button" > - + {workingAgents.slice(0, 2).map((agent) => ( {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..4991e6e403 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -25,7 +25,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 +39,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, @@ -408,6 +408,9 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel?.id ?? null, ); const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0; + // Whether the composer dock trades its quiet-state spacer for the + // conditional activity accessory (agent working and/or someone typing). + const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity; const threadComposerBotTypingPubkeys = React.useMemo(() => { if (!openThreadHeadId) { return []; @@ -733,7 +736,14 @@ export const ChannelPane = React.memo(function ChannelPane({ data-testid="channel-composer-overlay" ref={composerWrapperRef} > -
+
{timeoutState.active ? ( -
-
- {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. */} +
)} diff --git a/desktop/src/features/messages/ui/ComposerActivityAccessory.tsx b/desktop/src/features/messages/ui/ComposerActivityAccessory.tsx new file mode 100644 index 0000000000..9bea41dd58 --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerActivityAccessory.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; + +import { cn } from "@/shared/lib/cn"; + +type ComposerActivityAccessoryProps = { + children: ReactNode; + className?: string; + testId?: string; + visible: boolean; +}; + +/** + * Fades activity content into the composer dock's reserved bottom rail without + * changing layout height. This keeps timeline scroll padding stable. + */ +export function ComposerActivityAccessory({ + children, + className, + testId, + visible, +}: ComposerActivityAccessoryProps) { + const prefersReducedMotion = useReducedMotion(); + + return ( + + {visible ? ( + + {children} + + ) : null} + + ); +} diff --git a/desktop/src/features/messages/ui/ComposerDockToolbar.tsx b/desktop/src/features/messages/ui/ComposerDockToolbar.tsx new file mode 100644 index 0000000000..ca880c43f0 --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerDockToolbar.tsx @@ -0,0 +1,34 @@ +import type { ComponentProps } from "react"; + +import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar"; +import { cn } from "@/shared/lib/cn"; + +type ComposerDockToolbarProps = ComponentProps< + typeof MessageComposerToolbar +> & { + accessoryVisible?: boolean; +}; + +/** + * Keeps the composer dock's total height stable by trading a quiet-state spacer + * for the equal-height activity rail outside the composer. + */ +export function ComposerDockToolbar({ + accessoryVisible, + ...toolbarProps +}: ComposerDockToolbarProps) { + return ( + <> + {accessoryVisible !== undefined ? ( + - void; + editTarget?: { + author: string; + body: string; + id: string; + imetaMedia?: ImetaMedia[]; + } | null; + isSending?: boolean; + mediaController?: MediaUploadController; + onCancelEdit?: () => void; + onCancelReply?: () => void; + onEditLastOwnMessage?: () => boolean; + onEditSave?: ( + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + ) => Promise; + onCaptureSendContext?: () => { + parentEventId: string | null; + threadHeadId: string | null; + } | null; + onPrepareSendChannel?: (pubkeys?: string[]) => Promise; + onPreparingMentionSendChange?: (isPreparing: boolean) => void; + onSend: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + ) => Promise; + placeholder?: string; + profiles?: UserProfileLookup; + replyTarget?: { + author: string; + body: string; + id: string; + } | null; + showTopBorder?: boolean; + toolbarExtraActions?: ReactNode; + typingParentEventId?: string | null; + typingRootEventId?: string | null; +}; diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index ddf9b9dda1..7e751e7616 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -38,6 +38,7 @@ import { import { Button } from "@/shared/ui/button"; import { Separator } from "@/shared/ui/separator"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; +import { ComposerActivityAccessory } from "./ComposerActivityAccessory"; import { MessageComposer } from "./MessageComposer"; import { ThreadMessageSkeleton } from "./MessageThreadPanelSkeleton"; import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow"; @@ -243,6 +244,10 @@ export function MessageThreadPanel({ const threadHeadId = threadHead?.id ?? null; useEscapeKey(onClose, isOverlay || isSinglePanelView || isFocusMode); const hasConstrainedColumn = columnMaxWidthPx != null; + // Whether the composer dock trades its quiet-state spacer for the + // conditional activity accessory (agent working and/or someone typing). + const hasComposerBottomActivity = + Boolean(toolbarExtraActions) || threadTypingPubkeys.length > 0; useComposerHeightPadding( threadBodyRef, threadComposerWrapperRef, @@ -821,7 +826,10 @@ export function MessageThreadPanel({ >
-
-
+
{toolbarExtraActions ? (
{toolbarExtraActions} @@ -879,7 +889,7 @@ export function MessageThreadPanel({ /> ) : null}
-
+
diff --git a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx index efb5b9ef09..ef9a8f1846 100644 --- a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx +++ b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx @@ -79,9 +79,7 @@ export function TypingIndicatorRow({ aria-live="polite" className={cn( "shrink-0 bg-transparent", - isActivityVariant - ? "flex h-7 items-center px-0 py-0" - : "px-4 py-2 sm:px-6", + isActivityVariant ? "flex items-center px-0 py-0" : "px-4 py-2 sm:px-6", className, )} {...(labels.length > 0 diff --git a/desktop/src/shared/styles/globals/composer.css b/desktop/src/shared/styles/globals/composer.css index 912ed1c799..f90a7989cd 100644 --- a/desktop/src/shared/styles/globals/composer.css +++ b/desktop/src/shared/styles/globals/composer.css @@ -1,12 +1,30 @@ .composer-overlay-corner-masks::before, .composer-overlay-corner-masks::after { position: absolute; - bottom: 2.5rem; + /* Quiet dock: the composer box sits above the wrapper's pb-5 (1.25rem) + bottom inset. The composer-overflow e2e spec pins this geometry. */ + bottom: 1.25rem; z-index: 0; width: 1rem; height: 1rem; background: hsl(var(--background)); content: ""; + transition: bottom 200ms cubic-bezier(0.23, 1, 0.32, 1); +} + +@media (prefers-reduced-motion: reduce) { + .composer-overlay-corner-masks::before, + .composer-overlay-corner-masks::after { + transition: none; + } +} + +/* Active dock: the composer box sits above the 2.125rem accessory rail + (pb-8.5). The activity content is independently anchored 0.5rem from the + dock bottom, so it remains natural-height without affecting layout. */ +.composer-overlay-corner-masks--with-activity::before, +.composer-overlay-corner-masks--with-activity::after { + bottom: 2.125rem; } .composer-overlay-corner-masks::before { From 984b2e4f144ab13ffba2a0d43c50bc25fc058821 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:09:10 -0700 Subject: [PATCH 2/4] fix: polish composer activity transitions Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../features/channels/ui/BotActivityBar.tsx | 7 +- .../src/features/channels/ui/ChannelPane.tsx | 37 +++-- .../messages/ui/ComposerActivityAccessory.tsx | 5 +- .../messages/ui/ComposerDockBackdrop.tsx | 36 +++++ .../features/messages/ui/MessageComposer.tsx | 6 +- .../messages/ui/MessageThreadPanel.tsx | 129 ++++++++++-------- .../features/messages/ui/MessageTimeline.tsx | 8 +- .../features/messages/ui/useAnchoredScroll.ts | 26 ++++ .../messages/ui/useComposerHeightPadding.ts | 9 +- .../src/shared/styles/globals/animations.css | 56 ++++---- desktop/src/shared/ui/Shimmer.tsx | 6 +- desktop/src/shared/ui/UserAvatar.tsx | 4 +- 12 files changed, 215 insertions(+), 114 deletions(-) create mode 100644 desktop/src/features/messages/ui/ComposerDockBackdrop.tsx diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 41fca5a99a..d685a96103 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -187,6 +187,7 @@ export function BotActivityComposerAction({ isInline ? "!h-4.5 !w-4.5 text-3xs" : "shrink-0", )} displayName={agent.name} + fallbackDelayMs={isInline ? 0 : undefined} key={agent.pubkey} size="xs" /> @@ -200,12 +201,14 @@ export function BotActivityComposerAction({ {isInline ? ( - {visibleStatusLabel} + + {visibleStatusLabel} + ) : ( "working" )} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 4991e6e403..7ff7e75907 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"; @@ -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,29 +410,18 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel?.id ?? null, ); const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0; - // Whether the composer dock trades its quiet-state spacer for the - // conditional activity accessory (agent working and/or someone typing). 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; @@ -757,6 +748,10 @@ export const ChannelPane = React.memo(function ChannelPane({ />
) : null} + +