From e84cb4f04ab69de74c4db2c0187f4e37d5a5df4a Mon Sep 17 00:00:00 2001 From: Junjie Xiong Date: Sat, 4 Apr 2026 03:45:39 -0700 Subject: [PATCH] feat: wire kanban modal upvote, downvote, and reply to API Vote buttons now call POST /feed/{id}/vote and POST /comments/{id}/vote. Reply input posts to POST /items/{id}/comments. Optimistic vote count updates and auto-refresh after reply. --- ui/src/app/task/[id]/page.tsx | 3 +- .../components/kanban/kanban-card-modal.tsx | 130 +++++++++++++++++- ui/src/hooks/use-items.ts | 3 +- 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/ui/src/app/task/[id]/page.tsx b/ui/src/app/task/[id]/page.tsx index 2878494..8c29716 100644 --- a/ui/src/app/task/[id]/page.tsx +++ b/ui/src/app/task/[id]/page.tsx @@ -232,7 +232,7 @@ export default function TaskDetailPage() { const [kanbanFilters, setKanbanFilters] = useState({ status: "all", priority: "all" }); const [kanbanSearch, setKanbanSearch] = useState(""); const [selectedCard, setSelectedCard] = useState(null); - const { activities: cardActivities, loading: cardActivitiesLoading } = useItemActivity(taskId, selectedCard?.id ?? null); + const { activities: cardActivities, loading: cardActivitiesLoading, mutate: mutateCardActivities } = useItemActivity(taskId, selectedCard?.id ?? null); const filteredKanbanItems = useMemo(() => { let result = kanbanItems; @@ -869,6 +869,7 @@ export default function TaskDetailPage() { activitiesLoading={cardActivitiesLoading} onClose={() => setSelectedCard(null)} taskId={taskId} + onActivityRefresh={mutateCardActivities} /> )} diff --git a/ui/src/components/kanban/kanban-card-modal.tsx b/ui/src/components/kanban/kanban-card-modal.tsx index 3d1b1ec..981aa8d 100644 --- a/ui/src/components/kanban/kanban-card-modal.tsx +++ b/ui/src/components/kanban/kanban-card-modal.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState, useCallback } from "react"; import { Item, ItemActivity } from "@/types/items"; import { Avatar } from "@/components/shared/avatar"; import { relativeTime } from "@/lib/time"; +import { apiPostJson } from "@/lib/api"; const statusLabels: Record = { backlog: "Backlog", @@ -34,9 +35,37 @@ interface ModalProps { activitiesLoading?: boolean; onClose: () => void; taskId: string; + agentToken?: string; + onActivityRefresh?: () => void; } -export function KanbanCardModal({ item, activities, activitiesLoading, onClose, taskId }: ModalProps) { +type VoteType = "up" | "down"; + +function voteEndpoint(taskId: string, activity: ItemActivity, token?: string): string | null { + const qs = token ? `?token=${encodeURIComponent(token)}` : ""; + if (activity.type === "post" || activity.type === "run") { + return `/tasks/${taskId}/feed/${activity.id}/vote${qs}`; + } + if (activity.type === "feed_comment") { + return `/tasks/${taskId}/comments/${activity.id}/vote${qs}`; + } + return null; +} + +export function KanbanCardModal({ + item, + activities, + activitiesLoading, + onClose, + taskId, + agentToken, + onActivityRefresh, +}: ModalProps) { + const [voteDeltas, setVoteDeltas] = useState>({}); + const [votingIds, setVotingIds] = useState>(new Set()); + const [replyText, setReplyText] = useState(""); + const [submittingReply, setSubmittingReply] = useState(false); + useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); @@ -45,6 +74,55 @@ export function KanbanCardModal({ item, activities, activitiesLoading, onClose, return () => document.removeEventListener("keydown", handler); }, [onClose]); + const handleVote = useCallback( + async (activity: ItemActivity, type: VoteType) => { + const key = `${activity.type}-${activity.id}`; + const endpoint = voteEndpoint(taskId, activity, agentToken); + if (!endpoint) return; + if (votingIds.has(key)) return; + + // Optimistic update + setVoteDeltas((prev) => { + const cur = prev[key] ?? { up: 0, down: 0 }; + return { ...prev, [key]: { ...cur, [type]: cur[type] + 1 } }; + }); + setVotingIds((prev) => new Set(prev).add(key)); + + try { + await apiPostJson(endpoint, { type }); + } catch { + // Revert optimistic update on failure + setVoteDeltas((prev) => { + const cur = prev[key] ?? { up: 0, down: 0 }; + return { ...prev, [key]: { ...cur, [type]: Math.max(0, cur[type] - 1) } }; + }); + } finally { + setVotingIds((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + } + }, + [taskId, agentToken, votingIds], + ); + + const handleReply = useCallback(async () => { + const content = replyText.trim(); + if (!content || submittingReply) return; + const qs = agentToken ? `?token=${encodeURIComponent(agentToken)}` : ""; + setSubmittingReply(true); + try { + await apiPostJson(`/tasks/${taskId}/items/${item.id}/comments${qs}`, { content }); + setReplyText(""); + onActivityRefresh?.(); + } catch { + // Silently fail; user can retry + } finally { + setSubmittingReply(false); + } + }, [replyText, submittingReply, taskId, item.id, agentToken, onActivityRefresh]); + const pi = priorityLabels[item.priority] ?? priorityLabels.none; return ( @@ -132,8 +210,12 @@ export function KanbanCardModal({ item, activities, activitiesLoading, onClose,
{activities.map((a) => { const tl = typeLabels[a.type] ?? typeLabels.post; + const key = `${a.type}-${a.id}`; + const deltas = voteDeltas[key] ?? { up: 0, down: 0 }; + const canVote = voteEndpoint(taskId, a, agentToken) !== null; + const isVoting = votingIds.has(key); return ( -
+
@@ -166,17 +248,31 @@ export function KanbanCardModal({ item, activities, activitiesLoading, onClose,
)} - {/* Action buttons (UI only) */} + {/* Action buttons */}
- -
@@ -186,6 +282,28 @@ export function KanbanCardModal({ item, activities, activitiesLoading, onClose, })}
+ + {/* Reply input */} +
+
+ setReplyText(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleReply(); } }} + placeholder="Add a comment..." + disabled={submittingReply} + className="flex-1 text-[11px] px-2.5 py-1.5 bg-[var(--color-layer-2)] border border-[var(--color-border)] text-[var(--color-text)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-accent)] transition-colors disabled:opacity-50" + /> + +
+
); diff --git a/ui/src/hooks/use-items.ts b/ui/src/hooks/use-items.ts index 656a94b..4cd04d5 100644 --- a/ui/src/hooks/use-items.ts +++ b/ui/src/hooks/use-items.ts @@ -18,7 +18,7 @@ export function useItems(taskId: string, status?: string) { } export function useItemActivity(taskId: string, itemId: string | null) { - const { data, isLoading } = useSWR( + const { data, isLoading, mutate } = useSWR( taskId && itemId ? `/tasks/${taskId}/items/${itemId}/activity?per_page=50` : null, apiFetch, { revalidateOnFocus: false, dedupingInterval: 5000 }, @@ -27,6 +27,7 @@ export function useItemActivity(taskId: string, itemId: string | null) { return { activities: data?.activities ?? [], loading: isLoading, + mutate, }; }