diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 7fb11efd..902cbcf4 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -25,10 +25,13 @@ import { MoreHorizontal, Play, ChevronDown, + Globe, + Lock, } from "lucide-react"; import type { Agent, AgentStatus, + AgentVisibility, AgentTool, AgentTrigger, AgentTriggerType, @@ -126,6 +129,7 @@ function CreateAgentDialog({ const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [selectedRuntimeId, setSelectedRuntimeId] = useState(runtimes[0]?.id ?? ""); + const [visibility, setVisibility] = useState("private"); const [creating, setCreating] = useState(false); const [runtimeOpen, setRuntimeOpen] = useState(false); @@ -145,6 +149,7 @@ function CreateAgentDialog({ name: name.trim(), description: description.trim(), runtime_id: selectedRuntime.id, + visibility, triggers: [{ id: generateId(), type: "on_assign", enabled: true, config: {} }], }); onClose(); @@ -189,6 +194,42 @@ function CreateAgentDialog({ /> +
+ +
+ + +
+
+
diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index e00ba229..867a1b07 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -52,6 +52,7 @@ const typeLabels: Record = { task_failed: "Task failed", agent_blocked: "Agent blocked", agent_completed: "Agent completed", + reaction_added: "Reacted", }; function timeAgo(dateStr: string): string { @@ -126,6 +127,11 @@ function InboxDetailLabel({ item }: { item: InboxItem }) { if (item.body) return {item.body}; return {typeLabels[item.type]}; } + case "reaction_added": { + const emoji = details.emoji; + if (emoji) return Reacted {emoji} to your comment; + return {typeLabels[item.type]}; + } default: return {typeLabels[item.type] ?? item.type}; } diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index b6b6b151..c9ffdb1d 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -291,6 +291,7 @@ describe("IssueDetailPage", () => { author_type: "member", author_id: "user-1", parent_id: null, + reactions: [], created_at: "2026-01-18T00:00:00Z", updated_at: "2026-01-18T00:00:00Z", }; diff --git a/apps/web/components/common/emoji-picker.tsx b/apps/web/components/common/emoji-picker.tsx new file mode 100644 index 00000000..93b12824 --- /dev/null +++ b/apps/web/components/common/emoji-picker.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import data from "@emoji-mart/data"; +import { Picker } from "emoji-mart"; + +interface EmojiPickerProps { + onSelect: (emoji: string) => void; +} + +export function EmojiPicker({ onSelect }: EmojiPickerProps) { + const containerRef = useRef(null); + const onSelectRef = useRef(onSelect); + onSelectRef.current = onSelect; + + const handleSelect = useCallback((emoji: { native: string }) => { + onSelectRef.current(emoji.native); + }, []); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const picker = new Picker({ + data, + onEmojiSelect: handleSelect, + theme: "auto", + set: "native", + previewPosition: "none", + skinTonePosition: "search", + maxFrequentRows: 2, + }); + + container.appendChild(picker as unknown as Node); + + return () => { + container.replaceChildren(); + }; + }, [handleSelect]); + + return
; +} diff --git a/apps/web/components/common/reaction-bar.tsx b/apps/web/components/common/reaction-bar.tsx new file mode 100644 index 00000000..fa98a5fc --- /dev/null +++ b/apps/web/components/common/reaction-bar.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useState, lazy, Suspense } from "react"; +import { SmilePlus } from "lucide-react"; +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { useActorName } from "@/features/workspace"; + +const EmojiPicker = lazy(() => + import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })), +); + +const QUICK_EMOJIS = ["👍", "👎", "❤️", "😄", "🎉", "😕", "🚀", "👀"]; + +interface ReactionItem { + id: string; + actor_type: string; + actor_id: string; + emoji: string; +} + +interface GroupedReaction { + emoji: string; + count: number; + reacted: boolean; + actors: { type: string; id: string }[]; +} + +function groupReactions(reactions: ReactionItem[], currentUserId?: string): GroupedReaction[] { + const map = new Map(); + for (const r of reactions) { + let group = map.get(r.emoji); + if (!group) { + group = { emoji: r.emoji, count: 0, reacted: false, actors: [] }; + map.set(r.emoji, group); + } + group.count++; + group.actors.push({ type: r.actor_type, id: r.actor_id }); + if (r.actor_type === "member" && r.actor_id === currentUserId) { + group.reacted = true; + } + } + return Array.from(map.values()); +} + +export function ReactionBar({ + reactions, + currentUserId, + onToggle, + className, +}: { + reactions: ReactionItem[]; + currentUserId?: string; + onToggle: (emoji: string) => void; + className?: string; +}) { + const [pickerOpen, setPickerOpen] = useState(false); + const [showFullPicker, setShowFullPicker] = useState(false); + const grouped = groupReactions(reactions, currentUserId); + const { getActorName } = useActorName(); + + const handlePickerOpenChange = (open: boolean) => { + setPickerOpen(open); + if (!open) setShowFullPicker(false); + }; + + return ( +
+ {grouped.map((g) => ( + + onToggle(g.emoji)} + className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors hover:bg-accent ${ + g.reacted + ? "border-primary/40 bg-primary/10 text-primary" + : "border-border text-muted-foreground" + }`} + > + {g.emoji} + {g.count} + + } + /> + + {g.actors.map((a) => getActorName(a.type, a.id)).join(", ")} + + + ))} + + + + + } + /> + + {showFullPicker ? ( + Loading...
}> + { + onToggle(emoji); + setPickerOpen(false); + setShowFullPicker(false); + }} + /> + + ) : ( +
+
+ {QUICK_EMOJIS.map((emoji) => ( + + ))} +
+ +
+ )} + + +
+ ); +} diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index 5db099fb..eef902d4 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { ActorAvatar } from "@/components/common/actor-avatar"; +import { ReactionBar } from "@/components/common/reaction-bar"; import { Markdown } from "@/components/markdown"; import { useActorName } from "@/features/workspace"; import { timeAgo } from "@/shared/utils"; @@ -31,6 +32,7 @@ interface CommentCardProps { onReply: (parentId: string, content: string) => Promise; onEdit: (commentId: string, content: string) => Promise; onDelete: (commentId: string) => void; + onToggleReaction: (commentId: string, emoji: string) => void; } // --------------------------------------------------------------------------- @@ -42,11 +44,13 @@ function CommentRow({ currentUserId, onEdit, onDelete, + onToggleReaction, }: { entry: TimelineEntry; currentUserId?: string; onEdit: (commentId: string, content: string) => Promise; onDelete: (commentId: string) => void; + onToggleReaction: (commentId: string, emoji: string) => void; }) { const { getActorName } = useActorName(); const [editing, setEditing] = useState(false); @@ -77,6 +81,8 @@ function CommentRow({ } }; + const reactions = entry.reactions ?? []; + return (
@@ -136,9 +142,19 @@ function CommentRow({
) : ( -
- {entry.content ?? ""} -
+ <> +
+ {entry.content ?? ""} +
+ {!isTemp && ( + onToggleReaction(entry.id, emoji)} + className="mt-1.5 pl-8" + /> + )} + )}
); @@ -155,6 +171,7 @@ function CommentCard({ onReply, onEdit, onDelete, + onToggleReaction, }: CommentCardProps) { // Collect all nested replies recursively into a flat list const allNestedReplies: TimelineEntry[] = []; @@ -176,6 +193,7 @@ function CommentCard({ currentUserId={currentUserId} onEdit={onEdit} onDelete={onDelete} + onToggleReaction={onToggleReaction} />
@@ -187,6 +205,7 @@ function CommentCard({ currentUserId={currentUserId} onEdit={onEdit} onDelete={onDelete} + onToggleReaction={onToggleReaction} /> ))} diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index a7a6ec89..48a72778 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -55,7 +55,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"; import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; import { ActorAvatar } from "@/components/common/actor-avatar"; -import type { Issue, Comment, IssueSubscriber, UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types"; +import type { Issue, IssueReaction, Comment, IssueSubscriber, UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; import { StatusIcon, PriorityIcon, DueDatePicker } from "@/features/issues/components"; import { CommentCard } from "./comment-card"; @@ -66,7 +66,8 @@ import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; import { useWSEvent } from "@/features/realtime"; import { useIssueStore } from "@/features/issues"; -import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload, SubscriberAddedPayload, SubscriberRemovedPayload, ActivityCreatedPayload } from "@/shared/types"; +import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload, SubscriberAddedPayload, SubscriberRemovedPayload, ActivityCreatedPayload, ReactionAddedPayload, ReactionRemovedPayload, IssueReactionAddedPayload, IssueReactionRemovedPayload } from "@/shared/types"; +import { ReactionBar } from "@/components/common/reaction-bar"; import { timeAgo } from "@/shared/utils"; function shortDate(date: string | null): string { @@ -136,6 +137,7 @@ function commentToTimelineEntry(c: Comment): TimelineEntry { created_at: c.created_at, updated_at: c.updated_at, comment_type: c.type, + reactions: c.reactions ?? [], }; } @@ -196,6 +198,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const sidebarRef = usePanelRef(); const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen); const [issue, setIssue] = useState(null); + const [issueReactions, setIssueReactions] = useState([]); const [timeline, setTimeline] = useState([]); const [subscribers, setSubscribers] = useState([]); const [loading, setLoading] = useState(true); @@ -229,12 +232,14 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo wasLoadedRef.current = false; setIssue(null); setTitleDraft(""); + setIssueReactions([]); setTimeline([]); setSubscribers([]); setLoading(true); Promise.all([api.getIssue(id), api.listTimeline(id), api.listIssueSubscribers(id)]) .then(([iss, entries, subs]) => { setIssue(iss); + setIssueReactions(iss.reactions ?? []); setTitleDraft(iss.title); setTimeline(entries); setSubscribers(subs); @@ -431,6 +436,157 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo }, [id]), ); + // Real-time reaction updates + useWSEvent( + "reaction:added", + useCallback((payload: unknown) => { + const { reaction, issue_id } = payload as ReactionAddedPayload; + if (issue_id !== id) return; + // Skip own reactions — already added optimistically + if (reaction.actor_type === "member" && reaction.actor_id === user?.id) return; + setTimeline((prev) => prev.map((e) => { + if (e.id !== reaction.comment_id) return e; + const existing = e.reactions ?? []; + if (existing.some((r) => r.id === reaction.id)) return e; + return { ...e, reactions: [...existing, reaction] }; + })); + }, [id, user?.id]), + ); + + useWSEvent( + "reaction:removed", + useCallback((payload: unknown) => { + const p = payload as ReactionRemovedPayload; + if (p.issue_id !== id) return; + // Skip own removals — already removed optimistically + if (p.actor_type === "member" && p.actor_id === user?.id) return; + setTimeline((prev) => prev.map((e) => { + if (e.id !== p.comment_id) return e; + return { + ...e, + reactions: (e.reactions ?? []).filter( + (r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id), + ), + }; + })); + }, [id, user?.id]), + ); + + // Real-time issue reaction updates + useWSEvent( + "issue_reaction:added", + useCallback((payload: unknown) => { + const { reaction, issue_id } = payload as IssueReactionAddedPayload; + if (issue_id !== id) return; + if (reaction.actor_type === "member" && reaction.actor_id === user?.id) return; + setIssueReactions((prev) => { + if (prev.some((r) => r.id === reaction.id)) return prev; + return [...prev, reaction]; + }); + }, [id, user?.id]), + ); + + useWSEvent( + "issue_reaction:removed", + useCallback((payload: unknown) => { + const p = payload as IssueReactionRemovedPayload; + if (p.issue_id !== id) return; + if (p.actor_type === "member" && p.actor_id === user?.id) return; + setIssueReactions((prev) => + prev.filter((r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id)), + ); + }, [id, user?.id]), + ); + + const handleToggleIssueReaction = async (emoji: string) => { + if (!user) return; + const existing = issueReactions.find( + (r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === user.id, + ); + if (existing) { + setIssueReactions((prev) => prev.filter((r) => r.id !== existing.id)); + try { + await api.removeIssueReaction(id, emoji); + } catch { + setIssueReactions((prev) => [...prev, existing]); + toast.error("Failed to remove reaction"); + } + } else { + const temp = { + id: `temp-${Date.now()}`, + issue_id: id, + actor_type: "member", + actor_id: user.id, + emoji, + created_at: new Date().toISOString(), + }; + setIssueReactions((prev) => [...prev, temp]); + try { + const reaction = await api.addIssueReaction(id, emoji); + setIssueReactions((prev) => prev.map((r) => (r.id === temp.id ? reaction : r))); + } catch { + setIssueReactions((prev) => prev.filter((r) => r.id !== temp.id)); + toast.error("Failed to add reaction"); + } + } + }; + + const handleToggleReaction = async (commentId: string, emoji: string) => { + if (!user) return; + const entry = timeline.find((e) => e.id === commentId); + const existing = (entry?.reactions ?? []).find( + (r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === user.id, + ); + if (existing) { + // Optimistic remove + setTimeline((prev) => prev.map((e) => { + if (e.id !== commentId) return e; + return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== existing.id) }; + })); + try { + await api.removeReaction(commentId, emoji); + } catch { + // Rollback + setTimeline((prev) => prev.map((e) => { + if (e.id !== commentId) return e; + return { ...e, reactions: [...(e.reactions ?? []), existing] }; + })); + toast.error("Failed to remove reaction"); + } + } else { + // Optimistic add + const tempReaction = { + id: `temp-${Date.now()}`, + comment_id: commentId, + actor_type: "member", + actor_id: user.id, + emoji, + created_at: new Date().toISOString(), + }; + setTimeline((prev) => prev.map((e) => { + if (e.id !== commentId) return e; + return { ...e, reactions: [...(e.reactions ?? []), tempReaction] }; + })); + try { + const reaction = await api.addReaction(commentId, emoji); + setTimeline((prev) => prev.map((e) => { + if (e.id !== commentId) return e; + return { + ...e, + reactions: (e.reactions ?? []).map((r) => (r.id === tempReaction.id ? reaction : r)), + }; + })); + } catch { + // Rollback + setTimeline((prev) => prev.map((e) => { + if (e.id !== commentId) return e; + return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== tempReaction.id) }; + })); + toast.error("Failed to add reaction"); + } + } + }; + // Real-time subscriber updates useWSEvent( "subscriber:added", @@ -771,6 +927,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo className="mt-5" /> + +
{/* Activity / Comments */} @@ -933,6 +1096,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo onReply={handleSubmitReply} onEdit={handleEditComment} onDelete={handleDeleteComment} + onToggleReaction={handleToggleReaction} /> ); } diff --git a/apps/web/features/issues/components/pickers/assignee-picker.tsx b/apps/web/features/issues/components/pickers/assignee-picker.tsx index a41ba9cc..5ffd0d2c 100644 --- a/apps/web/features/issues/components/pickers/assignee-picker.tsx +++ b/apps/web/features/issues/components/pickers/assignee-picker.tsx @@ -1,8 +1,9 @@ "use client"; import { useState } from "react"; -import { Bot, UserMinus } from "lucide-react"; -import type { IssueAssigneeType, UpdateIssueRequest } from "@/shared/types"; +import { Bot, Lock, UserMinus } from "lucide-react"; +import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types"; +import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; import { PropertyPicker, @@ -11,6 +12,13 @@ import { PickerEmpty, } from "./property-picker"; +function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean { + if (agent.visibility !== "private") return true; + if (agent.owner_id === userId) return true; + if (memberRole === "owner" || memberRole === "admin") return true; + return false; +} + export function AssigneePicker({ assigneeType, assigneeId, @@ -24,10 +32,14 @@ export function AssigneePicker({ }) { const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); + const user = useAuthStore((s) => s.user); const members = useWorkspaceStore((s) => s.members); const agents = useWorkspaceStore((s) => s.agents); const { getActorName, getActorInitials } = useActorName(); + const currentMember = members.find((m) => m.user_id === user?.id); + const memberRole = currentMember?.role; + const query = filter.toLowerCase(); const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(query), @@ -117,24 +129,32 @@ export function AssigneePicker({ {/* Agents */} {filteredAgents.length > 0 && ( - {filteredAgents.map((a) => ( - { - onUpdate({ - assignee_type: "agent", - assignee_id: a.id, - }); - setOpen(false); - }} - > -
- -
- {a.name} -
- ))} + {filteredAgents.map((a) => { + const allowed = canAssignAgent(a, user?.id, memberRole); + return ( + { + if (!allowed) return; + onUpdate({ + assignee_type: "agent", + assignee_id: a.id, + }); + setOpen(false); + }} + > +
+ +
+ {a.name} + {a.visibility === "private" && ( + + )} +
+ ); + })}
)} diff --git a/apps/web/features/issues/components/pickers/property-picker.tsx b/apps/web/features/issues/components/pickers/property-picker.tsx index 2aa2048d..1329fa4d 100644 --- a/apps/web/features/issues/components/pickers/property-picker.tsx +++ b/apps/web/features/issues/components/pickers/property-picker.tsx @@ -79,11 +79,13 @@ export function PropertyPicker({ export function PickerItem({ selected, + disabled, onClick, hoverClassName, children, }: { selected: boolean; + disabled?: boolean; onClick: () => void; hoverClassName?: string; children: React.ReactNode; @@ -91,8 +93,9 @@ export function PickerItem({ return (