From 96eef0cfa5da5421ec4af658366c2cc4bdfc24c1 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 12 May 2026 22:17:47 -0400 Subject: [PATCH 1/7] fix(desktop): inline update control in header Move the desktop update affordance into the shared header actions and avoid fake ready states that can trigger relaunch without an installed update. Co-authored-by: Cursor --- desktop/src/app/AppShell.tsx | 6 -- desktop/src/features/chat/ui/ChatHeader.tsx | 6 +- .../src/features/settings/UpdateIndicator.tsx | 64 +++++++++++-------- .../features/settings/hooks/use-updater.ts | 8 ++- 4 files changed, 50 insertions(+), 34 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e6e649e8b5..6ae8e12a48 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -61,7 +61,6 @@ import { SidebarProvider, SidebarTrigger, } from "@/shared/ui/sidebar"; -import { UpdateIndicator } from "@/features/settings/UpdateIndicator"; type AppView = | "home" @@ -599,11 +598,6 @@ export function AppShell() { -
- handleOpenSettings("updates")} - /> -
- {actions ?
{actions}
: null} +
+ + {actions ?
{actions}
: null} +
); } diff --git a/desktop/src/features/settings/UpdateIndicator.tsx b/desktop/src/features/settings/UpdateIndicator.tsx index 1c2818dd5c..b55ebfc1df 100644 --- a/desktop/src/features/settings/UpdateIndicator.tsx +++ b/desktop/src/features/settings/UpdateIndicator.tsx @@ -1,26 +1,27 @@ -import { Download, Loader2, RefreshCw } from "lucide-react"; +import { Loader2, RefreshCcw, RotateCw } from "lucide-react"; import { Button } from "@/shared/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { useUpdaterContext } from "./hooks/UpdaterProvider"; import type { UpdateStatus } from "./hooks/use-updater"; const indicatorButtonClass = - "relative h-7 px-2 text-xs text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"; + "relative h-8 w-8 text-muted-foreground/80 hover:bg-muted/60 hover:text-foreground"; -const iconClass = "h-3 w-3"; +const iconClass = "h-4 w-4"; const variants: Record< "available" | "downloading" | "installing" | "ready", { - Icon: typeof Download; + Icon: typeof RefreshCcw; label: string; badgeColor: string; iconClass: string; } > = { available: { - Icon: Download, + Icon: RefreshCcw, label: "Update available", badgeColor: "bg-primary", iconClass: iconClass, @@ -38,7 +39,7 @@ const variants: Record< iconClass: `${iconClass} animate-spin`, }, ready: { - Icon: RefreshCw, + Icon: RotateCw, label: "Restart to update", badgeColor: "bg-emerald-500", iconClass: iconClass, @@ -57,12 +58,8 @@ function getVariant(state: UpdateStatus["state"]) { return null; } -export function UpdateIndicator({ - onOpenUpdates, -}: { - onOpenUpdates: () => void; -}) { - const { status } = useUpdaterContext(); +export function UpdateIndicator({ className }: { className?: string }) { + const { status, downloadAndInstall, relaunch } = useUpdaterContext(); const variant = getVariant(status.state); if (!variant) { @@ -70,20 +67,37 @@ export function UpdateIndicator({ } const { Icon, label, badgeColor, iconClass: variantIconClass } = variant; + const isActionable = status.state === "available" || status.state === "ready"; + const handleClick = + status.state === "ready" + ? relaunch + : status.state === "available" + ? downloadAndInstall + : null; return ( - + + + + + {label} + ); } diff --git a/desktop/src/features/settings/hooks/use-updater.ts b/desktop/src/features/settings/hooks/use-updater.ts index 5cee98d2d6..4460ccf5e2 100644 --- a/desktop/src/features/settings/hooks/use-updater.ts +++ b/desktop/src/features/settings/hooks/use-updater.ts @@ -37,9 +37,13 @@ function canRunBackgroundCheck(status: UpdateStatus): boolean { return !BACKGROUND_BLOCKED_STATES.has(status.state); } +function initialUpdateStatus(): UpdateStatus { + return { state: "idle" }; +} + export function useUpdater() { - const [status, setStatusState] = useState({ state: "idle" }); - const statusRef = useRef({ state: "idle" }); + const [status, setStatusState] = useState(initialUpdateStatus); + const statusRef = useRef(initialUpdateStatus()); const updateRef = useRef(null); const checkInFlightRef = useRef(false); const downloadInFlightRef = useRef(false); From 2c5e1011a08028450801969b5ef17765deeee619 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 13 May 2026 13:30:13 -0400 Subject: [PATCH 2/7] fix(desktop): keep header aligned during text scaling Co-authored-by: Cursor --- desktop/src/features/chat/ui/ChatHeader.tsx | 10 +++++----- desktop/src/shared/ui/ViewLoadingFallback.tsx | 6 +++--- desktop/src/shared/ui/sidebar.tsx | 10 +++++----- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 01476324ca..7722f0fe57 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -27,7 +27,7 @@ type ChatHeaderProps = { statusBadge?: React.ReactNode; }; -const HEADER_ICON_CLASS = "h-3.5 w-3.5 text-muted-foreground"; +const HEADER_ICON_CLASS = "h-[14px] w-[14px] text-muted-foreground"; function ChannelIcon({ channelType, @@ -90,15 +90,15 @@ export function ChatHeader({ return (
-
+
-
+
diff --git a/desktop/src/shared/ui/sidebar.tsx b/desktop/src/shared/ui/sidebar.tsx index a40fe28685..83cd5a7579 100644 --- a/desktop/src/shared/ui/sidebar.tsx +++ b/desktop/src/shared/ui/sidebar.tsx @@ -26,9 +26,9 @@ import { const SIDEBAR_COOKIE_NAME = "sidebar_state"; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; -const SIDEBAR_WIDTH = "16rem"; -const SIDEBAR_WIDTH_MOBILE = "18rem"; -const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_WIDTH = "256px"; +const SIDEBAR_WIDTH_MOBILE = "288px"; +const SIDEBAR_WIDTH_ICON = "48px"; const SIDEBAR_KEYBOARD_SHORTCUT = "s"; type SidebarContextProps = { @@ -237,7 +237,7 @@ const Sidebar = React.forwardRef< "group-data-[collapsible=offcanvas]:w-0", "group-data-[side=right]:rotate-180", variant === "floating" || variant === "inset" - ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]" + ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_16px)]" : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]", )} /> @@ -249,7 +249,7 @@ const Sidebar = React.forwardRef< : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", // Adjust the padding for floating and inset variants. variant === "floating" || variant === "inset" - ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]" + ? "p-[8px] group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_18px)]" : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]", className, )} From 51e8aa40d667e0fc9b9cc78555cee35ff93a4222 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 13 May 2026 13:35:16 -0400 Subject: [PATCH 3/7] fix(desktop): scale text without moving window chrome Co-authored-by: Cursor --- desktop/src/app/useWebviewZoomShortcuts.ts | 48 ++++++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts index 162062f0d9..db715f176e 100644 --- a/desktop/src/app/useWebviewZoomShortcuts.ts +++ b/desktop/src/app/useWebviewZoomShortcuts.ts @@ -4,9 +4,11 @@ import { getCurrentWebview } from "@tauri-apps/api/webview"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; const DEFAULT_ZOOM_FACTOR = 1; -const MIN_ZOOM_FACTOR = 0.2; -const MAX_ZOOM_FACTOR = 10; -const ZOOM_STEP = 0.2; +const MIN_ZOOM_FACTOR = 0.75; +const MAX_ZOOM_FACTOR = 1.5; +const ZOOM_STEP = 0.1; +const BASE_FONT_SIZE_PX = 16; +const TEXT_SCALE_STORAGE_KEY = "sprout:text-scale"; type ZoomAction = "increase" | "decrease" | "reset"; @@ -55,11 +57,45 @@ function getNextZoomFactor(action: ZoomAction, zoomFactor: number) { return Math.max(zoomFactor - ZOOM_STEP, MIN_ZOOM_FACTOR); } +function readStoredZoomFactor() { + const raw = window.localStorage.getItem(TEXT_SCALE_STORAGE_KEY); + if (!raw) { + return DEFAULT_ZOOM_FACTOR; + } + + const parsed = Number.parseFloat(raw); + if (!Number.isFinite(parsed)) { + return DEFAULT_ZOOM_FACTOR; + } + + return Math.min(Math.max(parsed, MIN_ZOOM_FACTOR), MAX_ZOOM_FACTOR); +} + +function applyTextScale(zoomFactor: number) { + if (zoomFactor === DEFAULT_ZOOM_FACTOR) { + document.documentElement.style.fontSize = ""; + window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY); + return; + } + + document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * zoomFactor}px`; + window.localStorage.setItem(TEXT_SCALE_STORAGE_KEY, String(zoomFactor)); +} + export function useWebviewZoomShortcuts() { const zoomFactorRef = React.useRef(DEFAULT_ZOOM_FACTOR); React.useLayoutEffect(() => { const webview = getCurrentWebview(); + const storedZoomFactor = readStoredZoomFactor(); + + zoomFactorRef.current = storedZoomFactor; + applyTextScale(storedZoomFactor); + + // Keep the webview coordinate system stable; only text should scale. + void webview.setZoom(DEFAULT_ZOOM_FACTOR).catch((error) => { + console.error("Failed to reset webview zoom", error); + }); function handleKeyDown(event: KeyboardEvent) { const action = getZoomAction(event); @@ -77,11 +113,7 @@ export function useWebviewZoomShortcuts() { } zoomFactorRef.current = nextZoomFactor; - - void webview.setZoom(nextZoomFactor).catch((error) => { - zoomFactorRef.current = previousZoomFactor; - console.error("Failed to update webview zoom", error); - }); + applyTextScale(nextZoomFactor); } window.addEventListener("keydown", handleKeyDown); From 04c6b995cd7465756847b0fd36e7d21844dc05db Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 13 May 2026 18:44:22 -0400 Subject: [PATCH 4/7] fix(desktop): remove header drop shadow Co-authored-by: Cursor --- desktop/src/features/chat/ui/ChatHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 7722f0fe57..20b070ce67 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -90,7 +90,7 @@ export function ChatHeader({ return (
Date: Thu, 14 May 2026 09:16:26 -0400 Subject: [PATCH 5/7] feat(desktop): move agent activity below composer Co-authored-by: Cursor --- .../features/channels/ui/BotActivityBar.tsx | 100 +++++++++++++++++- .../src/features/channels/ui/ChannelPane.tsx | 66 +++++++----- .../messages/ui/MessageThreadPanel.tsx | 27 +++-- .../messages/ui/TypingIndicatorRow.tsx | 43 ++++++-- desktop/src/shared/styles/globals.css | 41 +++++++ 5 files changed, 230 insertions(+), 47 deletions(-) diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 8d023dd961..c62f030929 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -1,6 +1,9 @@ import * as React from "react"; import { Loader2 } from "lucide-react"; +import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; +import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; +import { formatToolTitle } from "@/features/agents/ui/agentSessionToolCatalog"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -11,21 +14,38 @@ export type BotActivityAgent = Pick; type BotActivityBarProps = { agents: BotActivityAgent[]; + channelId?: string | null; onOpenAgentSession: (pubkey: string) => void; openAgentSessionPubkey: string | null; profiles?: UserProfileLookup; typingBotPubkeys: string[]; + variant?: "toolbar" | "inline"; }; const HOVER_OPEN_DELAY_MS = 150; const HOVER_CLOSE_DELAY_MS = 180; +const HEADLINE_ROTATION_MS = 2200; + +function getActivityHeadline(item: TranscriptItem): string | null { + if (item.type === "tool") { + return formatToolTitle(item.sproutToolName ?? item.toolName, item.title); + } + + if (item.type === "message") { + return item.role === "assistant" ? "Responding" : item.title; + } + + return item.title; +} export function BotActivityComposerAction({ agents, + channelId = null, onOpenAgentSession, openAgentSessionPubkey, profiles, typingBotPubkeys, + variant = "toolbar", }: BotActivityBarProps) { const [open, setOpen] = React.useState(false); const hoverTimerRef = React.useRef | null>( @@ -39,6 +59,39 @@ export function BotActivityComposerAction({ return agents.filter((agent) => typingSet.has(agent.pubkey.toLowerCase())); }, [agents, typingBotPubkeys]); + const singleTypingAgent = + typingAgents.length === 1 ? (typingAgents[0] ?? null) : null; + const transcript = useAgentTranscript( + Boolean(singleTypingAgent), + singleTypingAgent?.pubkey, + ); + const activityHeadlines = React.useMemo(() => { + if (!singleTypingAgent) { + return []; + } + + const seen = new Set(); + const headlines: string[] = []; + const scopedTranscript = channelId + ? transcript.filter((item) => item.channelId === channelId) + : transcript; + + for (let i = scopedTranscript.length - 1; i >= 0; i--) { + const headline = getActivityHeadline(scopedTranscript[i]); + if (!headline || seen.has(headline)) { + continue; + } + + seen.add(headline); + headlines.unshift(headline); + if (headlines.length >= 5) { + break; + } + } + + return headlines; + }, [channelId, singleTypingAgent, transcript]); + const [headlineIndex, setHeadlineIndex] = React.useState(0); const clearHoverTimer = React.useCallback(() => { if (hoverTimerRef.current !== null) { @@ -69,6 +122,18 @@ export function BotActivityComposerAction({ return () => clearHoverTimer(); }, [clearHoverTimer]); + React.useEffect(() => { + if (activityHeadlines.length <= 1) { + return; + } + + const interval = window.setInterval(() => { + setHeadlineIndex((current) => (current + 1) % activityHeadlines.length); + }, HEADLINE_ROTATION_MS); + + return () => window.clearInterval(interval); + }, [activityHeadlines.length]); + if (typingAgents.length === 0) { return null; } @@ -80,13 +145,26 @@ export function BotActivityComposerAction({ typingAgents.length === 1 ? `${typingAgents[0]?.name ?? "Agent"} is working` : `${typingAgents.length} agents working`; + const isInline = variant === "inline"; + const visibleStatusLabel = + typingAgents.length === 1 + ? `${typingAgents[0]?.name ?? "Agent"}: ${ + activityHeadlines[headlineIndex % activityHeadlines.length] ?? + "Working" + }` + : `${typingAgents[0]?.name ?? "Agent"} +${typingAgents.length - 1}`; return ( 0; const threadComposerBotTypingPubkeys = React.useMemo(() => { if (!openThreadHeadId) { return []; @@ -281,6 +282,8 @@ export const ChannelPane = React.memo(function ChannelPane({ } return pubkeys; }, [botTypingEntries, openThreadHeadId]); + const hasThreadComposerBotActivity = + threadComposerBotTypingPubkeys.length > 0; const selectedAgent = React.useMemo( () => @@ -378,15 +381,6 @@ export const ChannelPane = React.memo(function ChannelPane({ onEditSave={onEditSave} onSend={onSendMessage} profiles={profiles} - toolbarExtraActions={ - - } placeholder={ activeChannel?.archivedAt ? "Archived channels are read-only." @@ -398,16 +392,32 @@ export const ChannelPane = React.memo(function ChannelPane({ } showTopBorder={false} /> -
- {hasTypingActivity ? ( - - ) : null} +
+
+ {hasComposerBotActivity ? ( +
+ +
+ ) : null} + {hasTypingActivity ? ( + + ) : null} +
@@ -446,13 +456,17 @@ export const ChannelPane = React.memo(function ChannelPane({ threadReplies={threadMessages} threadTypingPubkeys={threadTypingPubkeys} toolbarExtraActions={ - + hasThreadComposerBotActivity ? ( + + ) : null } /> ) : activeChannel && selectedAgent ? ( diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 5119469854..cef37b6ead 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -310,20 +310,25 @@ export function MessageThreadPanel({ placeholder={`Reply in thread to ${threadHead.author}`} profiles={profiles} replyTarget={composerReplyTarget} - toolbarExtraActions={toolbarExtraActions} typingParentEventId={threadHead.id} typingRootEventId={threadHead.rootId} /> -
- {threadTypingPubkeys.length > 0 ? ( - - ) : null} +
+
+ {toolbarExtraActions ? ( +
{toolbarExtraActions}
+ ) : null} + {threadTypingPubkeys.length > 0 ? ( + + ) : null} +
diff --git a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx index eb9fb7a9e0..2a843ad9fc 100644 --- a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx +++ b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx @@ -14,6 +14,7 @@ type TypingIndicatorRowProps = { currentPubkey?: string; profiles?: UserProfileLookup; typingPubkeys: string[]; + variant?: "default" | "activity"; }; function resolveFallbackName(channel: Channel | null, pubkey: string) { @@ -54,7 +55,9 @@ export function TypingIndicatorRow({ currentPubkey, profiles, typingPubkeys, + variant = "default", }: TypingIndicatorRowProps) { + const isActivityVariant = variant === "activity"; const labels = React.useMemo( () => typingPubkeys.map((pubkey) => @@ -72,13 +75,25 @@ export function TypingIndicatorRow({ return (
0 ? { "data-testid": "message-typing-indicator" } : {})} > {labels.length > 0 && ( -
+
{typingPubkeys.map((pubkey, index) => { const profile = profiles?.[pubkey.toLowerCase()]; @@ -86,21 +101,37 @@ export function TypingIndicatorRow({ return (
0 ? " -ml-1.5" : ""}`} + className={cn( + "relative shrink-0 rounded-full ring-1 ring-background", + isActivityVariant ? "h-[18px] w-[18px]" : "h-5 w-5", + index > 0 && "-ml-1.5", + )} data-testid="message-typing-avatar" >
); })}

{formatTypingLabel(labels)} diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index 536cf9cc9b..9a7ba1cd92 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -107,6 +107,47 @@ font-weight: 600; } +.agent-activity-shimmer { + display: inline-block; + color: transparent; + background-image: linear-gradient( + 90deg, + hsl(var(--muted-foreground) / 0.48) 0%, + hsl(var(--muted-foreground) / 0.58) 34%, + hsl(var(--foreground) / 0.96) 48%, + hsl(var(--foreground)) 50%, + hsl(var(--foreground) / 0.96) 52%, + hsl(var(--muted-foreground) / 0.58) 66%, + hsl(var(--muted-foreground) / 0.48) 100% + ); + background-size: 260% 100%; + background-position: 100% 50%; + -webkit-background-clip: text; + background-clip: text; + animation: agent-activity-shimmer 4.8s cubic-bezier(0.65, 0, 0.35, 1) infinite + alternate; +} + +@keyframes agent-activity-shimmer { + 0%, + 18% { + background-position: 100% 50%; + } + + 82%, + 100% { + background-position: 0% 50%; + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-activity-shimmer { + color: hsl(var(--muted-foreground)); + background-image: none; + animation: none; + } +} + @layer base { :root { /* Catppuccin Latte (mauve accent) */ From 5f58e11196f0a577d85a4a60c51d14897b489515 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 14 May 2026 10:11:53 -0400 Subject: [PATCH 6/7] Revert "feat(desktop): move agent activity below composer" This reverts commit a77d85480ef53be1c26406690169f9d267274690. --- .../features/channels/ui/BotActivityBar.tsx | 100 +----------------- .../src/features/channels/ui/ChannelPane.tsx | 66 +++++------- .../messages/ui/MessageThreadPanel.tsx | 27 ++--- .../messages/ui/TypingIndicatorRow.tsx | 43 ++------ desktop/src/shared/styles/globals.css | 41 ------- 5 files changed, 47 insertions(+), 230 deletions(-) diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index c62f030929..8d023dd961 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -1,9 +1,6 @@ import * as React from "react"; import { Loader2 } from "lucide-react"; -import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; -import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; -import { formatToolTitle } from "@/features/agents/ui/agentSessionToolCatalog"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -14,38 +11,21 @@ export type BotActivityAgent = Pick; type BotActivityBarProps = { agents: BotActivityAgent[]; - channelId?: string | null; onOpenAgentSession: (pubkey: string) => void; openAgentSessionPubkey: string | null; profiles?: UserProfileLookup; typingBotPubkeys: string[]; - variant?: "toolbar" | "inline"; }; const HOVER_OPEN_DELAY_MS = 150; const HOVER_CLOSE_DELAY_MS = 180; -const HEADLINE_ROTATION_MS = 2200; - -function getActivityHeadline(item: TranscriptItem): string | null { - if (item.type === "tool") { - return formatToolTitle(item.sproutToolName ?? item.toolName, item.title); - } - - if (item.type === "message") { - return item.role === "assistant" ? "Responding" : item.title; - } - - return item.title; -} export function BotActivityComposerAction({ agents, - channelId = null, onOpenAgentSession, openAgentSessionPubkey, profiles, typingBotPubkeys, - variant = "toolbar", }: BotActivityBarProps) { const [open, setOpen] = React.useState(false); const hoverTimerRef = React.useRef | null>( @@ -59,39 +39,6 @@ export function BotActivityComposerAction({ return agents.filter((agent) => typingSet.has(agent.pubkey.toLowerCase())); }, [agents, typingBotPubkeys]); - const singleTypingAgent = - typingAgents.length === 1 ? (typingAgents[0] ?? null) : null; - const transcript = useAgentTranscript( - Boolean(singleTypingAgent), - singleTypingAgent?.pubkey, - ); - const activityHeadlines = React.useMemo(() => { - if (!singleTypingAgent) { - return []; - } - - const seen = new Set(); - const headlines: string[] = []; - const scopedTranscript = channelId - ? transcript.filter((item) => item.channelId === channelId) - : transcript; - - for (let i = scopedTranscript.length - 1; i >= 0; i--) { - const headline = getActivityHeadline(scopedTranscript[i]); - if (!headline || seen.has(headline)) { - continue; - } - - seen.add(headline); - headlines.unshift(headline); - if (headlines.length >= 5) { - break; - } - } - - return headlines; - }, [channelId, singleTypingAgent, transcript]); - const [headlineIndex, setHeadlineIndex] = React.useState(0); const clearHoverTimer = React.useCallback(() => { if (hoverTimerRef.current !== null) { @@ -122,18 +69,6 @@ export function BotActivityComposerAction({ return () => clearHoverTimer(); }, [clearHoverTimer]); - React.useEffect(() => { - if (activityHeadlines.length <= 1) { - return; - } - - const interval = window.setInterval(() => { - setHeadlineIndex((current) => (current + 1) % activityHeadlines.length); - }, HEADLINE_ROTATION_MS); - - return () => window.clearInterval(interval); - }, [activityHeadlines.length]); - if (typingAgents.length === 0) { return null; } @@ -145,26 +80,13 @@ export function BotActivityComposerAction({ typingAgents.length === 1 ? `${typingAgents[0]?.name ?? "Agent"} is working` : `${typingAgents.length} agents working`; - const isInline = variant === "inline"; - const visibleStatusLabel = - typingAgents.length === 1 - ? `${typingAgents[0]?.name ?? "Agent"}: ${ - activityHeadlines[headlineIndex % activityHeadlines.length] ?? - "Working" - }` - : `${typingAgents[0]?.name ?? "Agent"} +${typingAgents.length - 1}`; return ( 0; const threadComposerBotTypingPubkeys = React.useMemo(() => { if (!openThreadHeadId) { return []; @@ -282,8 +281,6 @@ export const ChannelPane = React.memo(function ChannelPane({ } return pubkeys; }, [botTypingEntries, openThreadHeadId]); - const hasThreadComposerBotActivity = - threadComposerBotTypingPubkeys.length > 0; const selectedAgent = React.useMemo( () => @@ -381,6 +378,15 @@ export const ChannelPane = React.memo(function ChannelPane({ onEditSave={onEditSave} onSend={onSendMessage} profiles={profiles} + toolbarExtraActions={ + + } placeholder={ activeChannel?.archivedAt ? "Archived channels are read-only." @@ -392,32 +398,16 @@ export const ChannelPane = React.memo(function ChannelPane({ } showTopBorder={false} /> -

-
- {hasComposerBotActivity ? ( -
- -
- ) : null} - {hasTypingActivity ? ( - - ) : null} -
+
+ {hasTypingActivity ? ( + + ) : null}
@@ -456,17 +446,13 @@ export const ChannelPane = React.memo(function ChannelPane({ threadReplies={threadMessages} threadTypingPubkeys={threadTypingPubkeys} toolbarExtraActions={ - hasThreadComposerBotActivity ? ( - - ) : null + } /> ) : activeChannel && selectedAgent ? ( diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index cef37b6ead..5119469854 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -310,25 +310,20 @@ export function MessageThreadPanel({ placeholder={`Reply in thread to ${threadHead.author}`} profiles={profiles} replyTarget={composerReplyTarget} + toolbarExtraActions={toolbarExtraActions} typingParentEventId={threadHead.id} typingRootEventId={threadHead.rootId} /> -
-
- {toolbarExtraActions ? ( -
{toolbarExtraActions}
- ) : null} - {threadTypingPubkeys.length > 0 ? ( - - ) : null} -
+
+ {threadTypingPubkeys.length > 0 ? ( + + ) : null}
diff --git a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx index 2a843ad9fc..eb9fb7a9e0 100644 --- a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx +++ b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx @@ -14,7 +14,6 @@ type TypingIndicatorRowProps = { currentPubkey?: string; profiles?: UserProfileLookup; typingPubkeys: string[]; - variant?: "default" | "activity"; }; function resolveFallbackName(channel: Channel | null, pubkey: string) { @@ -55,9 +54,7 @@ export function TypingIndicatorRow({ currentPubkey, profiles, typingPubkeys, - variant = "default", }: TypingIndicatorRowProps) { - const isActivityVariant = variant === "activity"; const labels = React.useMemo( () => typingPubkeys.map((pubkey) => @@ -75,25 +72,13 @@ export function TypingIndicatorRow({ return (
0 ? { "data-testid": "message-typing-indicator" } : {})} > {labels.length > 0 && ( -
+
{typingPubkeys.map((pubkey, index) => { const profile = profiles?.[pubkey.toLowerCase()]; @@ -101,37 +86,21 @@ export function TypingIndicatorRow({ return (
0 && "-ml-1.5", - )} + className={`relative h-5 w-5 shrink-0 rounded-full ring-1 ring-background${index > 0 ? " -ml-1.5" : ""}`} data-testid="message-typing-avatar" >
); })}

{formatTypingLabel(labels)} diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index 9a7ba1cd92..536cf9cc9b 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -107,47 +107,6 @@ font-weight: 600; } -.agent-activity-shimmer { - display: inline-block; - color: transparent; - background-image: linear-gradient( - 90deg, - hsl(var(--muted-foreground) / 0.48) 0%, - hsl(var(--muted-foreground) / 0.58) 34%, - hsl(var(--foreground) / 0.96) 48%, - hsl(var(--foreground)) 50%, - hsl(var(--foreground) / 0.96) 52%, - hsl(var(--muted-foreground) / 0.58) 66%, - hsl(var(--muted-foreground) / 0.48) 100% - ); - background-size: 260% 100%; - background-position: 100% 50%; - -webkit-background-clip: text; - background-clip: text; - animation: agent-activity-shimmer 4.8s cubic-bezier(0.65, 0, 0.35, 1) infinite - alternate; -} - -@keyframes agent-activity-shimmer { - 0%, - 18% { - background-position: 100% 50%; - } - - 82%, - 100% { - background-position: 0% 50%; - } -} - -@media (prefers-reduced-motion: reduce) { - .agent-activity-shimmer { - color: hsl(var(--muted-foreground)); - background-image: none; - animation: none; - } -} - @layer base { :root { /* Catppuccin Latte (mauve accent) */ From d014466452d4be37121218e14da9d87a5384eb2d Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 14 May 2026 10:44:03 -0400 Subject: [PATCH 7/7] fix(desktop): align zoom e2e with text scaling Co-authored-by: Cursor --- desktop/src/app/useWebviewZoomShortcuts.ts | 8 ++- desktop/tests/e2e/profile.spec.ts | 83 ++++++++++++++-------- 2 files changed, 61 insertions(+), 30 deletions(-) diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts index db715f176e..5ac6dc56ce 100644 --- a/desktop/src/app/useWebviewZoomShortcuts.ts +++ b/desktop/src/app/useWebviewZoomShortcuts.ts @@ -12,6 +12,10 @@ const TEXT_SCALE_STORAGE_KEY = "sprout:text-scale"; type ZoomAction = "increase" | "decrease" | "reset"; +function roundZoomFactor(zoomFactor: number) { + return Math.round(zoomFactor * 10) / 10; +} + function getZoomAction(event: KeyboardEvent): ZoomAction | null { if (!hasPrimaryShortcutModifier(event) || event.altKey) { return null; @@ -51,10 +55,10 @@ function getNextZoomFactor(action: ZoomAction, zoomFactor: number) { } if (action === "increase") { - return Math.min(zoomFactor + ZOOM_STEP, MAX_ZOOM_FACTOR); + return Math.min(roundZoomFactor(zoomFactor + ZOOM_STEP), MAX_ZOOM_FACTOR); } - return Math.max(zoomFactor - ZOOM_STEP, MIN_ZOOM_FACTOR); + return Math.max(roundZoomFactor(zoomFactor - ZOOM_STEP), MIN_ZOOM_FACTOR); } function readStoredZoomFactor() { diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 59e74cedb4..893a2e8d11 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -346,40 +346,67 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("chat-title")).toHaveText("Home"); - await page.keyboard.press( - process.platform === "darwin" ? "Meta+Shift+Equal" : "Control+Shift+Equal", - ); + const getTextScaleState = () => + page.evaluate(() => ({ + fontSize: getComputedStyle(document.documentElement).fontSize, + storedScale: localStorage.getItem("sprout:text-scale"), + webviewZoom: window.__SPROUT_E2E_WEBVIEW_ZOOM__, + })); + const dispatchPrimaryShortcut = ( + key: string, + code: string, + shiftKey = false, + ) => + page.evaluate( + ({ code, key, shiftKey }) => { + const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + code, + ctrlKey: !isMac, + key, + metaKey: isMac, + shiftKey, + }), + ); + }, + { code, key, shiftKey }, + ); + + await dispatchPrimaryShortcut("+", "Equal", true); + + await expect.poll(getTextScaleState).toEqual({ + fontSize: "17.6px", + storedScale: "1.1", + webviewZoom: 1, + }); - await expect - .poll(() => page.evaluate(() => window.__SPROUT_E2E_WEBVIEW_ZOOM__)) - .toBe(1.2); + await dispatchPrimaryShortcut("-", "Minus"); - await page.keyboard.press( - process.platform === "darwin" ? "Meta+Minus" : "Control+Minus", - ); + await expect.poll(getTextScaleState).toEqual({ + fontSize: "16px", + storedScale: null, + webviewZoom: 1, + }); - await expect - .poll(() => page.evaluate(() => window.__SPROUT_E2E_WEBVIEW_ZOOM__)) - .toBe(1); + await dispatchPrimaryShortcut("+", "Equal", true); + await dispatchPrimaryShortcut("+", "Equal", true); - await page.keyboard.press( - process.platform === "darwin" ? "Meta+Shift+Equal" : "Control+Shift+Equal", - ); - await page.keyboard.press( - process.platform === "darwin" ? "Meta+Shift+Equal" : "Control+Shift+Equal", - ); - - await expect - .poll(() => page.evaluate(() => window.__SPROUT_E2E_WEBVIEW_ZOOM__)) - .toBe(1.4); + await expect.poll(getTextScaleState).toEqual({ + fontSize: "19.2px", + storedScale: "1.2", + webviewZoom: 1, + }); - await page.keyboard.press( - process.platform === "darwin" ? "Meta+Digit0" : "Control+Digit0", - ); + await dispatchPrimaryShortcut("0", "Digit0"); - await expect - .poll(() => page.evaluate(() => window.__SPROUT_E2E_WEBVIEW_ZOOM__)) - .toBe(1); + await expect.poll(getTextScaleState).toEqual({ + fontSize: "16px", + storedScale: null, + webviewZoom: 1, + }); }); test("shows doctor checks for local sprout tooling", async ({ page }) => {