diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 17fe071e67..b0b7cacb8a 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -16,7 +16,9 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { AnimatedThreadDock } from "@posthog/ui/features/canvas/components/AnimatedThreadDock"; import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; +import { ThreadSidebar } from "@posthog/ui/features/canvas/components/ThreadSidebar"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; import { normalizeChannelName } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; @@ -35,14 +37,19 @@ function ActivityRow({ item, folderChannelId, isNew, + isSelected, currentUserEmail, + onOpen, }: { item: MentionActivityItem; /** Desktop folder channel id (the /website route param); null when unmapped. */ folderChannelId: string | null; /** Arrived since the viewer last opened this page. */ isNew: boolean; + /** This row's thread is the one currently open in the side panel. */ + isSelected: boolean; currentUserEmail?: string | null; + onOpen: () => void; }) { const openThread = () => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { @@ -51,13 +58,9 @@ function ActivityRow({ channel_id: folderChannelId ?? undefined, task_id: item.taskId, }); - // The channel thread route is the deep-link target; tasks whose channel - // folder is gone fall back to the plain task view. - if (folderChannelId) { - navigateToChannelTask(folderChannelId, item.taskId); - } else { - navigateToTaskDetail(item.taskId); - } + // Open the thread in the right-hand panel, scrolled to the mention — no + // navigation away from Activity. + onOpen(); }; return ( @@ -65,7 +68,9 @@ function ActivityRow({ {agentStatus && } @@ -704,6 +807,7 @@ export function ThreadPanel({ onToggleCollapsed, onOpenFull, showTaskSummary = true, + focusMessageId, }: { taskId: string; channelId: string; @@ -713,6 +817,8 @@ export function ThreadPanel({ onToggleCollapsed?: () => void; onOpenFull?: () => void; showTaskSummary?: boolean; + /** Thread message to scroll to and pulse once (e.g. an Activity mention). */ + focusMessageId?: string; }) { const { data: fetchedTask } = useQuery({ ...taskDetailQuery(taskId), @@ -747,6 +853,7 @@ export function ThreadPanel({ onToggleCollapsed={onToggleCollapsed} onOpenFull={onOpenFull} showTaskSummary={showTaskSummary} + focusMessageId={focusMessageId} /> ); } diff --git a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx index 4a81cf8700..5b4ed23383 100644 --- a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx @@ -17,6 +17,7 @@ export function ThreadSidebar({ onClose, onOpenFull, showTaskSummary, + focusMessageId, }: { taskId: string; channelId: string; @@ -25,6 +26,8 @@ export function ThreadSidebar({ onClose?: () => void; onOpenFull?: () => void; showTaskSummary?: boolean; + /** Thread message to scroll to and pulse once (e.g. an Activity mention). */ + focusMessageId?: string; }) { const collapsed = useThreadPanelStore((s) => s.collapsed); const width = useThreadPanelStore((s) => s.width); @@ -49,6 +52,7 @@ export function ThreadSidebar({ task={task} collapsed onToggleCollapsed={() => toggleCollapsed(false)} + focusMessageId={focusMessageId} /> ); } @@ -70,6 +74,7 @@ export function ThreadSidebar({ onToggleCollapsed={() => toggleCollapsed(true)} onOpenFull={onOpenFull} showTaskSummary={showTaskSummary} + focusMessageId={focusMessageId} /> ); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 16a29e7fbf..444ed76d8f 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -3,6 +3,7 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; import { isTerminalStatus } from "@posthog/shared/domain-types"; import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; +import { AnimatedThreadDock } from "@posthog/ui/features/canvas/components/AnimatedThreadDock"; import { ChannelFeedView, type PendingKickoff, @@ -43,7 +44,7 @@ import { track } from "@posthog/ui/shell/analytics"; import { Heading, Text } from "@radix-ui/themes"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; // A channel: a Slack-style multiplayer feed. Each member message kicks off a // task rendered as a card everyone in the channel sees; the composer stays @@ -125,6 +126,19 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const openThread = useThreadPanelStore((s) => s.openThread); const closeThread = useThreadPanelStore((s) => s.closeThread); + // Esc closes the open thread from anywhere in the channel — no need to have + // focus inside the panel. + useEffect(() => { + if (!threadTaskId) return; + const onKeyDown = (event: KeyboardEvent) => { + // Let an open menu/popover consume Esc first (it preventDefaults). + if (event.key === "Escape" && !event.defaultPrevented) + closeThread(channelId); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [threadTaskId, channelId, closeThread]); + const handleSuggestionSelect = useCallback( (prompt: string, mode?: string) => { composerRef.current?.applySuggestion(prompt, mode); @@ -296,15 +310,18 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { - {threadTaskId && ( - closeThread(channelId)} - onOpenFull={() => handleOpenFull(threadTaskId)} - /> - )} + + {threadTaskId && ( + closeThread(channelId)} + onOpenFull={() => handleOpenFull(threadTaskId)} + /> + )} + {channelName && ( - + ); } diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index b9e19787dc..47fb88fd8e 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -435,6 +435,34 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { animation: ph-pulse 1s ease-in-out infinite; } +/* One-shot attention flash for a thread message deep-linked from Activity. + Fades a tinted background + ring back to transparent so the mentioned row + pulses once when scrolled into view. */ +@keyframes thread-mention-highlight { + 0% { + background-color: var(--accent-a5); + box-shadow: 0 0 0 2px var(--accent-a7); + } + 60% { + background-color: var(--accent-a4); + box-shadow: 0 0 0 2px var(--accent-a6); + } + 100% { + background-color: transparent; + box-shadow: 0 0 0 2px transparent; + } +} + +.thread-mention-highlight { + animation: thread-mention-highlight 1.6s ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .thread-mention-highlight { + animation: none; + } +} + /* Freeze CSS keyframe animations while the app window is unfocused or hidden. Perpetual indicators (spinners, pulses, floats) otherwise keep the compositor and GPU busy in the background for animations nobody is looking at — and some,