Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 4914224

Browse files
authored
feat(ui): replace My tasks with a drag-in Watch list, MenuLabel section headers
The cross-space My tasks section becomes a Watch list: drag any task row onto it (they all carry the text/x-task-id payload) to keep a local reference, newest first, rendered with the same ChannelItemRows and muted space names; a hover × forgets the reference. Watching is a local-only pointer for now, persisted in the sidebar store. Both it and the Spaces header now use the MenuLabel section style (the code sidebar's "Sessions" look), with a Separator between the two sections. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986
1 parent dfce794 commit 4914224

4 files changed

Lines changed: 236 additions & 164 deletions

File tree

packages/ui/src/features/canvas/components/MyTasksSection.tsx

Lines changed: 0 additions & 152 deletions
This file was deleted.

packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { useSortable } from "@dnd-kit/react/sortable";
44
import { PlusIcon } from "@phosphor-icons/react";
55
import {
66
Button,
7+
MenuLabel,
8+
Separator,
79
Switch,
810
Tooltip,
911
TooltipContent,
@@ -12,8 +14,8 @@ import {
1214
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
1315
import { AllSpacesSection } from "@posthog/ui/features/canvas/components/AllSpacesSection";
1416
import { ChannelNav } from "@posthog/ui/features/canvas/components/ChannelNav";
15-
import { MyTasksSection } from "@posthog/ui/features/canvas/components/MyTasksSection";
1617
import { SpaceSection } from "@posthog/ui/features/canvas/components/SpaceSection";
18+
import { WatchListSection } from "@posthog/ui/features/canvas/components/WatchListSection";
1719
import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels";
1820
import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots";
1921
import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels";
@@ -118,14 +120,16 @@ export function SpacesSidebarNav() {
118120
</Button>
119121
</div>
120122

121-
{/* The viewer's tasks across every space. */}
122-
<div className="shrink-0 px-2 pb-1">
123-
<MyTasksSection />
123+
{/* Dragged-in task references, kept locally. */}
124+
<div className="shrink-0 px-2">
125+
<WatchListSection />
124126
</div>
125127

128+
<Separator className="my-1 shrink-0" />
129+
126130
{/* The section label, with one filter over every space's task list. */}
127-
<div className="flex shrink-0 items-center justify-between px-3 pb-1.5 text-[12px] text-muted-foreground">
128-
Spaces
131+
<div className="flex shrink-0 items-center justify-between px-2 pr-3">
132+
<MenuLabel>Spaces</MenuLabel>
129133
<Tooltip>
130134
<TooltipTrigger
131135
render={
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { CaretRightIcon, XIcon } from "@phosphor-icons/react";
2+
import {
3+
buildChannelItems,
4+
type ChannelItemModel,
5+
} from "@posthog/core/canvas/channelItems";
6+
import { Button, cn, MenuLabel } from "@posthog/quill";
7+
import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds";
8+
import { useArchiveTask } from "@posthog/ui/features/archive/useArchiveTask";
9+
import type { ChannelItemActions } from "@posthog/ui/features/canvas/components/ChannelItemRow";
10+
import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow";
11+
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
12+
import {
13+
normalizeChannelName,
14+
PERSONAL_CHANNEL_NAME,
15+
useTaskChannels,
16+
} from "@posthog/ui/features/canvas/hooks/useTaskChannels";
17+
import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore";
18+
import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks";
19+
import { useTasks } from "@posthog/ui/features/tasks/useTasks";
20+
import { toast } from "@posthog/ui/primitives/toast";
21+
import { useNavigate, useRouterState } from "@tanstack/react-router";
22+
import { type DragEvent, useMemo, useState } from "react";
23+
24+
/**
25+
* A personal watch list: drag any task row onto the section (they all carry
26+
* the text/x-task-id payload) to keep a reference to it here, newest first.
27+
* Watching is a local-only pointer — the task stays in its space, and rows
28+
* render exactly like a space's own list, each wearing its space's name. The
29+
* hover × forgets the reference; the header is a MenuLabel section like the
30+
* code sidebar's "Sessions".
31+
*/
32+
export function WatchListSection() {
33+
const open = useSpacesSidebarStore((s) => s.openWatchList);
34+
const toggleWatchList = useSpacesSidebarStore((s) => s.toggleWatchList);
35+
const watchList = useSpacesSidebarStore((s) => s.watchList);
36+
const addToWatchList = useSpacesSidebarStore((s) => s.addToWatchList);
37+
const removeFromWatchList = useSpacesSidebarStore(
38+
(s) => s.removeFromWatchList,
39+
);
40+
const navigate = useNavigate();
41+
const pathname = useRouterState({ select: (s) => s.location.pathname });
42+
const [isDropTarget, setIsDropTarget] = useState(false);
43+
44+
// Anyone's task can be watched, so resolve against the full list.
45+
const { data: allTasks = [] } = useTasks({ showAllUsers: true });
46+
const { channels: backendChannels } = useTaskChannels({ enabled: open });
47+
const { channels: folderChannels } = useChannels();
48+
const archivedTaskIds = useArchivedTaskIds();
49+
const { pinnedTaskIds, togglePin } = usePinnedTasks();
50+
const { archiveTask } = useArchiveTask({ navigateSpace: "website" });
51+
52+
// Same item shape the space lists use, held in watch-list order (newest
53+
// watched first) rather than the builder's recency sort.
54+
const items = useMemo<ChannelItemModel[]>(() => {
55+
const watched = new Set(watchList);
56+
const built = buildChannelItems({
57+
dashboards: [],
58+
feedTasks: allTasks.filter((t) => watched.has(t.id)),
59+
archivedTaskIds,
60+
pinnedTaskIds,
61+
ownedBy: null,
62+
});
63+
const byId = new Map(built.map((item) => [item.id, item]));
64+
return watchList.flatMap((id) => byId.get(id) ?? []);
65+
}, [watchList, allTasks, archivedTaskIds, pinnedTaskIds]);
66+
67+
// Each task's space: backend channel → display name → folder channel (which
68+
// the routes need). Unmapped tasks open under #me and carry no label.
69+
const spaceFor = useMemo(() => {
70+
const backendById = new Map(backendChannels.map((c) => [c.id, c]));
71+
const folderByName = new Map(
72+
folderChannels.map((c) => [normalizeChannelName(c.name), c]),
73+
);
74+
const me = folderChannels.find((c) => c.name === PERSONAL_CHANNEL_NAME);
75+
const byTaskId = new Map<
76+
string,
77+
{ spaceName: string | null; folderId: string | undefined }
78+
>();
79+
for (const item of items) {
80+
const backend = item.task?.channel
81+
? backendById.get(item.task.channel)
82+
: undefined;
83+
const spaceName = backend
84+
? backend.channel_type === "personal"
85+
? PERSONAL_CHANNEL_NAME
86+
: backend.name
87+
: null;
88+
const folder = spaceName
89+
? folderByName.get(normalizeChannelName(spaceName))
90+
: undefined;
91+
byTaskId.set(item.id, { spaceName, folderId: (folder ?? me)?.id });
92+
}
93+
return byTaskId;
94+
}, [items, backendChannels, folderChannels]);
95+
96+
const actions = useMemo<ChannelItemActions>(
97+
() => ({
98+
open: (item) => {
99+
const folderId = spaceFor.get(item.id)?.folderId;
100+
if (!folderId) return;
101+
void navigate({
102+
to: "/website/$channelId/tasks/$taskId",
103+
params: { channelId: folderId, taskId: item.id },
104+
});
105+
},
106+
togglePin: (item) => {
107+
togglePin(item.id).catch(() => {
108+
toast.error("Couldn't update pin");
109+
});
110+
},
111+
archive: (item) => {
112+
void archiveTask({ taskId: item.id });
113+
},
114+
// Tasks only here — canvases (the deletable kind) never appear.
115+
remove: () => {},
116+
}),
117+
[spaceFor, navigate, togglePin, archiveTask],
118+
);
119+
120+
const handleDragOver = (e: DragEvent) => {
121+
if (!e.dataTransfer.types.includes("text/x-task-id")) return;
122+
e.preventDefault();
123+
e.dataTransfer.dropEffect = "copy";
124+
setIsDropTarget(true);
125+
};
126+
const handleDragLeave = (e: DragEvent) => {
127+
// dragleave fires when crossing into children; only clear on a real exit.
128+
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
129+
setIsDropTarget(false);
130+
};
131+
const handleDrop = (e: DragEvent) => {
132+
setIsDropTarget(false);
133+
const taskId = e.dataTransfer.getData("text/x-task-id");
134+
if (!taskId) return;
135+
e.preventDefault();
136+
addToWatchList(taskId);
137+
};
138+
139+
return (
140+
// biome-ignore lint/a11y/noStaticElementInteractions: drop target for task drags; every row inside stays keyboard-reachable
141+
<div
142+
className={cn(
143+
"flex flex-col gap-px rounded-md transition-colors",
144+
isDropTarget && "bg-fill-hover",
145+
)}
146+
onDragOver={handleDragOver}
147+
onDragLeave={handleDragLeave}
148+
onDrop={handleDrop}
149+
>
150+
{/* Same section-label shape as the code sidebar's "Sessions" header,
151+
folding on click. */}
152+
<MenuLabel
153+
render={
154+
<button
155+
type="button"
156+
onClick={toggleWatchList}
157+
aria-expanded={open}
158+
className="flex w-full items-center gap-1.5"
159+
/>
160+
}
161+
>
162+
Watch list
163+
<CaretRightIcon
164+
size={10}
165+
className={cn(
166+
"text-muted-foreground transition-transform",
167+
open && "rotate-90",
168+
)}
169+
/>
170+
</MenuLabel>
171+
172+
{open &&
173+
(items.length === 0 ? (
174+
<div className="px-2 pt-0.5 pb-2 text-[12px] text-muted-foreground">
175+
Drag a session here to keep an eye on it
176+
</div>
177+
) : (
178+
<div className="flex flex-col gap-px pb-1">
179+
{items.map((item) => (
180+
// Overlay, not endContent: the row is a button already.
181+
<div key={item.key} className="group/watch relative">
182+
<ChannelItemRow
183+
item={item}
184+
channelId={spaceFor.get(item.id)?.folderId}
185+
isActive={pathname.endsWith(`/tasks/${item.id}`)}
186+
actions={actions}
187+
contextLabel={spaceFor.get(item.id)?.spaceName ?? undefined}
188+
/>
189+
<Button
190+
variant="default"
191+
size="icon-sm"
192+
aria-label="Remove from watch list"
193+
onClick={() => removeFromWatchList(item.id)}
194+
className="-translate-y-1/2 absolute top-1/2 right-0.5 text-muted-foreground opacity-0 transition-opacity focus-visible:opacity-100 group-hover/watch:opacity-100"
195+
>
196+
<XIcon size={12} />
197+
</Button>
198+
</div>
199+
))}
200+
</div>
201+
))}
202+
</div>
203+
);
204+
}

0 commit comments

Comments
 (0)