Skip to content

Commit 5ecdb80

Browse files
committed
reply to thread added to fake composer, working
1 parent aff8287 commit 5ecdb80

6 files changed

Lines changed: 171 additions & 3 deletions

File tree

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,20 @@ import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon";
4848
import {
4949
MentionText,
5050
mentionChipClass,
51+
TaskLinkIcon,
5152
} from "@posthog/ui/features/canvas/components/MentionText";
5253
import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages";
5354
import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData";
5455
import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread";
56+
import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore";
5557
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
5658
import {
5759
type SidebarPrState,
5860
useTaskPrStatus,
5961
} from "@posthog/ui/features/sidebar/useTaskPrStatus";
6062
import { useInView } from "@posthog/ui/primitives/hooks/useInView";
6163
import { Text } from "@radix-ui/themes";
64+
import { useNavigate } from "@tanstack/react-router";
6265
import { Fragment, memo, type ReactNode, useMemo } from "react";
6366

6467
// Feed rows poll their reply counts slower than the open thread panel — the
@@ -532,15 +535,33 @@ export function DemoMessageItem({
532535
fromKind,
533536
content,
534537
createdAt,
538+
replyTo,
535539
onDelete,
536540
}: {
537541
fromName: string;
538542
fromKind: "human" | "agent";
539543
content: string;
540544
createdAt?: string;
541-
/** When set, a hover "…" menu offers a destructive delete. */
545+
/** When set, renders a Slack-style "replied to a thread: …" preview line. */
546+
replyTo?: { label: string; href: string };
542547
onDelete?: () => void;
543548
}) {
549+
const navigate = useNavigate();
550+
const openThread = useThreadPanelStore((s) => s.openThread);
551+
// The reply target is a task deep link; clicking opens that task's *thread*
552+
// dock (not the full task page) in its channel home.
553+
const replyMatch = replyTo?.href.match(
554+
/\/website\/([^/]+)\/tasks\/([^/?#]+)/,
555+
);
556+
const openReplyThread = () => {
557+
if (!replyMatch) return;
558+
const [, replyChannelId, taskId] = replyMatch;
559+
openThread(replyChannelId, taskId);
560+
void navigate({
561+
to: "/website/$channelId",
562+
params: { channelId: replyChannelId },
563+
});
564+
};
544565
return (
545566
<ThreadItem className="rounded-none py-4 pr-8">
546567
<ThreadItemGutter>
@@ -564,6 +585,21 @@ export function DemoMessageItem({
564585
</ThreadItemTimestamp>
565586
)}
566587
</ThreadItemHeader>
588+
{replyTo && (
589+
<div className="flex items-center gap-1 text-muted-foreground text-xs">
590+
<span>replied to a thread:</span>
591+
{/* Opens the task's thread dock (not the task page); the live
592+
task-status icon matches reference links. */}
593+
<button
594+
type="button"
595+
onClick={openReplyThread}
596+
className="inline-flex min-w-0 items-center gap-1 text-primary hover:underline"
597+
>
598+
{replyMatch && <TaskLinkIcon taskId={replyMatch[2]} />}
599+
<span className="truncate">{replyTo.label}</span>
600+
</button>
601+
</div>
602+
)}
567603
<ThreadItemBody className="wrap-break-word">
568604
{content ? (
569605
// Same renderer as real thread messages: @mentions become chips and
@@ -620,6 +656,7 @@ function DemoFeedRow({
620656
fromKind={demo.fromKind}
621657
content={demo.content}
622658
createdAt={message.createdAt}
659+
replyTo={demo.replyTo}
623660
onDelete={onDelete ? () => onDelete(message.id) : undefined}
624661
/>
625662
</ChatMessageScrollerItem>

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import {
1717
import { DemoMessageItem } from "@posthog/ui/features/canvas/components/ChannelFeedView";
1818
import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer";
1919
import { ReferencePicker } from "@posthog/ui/features/canvas/components/ReferencePicker";
20+
import {
21+
type ThreadReply,
22+
ThreadReplyPicker,
23+
} from "@posthog/ui/features/canvas/components/ThreadReplyPicker";
2024
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
2125
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
2226
import { useDemoFeedStore } from "@posthog/ui/features/canvas/stores/demoFeedStore";
@@ -55,6 +59,7 @@ export function InsertFakeMessageDialog({
5559
const [fromName, setFromName] = useState("");
5660
const [fromKind, setFromKind] = useState<"human" | "agent">("human");
5761
const [content, setContent] = useState("");
62+
const [replyTo, setReplyTo] = useState<ThreadReply | null>(null);
5863

5964
// Reset fields on each open so a prior draft never lingers (prev-prop compare,
6065
// not an effect, so there's no stale flash).
@@ -65,6 +70,7 @@ export function InsertFakeMessageDialog({
6570
setFromName("");
6671
setFromKind("human");
6772
setContent("");
73+
setReplyTo(null);
6874
}
6975
}
7076

@@ -98,6 +104,7 @@ export function InsertFakeMessageDialog({
98104
fromKind === "agent" ? AGENT_PERSONA : fromName.trim() || "Someone",
99105
fromKind,
100106
content: content.trim(),
107+
replyTo: replyTo ?? undefined,
101108
});
102109
toast.success("Added to the channel feed");
103110
onOpenChange(false);
@@ -170,6 +177,16 @@ export function InsertFakeMessageDialog({
170177
)}
171178
</Field>
172179

180+
{/* Optional: render the message as a reply to a thread (a task). */}
181+
<Field>
182+
<FieldLabel>Reply to thread (optional)</FieldLabel>
183+
<ThreadReplyPicker
184+
channelId={channelId}
185+
value={replyTo}
186+
onChange={setReplyTo}
187+
/>
188+
</Field>
189+
173190
{/* Message body: a real thread composer, so @ opens the same member
174191
picker as a thread. The reference picker inserts canvas / channel /
175192
task / CONTEXT.md deep links. */}
@@ -202,6 +219,7 @@ export function InsertFakeMessageDialog({
202219
fromName={fromName}
203220
fromKind={fromKind}
204221
content={content}
222+
replyTo={replyTo ?? undefined}
205223
/>
206224
</div>
207225
</div>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
// The task-list status icon for a `…/tasks/<id>` link — live, like the sidebar
2525
// and feed cards (generating spinner, PR/cloud state). Falls back to the code
2626
// glyph while the task loads or if it isn't cached.
27-
function TaskLinkIcon({ taskId }: { taskId: string }) {
27+
export function TaskLinkIcon({ taskId }: { taskId: string }) {
2828
const { data } = useQuery({ ...taskDetailQuery(taskId), staleTime: 30_000 });
2929
return <TaskTabIcon task={data ?? getCachedTask(taskId)} size={12} />;
3030
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { CaretDownIcon, XIcon } from "@phosphor-icons/react";
2+
import {
3+
Button,
4+
DropdownMenu,
5+
DropdownMenuContent,
6+
DropdownMenuGroup,
7+
DropdownMenuItem,
8+
DropdownMenuLabel,
9+
DropdownMenuSeparator,
10+
DropdownMenuTrigger,
11+
} from "@posthog/quill";
12+
import type { Task } from "@posthog/shared/domain-types";
13+
import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon";
14+
import { useChannelFeed } from "@posthog/ui/features/canvas/hooks/useChannelFeed";
15+
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
16+
import { useBackendChannel } from "@posthog/ui/features/canvas/hooks/useTaskChannels";
17+
import { useTasks } from "@posthog/ui/features/tasks/useTasks";
18+
19+
export interface ThreadReply {
20+
label: string;
21+
href: string;
22+
}
23+
24+
// Picks the thread (a task) a fake message replies to. Same task list as the
25+
// reference picker — this channel's tasks first, then all others.
26+
export function ThreadReplyPicker({
27+
channelId,
28+
value,
29+
onChange,
30+
}: {
31+
channelId: string;
32+
value: ThreadReply | null;
33+
onChange: (value: ThreadReply | null) => void;
34+
}) {
35+
const { channels } = useChannels();
36+
const channelName = channels.find((c) => c.id === channelId)?.name;
37+
const { channel: backendChannel } = useBackendChannel(channelName);
38+
const { tasks: channelTasks } = useChannelFeed(backendChannel?.id);
39+
const { data: allTasks = [] } = useTasks();
40+
41+
const channelTaskIds = new Set(channelTasks.map((t) => t.id));
42+
const otherTasks = allTasks.filter((t) => !channelTaskIds.has(t.id));
43+
44+
const taskItem = (t: Task) => (
45+
<DropdownMenuItem
46+
key={t.id}
47+
onClick={() =>
48+
onChange({
49+
label: t.title || "Untitled task",
50+
href: `/website/${channelId}/tasks/${t.id}`,
51+
})
52+
}
53+
>
54+
<TaskTabIcon task={t} size={14} />
55+
<span className="truncate">{t.title || "Untitled task"}</span>
56+
</DropdownMenuItem>
57+
);
58+
59+
return (
60+
<div className="flex items-center gap-2">
61+
<DropdownMenu>
62+
<DropdownMenuTrigger
63+
render={
64+
<Button variant="outline" size="sm" className="max-w-full">
65+
<span className="truncate">
66+
{value ? value.label : "Choose a thread…"}
67+
</span>
68+
<CaretDownIcon size={12} className="ms-auto shrink-0" />
69+
</Button>
70+
}
71+
/>
72+
<DropdownMenuContent align="start" className="w-72">
73+
{channelTasks.length === 0 && otherTasks.length === 0 ? (
74+
<DropdownMenuItem disabled>No tasks yet</DropdownMenuItem>
75+
) : (
76+
<>
77+
{channelTasks.length > 0 && (
78+
<DropdownMenuGroup>
79+
<DropdownMenuLabel>This channel</DropdownMenuLabel>
80+
{channelTasks.map(taskItem)}
81+
</DropdownMenuGroup>
82+
)}
83+
{otherTasks.length > 0 && (
84+
<DropdownMenuGroup>
85+
{channelTasks.length > 0 && <DropdownMenuSeparator />}
86+
<DropdownMenuLabel>Other tasks</DropdownMenuLabel>
87+
{otherTasks.map(taskItem)}
88+
</DropdownMenuGroup>
89+
)}
90+
</>
91+
)}
92+
</DropdownMenuContent>
93+
</DropdownMenu>
94+
{value && (
95+
<Button
96+
variant="outline"
97+
size="icon-sm"
98+
aria-label="Clear reply"
99+
onClick={() => onChange(null)}
100+
>
101+
<XIcon size={13} />
102+
</Button>
103+
)}
104+
</div>
105+
);
106+
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,12 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) {
7373
id: e.id,
7474
createdAt: e.createdAt,
7575
text: e.content,
76-
demo: { fromName: e.fromName, fromKind: e.fromKind, content: e.content },
76+
demo: {
77+
fromName: e.fromName,
78+
fromKind: e.fromKind,
79+
content: e.content,
80+
replyTo: e.replyTo,
81+
},
7782
}));
7883
const base = [...feedMessages, ...demoMessages];
7984
const creation = channelCreationMessage(backendChannel);

packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export interface DemoFeedMessage {
2020
fromKind: "human" | "agent";
2121
/** Markdown; may embed links to canvases / other channels. */
2222
content: string;
23+
/** When set, the message renders as a reply to this thread (a task deep link). */
24+
replyTo?: { label: string; href: string };
2325
}
2426

2527
// The `payload` key that marks a feed message as a demo composer post — the

0 commit comments

Comments
 (0)