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:
Forgotten
2026-02-25 08:39:31 -06:00
parent 32cbdbc0b9
commit 82251b7b27
9 changed files with 724 additions and 100 deletions

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>