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

Commit 7007eac

Browse files
authored
feat(ui): drag spaces from the directory to pin; live watch-list status
All-spaces rows now carry a text/x-space-id drag payload and the pinned region accepts them — dropping stars the space (one gesture instead of the hover star) and lands it at the bottom of the user's order, ready to reorder. The watch list stops going stale: each watched task gets its own polled detail query, merged over the list copies, so a task the task-list query doesn't return still renders with its real status dot and badges instead of the synthesized quiet row. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986
1 parent b7f63dd commit 7007eac

3 files changed

Lines changed: 96 additions & 7 deletions

File tree

‎packages/ui/src/features/canvas/components/AllSpacesSection.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ export function AllSpacesSection() {
116116
label={channel.name}
117117
isActive={isActive}
118118
onClick={() => openSpace(channel)}
119+
// Dragging a directory row up into the pinned area pins
120+
// it — same result as the star, one gesture instead.
121+
draggable
122+
onDragStart={(e) => {
123+
e.dataTransfer.setData("text/x-space-id", channel.id);
124+
e.dataTransfer.effectAllowed = "copy";
125+
}}
119126
// Star well, so the name truncates clear of the star.
120127
endContent={
121128
<span aria-hidden className="size-5 shrink-0" />

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

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,21 @@ import { AllSpacesSection } from "@posthog/ui/features/canvas/components/AllSpac
1818
import { ChannelNav } from "@posthog/ui/features/canvas/components/ChannelNav";
1919
import { SpaceSection } from "@posthog/ui/features/canvas/components/SpaceSection";
2020
import { WatchListSection } from "@posthog/ui/features/canvas/components/WatchListSection";
21-
import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels";
21+
import {
22+
useChannelStarMutations,
23+
useChannelStars,
24+
} from "@posthog/ui/features/canvas/hooks/useChannelStars";
25+
import {
26+
type Channel,
27+
useChannels,
28+
} from "@posthog/ui/features/canvas/hooks/useChannels";
2229
import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots";
2330
import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels";
2431
import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore";
32+
import { toast } from "@posthog/ui/primitives/toast";
2533
import { openTaskInput } from "@posthog/ui/router/useOpenTask";
2634
import { track } from "@posthog/ui/shell/analytics";
27-
import type { RefCallback } from "react";
35+
import type { DragEvent, RefCallback } from "react";
2836
import { useMemo, useState } from "react";
2937

3038
/**
@@ -81,6 +89,13 @@ export function SpacesSidebarNav() {
8189
? searchText.trim().toLowerCase() || undefined
8290
: undefined;
8391

92+
// Dropping an All-spaces row into this region pins it (stars it), same as
93+
// the directory's star but as one gesture.
94+
const { channels } = useChannels();
95+
const { starredRefToShortcutId } = useChannelStars();
96+
const { star } = useChannelStarMutations();
97+
const [isSpaceDropTarget, setIsSpaceDropTarget] = useState(false);
98+
8499
const me = pinnedSpaces.find((c) => c.name === PERSONAL_CHANNEL_NAME);
85100
// The user's drag order over the starred set; spaces they've never dragged
86101
// keep their backend order after the ranked ones (sort is stable).
@@ -96,6 +111,41 @@ export function SpacesSidebarNav() {
96111
);
97112
}, [pinnedSpaces, spaceOrder]);
98113

114+
const handleSpaceDragOver = (e: DragEvent) => {
115+
if (!e.dataTransfer.types.includes("text/x-space-id")) return;
116+
e.preventDefault();
117+
e.dataTransfer.dropEffect = "copy";
118+
setIsSpaceDropTarget(true);
119+
};
120+
const handleSpaceDragLeave = (e: DragEvent) => {
121+
// dragleave fires when crossing into children; only clear on a real exit.
122+
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
123+
setIsSpaceDropTarget(false);
124+
};
125+
const handleSpaceDrop = (e: DragEvent) => {
126+
setIsSpaceDropTarget(false);
127+
const spaceId = e.dataTransfer.getData("text/x-space-id");
128+
if (!spaceId) return;
129+
e.preventDefault();
130+
const channel = channels.find((c) => c.id === spaceId);
131+
if (!channel || starredRefToShortcutId.has(channel.path)) return;
132+
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
133+
action_type: "star",
134+
surface: "sidebar",
135+
channel_id: channel.id,
136+
});
137+
star(channel).catch((error: unknown) =>
138+
toast.error("Couldn't pin space", {
139+
description: error instanceof Error ? error.message : String(error),
140+
}),
141+
);
142+
// Pins land at the bottom of the user's order, ready to drag into place.
143+
setSpaceOrder([
144+
...orderedSpaces.map((c) => c.id).filter((id) => id !== spaceId),
145+
spaceId,
146+
]);
147+
};
148+
99149
const handleDragEnd: DragDropEvents["dragend"] = (event) => {
100150
if (event.canceled) return;
101151
const sourceId = event.operation.source?.id;
@@ -201,8 +251,18 @@ export function SpacesSidebarNav() {
201251

202252
{/* Pinned (starred) spaces; #me first and fixed, the rest reorderable.
203253
This region is the sidebar's one scroll container — spaces unfold
204-
their full lists inside it, so there are no nested scrollbars. */}
205-
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
254+
their full lists inside it, so there are no nested scrollbars. It
255+
also accepts All-spaces rows dragged up from the directory below. */}
256+
{/* biome-ignore lint/a11y/noStaticElementInteractions: drop target for space drags; the directory's star is the keyboard path */}
257+
<div
258+
className={cn(
259+
"min-h-0 flex-1 overflow-y-auto px-2 pb-2 transition-colors",
260+
isSpaceDropTarget && "bg-fill-hover",
261+
)}
262+
onDragOver={handleSpaceDragOver}
263+
onDragLeave={handleSpaceDragLeave}
264+
onDrop={handleSpaceDrop}
265+
>
206266
<div className="flex flex-col gap-px">
207267
{me && <SpaceSection channel={me} query={query} />}
208268
{/* The handle doubles as the fold toggle, so a small pickup

‎packages/ui/src/features/canvas/components/WatchListSection.tsx‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ import {
1616
} from "@posthog/ui/features/canvas/hooks/useTaskChannels";
1717
import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore";
1818
import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks";
19+
import { taskDetailQuery } from "@posthog/ui/features/tasks/queries";
1920
import { useTasks } from "@posthog/ui/features/tasks/useTasks";
2021
import { toast } from "@posthog/ui/primitives/toast";
22+
import { useQueries } from "@tanstack/react-query";
2123
import { useNavigate, useRouterState } from "@tanstack/react-router";
2224
import { type DragEvent, useMemo, useState } from "react";
2325

26+
const WATCHED_TASK_POLL_INTERVAL_MS = 30_000;
27+
2428
/**
2529
* A personal watch list: drag any task row onto the section (they all carry
2630
* the text/x-task-id payload) to keep a reference to it here, newest first.
@@ -43,6 +47,16 @@ export function WatchListSection() {
4347

4448
// Anyone's task can be watched, so resolve against the full list.
4549
const { data: allTasks = [] } = useTasks({ showAllUsers: true });
50+
// The list query misses tasks (its own filters and page), and a synthesized
51+
// fallback row carries no live status — so each watched task also gets its
52+
// own polled detail query, the same authoritative record the task views
53+
// read. The status dot then agrees with the space lists.
54+
const watchedTaskQueries = useQueries({
55+
queries: watchList.map((entry) => ({
56+
...taskDetailQuery(entry.id),
57+
refetchInterval: WATCHED_TASK_POLL_INTERVAL_MS,
58+
})),
59+
});
4660
const { channels: backendChannels } = useTaskChannels({ enabled: open });
4761
const { channels: folderChannels } = useChannels();
4862
const archivedTaskIds = useArchivedTaskIds();
@@ -55,9 +69,17 @@ export function WatchListSection() {
5569
// renders, from the reference captured at drop time.
5670
const items = useMemo<ChannelItemModel[]>(() => {
5771
const watched = new Set(watchList.map((entry) => entry.id));
72+
// Detail records win over list copies: fresher, and they cover watched
73+
// tasks the list query doesn't return at all.
74+
const taskById = new Map(
75+
allTasks.filter((t) => watched.has(t.id)).map((t) => [t.id, t]),
76+
);
77+
for (const query of watchedTaskQueries) {
78+
if (query.data) taskById.set(query.data.id, query.data);
79+
}
5880
const built = buildChannelItems({
5981
dashboards: [],
60-
feedTasks: allTasks.filter((t) => watched.has(t.id)),
82+
feedTasks: [...taskById.values()],
6183
archivedTaskIds,
6284
pinnedTaskIds,
6385
ownedBy: null,
@@ -80,7 +102,7 @@ export function WatchListSection() {
80102
task: null,
81103
},
82104
);
83-
}, [watchList, allTasks, archivedTaskIds, pinnedTaskIds]);
105+
}, [watchList, allTasks, watchedTaskQueries, archivedTaskIds, pinnedTaskIds]);
84106

85107
// Each task's space: backend channel → display name → folder channel (which
86108
// the routes need). Unmapped tasks open under #me and carry no label.
@@ -222,7 +244,7 @@ export function WatchListSection() {
222244
size="icon-sm"
223245
aria-label="Remove from watch list"
224246
onClick={() => removeFromWatchList(item.id)}
225-
className="-translate-y-1/2 absolute top-1/2 right-0.5 bg-chrome text-muted-foreground opacity-0 shadow-sm transition-opacity focus-visible:opacity-100 group-hover/watch:opacity-100 hover:text-foreground"
247+
className="-translate-y-1/2 absolute top-1/2 right-0.5 bg-chrome text-muted-foreground opacity-0 shadow-sm transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/watch:opacity-100"
226248
>
227249
<XIcon size={13} weight="bold" />
228250
</Button>

0 commit comments

Comments
 (0)