feat(ui): org chart page, issue detail tabs, and UX improvements
- Add org chart page with tree visualization and sidebar nav link - Restructure issue detail into tabbed layout (comments/activity/sub-issues) - Persist comment drafts to localStorage with debounce - Add inline assignee picker to issues list with search - Fix assignee clear to reset both agent and user assignee - Fix InlineEditor nesting when rendering markdown content Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { IssueComment, Agent } from "@paperclip/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -18,15 +18,46 @@ interface CommentThreadProps {
|
||||
issueStatus?: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
draftKey?: string;
|
||||
}
|
||||
|
||||
const CLOSED_STATUSES = new Set(["done", "cancelled"]);
|
||||
const DRAFT_DEBOUNCE_MS = 800;
|
||||
|
||||
export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUploadHandler }: CommentThreadProps) {
|
||||
function loadDraft(draftKey: string): string {
|
||||
try {
|
||||
return localStorage.getItem(draftKey) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft(draftKey: string, value: string) {
|
||||
try {
|
||||
if (value.trim()) {
|
||||
localStorage.setItem(draftKey, value);
|
||||
} else {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft(draftKey: string) {
|
||||
try {
|
||||
localStorage.removeItem(draftKey);
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUploadHandler, draftKey }: CommentThreadProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const isClosed = issueStatus ? CLOSED_STATUSES.has(issueStatus) : false;
|
||||
|
||||
@@ -47,6 +78,25 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUpl
|
||||
}));
|
||||
}, [agentMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
setBody(loadDraft(draftKey));
|
||||
}, [draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
draftTimer.current = setTimeout(() => {
|
||||
saveDraft(draftKey, body);
|
||||
}, DRAFT_DEBOUNCE_MS);
|
||||
}, [body, draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSubmit() {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
@@ -55,6 +105,7 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUpl
|
||||
try {
|
||||
await onAdd(trimmed, isClosed && reopen ? true : undefined);
|
||||
setBody("");
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
|
||||
@@ -127,8 +127,12 @@ export function InlineEditor({
|
||||
);
|
||||
}
|
||||
|
||||
// Use div instead of Tag when rendering markdown to avoid invalid nesting
|
||||
// (e.g. <p> cannot contain the <div>/<p> elements that markdown produces)
|
||||
const DisplayTag = value && multiline ? "div" : Tag;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
<DisplayTag
|
||||
className={cn(
|
||||
"cursor-pointer rounded hover:bg-accent/50 transition-colors",
|
||||
pad,
|
||||
@@ -142,6 +146,6 @@ export function InlineEditor({
|
||||
) : (
|
||||
value || placeholder
|
||||
)}
|
||||
</Tag>
|
||||
</DisplayTag>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,9 +253,9 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
!issue.assigneeAgentId && "bg-accent"
|
||||
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { onUpdate({ assigneeAgentId: null }); setAssigneeOpen(false); }}
|
||||
onClick={() => { onUpdate({ assigneeAgentId: null, assigneeUserId: null }); setAssigneeOpen(false); }}
|
||||
>
|
||||
No assignee
|
||||
</button>
|
||||
@@ -273,7 +273,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
a.id === issue.assigneeAgentId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { onUpdate({ assigneeAgentId: a.id }); setAssigneeOpen(false); }}
|
||||
onClick={() => { onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }}
|
||||
>
|
||||
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||
{a.name}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import { formatDate } from "../lib/utils";
|
||||
import { formatDate, cn } from "../lib/utils";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
@@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3 } from "lucide-react";
|
||||
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User } from "lucide-react";
|
||||
import { KanbanBoard } from "./KanbanBoard";
|
||||
import type { Issue } from "@paperclip/shared";
|
||||
|
||||
@@ -161,6 +161,8 @@ export function IssuesList({
|
||||
}
|
||||
return getViewState(viewStateKey);
|
||||
});
|
||||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
|
||||
const updateView = useCallback((patch: Partial<IssueViewState>) => {
|
||||
setViewState((prev) => {
|
||||
@@ -223,6 +225,12 @@ export function IssuesList({
|
||||
return defaults;
|
||||
};
|
||||
|
||||
const assignIssue = (issueId: string, assigneeAgentId: string | null) => {
|
||||
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null });
|
||||
setAssigneePickerIssueId(null);
|
||||
setAssigneeSearch("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
@@ -548,6 +556,84 @@ export function IssuesList({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-auto">
|
||||
<Popover
|
||||
open={assigneePickerIssueId === issue.id}
|
||||
onOpenChange={(open) => {
|
||||
setAssigneePickerIssueId(open ? issue.id : null);
|
||||
if (!open) setAssigneeSearch("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 hover:bg-accent/50 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
Assignee
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-56 p-1"
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDownOutside={() => setAssigneeSearch("")}
|
||||
>
|
||||
<input
|
||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||
placeholder="Search agents..."
|
||||
value={assigneeSearch}
|
||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
!issue.assigneeAgentId && "bg-accent"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, null);
|
||||
}}
|
||||
>
|
||||
No assignee
|
||||
</button>
|
||||
{(agents ?? [])
|
||||
.filter((agent) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
|
||||
})
|
||||
.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
|
||||
issue.assigneeAgentId === agent.id && "bg-accent"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
assignIssue(issue.id, agent.id);
|
||||
}}
|
||||
>
|
||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{liveIssueIds?.has(issue.id) && (
|
||||
<span className="inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 rounded-full bg-blue-500/10">
|
||||
<span className="relative flex h-2 w-2">
|
||||
@@ -557,12 +643,6 @@ export function IssuesList({
|
||||
<span className="text-[11px] font-medium text-blue-400 hidden sm:inline">Live</span>
|
||||
</span>
|
||||
)}
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name
|
||||
? <Identity name={name} size="sm" />
|
||||
: <span className="text-xs text-muted-foreground font-mono">{issue.assigneeAgentId.slice(0, 8)}</span>;
|
||||
})()}
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
{formatDate(issue.createdAt)}
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
History,
|
||||
Search,
|
||||
SquarePen,
|
||||
Network,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
@@ -90,6 +91,7 @@ export function Sidebar() {
|
||||
<SidebarAgents />
|
||||
|
||||
<SidebarSection label="Company">
|
||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||
</SidebarSection>
|
||||
|
||||
Reference in New Issue
Block a user