merge: resolve conflicts with main
This commit is contained in:
commit
4780540bd2
121 changed files with 6937 additions and 1556 deletions
|
|
@ -8,6 +8,7 @@ import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskC
|
|||
import type { AgentTask } from "@/shared/types/agent";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { redactSecrets } from "../utils/redact";
|
||||
|
||||
// ─── Shared types & helpers ─────────────────────────────────────────────────
|
||||
|
|
@ -96,12 +97,11 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
|
|||
|
||||
interface AgentLiveCardProps {
|
||||
issueId: string;
|
||||
assigneeType: string | null;
|
||||
assigneeId: string | null;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }: AgentLiveCardProps) {
|
||||
export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
const { getActorName } = useActorName();
|
||||
const [activeTask, setActiveTask] = useState<AgentTask | null>(null);
|
||||
const [items, setItems] = useState<TimelineItem[]>([]);
|
||||
const [elapsed, setElapsed] = useState("");
|
||||
|
|
@ -112,11 +112,6 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }:
|
|||
|
||||
// Check for active task on mount
|
||||
useEffect(() => {
|
||||
if (assigneeType !== "agent" || !assigneeId) {
|
||||
setActiveTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
api.getActiveTaskForIssue(issueId).then(({ task }) => {
|
||||
if (!cancelled) {
|
||||
|
|
@ -134,7 +129,7 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }:
|
|||
}).catch(() => {});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [issueId, assigneeType, assigneeId]);
|
||||
}, [issueId]);
|
||||
|
||||
// Handle real-time task messages
|
||||
useWSEvent(
|
||||
|
|
@ -258,7 +253,7 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }:
|
|||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-info shrink-0" />
|
||||
<span className="truncate">{agentName ?? "Agent"} is working</span>
|
||||
<span className="truncate">{(activeTask?.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"} is working</span>
|
||||
</div>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
|
||||
{toolCount > 0 && (
|
||||
|
|
@ -316,17 +311,15 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }:
|
|||
|
||||
interface TaskRunHistoryProps {
|
||||
issueId: string;
|
||||
assigneeType: string | null;
|
||||
}
|
||||
|
||||
export function TaskRunHistory({ issueId, assigneeType }: TaskRunHistoryProps) {
|
||||
export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
|
||||
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (assigneeType !== "agent") return;
|
||||
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
|
||||
}, [issueId, assigneeType]);
|
||||
}, [issueId]);
|
||||
|
||||
// Refresh when a task completes
|
||||
useWSEvent(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Trash2, Bot, UserMinus } from "lucide-react";
|
||||
import { X, Trash2, Lock, UserMinus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -19,12 +19,14 @@ import {
|
|||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import type { UpdateIssueRequest } from "@/shared/types";
|
||||
import type { Agent, UpdateIssueRequest } from "@/shared/types";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||
import { api } from "@/shared/api";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { PriorityIcon } from "./priority-icon";
|
||||
|
||||
|
|
@ -206,6 +208,13 @@ export function BatchActionToolbar() {
|
|||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function BatchAssigneePicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -218,9 +227,11 @@ function BatchAssigneePicker({
|
|||
loading: boolean;
|
||||
}) {
|
||||
const [filter, setFilter] = useState("");
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const { 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) =>
|
||||
|
|
@ -283,9 +294,7 @@ function BatchAssigneePicker({
|
|||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
|
||||
{getActorInitials("member", m.user_id)}
|
||||
</div>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
|
||||
<span>{m.name}</span>
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -297,22 +306,28 @@ function BatchAssigneePicker({
|
|||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Agents
|
||||
</div>
|
||||
{filteredAgents.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onUpdate({ assignee_type: "agent", assignee_id: a.id });
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
<span>{a.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredAgents.map((a) => {
|
||||
const allowed = canAssignAgent(a, user?.id, memberRole);
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
disabled={!allowed}
|
||||
onClick={() => {
|
||||
if (!allowed) return;
|
||||
onUpdate({ assignee_type: "agent", assignee_id: a.id });
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${allowed ? "hover:bg-accent" : "opacity-50 cursor-not-allowed"}`}
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={18} />
|
||||
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
|
||||
{a.visibility === "private" && (
|
||||
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ChevronRight, Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -13,11 +13,17 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { ReactionBar } from "@/components/common/reaction-bar";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { timeAgo } from "@/shared/utils";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { Markdown } from "@/components/markdown/Markdown";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { ReplyInput } from "./reply-input";
|
||||
import type { TimelineEntry } from "@/shared/types";
|
||||
|
||||
|
|
@ -26,10 +32,11 @@ import type { TimelineEntry } from "@/shared/types";
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CommentCardProps {
|
||||
issueId: string;
|
||||
entry: TimelineEntry;
|
||||
allReplies: Map<string, TimelineEntry[]>;
|
||||
currentUserId?: string;
|
||||
onReply: (parentId: string, content: string) => Promise<void>;
|
||||
onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
onEdit: (commentId: string, content: string) => Promise<void>;
|
||||
onDelete: (commentId: string) => void;
|
||||
onToggleReaction: (commentId: string, emoji: string) => void;
|
||||
|
|
@ -40,12 +47,14 @@ interface CommentCardProps {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CommentRow({
|
||||
issueId,
|
||||
entry,
|
||||
currentUserId,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleReaction,
|
||||
}: {
|
||||
issueId: string;
|
||||
entry: TimelineEntry;
|
||||
currentUserId?: string;
|
||||
onEdit: (commentId: string, content: string) => Promise<void>;
|
||||
|
|
@ -54,28 +63,36 @@ function CommentRow({
|
|||
}) {
|
||||
const { getActorName } = useActorName();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState("");
|
||||
const editEditorRef = useRef<RichTextEditorRef>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
|
||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||
const isTemp = entry.id.startsWith("temp-");
|
||||
|
||||
const startEdit = () => {
|
||||
setEditContent(entry.content ?? "");
|
||||
cancelledRef.current = false;
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
cancelledRef.current = true;
|
||||
setEditing(false);
|
||||
setEditContent("");
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
const trimmed = editContent.trim();
|
||||
if (!trimmed) return;
|
||||
if (cancelledRef.current) return;
|
||||
const trimmed = editEditorRef.current
|
||||
?.getMarkdown()
|
||||
?.replace(/(\n\s*)+$/, "")
|
||||
.trim();
|
||||
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onEdit(entry.id, trimmed);
|
||||
setEditing(false);
|
||||
setEditContent("");
|
||||
} catch {
|
||||
toast.error("Failed to update comment");
|
||||
}
|
||||
|
|
@ -104,10 +121,15 @@ function CommentRow({
|
|||
</Tooltip>
|
||||
|
||||
{!isTemp && (
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<QuickEmojiPicker
|
||||
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
|
||||
align="end"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-xs" className="ml-auto text-muted-foreground">
|
||||
<Button variant="ghost" size="icon-xs" className="text-muted-foreground">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
|
|
@ -136,27 +158,36 @@ function CommentRow({
|
|||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); saveEdit(); }}
|
||||
className="mt-2 pl-8"
|
||||
<div
|
||||
className="mt-1.5 pl-8"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
aria-label="Edit comment"
|
||||
className="w-full text-sm bg-transparent border-b border-border outline-none py-1"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||
/>
|
||||
<div className="flex gap-2 mt-1.5">
|
||||
<Button size="sm" type="submit">Save</Button>
|
||||
<Button size="sm" variant="ghost" type="button" onClick={cancelEdit}>Cancel</Button>
|
||||
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
|
||||
<RichTextEditor
|
||||
ref={editEditorRef}
|
||||
defaultValue={entry.content ?? ""}
|
||||
placeholder="Edit comment..."
|
||||
onSubmit={saveEdit}
|
||||
debounceMs={100}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={(file) => uploadWithToast(file, { issueId })}
|
||||
onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
|
||||
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
|
||||
|
|
@ -167,6 +198,7 @@ function CommentRow({
|
|||
reactions={reactions}
|
||||
currentUserId={currentUserId}
|
||||
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
|
||||
hideAddButton
|
||||
className="mt-1.5 pl-8"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -181,6 +213,7 @@ function CommentRow({
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CommentCard({
|
||||
issueId,
|
||||
entry,
|
||||
allReplies,
|
||||
currentUserId,
|
||||
|
|
@ -189,6 +222,44 @@ function CommentCard({
|
|||
onDelete,
|
||||
onToggleReaction,
|
||||
}: CommentCardProps) {
|
||||
const { getActorName } = useActorName();
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const editEditorRef = useRef<RichTextEditorRef>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||
const isTemp = entry.id.startsWith("temp-");
|
||||
|
||||
const startEdit = () => {
|
||||
cancelledRef.current = false;
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
cancelledRef.current = true;
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (cancelledRef.current) return;
|
||||
const trimmed = editEditorRef.current
|
||||
?.getMarkdown()
|
||||
?.replace(/(\n\s*)+$/, "")
|
||||
.trim();
|
||||
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onEdit(entry.id, trimmed);
|
||||
setEditing(false);
|
||||
} catch {
|
||||
toast.error("Failed to update comment");
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all nested replies recursively into a flat list
|
||||
const allNestedReplies: TimelineEntry[] = [];
|
||||
const collectReplies = (parentId: string) => {
|
||||
|
|
@ -200,42 +271,164 @@ function CommentCard({
|
|||
};
|
||||
collectReplies(entry.id);
|
||||
|
||||
const replyCount = allNestedReplies.length;
|
||||
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
|
||||
const reactions = entry.reactions ?? [];
|
||||
|
||||
return (
|
||||
<Card className={`!py-0 !gap-0 overflow-hidden${entry.id.startsWith("temp-") ? " opacity-60" : ""}`}>
|
||||
{/* Parent comment */}
|
||||
<div className="px-4">
|
||||
<CommentRow
|
||||
entry={entry}
|
||||
currentUserId={currentUserId}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onToggleReaction={onToggleReaction}
|
||||
/>
|
||||
</div>
|
||||
<Card className={`!py-0 !gap-0 overflow-hidden${isTemp ? " opacity-60" : ""}`}>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
{/* Header — always visible, acts as toggle */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<CollapsibleTrigger className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-90")} />
|
||||
</CollapsibleTrigger>
|
||||
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} />
|
||||
<span className="shrink-0 text-sm font-medium">
|
||||
{getActorName(entry.actor_type, entry.actor_id)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="shrink-0 text-xs text-muted-foreground cursor-default">
|
||||
{timeAgo(entry.created_at)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">
|
||||
{new Date(entry.created_at).toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Replies — flat, separated by border */}
|
||||
{allNestedReplies.map((reply) => (
|
||||
<div key={reply.id} className="border-t border-border/50 px-4">
|
||||
<CommentRow
|
||||
entry={reply}
|
||||
currentUserId={currentUserId}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onToggleReaction={onToggleReaction}
|
||||
/>
|
||||
{!open && contentPreview && (
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{contentPreview}
|
||||
</span>
|
||||
)}
|
||||
{!open && replyCount > 0 && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{replyCount} {replyCount === 1 ? "reply" : "replies"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{open && !isTemp && (
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<QuickEmojiPicker
|
||||
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
|
||||
align="end"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-xs" className="text-muted-foreground">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
navigator.clipboard.writeText(entry.content ?? "");
|
||||
toast.success("Copied");
|
||||
}}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
{isOwn && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={startEdit}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Reply input — always visible at bottom */}
|
||||
<div className="border-t border-border/50 px-4 py-2.5">
|
||||
<ReplyInput
|
||||
placeholder="Leave a reply..."
|
||||
size="sm"
|
||||
avatarType="member"
|
||||
avatarId={currentUserId ?? ""}
|
||||
onSubmit={(content) => onReply(entry.id, content)}
|
||||
/>
|
||||
</div>
|
||||
{/* Collapsible body */}
|
||||
<CollapsibleContent>
|
||||
{/* Parent comment body */}
|
||||
<div className="px-4 pb-3">
|
||||
{editing ? (
|
||||
<div
|
||||
className="pl-10"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||
>
|
||||
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
|
||||
<RichTextEditor
|
||||
ref={editEditorRef}
|
||||
defaultValue={entry.content ?? ""}
|
||||
placeholder="Edit comment..."
|
||||
onSubmit={saveEdit}
|
||||
debounceMs={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={(file) => uploadWithToast(file, { issueId })}
|
||||
onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
|
||||
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
|
||||
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
|
||||
</div>
|
||||
{!isTemp && (
|
||||
<ReactionBar
|
||||
reactions={reactions}
|
||||
currentUserId={currentUserId}
|
||||
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
|
||||
className="mt-1.5 pl-10"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Replies */}
|
||||
{allNestedReplies.map((reply) => (
|
||||
<div key={reply.id} className="border-t border-border/50 px-4">
|
||||
<CommentRow
|
||||
issueId={issueId}
|
||||
entry={reply}
|
||||
currentUserId={currentUserId}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onToggleReaction={onToggleReaction}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Reply input */}
|
||||
<div className="border-t border-border/50 px-4 py-2.5">
|
||||
<ReplyInput
|
||||
issueId={issueId}
|
||||
placeholder="Leave a reply..."
|
||||
size="sm"
|
||||
avatarType="member"
|
||||
avatarId={currentUserId ?? ""}
|
||||
onSubmit={(content, attachmentIds) => onReply(entry.id, content, attachmentIds)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
interface CommentInputProps {
|
||||
onSubmit: (content: string) => Promise<void>;
|
||||
issueId: string;
|
||||
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
function CommentInput({ onSubmit }: CommentInputProps) {
|
||||
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const editorRef = useRef<RichTextEditorRef>(null);
|
||||
const attachmentIdsRef = useRef<string[]>([]);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { uploadWithToast, uploading } = useFileUpload();
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) attachmentIdsRef.current.push(result.id);
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(content);
|
||||
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
|
||||
await onSubmit(content, ids);
|
||||
editorRef.current?.clearContent();
|
||||
attachmentIdsRef.current = [];
|
||||
setIsEmpty(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
|
|
@ -28,23 +41,36 @@ function CommentInput({ onSubmit }: CommentInputProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg bg-card ring-1 ring-border">
|
||||
<div className="min-h-20 max-h-48 overflow-y-auto px-3 py-2 pb-8">
|
||||
<div className="relative flex max-h-56 flex-col rounded-lg bg-card pb-8 ring-1 ring-border">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<RichTextEditor
|
||||
ref={editorRef}
|
||||
placeholder="Leave a comment..."
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1.5 right-1.5">
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={handleUpload}
|
||||
onInsert={(result, isImage) =>
|
||||
editorRef.current?.insertFile(result.filename, result.link, isImage)
|
||||
}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
size="icon-xs"
|
||||
disabled={isEmpty || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
{submitting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ export { IssuesPage } from "./issues-page";
|
|||
export { CommentCard } from "./comment-card";
|
||||
export { CommentInput } from "./comment-input";
|
||||
export { ReplyInput } from "./reply-input";
|
||||
export { IssueMentionCard } from "./issue-mention-card";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
|
|||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Bot,
|
||||
Calendar,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
|
|
@ -43,8 +42,9 @@ import {
|
|||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RichTextEditor } from "@/components/common/rich-text-editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { TitleEditor } from "@/components/common/title-editor";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
|
|
@ -69,6 +69,7 @@ import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline";
|
|||
import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
|
||||
import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers";
|
||||
import { ReactionBar } from "@/components/common/reaction-bar";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { timeAgo } from "@/shared/utils";
|
||||
|
||||
function shortDate(date: string | null): string {
|
||||
|
|
@ -179,14 +180,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
|
||||
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
|
||||
const { getActorName, getActorInitials } = useActorName();
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: layoutId,
|
||||
});
|
||||
const sidebarRef = usePanelRef();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const titleFocusedRef = useRef(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [propertiesOpen, setPropertiesOpen] = useState(true);
|
||||
const [detailsOpen, setDetailsOpen] = useState(true);
|
||||
|
|
@ -211,13 +211,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
.finally(() => setIssueLoading(false));
|
||||
}, [id, !!issue]);
|
||||
|
||||
// Sync titleDraft when issue title changes (from WS or other views)
|
||||
useEffect(() => {
|
||||
if (issue && !titleFocusedRef.current) {
|
||||
setTitleDraft(issue.title);
|
||||
}
|
||||
}, [issue?.title]);
|
||||
|
||||
// Custom hooks — encapsulate timeline, reactions, subscribers
|
||||
const {
|
||||
timeline, submitting, submitComment, submitReply,
|
||||
|
|
@ -249,6 +242,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
[issue, id],
|
||||
);
|
||||
|
||||
const descEditorRef = useRef<import("@/components/common/rich-text-editor").RichTextEditorRef>(null);
|
||||
const handleDescriptionUpload = useCallback(
|
||||
(file: File) => uploadWithToast(file, { issueId: id }),
|
||||
[uploadWithToast, id],
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
|
|
@ -421,9 +420,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
key={m.user_id}
|
||||
onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}
|
||||
>
|
||||
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
|
||||
{getActorInitials("member", m.user_id)}
|
||||
</div>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
|
||||
{m.name}
|
||||
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -433,9 +430,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
key={a.id}
|
||||
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
|
||||
>
|
||||
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
|
||||
{a.name}
|
||||
{issue.assignee_type === "agent" && issue.assignee_id === a.id && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -547,43 +542,40 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
{/* Content — scrollable */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-4xl px-8 py-8">
|
||||
<input
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onFocus={() => { titleFocusedRef.current = true; }}
|
||||
onBlur={() => {
|
||||
titleFocusedRef.current = false;
|
||||
const trimmed = titleDraft.trim();
|
||||
<TitleEditor
|
||||
key={`title-${id}`}
|
||||
defaultValue={issue.title}
|
||||
placeholder="Issue title"
|
||||
className="w-full text-2xl font-bold leading-snug tracking-tight"
|
||||
onBlur={(value) => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed && trimmed !== issue.title) handleUpdateField({ title: trimmed });
|
||||
else setTitleDraft(issue.title);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
} else if (e.key === "Escape") {
|
||||
setTitleDraft(issue.title);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
className="w-full bg-transparent text-2xl font-bold leading-snug tracking-tight outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
|
||||
<RichTextEditor
|
||||
ref={descEditorRef}
|
||||
key={id}
|
||||
defaultValue={issue.description || ""}
|
||||
placeholder="Add description..."
|
||||
onUpdate={(md) => handleUpdateField({ description: md || undefined })}
|
||||
onUploadFile={handleDescriptionUpload}
|
||||
debounceMs={1500}
|
||||
className="mt-5"
|
||||
/>
|
||||
|
||||
<ReactionBar
|
||||
reactions={issueReactions}
|
||||
currentUserId={user?.id}
|
||||
onToggle={handleToggleIssueReaction}
|
||||
className="mt-3"
|
||||
/>
|
||||
<div className="flex items-center gap-1 mt-3">
|
||||
<ReactionBar
|
||||
reactions={issueReactions}
|
||||
currentUserId={user?.id}
|
||||
onToggle={handleToggleIssueReaction}
|
||||
/>
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={handleDescriptionUpload}
|
||||
onInsert={(result, isImage) => descEditorRef.current?.insertFile(result.filename, result.link, isImage)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="my-8 border-t" />
|
||||
|
||||
|
|
@ -675,15 +667,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
<div className="mt-4">
|
||||
<AgentLiveCard
|
||||
issueId={id}
|
||||
assigneeType={issue.assignee_type}
|
||||
assigneeId={issue.assignee_id}
|
||||
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent execution history */}
|
||||
<div className="mt-3">
|
||||
<TaskRunHistory issueId={id} assigneeType={issue.assignee_type} />
|
||||
<TaskRunHistory issueId={id} />
|
||||
</div>
|
||||
|
||||
{/* Timeline entries */}
|
||||
|
|
@ -741,6 +731,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
return (
|
||||
<CommentCard
|
||||
key={entry.id}
|
||||
issueId={id}
|
||||
entry={entry}
|
||||
allReplies={repliesByParent}
|
||||
currentUserId={user?.id}
|
||||
|
|
@ -803,7 +794,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
|
||||
{/* Bottom comment input — no avatar, full width */}
|
||||
<div className="mt-4">
|
||||
<CommentInput onSubmit={submitComment} />
|
||||
<CommentInput issueId={id} onSubmit={submitComment} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -904,9 +895,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
<DropdownMenuLabel>Members</DropdownMenuLabel>
|
||||
{members.map((m) => (
|
||||
<DropdownMenuItem key={m.user_id} onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}>
|
||||
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
|
||||
{getActorInitials("member", m.user_id)}
|
||||
</div>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
|
||||
{m.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
|
@ -920,9 +909,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
<DropdownMenuLabel>Agents</DropdownMenuLabel>
|
||||
{agents.map((a) => (
|
||||
<DropdownMenuItem key={a.id} onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}>
|
||||
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
|
||||
{a.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
|
|
|||
37
apps/web/features/issues/components/issue-mention-card.tsx
Normal file
37
apps/web/features/issues/components/issue-mention-card.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
|
||||
interface IssueMentionCardProps {
|
||||
issueId: string;
|
||||
/** Fallback text when issue is not in store (e.g. "MUL-7") */
|
||||
fallbackLabel?: string;
|
||||
}
|
||||
|
||||
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issueId}`}
|
||||
className="text-primary font-medium cursor-pointer hover:underline"
|
||||
>
|
||||
{fallbackLabel ?? issueId.slice(0, 8)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issueId}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-sm hover:bg-accent transition-colors cursor-pointer no-underline"
|
||||
>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-muted-foreground">{issue.identifier}</span>
|
||||
<span className="text-foreground">{issue.title}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bot, Lock, UserMinus } from "lucide-react";
|
||||
import { 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 { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import {
|
||||
PropertyPicker,
|
||||
PickerItem,
|
||||
|
|
@ -35,7 +36,7 @@ export function AssigneePicker({
|
|||
const user = useAuthStore((s) => s.user);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const { getActorName, getActorInitials } = useActorName();
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id);
|
||||
const memberRole = currentMember?.role;
|
||||
|
|
@ -70,19 +71,7 @@ export function AssigneePicker({
|
|||
trigger={
|
||||
customTrigger ? customTrigger : assigneeType && assigneeId ? (
|
||||
<>
|
||||
<div
|
||||
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] size-4.5 ${
|
||||
assigneeType === "agent"
|
||||
? "bg-info/10 text-info"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{assigneeType === "agent" ? (
|
||||
<Bot className="size-2.5" />
|
||||
) : (
|
||||
getActorInitials(assigneeType, assigneeId)
|
||||
)}
|
||||
</div>
|
||||
<ActorAvatar actorType={assigneeType} actorId={assigneeId} size={18} />
|
||||
<span className="truncate">{triggerLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -117,9 +106,7 @@ export function AssigneePicker({
|
|||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
|
||||
{getActorInitials("member", m.user_id)}
|
||||
</div>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
|
||||
<span>{m.name}</span>
|
||||
</PickerItem>
|
||||
))}
|
||||
|
|
@ -145,9 +132,7 @@ export function AssigneePicker({
|
|||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className={`inline-flex size-4.5 shrink-0 items-center justify-center rounded-full ${allowed ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"}`}>
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={18} />
|
||||
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
|
||||
{a.visibility === "private" && (
|
||||
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ReplyInputProps {
|
||||
issueId: string;
|
||||
placeholder?: string;
|
||||
avatarType: string;
|
||||
avatarId: string;
|
||||
onSubmit: (content: string) => Promise<void>;
|
||||
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
|
||||
|
|
@ -23,6 +26,7 @@ interface ReplyInputProps {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ReplyInput({
|
||||
issueId,
|
||||
placeholder = "Leave a reply...",
|
||||
avatarType,
|
||||
avatarId,
|
||||
|
|
@ -30,16 +34,39 @@ function ReplyInput({
|
|||
size = "default",
|
||||
}: ReplyInputProps) {
|
||||
const editorRef = useRef<RichTextEditorRef>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const attachmentIdsRef = useRef<string[]>([]);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { uploadWithToast, uploading } = useFileUpload();
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) setIsExpanded(entry.contentRect.height > 32);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) attachmentIdsRef.current.push(result.id);
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(content);
|
||||
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
|
||||
await onSubmit(content, ids);
|
||||
editorRef.current?.clearContent();
|
||||
attachmentIdsRef.current = [];
|
||||
setIsEmpty(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
|
|
@ -49,45 +76,54 @@ function ReplyInput({
|
|||
const avatarSize = size === "sm" ? 22 : 28;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="group/editor flex items-start gap-2.5">
|
||||
<ActorAvatar
|
||||
actorType={avatarType}
|
||||
actorId={avatarId}
|
||||
size={avatarSize}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={`overflow-y-auto text-sm ${
|
||||
size === "sm" ? "max-h-32" : "max-h-48"
|
||||
}`}
|
||||
>
|
||||
<RichTextEditor
|
||||
ref={editorRef}
|
||||
placeholder={placeholder}
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
onSubmit={handleSubmit}
|
||||
debounceMs={100}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`grid transition-all duration-150 ${
|
||||
isEmpty ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100"
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="flex items-center justify-end pt-1">
|
||||
<Button
|
||||
size="icon-xs"
|
||||
disabled={isEmpty || submitting}
|
||||
onClick={handleSubmit}
|
||||
tabIndex={isEmpty ? -1 : 0}
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-w-0 flex-1 flex flex-col",
|
||||
size === "sm" ? "max-h-40" : "max-h-56",
|
||||
isExpanded && "pb-7",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-14">
|
||||
<div ref={measureRef}>
|
||||
<RichTextEditor
|
||||
ref={editorRef}
|
||||
placeholder={placeholder}
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 flex items-center gap-1 text-muted-foreground transition-colors group-focus-within/editor:text-foreground">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onUpload={handleUpload}
|
||||
onInsert={(result, isImage) =>
|
||||
editorRef.current?.insertFile(result.filename, result.link, isImage)
|
||||
}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isEmpty || submitting}
|
||||
onClick={handleSubmit}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue