Skip to content
150 changes: 103 additions & 47 deletions packages/ui/src/features/canvas/components/ActivityView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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, {
Expand All @@ -51,21 +58,19 @@ 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 (
<div className="group relative">
<button
type="button"
onClick={openThread}
className="flex w-full gap-2 rounded-md px-2 py-2 text-left hover:bg-fill-secondary"
className={`flex w-full gap-2 rounded-md px-2 py-2 text-left hover:bg-fill-secondary ${
isSelected ? "bg-fill-secondary" : ""
}`}
>
<span className="relative mt-0.5 shrink-0">
<Avatar size="xs">
Expand Down Expand Up @@ -155,6 +160,13 @@ export function ActivityView() {
const [seenAtOpen] = useState(
() => useActivitySeenStore.getState().lastSeenAt,
);
// The mention whose thread is open in the right-hand panel. `messageId`
// deep-links the ThreadPanel to scroll to and pulse that message.
const [selected, setSelected] = useState<{
taskId: string;
channelId: string;
messageId: string;
} | null>(null);

useEffect(() => {
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
Expand All @@ -169,48 +181,92 @@ 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);
Comment on lines +188 to +190

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Escape Can Close Two Layers

When a focused menu or popover handles Escape without calling preventDefault() on the native event, this window listener also clears the selected thread. One key press then closes both the overlay and the underlying thread instead of only the topmost layer.

};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [selected]);

return (
<div className="h-full overflow-y-auto bg-gray-1">
<div className="mx-auto w-full max-w-[680px] px-4 py-6">
<Text size="5" weight="bold" className="block">
Activity
</Text>
<Text size="2" className="block text-muted-foreground">
Mentions of you across channels.
</Text>
<div className="mt-4">
{isLoading && items.length === 0 ? (
<div className="flex justify-center py-16">
<Spinner />
</div>
) : items.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<AtIcon size={20} />
</EmptyMedia>
<EmptyTitle>No mentions yet</EmptyTitle>
<EmptyDescription>
When a teammate tags you with @ in a channel thread, it lands
here.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="flex flex-col gap-0.5">
{items.map((item) => (
<ActivityRow
key={item.messageId}
item={item}
folderChannelId={folderChannelIdFor(item.channelName)}
isNew={!seenAtOpen || item.createdAt > seenAtOpen}
currentUserEmail={currentUser?.email}
/>
))}
</div>
)}
<div className="flex h-full min-h-0 bg-gray-1">
<div className="h-full min-w-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[680px] px-4 py-6">
<Text size="5" weight="bold" className="block">
Activity
</Text>
<Text size="2" className="block text-muted-foreground">
Mentions of you across channels.
</Text>
<div className="mt-4">
{isLoading && items.length === 0 ? (
<div className="flex justify-center py-16">
<Spinner />
</div>
) : items.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<AtIcon size={20} />
</EmptyMedia>
<EmptyTitle>No mentions yet</EmptyTitle>
<EmptyDescription>
When a teammate tags you with @ in a channel thread, it
lands here.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="flex flex-col gap-0.5">
{items.map((item) => {
const folderChannelId = folderChannelIdFor(item.channelName);
return (
<ActivityRow
key={item.messageId}
item={item}
folderChannelId={folderChannelId}
isNew={!seenAtOpen || item.createdAt > seenAtOpen}
isSelected={selected?.messageId === item.messageId}
currentUserEmail={currentUser?.email}
onOpen={() =>
setSelected({
taskId: item.taskId,
channelId: folderChannelId ?? "",
messageId: item.messageId,
})
}
/>
);
})}
</div>
)}
</div>
</div>
</div>
<AnimatedThreadDock open={!!selected}>
{selected && (
<ThreadSidebar
// Remount per task so the mention-scroll starts fresh; switching
// messages within the same task updates focusMessageId in place.
key={selected.taskId}
taskId={selected.taskId}
channelId={selected.channelId}
focusMessageId={selected.messageId}
showTaskSummary={!!selected.channelId}
onClose={() => setSelected(null)}
onOpenFull={() =>
selected.channelId
? navigateToChannelTask(selected.channelId, selected.taskId)
: navigateToTaskDetail(selected.taskId)
}
/>
)}
</AnimatedThreadDock>
</div>
);
}
49 changes: 49 additions & 0 deletions packages/ui/src/features/canvas/components/AnimatedThreadDock.tsx
Original file line number Diff line number Diff line change
@@ -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 && <ThreadSidebar .../>}`) 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 (
<AnimatePresence>
{open && (
<motion.div
key="thread"
className={`h-full shrink-0 ${animating ? "overflow-hidden" : ""}`}
initial={reduceMotion ? false : { width: 0, opacity: 0 }}
animate={{ width: "auto", opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : { width: 0, opacity: 0 }}
transition={{
duration: reduceMotion ? 0 : 0.2,
ease: [0, 0, 0.2, 1],
}}
onAnimationStart={() => setAnimating(true)}
onAnimationComplete={() => setAnimating(false)}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}
Loading
Loading