Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions desktop/src/features/channels/ui/BotActivityBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -178,17 +178,16 @@ export function BotActivityComposerAction({
onMouseLeave={closeWithDelay}
type="button"
>
<span className="flex items-center overflow-visible py-px -space-x-1">
<span className="flex h-4.5 items-center overflow-visible -space-x-1">
{workingAgents.slice(0, 2).map((agent) => (
<UserAvatar
avatarUrl={agentAvatarUrl(agent)}
className={cn(
"border border-background",
isInline
? "!h-[18px] !w-[18px] shadow-xs ring-1 ring-primary/25 text-3xs"
: "shrink-0",
isInline ? "!h-4.5 !w-4.5 text-3xs" : "shrink-0",
)}
displayName={agent.name}
fallbackDelayMs={isInline ? 0 : undefined}
key={agent.pubkey}
size="xs"
/>
Expand All @@ -201,11 +200,15 @@ export function BotActivityComposerAction({
) : null}
<span
className={cn(
isInline ? "min-w-0 flex-1 overflow-hidden" : "sr-only",
isInline
? "flex h-4.5 min-w-0 flex-1 items-center overflow-visible leading-none"
: "sr-only",
)}
>
{isInline ? (
<Shimmer className="block truncate">{visibleStatusLabel}</Shimmer>
<Shimmer className="-my-px truncate py-px">
{visibleStatusLabel}
</Shimmer>
) : (
"working"
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof BotActivityComposerAction>["agents"];
channel: ComponentProps<typeof TypingIndicatorRow>["channel"];
currentPubkey: ComponentProps<typeof TypingIndicatorRow>["currentPubkey"];
onOpenAgentSession: ComponentProps<
typeof BotActivityComposerAction
>["onOpenAgentSession"];
openAgentSessionPubkey: ComponentProps<
typeof BotActivityComposerAction
>["openAgentSessionPubkey"];
profiles: ComponentProps<typeof BotActivityComposerAction>["profiles"];
typingPubkeys: string[];
visible: boolean;
workingBotPubkeys: string[];
};

export function ChannelComposerActivityAccessory({
agents,
channel,
currentPubkey,
onOpenAgentSession,
openAgentSessionPubkey,
profiles,
typingPubkeys,
visible,
workingBotPubkeys,
}: ChannelComposerActivityAccessoryProps) {
return (
<ComposerActivityAccessory
className="px-5"
testId="channel-composer-activity-row"
visible={visible}
>
<div className="flex w-full items-center gap-2 overflow-visible pl-2">
{workingBotPubkeys.length > 0 ? (
<div className="flex min-w-0 flex-1 overflow-visible">
<BotActivityComposerAction
agents={agents}
channelId={channel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
workingBotPubkeys={workingBotPubkeys}
variant="inline"
/>
</div>
) : null}
{typingPubkeys.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)]"
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
) : null}
</div>
</ComposerActivityAccessory>
);
}
92 changes: 40 additions & 52 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -733,7 +727,12 @@ export const ChannelPane = React.memo(function ChannelPane({
data-testid="channel-composer-overlay"
ref={composerWrapperRef}
>
<div className="composer-overlay-corner-masks pointer-events-auto">
<div
className={cn(
"composer-dock composer-overlay-corner-masks relative pointer-events-auto",
hasComposerBottomActivity && "composer-dock--with-activity",
)}
>
{timeoutState.active ? (
<ComposerTimeoutBanner
expiresAtMs={timeoutState.expiresAtMs}
Expand All @@ -747,11 +746,13 @@ export const ChannelPane = React.memo(function ChannelPane({
/>
</div>
) : null}
<ComposerDockBackdrop gutterClassName="inset-x-5" />
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
channelType={activeChannel?.channelType ?? null}
containerClassName="px-5"
containerClassName="px-5 pb-0"
layoutMode="dock"
disabled={isComposerDisabled}
editTarget={mainEditTarget}
autoSubmitDraftKey={autoSendDraftKey}
Expand Down Expand Up @@ -786,35 +787,21 @@ export const ChannelPane = React.memo(function ChannelPane({
}
showTopBorder={false}
/>
<div
className="min-h-8 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">
{hasComposerBotActivity ? (
<div className="flex min-w-0 flex-1 overflow-visible">
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
workingBotPubkeys={composerWorkingBotPubkeys}
variant="inline"
/>
</div>
) : null}
{hasTypingActivity ? (
<TypingIndicatorRow
channel={activeChannel}
className="min-w-0 flex-1 py-0 pl-[calc(0.75rem+1px)] pr-0 sm:pl-[calc(1rem+1px)]"
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
) : null}
</div>
</div>
{/* 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. */}
<ChannelComposerActivityAccessory
agents={activityAgents}
channel={activeChannel}
currentPubkey={currentPubkey}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
visible={hasComposerBottomActivity}
workingBotPubkeys={composerWorkingBotPubkeys}
/>
</div>
</div>
)}
Expand Down Expand Up @@ -898,7 +885,8 @@ export const ChannelPane = React.memo(function ChannelPane({
)}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadTypingPubkeys={threadTypingPubkeys}
toolbarExtraActions={
activityAccessoryVisible={hasThreadComposerBotActivity}
activityAccessoryContent={
hasThreadComposerBotActivity ? (
<BotActivityComposerAction
agents={activityAgents}
Expand Down
47 changes: 47 additions & 0 deletions desktop/src/features/messages/ui/ComposerActivityAccessory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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 (
<AnimatePresence initial={false}>
{visible ? (
<motion.div
animate={{ opacity: 1 }}
className={cn(
"composer-dock-activity absolute inset-x-0 z-10 bg-background",
className,
)}
data-testid={testId}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
transition={{
duration: prefersReducedMotion ? 0 : 0.2,
ease: [0.23, 1, 0.32, 1],
}}
>
{children}
</motion.div>
) : null}
</AnimatePresence>
);
}
33 changes: 33 additions & 0 deletions desktop/src/features/messages/ui/ComposerDockBackdrop.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-y-0 z-0",
gutterClassName,
)}
data-testid="composer-dock-backdrop"
>
<div className="h-full w-full rounded-2xl backdrop-blur-md dark:backdrop-blur-xl" />
</div>
<div
aria-hidden="true"
className="composer-dock-rail-mask pointer-events-none absolute inset-x-0 bottom-0 z-[5] bg-background"
data-testid="composer-dock-rail-mask"
/>
</>
);
}
30 changes: 30 additions & 0 deletions desktop/src/features/messages/ui/ComposerDockToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { ComponentProps } from "react";

import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar";

type ComposerDockToolbarProps = ComponentProps<
typeof MessageComposerToolbar
> & {
layoutMode: "dock" | "standalone";
};

/**
* 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({
layoutMode,
...toolbarProps
}: ComposerDockToolbarProps) {
return (
<>
{layoutMode === "dock" ? (
<div
aria-hidden="true"
className="composer-dock-quiet-spacer shrink-0"
/>
) : null}
<MessageComposerToolbar {...toolbarProps} />
</>
);
}
Loading
Loading