feat(ui): add auth pages, company rail, inbox redesign, and page improvements
Add Auth sign-in/sign-up page and InviteLanding page for invite acceptance. Add CloudAccessGate that checks deployment mode and redirects to /auth when session is required. Add CompanyRail with drag-and-drop company switching. Add MarkdownBody prose renderer. Redesign Inbox with category filters and inline join-request approval. Refactor AgentDetail to overview/configure/runs views with claude-login support. Replace navigate() anti-patterns with <Link> components in Dashboard and MetricCard. Add live-run indicators in sidebar agents. Fix LiveUpdatesProvider cache key resolution for issue identifiers. Add auth, health, and access API clients. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Identity } from "./Identity";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -85,8 +85,6 @@ interface ActivityRowProps {
|
||||
}
|
||||
|
||||
export function ActivityRow({ event, agentMap, entityNameMap, className }: ActivityRowProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const verb = formatVerb(event.action, event.details);
|
||||
|
||||
const isHeartbeatEvent = event.entityType === "heartbeat_run";
|
||||
@@ -104,27 +102,38 @@ export function ActivityRow({ event, agentMap, entityNameMap, className }: Activ
|
||||
|
||||
const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null;
|
||||
|
||||
const inner = (
|
||||
<div className="flex gap-3">
|
||||
<p className="flex-1 min-w-0">
|
||||
<Identity
|
||||
name={actor?.name ?? (event.actorType === "system" ? "System" : event.actorId || "You")}
|
||||
size="xs"
|
||||
className="align-baseline"
|
||||
/>
|
||||
<span className="text-muted-foreground ml-1">{verb} </span>
|
||||
{name && <span className="font-medium">{name}</span>}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">{timeAgo(event.createdAt)}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const classes = cn(
|
||||
"px-4 py-2 text-sm",
|
||||
link && "cursor-pointer hover:bg-accent/50 transition-colors",
|
||||
className,
|
||||
);
|
||||
|
||||
if (link) {
|
||||
return (
|
||||
<Link to={link} className={cn(classes, "no-underline text-inherit block")}>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm",
|
||||
link && "cursor-pointer hover:bg-accent/50 transition-colors",
|
||||
className,
|
||||
)}
|
||||
onClick={link ? () => navigate(link) : undefined}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<p className="flex-1 min-w-0">
|
||||
<Identity
|
||||
name={actor?.name ?? (event.actorType === "system" ? "System" : event.actorId || "You")}
|
||||
size="xs"
|
||||
className="align-baseline"
|
||||
/>
|
||||
<span className="text-muted-foreground ml-1">{verb} </span>
|
||||
{name && <span className="font-medium">{name}</span>}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">{timeAgo(event.createdAt)}</span>
|
||||
</div>
|
||||
<div className={classes}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ type AgentConfigFormProps = {
|
||||
onSaveActionChange?: (save: (() => void) | null) => void;
|
||||
onCancelActionChange?: (cancel: (() => void) | null) => void;
|
||||
hideInlineSave?: boolean;
|
||||
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
|
||||
sectionLayout?: "inline" | "cards";
|
||||
} & (
|
||||
| {
|
||||
mode: "create";
|
||||
@@ -138,6 +140,7 @@ function extractPickedDirectoryPath(handle: unknown): string | null {
|
||||
export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const { mode, adapterModels: externalModels } = props;
|
||||
const isCreate = mode === "create";
|
||||
const cards = props.sectionLayout === "cards";
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -324,7 +327,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className={cn("relative", cards && "space-y-6")}>
|
||||
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
||||
{isDirty && !props.hideInlineSave && (
|
||||
<div className="sticky top-0 z-10 flex items-center justify-end px-4 py-2 bg-background/90 backdrop-blur-sm border-b border-primary/20">
|
||||
@@ -343,9 +346,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
|
||||
{/* ---- Identity (edit only) ---- */}
|
||||
{!isCreate && (
|
||||
<div className="border-b border-border">
|
||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">Identity</div>
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
<div className={cn(!cards && "border-b border-border")}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium mb-3">Identity</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Identity</div>
|
||||
}
|
||||
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
||||
<Field label="Name" hint={help.name}>
|
||||
<DraftInput
|
||||
value={eff("identity", "name", props.agent.name)}
|
||||
@@ -403,11 +409,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
)}
|
||||
|
||||
{/* ---- Adapter ---- */}
|
||||
<div className={cn(isCreate ? "border-t border-border" : "border-b border-border")}>
|
||||
<div className="px-4 py-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Adapter
|
||||
</span>
|
||||
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
||||
<div className={cn(cards ? "flex items-center justify-between mb-3" : "px-4 py-2 flex items-center justify-between gap-2")}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium">Adapter</h3>
|
||||
: <span className="text-xs font-medium text-muted-foreground">Adapter</span>
|
||||
}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -419,7 +426,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
{testEnvironment.isPending ? "Testing..." : "Test environment"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
||||
<Field label="Adapter type" hint={help.adapterType}>
|
||||
<AdapterTypeDropdown
|
||||
value={adapterType}
|
||||
@@ -531,11 +538,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
|
||||
{/* ---- Permissions & Configuration ---- */}
|
||||
{isLocal && (
|
||||
<div className="border-b border-border">
|
||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">
|
||||
Permissions & Configuration
|
||||
</div>
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
<div className={cn(!cards && "border-b border-border")}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium mb-3">Permissions & Configuration</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Permissions & Configuration</div>
|
||||
}
|
||||
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
||||
<Field label="Command" hint={help.localCommand}>
|
||||
<DraftInput
|
||||
value={
|
||||
@@ -689,12 +697,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
|
||||
{/* ---- Run Policy ---- */}
|
||||
{isCreate ? (
|
||||
<div className="border-b border-border">
|
||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Heart className="h-3 w-3" />
|
||||
Run Policy
|
||||
</div>
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
<div className={cn(!cards && "border-b border-border")}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
|
||||
}
|
||||
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
||||
<ToggleWithNumber
|
||||
label="Heartbeat on interval"
|
||||
hint={help.heartbeatInterval}
|
||||
@@ -710,30 +718,32 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-b border-border">
|
||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Heart className="h-3 w-3" />
|
||||
Run Policy
|
||||
</div>
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
<ToggleWithNumber
|
||||
label="Heartbeat on interval"
|
||||
hint={help.heartbeatInterval}
|
||||
checked={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
|
||||
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
|
||||
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
|
||||
numberLabel="sec"
|
||||
numberPrefix="Run heartbeat every"
|
||||
numberHint={help.intervalSec}
|
||||
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||
/>
|
||||
</div>
|
||||
<CollapsibleSection
|
||||
title="Advanced Run Policy"
|
||||
open={runPolicyAdvancedOpen}
|
||||
onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}
|
||||
>
|
||||
<div className={cn(!cards && "border-b border-border")}>
|
||||
{cards
|
||||
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
|
||||
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
|
||||
}
|
||||
<div className={cn(cards ? "border border-border rounded-lg overflow-hidden" : "")}>
|
||||
<div className={cn(cards ? "p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
||||
<ToggleWithNumber
|
||||
label="Heartbeat on interval"
|
||||
hint={help.heartbeatInterval}
|
||||
checked={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
|
||||
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
|
||||
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
|
||||
numberLabel="sec"
|
||||
numberPrefix="Run heartbeat every"
|
||||
numberHint={help.intervalSec}
|
||||
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||
/>
|
||||
</div>
|
||||
<CollapsibleSection
|
||||
title="Advanced Run Policy"
|
||||
bordered={cards}
|
||||
open={runPolicyAdvancedOpen}
|
||||
onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ToggleField
|
||||
label="Wake on demand"
|
||||
@@ -771,6 +781,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
</Field>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Identity } from "./Identity";
|
||||
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
|
||||
@@ -19,13 +20,15 @@ export function ApprovalCard({
|
||||
onApprove,
|
||||
onReject,
|
||||
onOpen,
|
||||
detailLink,
|
||||
isPending,
|
||||
}: {
|
||||
approval: Approval;
|
||||
requesterAgent: Agent | null;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onOpen: () => void;
|
||||
onOpen?: () => void;
|
||||
detailLink?: string;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
@@ -85,9 +88,15 @@ export function ApprovalCard({
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
|
||||
View details
|
||||
</Button>
|
||||
{detailLink ? (
|
||||
<Button variant="ghost" size="sm" className="text-xs px-0" asChild>
|
||||
<Link to={detailLink}>View details</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
|
||||
View details
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ export function BreadcrumbBar() {
|
||||
// Single breadcrumb = page title (uppercase)
|
||||
if (breadcrumbs.length === 1) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 py-4 flex items-center">
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
|
||||
{menuButton}
|
||||
<h1 className="text-sm font-semibold uppercase tracking-wider">
|
||||
{breadcrumbs[0].label}
|
||||
@@ -44,7 +44,7 @@ export function BreadcrumbBar() {
|
||||
|
||||
// Multiple breadcrumbs = breadcrumb trail
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 py-3 flex items-center">
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
|
||||
{menuButton}
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import Markdown from "react-markdown";
|
||||
import type { IssueComment, Agent } from "@paperclip/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Identity } from "./Identity";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { formatDateTime } from "../lib/utils";
|
||||
|
||||
@@ -17,11 +17,12 @@ interface CommentThreadProps {
|
||||
onAdd: (body: string, reopen?: boolean) => Promise<void>;
|
||||
issueStatus?: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
}
|
||||
|
||||
const CLOSED_STATUSES = new Set(["done", "cancelled"]);
|
||||
|
||||
export function CommentThread({ comments, onAdd, issueStatus, agentMap }: CommentThreadProps) {
|
||||
export function CommentThread({ comments, onAdd, issueStatus, agentMap, imageUploadHandler }: CommentThreadProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -35,13 +36,15 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
|
||||
[comments],
|
||||
);
|
||||
|
||||
// Build mention options from agent map
|
||||
// Build mention options from agent map (exclude terminated agents)
|
||||
const mentions = useMemo<MentionOption[]>(() => {
|
||||
if (!agentMap) return [];
|
||||
return Array.from(agentMap.values()).map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
}));
|
||||
return Array.from(agentMap.values())
|
||||
.filter((a) => a.status !== "terminated")
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
}));
|
||||
}, [agentMap]);
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -84,9 +87,7 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
|
||||
{formatDateTime(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-headings:my-2 prose-headings:text-sm">
|
||||
<Markdown>{comment.body}</Markdown>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
||||
{comment.runId && comment.runAgentId && (
|
||||
<div className="mt-2 pt-2 border-t border-border/60">
|
||||
<Link
|
||||
@@ -109,6 +110,7 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
|
||||
placeholder="Leave a comment..."
|
||||
mentions={mentions}
|
||||
onSubmit={handleSubmit}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
contentClassName="min-h-[60px] text-sm"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
|
||||
267
ui/src/components/CompanyRail.tsx
Normal file
267
ui/src/components/CompanyRail.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Paperclip, Plus } from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { Company } from "@paperclip/shared";
|
||||
|
||||
const COMPANY_COLORS = [
|
||||
"#6366f1", // indigo
|
||||
"#8b5cf6", // violet
|
||||
"#ec4899", // pink
|
||||
"#f43f5e", // rose
|
||||
"#f97316", // orange
|
||||
"#eab308", // yellow
|
||||
"#22c55e", // green
|
||||
"#14b8a6", // teal
|
||||
"#06b6d4", // cyan
|
||||
"#3b82f6", // blue
|
||||
];
|
||||
|
||||
const ORDER_STORAGE_KEY = "paperclip.companyOrder";
|
||||
|
||||
function companyColor(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return COMPANY_COLORS[Math.abs(hash) % COMPANY_COLORS.length]!;
|
||||
}
|
||||
|
||||
function getStoredOrder(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(ORDER_STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch { /* ignore */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveOrder(ids: string[]) {
|
||||
localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids));
|
||||
}
|
||||
|
||||
/** Sort companies by stored order, appending any new ones at the end. */
|
||||
function sortByStoredOrder(companies: Company[]): Company[] {
|
||||
const order = getStoredOrder();
|
||||
if (order.length === 0) return companies;
|
||||
|
||||
const byId = new Map(companies.map((c) => [c.id, c]));
|
||||
const sorted: Company[] = [];
|
||||
|
||||
for (const id of order) {
|
||||
const c = byId.get(id);
|
||||
if (c) {
|
||||
sorted.push(c);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
// Append any companies not in stored order
|
||||
for (const c of byId.values()) {
|
||||
sorted.push(c);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function SortableCompanyItem({
|
||||
company,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
company: Company;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: company.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
};
|
||||
|
||||
const color = companyColor(company.name);
|
||||
const initial = company.name.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="relative flex items-center justify-center group"
|
||||
>
|
||||
{/* Selection indicator pill */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-[-14px] w-1 rounded-r-full bg-foreground transition-all duration-200",
|
||||
isSelected
|
||||
? "h-5"
|
||||
: "h-0 group-hover:h-2"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-11 h-11 text-base font-semibold text-white transition-all duration-200",
|
||||
isSelected
|
||||
? "rounded-xl"
|
||||
: "rounded-[22px] group-hover:rounded-xl",
|
||||
isDragging && "shadow-lg scale-105"
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>{company.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanyRail() {
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { openOnboarding } = useDialog();
|
||||
|
||||
// Maintain sorted order in local state, synced from companies + localStorage
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(() =>
|
||||
sortByStoredOrder(companies).map((c) => c.id)
|
||||
);
|
||||
|
||||
// Sync order across tabs via the native storage event
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key !== ORDER_STORAGE_KEY) return;
|
||||
try {
|
||||
const ids: string[] = e.newValue ? JSON.parse(e.newValue) : [];
|
||||
setOrderedIds(ids);
|
||||
} catch { /* ignore malformed data */ }
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
// Re-derive when companies change (new company added/removed)
|
||||
const orderedCompanies = useMemo(() => {
|
||||
const byId = new Map(companies.map((c) => [c.id, c]));
|
||||
const result: Company[] = [];
|
||||
for (const id of orderedIds) {
|
||||
const c = byId.get(id);
|
||||
if (c) {
|
||||
result.push(c);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
// Append any new companies not yet in our order
|
||||
for (const c of byId.values()) {
|
||||
result.push(c);
|
||||
}
|
||||
return result;
|
||||
}, [companies, orderedIds]);
|
||||
|
||||
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const ids = orderedCompanies.map((c) => c.id);
|
||||
const oldIndex = ids.indexOf(active.id as string);
|
||||
const newIndex = ids.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const newIds = arrayMove(ids, oldIndex, newIndex);
|
||||
setOrderedIds(newIds);
|
||||
saveOrder(newIds);
|
||||
},
|
||||
[orderedCompanies]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-[72px] shrink-0 h-full bg-background border-r border-border">
|
||||
{/* Paperclip icon - aligned with top sections (implied line, no visible border) */}
|
||||
<div className="flex items-center justify-center h-12 w-full shrink-0">
|
||||
<Paperclip className="h-5 w-5 text-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Company list */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2 py-2 overflow-y-auto scrollbar-none">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedCompanies.map((c) => c.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{orderedCompanies.map((company) => (
|
||||
<SortableCompanyItem
|
||||
key={company.id}
|
||||
company={company}
|
||||
isSelected={company.id === selectedCompanyId}
|
||||
onSelect={() => setSelectedCompanyId(company.id)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Separator before add button */}
|
||||
<div className="w-8 h-px bg-border mx-auto shrink-0" />
|
||||
|
||||
{/* Add company button */}
|
||||
<div className="flex items-center justify-center py-2 shrink-0">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => openOnboarding()}
|
||||
className="flex items-center justify-center w-11 h-11 rounded-[22px] hover:rounded-xl border-2 border-dashed border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-all duration-200"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>Add company</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface EntityRowProps {
|
||||
@@ -8,6 +9,7 @@ interface EntityRowProps {
|
||||
subtitle?: string;
|
||||
trailing?: ReactNode;
|
||||
selected?: boolean;
|
||||
to?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -19,19 +21,20 @@ export function EntityRow({
|
||||
subtitle,
|
||||
trailing,
|
||||
selected,
|
||||
to,
|
||||
onClick,
|
||||
className,
|
||||
}: EntityRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 text-sm border-b border-border last:border-b-0 transition-colors",
|
||||
onClick && "cursor-pointer hover:bg-accent/50",
|
||||
selected && "bg-accent/30",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
const isClickable = !!(to || onClick);
|
||||
const classes = cn(
|
||||
"flex items-center gap-3 px-4 py-2 text-sm border-b border-border last:border-b-0 transition-colors",
|
||||
isClickable && "cursor-pointer hover:bg-accent/50",
|
||||
selected && "bg-accent/30",
|
||||
className
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{leading && <div className="flex items-center gap-2 shrink-0">{leading}</div>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -47,6 +50,20 @@ export function EntityRow({
|
||||
)}
|
||||
</div>
|
||||
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link to={to} className={cn(classes, "no-underline text-inherit")} onClick={onClick}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes} onClick={onClick}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Goal } from "@paperclip/shared";
|
||||
import { Link } from "react-router-dom";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -6,6 +7,7 @@ import { useState } from "react";
|
||||
|
||||
interface GoalTreeProps {
|
||||
goals: Goal[];
|
||||
goalLink?: (goal: Goal) => string;
|
||||
onSelect?: (goal: Goal) => void;
|
||||
}
|
||||
|
||||
@@ -14,41 +16,62 @@ interface GoalNodeProps {
|
||||
children: Goal[];
|
||||
allGoals: Goal[];
|
||||
depth: number;
|
||||
goalLink?: (goal: Goal) => string;
|
||||
onSelect?: (goal: Goal) => void;
|
||||
}
|
||||
|
||||
function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps) {
|
||||
function GoalNode({ goal, children, allGoals, depth, goalLink, onSelect }: GoalNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const hasChildren = children.length > 0;
|
||||
const link = goalLink?.(goal);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
className="p-0.5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground capitalize">{goal.level}</span>
|
||||
<span className="flex-1 truncate">{goal.title}</span>
|
||||
<StatusBadge status={goal.status} />
|
||||
</>
|
||||
);
|
||||
|
||||
const classes = cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 text-sm transition-colors cursor-pointer hover:bg-accent/50",
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 text-sm transition-colors cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||
onClick={() => onSelect?.(goal)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
className="p-0.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground capitalize">{goal.level}</span>
|
||||
<span className="flex-1 truncate">{goal.title}</span>
|
||||
<StatusBadge status={goal.status} />
|
||||
</div>
|
||||
{link ? (
|
||||
<Link
|
||||
to={link}
|
||||
className={cn(classes, "no-underline text-inherit")}
|
||||
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={classes}
|
||||
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||
onClick={() => onSelect?.(goal)}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
)}
|
||||
{hasChildren && expanded && (
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
@@ -58,6 +81,7 @@ function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps)
|
||||
children={allGoals.filter((g) => g.parentId === child.id)}
|
||||
allGoals={allGoals}
|
||||
depth={depth + 1}
|
||||
goalLink={goalLink}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
@@ -67,7 +91,7 @@ function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps)
|
||||
);
|
||||
}
|
||||
|
||||
export function GoalTree({ goals, onSelect }: GoalTreeProps) {
|
||||
export function GoalTree({ goals, goalLink, onSelect }: GoalTreeProps) {
|
||||
const roots = goals.filter((g) => !g.parentId);
|
||||
|
||||
if (goals.length === 0) {
|
||||
@@ -83,6 +107,7 @@ export function GoalTree({ goals, onSelect }: GoalTreeProps) {
|
||||
children={goals.filter((g) => g.parentId === goal.id)}
|
||||
allGoals={goals}
|
||||
depth={0}
|
||||
goalLink={goalLink}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
|
||||
interface InlineEditorProps {
|
||||
@@ -138,9 +138,7 @@ export function InlineEditor({
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{value && multiline ? (
|
||||
<div className="prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-headings:my-2 prose-headings:text-sm">
|
||||
<Markdown>{value}</Markdown>
|
||||
</div>
|
||||
<MarkdownBody>{value}</MarkdownBody>
|
||||
) : (
|
||||
value || placeholder
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { timeAgo } from "../lib/timeAgo";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { User, Hexagon, ArrowUpRight } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
|
||||
interface IssuePropertiesProps {
|
||||
issue: Issue;
|
||||
@@ -130,6 +131,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
||||
)}
|
||||
onClick={() => { onUpdate({ assigneeAgentId: a.id }); setAssigneeOpen(false); }}
|
||||
>
|
||||
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
@@ -151,7 +153,13 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors">
|
||||
{issue.projectId ? (
|
||||
<span className="text-sm">{projectName(issue.projectId)}</span>
|
||||
<>
|
||||
<span
|
||||
className="shrink-0 h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: projects?.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }}
|
||||
/>
|
||||
<span className="text-sm">{projectName(issue.projectId)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Hexagon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -160,7 +168,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 p-1" align="end">
|
||||
<PopoverContent className="w-fit min-w-[11rem] p-1" align="end">
|
||||
<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 projects..."
|
||||
@@ -170,7 +178,7 @@ 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",
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
||||
!issue.projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
|
||||
@@ -187,11 +195,15 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
||||
<button
|
||||
key={p.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
||||
p.id === issue.projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: p.color ?? "#6366f1" }}
|
||||
/>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import { formatDate } from "../lib/utils";
|
||||
@@ -139,7 +139,6 @@ export function IssuesList({
|
||||
onUpdateIssue,
|
||||
}: IssuesListProps) {
|
||||
const { openNewIssue } = useDialog();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [viewState, setViewState] = useState<IssueViewState>(() => getViewState(viewStateKey));
|
||||
|
||||
@@ -202,7 +201,7 @@ export function IssuesList({
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button size="sm" onClick={() => openNewIssue(newIssueDefaults())}>
|
||||
<Button size="sm" variant="outline" onClick={() => openNewIssue(newIssueDefaults())}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Issue
|
||||
</Button>
|
||||
@@ -434,16 +433,14 @@ export function IssuesList({
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
{group.items.map((issue) => (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
<Link
|
||||
key={issue.id}
|
||||
className="flex items-center gap-2 py-2 pl-1 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => navigate(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="flex items-center gap-2 py-2 pl-1 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit"
|
||||
>
|
||||
{/* Spacer matching caret width so status icon aligns with group title */}
|
||||
<div className="w-3.5 shrink-0" />
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="shrink-0" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
||||
@@ -473,7 +470,7 @@ export function IssuesList({
|
||||
{formatDate(issue.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { CompanyRail } from "./CompanyRail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
@@ -15,31 +19,50 @@ import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
import { healthApi } from "../api/health";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export function Layout() {
|
||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||
const { openNewIssue, openOnboarding } = useDialog();
|
||||
const { panelContent, closePanel } = usePanel();
|
||||
const { companies, loading: companiesLoading } = useCompany();
|
||||
const { companies, loading: companiesLoading, setSelectedCompanyId } = useCompany();
|
||||
const onboardingTriggered = useRef(false);
|
||||
const { data: health } = useQuery({
|
||||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (companiesLoading || onboardingTriggered.current) return;
|
||||
if (health?.deploymentMode === "authenticated") return;
|
||||
if (companies.length === 0) {
|
||||
onboardingTriggered.current = true;
|
||||
openOnboarding();
|
||||
}
|
||||
}, [companies, companiesLoading, openOnboarding]);
|
||||
}, [companies, companiesLoading, openOnboarding, health?.deploymentMode]);
|
||||
|
||||
const togglePanel = useCallback(() => {
|
||||
if (panelContent) closePanel();
|
||||
}, [panelContent, closePanel]);
|
||||
|
||||
// Cmd+1..9 to switch companies
|
||||
const switchCompany = useCallback(
|
||||
(index: number) => {
|
||||
if (index < companies.length) {
|
||||
setSelectedCompanyId(companies[index]!.id);
|
||||
}
|
||||
},
|
||||
[companies, setSelectedCompanyId],
|
||||
);
|
||||
|
||||
useKeyboardShortcuts({
|
||||
onNewIssue: () => openNewIssue(),
|
||||
onToggleSidebar: toggleSidebar,
|
||||
onTogglePanel: togglePanel,
|
||||
onSwitchCompany: switchCompany,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -52,24 +75,40 @@ export function Layout() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
|
||||
{isMobile ? (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 w-60 transition-transform duration-200 ease-in-out",
|
||||
"fixed inset-y-0 left-0 z-50 flex transition-transform duration-200 ease-in-out",
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
<Sidebar />
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<CompanyRail />
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2 bg-background">
|
||||
<SidebarNavItem to="/docs" label="Documentation" icon={BookOpen} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 h-full overflow-hidden transition-all duration-200 ease-in-out",
|
||||
sidebarOpen ? "w-60" : "w-0"
|
||||
)}
|
||||
>
|
||||
<Sidebar />
|
||||
<div className="flex flex-col shrink-0 h-full">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<CompanyRail />
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200 ease-in-out",
|
||||
sidebarOpen ? "w-60" : "w-0"
|
||||
)}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2">
|
||||
<SidebarNavItem to="/docs" label="Documentation" icon={BookOpen} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
21
ui/src/components/MarkdownBody.tsx
Normal file
21
ui/src/components/MarkdownBody.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface MarkdownBodyProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-headings:my-2 prose-headings:text-sm prose-table:my-2 prose-th:px-3 prose-th:py-1.5 prose-td:px-3 prose-td:py-1.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{children}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface MetricCardProps {
|
||||
@@ -7,25 +8,22 @@ interface MetricCardProps {
|
||||
value: string | number;
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
to?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function MetricCard({ icon: Icon, value, label, description, onClick }: MetricCardProps) {
|
||||
return (
|
||||
export function MetricCard({ icon: Icon, value, label, description, to, onClick }: MetricCardProps) {
|
||||
const isClickable = !!(to || onClick);
|
||||
|
||||
const inner = (
|
||||
<Card>
|
||||
<CardContent className="p-3 sm:p-4">
|
||||
<div className="flex gap-2 sm:gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-lg sm:text-2xl font-bold${onClick ? " cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<p className={`text-lg sm:text-2xl font-bold${isClickable ? " cursor-pointer" : ""}`}>
|
||||
{value}
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs sm:text-sm text-muted-foreground${onClick ? " cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<p className={`text-xs sm:text-sm text-muted-foreground${isClickable ? " cursor-pointer" : ""}`}>
|
||||
{label}
|
||||
</p>
|
||||
{description && (
|
||||
@@ -39,4 +37,22 @@ export function MetricCard({ icon: Icon, value, label, description, onClick }: M
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link to={to} className="no-underline text-inherit" onClick={onClick}>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<div className="cursor-pointer" onClick={onClick}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return inner;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { roleLabels } from "./agent-config-primitives";
|
||||
import { AgentConfigForm, type CreateConfigValues } from "./AgentConfigForm";
|
||||
import { defaultCreateValues } from "./agent-config-defaults";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
|
||||
export function NewAgentDialog() {
|
||||
const { newAgentOpen, closeNewAgent } = useDialog();
|
||||
@@ -163,9 +164,9 @@ export function NewAgentDialog() {
|
||||
|
||||
<div className="overflow-y-auto max-h-[70vh]">
|
||||
{/* Name */}
|
||||
<div className="px-4 pt-3">
|
||||
<div className="px-4 pt-4 pb-2 shrink-0">
|
||||
<input
|
||||
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Agent name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
@@ -225,13 +226,17 @@ export function NewAgentDialog() {
|
||||
)}
|
||||
disabled={isFirstAgent}
|
||||
>
|
||||
<User className="h-3 w-3 text-muted-foreground" />
|
||||
{currentReportsTo
|
||||
? `Reports to ${currentReportsTo.name}`
|
||||
: isFirstAgent
|
||||
? "Reports to: N/A (CEO)"
|
||||
: "Reports to..."
|
||||
}
|
||||
{currentReportsTo ? (
|
||||
<>
|
||||
<AgentIcon icon={currentReportsTo.icon} className="h-3 w-3 text-muted-foreground" />
|
||||
{`Reports to ${currentReportsTo.name}`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<User className="h-3 w-3 text-muted-foreground" />
|
||||
{isFirstAgent ? "Reports to: N/A (CEO)" : "Reports to..."}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="start">
|
||||
@@ -253,6 +258,7 @@ export function NewAgentDialog() {
|
||||
)}
|
||||
onClick={() => { setReportsTo(a.id); setReportsToOpen(false); }}
|
||||
>
|
||||
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||
{a.name}
|
||||
<span className="text-muted-foreground ml-auto">{roleLabels[a.role] ?? a.role}</span>
|
||||
</button>
|
||||
|
||||
@@ -151,9 +151,9 @@ export function NewGoalDialog() {
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="px-4 pt-3">
|
||||
<div className="px-4 pt-4 pb-2 shrink-0">
|
||||
<input
|
||||
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Goal title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import type { Project, Agent } from "@paperclip/shared";
|
||||
|
||||
const DRAFT_KEY = "paperclip:issue-draft";
|
||||
@@ -373,8 +374,17 @@ export function NewIssueDialog() {
|
||||
<Popover open={assigneeOpen} onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
<User className="h-3 w-3 text-muted-foreground" />
|
||||
{currentAssignee ? currentAssignee.name : "Assignee"}
|
||||
{currentAssignee ? (
|
||||
<>
|
||||
<AgentIcon icon={currentAssignee.icon} className="h-3 w-3 text-muted-foreground" />
|
||||
{currentAssignee.name}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<User className="h-3 w-3 text-muted-foreground" />
|
||||
Assignee
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 p-1" align="start">
|
||||
@@ -410,6 +420,7 @@ export function NewIssueDialog() {
|
||||
)}
|
||||
onClick={() => { setAssigneeId(a.id); setAssigneeOpen(false); }}
|
||||
>
|
||||
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
@@ -420,14 +431,26 @@ export function NewIssueDialog() {
|
||||
<Popover open={projectOpen} onOpenChange={setProjectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
<Hexagon className="h-3 w-3 text-muted-foreground" />
|
||||
{currentProject ? currentProject.name : "Project"}
|
||||
{currentProject ? (
|
||||
<>
|
||||
<span
|
||||
className="shrink-0 h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: currentProject.color ?? "#6366f1" }}
|
||||
/>
|
||||
{currentProject.name}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Hexagon className="h-3 w-3 text-muted-foreground" />
|
||||
Project
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="start">
|
||||
<PopoverContent className="w-fit min-w-[11rem] p-1" align="start">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
||||
!projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setProjectId(""); setProjectOpen(false); }}
|
||||
@@ -438,11 +461,15 @@ export function NewIssueDialog() {
|
||||
<button
|
||||
key={p.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
||||
p.id === projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setProjectId(p.id); setProjectOpen(false); }}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: p.color ?? "#6366f1" }}
|
||||
/>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { PROJECT_COLORS } from "@paperclip/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
@@ -89,6 +90,7 @@ export function NewProjectDialog() {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
color: PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)],
|
||||
...(goalIds.length > 0 ? { goalIds } : {}),
|
||||
...(targetDate ? { targetDate } : {}),
|
||||
});
|
||||
@@ -151,9 +153,9 @@ export function NewProjectDialog() {
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="px-4 pt-3">
|
||||
<div className="px-4 pt-4 pb-2 shrink-0">
|
||||
<input
|
||||
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Project name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
@@ -15,6 +16,27 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import type { Agent } from "@paperclip/shared";
|
||||
|
||||
/** BFS sort: roots first (no reportsTo), then their direct reports, etc. */
|
||||
function sortByHierarchy(agents: Agent[]): Agent[] {
|
||||
const byId = new Map(agents.map((a) => [a.id, a]));
|
||||
const childrenOf = new Map<string | null, Agent[]>();
|
||||
for (const a of agents) {
|
||||
const parent = a.reportsTo && byId.has(a.reportsTo) ? a.reportsTo : null;
|
||||
const list = childrenOf.get(parent) ?? [];
|
||||
list.push(a);
|
||||
childrenOf.set(parent, list);
|
||||
}
|
||||
const sorted: Agent[] = [];
|
||||
const queue = childrenOf.get(null) ?? [];
|
||||
while (queue.length > 0) {
|
||||
const agent = queue.shift()!;
|
||||
sorted.push(agent);
|
||||
const children = childrenOf.get(agent.id);
|
||||
if (children) queue.push(...children);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function SidebarAgents() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const { selectedCompanyId } = useCompany();
|
||||
@@ -27,9 +49,27 @@ export function SidebarAgents() {
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const visibleAgents = (agents ?? []).filter(
|
||||
(a: Agent) => a.status !== "terminated"
|
||||
);
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
const liveCountByAgent = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const run of liveRuns ?? []) {
|
||||
counts.set(run.agentId, (counts.get(run.agentId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [liveRuns]);
|
||||
|
||||
const visibleAgents = useMemo(() => {
|
||||
const filtered = (agents ?? []).filter(
|
||||
(a: Agent) => a.status !== "terminated"
|
||||
);
|
||||
return sortByHierarchy(filtered);
|
||||
}, [agents]);
|
||||
|
||||
const agentMatch = location.pathname.match(/^\/agents\/([^/]+)/);
|
||||
const activeAgentId = agentMatch?.[1] ?? null;
|
||||
@@ -54,24 +94,38 @@ export function SidebarAgents() {
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="flex flex-col gap-0.5 mt-0.5">
|
||||
{visibleAgents.map((agent: Agent) => (
|
||||
<NavLink
|
||||
key={agent.id}
|
||||
to={`/agents/${agent.id}`}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
activeAgentId === agent.id
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{agent.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{visibleAgents.map((agent: Agent) => {
|
||||
const runCount = liveCountByAgent.get(agent.id) ?? 0;
|
||||
return (
|
||||
<NavLink
|
||||
key={agent.id}
|
||||
to={`/agents/${agent.id}`}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
activeAgentId === agent.id
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{agent.name}</span>
|
||||
{runCount > 0 && (
|
||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-blue-400">
|
||||
{runCount} live
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface SidebarNavItemProps {
|
||||
badge?: number;
|
||||
badgeTone?: "default" | "danger";
|
||||
alert?: boolean;
|
||||
liveCount?: number;
|
||||
}
|
||||
|
||||
export function SidebarNavItem({
|
||||
@@ -21,6 +22,7 @@ export function SidebarNavItem({
|
||||
badge,
|
||||
badgeTone = "default",
|
||||
alert = false,
|
||||
liveCount,
|
||||
}: SidebarNavItemProps) {
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
|
||||
@@ -45,6 +47,15 @@ export function SidebarNavItem({
|
||||
)}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{liveCount != null && liveCount > 0 && (
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-blue-400">{liveCount} live</span>
|
||||
</span>
|
||||
)}
|
||||
{badge != null && badge > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -70,7 +70,7 @@ export function SidebarProjects() {
|
||||
{visibleProjects.map((project: Project) => (
|
||||
<NavLink
|
||||
key={project.id}
|
||||
to={`/projects/${project.id}`}
|
||||
to={`/projects/${project.id}/issues`}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user