From 30e2a9d0efa0d2413e054b27414f83f0025ad544 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sat, 18 Jul 2026 05:47:31 +0100 Subject: [PATCH 1/7] feat(activity): open mention thread in side panel, scroll to & pulse the message Clicking an Activity notification now opens the task thread in a right-hand ThreadSidebar instead of navigating away. The thread scrolls to the mentioned message (centered) and pulses it once, rather than jumping to the bottom. - ThreadPanel/ThreadSidebar take a focusMessageId; rows tag their ThreadItem with data-thread-message-id for targeting. - Bottom auto-scroll is suppressed while deep-linked; the mention is re-centered on timeline changes during the pulse so late-loading agent turns can't drift it off-screen. - Re-clicking the same settled message is a no-op (focusedRef guard). - Adds a thread-mention-highlight keyframe for the pulse. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01K18PGBxVQy1fnC9ADbst3y --- .../canvas/components/ActivityView.tsx | 135 ++++++++++++------ .../canvas/components/ThreadPanel.tsx | 77 +++++++++- .../canvas/components/ThreadSidebar.tsx | 5 + packages/ui/src/styles/globals.css | 23 +++ 4 files changed, 188 insertions(+), 52 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 17fe071e67..ce179eecdc 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -17,6 +17,7 @@ import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authCl import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; 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 +36,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 +57,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 +67,9 @@ function ActivityRow({ {agentStatus && } From 037d1e151fc1fa80ddde0b6ce57e51e8c09c1b50 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sat, 18 Jul 2026 06:08:05 +0100 Subject: [PATCH 3/7] feat(activity): close open thread on Escape from anywhere in Activity A window keydown listener (active only while a thread is open) clears the selection on Esc, so focus needn't be inside the panel. Skips when the event was already handled (e.g. an open dropdown consuming Esc). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01K18PGBxVQy1fnC9ADbst3y --- .../src/features/canvas/components/ActivityView.tsx | 12 ++++++++++++ .../src/features/canvas/components/ThreadPanel.tsx | 4 ++-- .../sessions/components/chat-thread/ChatThread.tsx | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index ce179eecdc..84faeb2307 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -180,6 +180,18 @@ export function ActivityView() { markSeen(); }, [markSeen, items.length]); + // Esc closes the open thread from anywhere in Activity — no need to have + // focus inside the panel. + useEffect(() => { + if (!selected) return; + const onKeyDown = (event: KeyboardEvent) => { + // Let an open menu/popover consume Esc first (it preventDefaults). + if (event.key === "Escape" && !event.defaultPrevented) setSelected(null); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [selected]); + return (
diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 82fe68f439..2e5a857f46 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -766,13 +766,13 @@ function ThreadConversation({
- {selected && ( - setSelected(null)} - onOpenFull={() => - selected.channelId - ? navigateToChannelTask(selected.channelId, selected.taskId) - : navigateToTaskDetail(selected.taskId) - } - /> - )} + {/* Slide the thread in/out by animating the wrapper width, so the list + reflows in lockstep — same 200ms / cubic-bezier(0,0,0.2,1) the docked + sidebar uses. `width: auto` (not a fixed px) once open so ThreadSidebar + keeps owning its resizable width. Keyed "thread" so switching between + mentions swaps the inner panel without a close/reopen. */} + + {selected && ( + + setSelected(null)} + onOpenFull={() => + selected.channelId + ? navigateToChannelTask(selected.channelId, selected.taskId) + : navigateToTaskDetail(selected.taskId) + } + /> + + )} + ); } From 407f2ee8f8182de8f8d7c083b20387e1abe003c9 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Sat, 18 Jul 2026 06:39:10 +0100 Subject: [PATCH 6/7] feat(channels): animate channel thread panel + close it on Escape Apply the Activity thread's slide to the channel view's ThreadSidebar: wrap the mount in AnimatePresence, animate wrapper width (auto <-> 0) + opacity at 200ms / cubic-bezier(0,0,0.2,1) so the feed reflows in lockstep. Also add a window Esc listener (active while a thread is open) that closes it from anywhere in the channel, matching Activity. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01K18PGBxVQy1fnC9ADbst3y --- .../canvas/components/WebsiteChannelHome.tsx | 54 +++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 16a29e7fbf..36c203e049 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -43,7 +43,8 @@ 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 { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +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 @@ -124,6 +125,20 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { ); const openThread = useThreadPanelStore((s) => s.openThread); const closeThread = useThreadPanelStore((s) => s.closeThread); + const reduceMotion = useReducedMotion(); + + // 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) => { @@ -296,15 +311,34 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { - {threadTaskId && ( - closeThread(channelId)} - onOpenFull={() => handleOpenFull(threadTaskId)} - /> - )} + {/* Slide the thread in/out by animating the wrapper width so the feed + reflows in lockstep — same 200ms / cubic-bezier(0,0,0.2,1) as the + docked sidebar. Keyed "thread" so opening a different task swaps the + inner panel in place instead of a close/reopen. */} + + {threadTaskId && ( + + closeThread(channelId)} + onOpenFull={() => handleOpenFull(threadTaskId)} + /> + + )} + {channelName && ( Date: Sat, 18 Jul 2026 07:12:03 +0100 Subject: [PATCH 7/7] refactor(canvas): extract AnimatedThreadDock; fix resize-handle clip + a11y Address review findings on the thread slide-in: - Extract the duplicated AnimatePresence/width-slide wrapper (Activity + channel) into a shared AnimatedThreadDock. - Apply overflow-hidden only while animating, so the settled panel no longer clips the ResizableSidebar resize handle (it overhangs the inner edge). - Guard the mention-pulse and jump-pill transition with prefers-reduced-motion. - Give all highlighted thread rows rounded-none for a consistent full-bleed pulse. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01K18PGBxVQy1fnC9ADbst3y --- .../canvas/components/ActivityView.tsx | 54 +++++++------------ .../canvas/components/AnimatedThreadDock.tsx | 49 +++++++++++++++++ .../canvas/components/ThreadPanel.tsx | 6 +-- .../canvas/components/WebsiteChannelHome.tsx | 39 ++++---------- packages/ui/src/styles/globals.css | 6 +++ 5 files changed, 87 insertions(+), 67 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/AnimatedThreadDock.tsx diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index d0ec30280b..b0b7cacb8a 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -16,6 +16,7 @@ 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"; @@ -30,7 +31,6 @@ import { } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; -import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { useEffect, useMemo, useState } from "react"; function ActivityRow({ @@ -167,7 +167,6 @@ export function ActivityView() { channelId: string; messageId: string; } | null>(null); - const reduceMotion = useReducedMotion(); useEffect(() => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { @@ -249,42 +248,25 @@ export function ActivityView() { - {/* Slide the thread in/out by animating the wrapper width, so the list - reflows in lockstep — same 200ms / cubic-bezier(0,0,0.2,1) the docked - sidebar uses. `width: auto` (not a fixed px) once open so ThreadSidebar - keeps owning its resizable width. Keyed "thread" so switching between - mentions swaps the inner panel without a close/reopen. */} - + {selected && ( - - setSelected(null)} - onOpenFull={() => - selected.channelId - ? navigateToChannelTask(selected.channelId, selected.taskId) - : navigateToTaskDetail(selected.taskId) - } - /> - + setSelected(null)} + onOpenFull={() => + selected.channelId + ? navigateToChannelTask(selected.channelId, selected.taskId) + : navigateToTaskDetail(selected.taskId) + } + /> )} - + ); } diff --git a/packages/ui/src/features/canvas/components/AnimatedThreadDock.tsx b/packages/ui/src/features/canvas/components/AnimatedThreadDock.tsx new file mode 100644 index 0000000000..f6092f0457 --- /dev/null +++ b/packages/ui/src/features/canvas/components/AnimatedThreadDock.tsx @@ -0,0 +1,49 @@ +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import type { ReactNode } from "react"; +import { useState } from "react"; + +// The right-hand thread dock, shared by Activity and the channel feed. Slides +// in/out by animating the wrapper width so the list/feed reflows in lockstep — +// the same 200ms / cubic-bezier(0,0,0.2,1) the docked ResizableSidebar uses. +// +// `width: auto` once open so the inner ThreadSidebar keeps owning its resizable +// width. overflow-hidden is applied ONLY while animating: a permanent clip +// would swallow the ResizableSidebar's resize handle (it overhangs the panel's +// inner edge), so it must be gone once the panel is settled open. +// +// Caller guards the child with the same condition it passes as `open` (e.g. +// `{selected && }`) so nothing is evaluated while closed; +// AnimatePresence retains the previous child through the exit animation. +export function AnimatedThreadDock({ + open, + children, +}: { + open: boolean; + children: ReactNode; +}) { + const reduceMotion = useReducedMotion(); + // Start clipped so the first frame (width: 0, full-width child) can't spill. + const [animating, setAnimating] = useState(true); + + return ( + + {open && ( + setAnimating(true)} + onAnimationComplete={() => setAnimating(false)} + > + {children} + + )} + + ); +} diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index aed0d651df..1b00dce5f0 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -232,7 +232,7 @@ export function AgentTurnRow({ return ( @@ -280,7 +280,7 @@ export function UserPromptRow({ return ( @@ -773,7 +773,7 @@ function ThreadConversation({ const el = scrollRef.current; el?.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); }} - className={`-translate-x-1/2 absolute bottom-3 left-1/2 z-10 rounded-full bg-background shadow-md transition-[opacity,scale] duration-200 hover:bg-background! ${ + className={`-translate-x-1/2 absolute bottom-3 left-1/2 z-10 rounded-full bg-background shadow-md transition-[opacity,scale] duration-200 hover:bg-background! motion-reduce:transition-none ${ showJump ? "scale-100 opacity-100" : "pointer-events-none scale-95 opacity-0" diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 36c203e049..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,6 @@ 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 { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; // A channel: a Slack-style multiplayer feed. Each member message kicks off a @@ -125,7 +125,6 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { ); const openThread = useThreadPanelStore((s) => s.openThread); const closeThread = useThreadPanelStore((s) => s.closeThread); - const reduceMotion = useReducedMotion(); // Esc closes the open thread from anywhere in the channel — no need to have // focus inside the panel. @@ -311,34 +310,18 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { - {/* Slide the thread in/out by animating the wrapper width so the feed - reflows in lockstep — same 200ms / cubic-bezier(0,0,0.2,1) as the - docked sidebar. Keyed "thread" so opening a different task swaps the - inner panel in place instead of a close/reopen. */} - + {threadTaskId && ( - - closeThread(channelId)} - onOpenFull={() => handleOpenFull(threadTaskId)} - /> - + closeThread(channelId)} + onOpenFull={() => handleOpenFull(threadTaskId)} + /> )} - + {channelName && (