feat(ui): active agents panel, sidebar context, and page enhancements

Add live ActiveAgentsPanel with real-time transcript feed, SidebarContext
for responsive sidebar state, agent config form with reasoning effort,
improved inbox with failed run alerts, enriched issue detail with project
picker, and various component refinements across pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-20 10:32:32 -06:00
parent b327687c92
commit adca44849a
29 changed files with 1461 additions and 146 deletions

View File

@@ -0,0 +1,402 @@
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclip/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import { getUIAdapter } from "../adapters";
import type { TranscriptEntry } from "../adapters";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { ExternalLink } from "lucide-react";
import { Identity } from "./Identity";
type FeedTone = "info" | "warn" | "error" | "assistant" | "tool";
interface FeedItem {
id: string;
ts: string;
runId: string;
agentId: string;
agentName: string;
text: string;
tone: FeedTone;
}
const MAX_FEED_ITEMS = 40;
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function summarizeEntry(entry: TranscriptEntry): { text: string; tone: FeedTone } | null {
if (entry.kind === "assistant") {
const text = entry.text.trim();
return text ? { text, tone: "assistant" } : null;
}
if (entry.kind === "thinking") {
const text = entry.text.trim();
return text ? { text: `[thinking] ${text}`, tone: "info" } : null;
}
if (entry.kind === "tool_call") {
return { text: `tool ${entry.name}`, tone: "tool" };
}
if (entry.kind === "tool_result") {
const base = entry.content.trim();
return {
text: entry.isError ? `tool error: ${base}` : `tool result: ${base}`,
tone: entry.isError ? "error" : "tool",
};
}
if (entry.kind === "stderr") {
const text = entry.text.trim();
return text ? { text, tone: "error" } : null;
}
if (entry.kind === "system") {
const text = entry.text.trim();
return text ? { text, tone: "warn" } : null;
}
if (entry.kind === "stdout") {
const text = entry.text.trim();
return text ? { text, tone: "info" } : null;
}
return null;
}
function createFeedItem(
run: LiveRunForIssue,
ts: string,
text: string,
tone: FeedTone,
nextId: number,
): FeedItem | null {
const trimmed = text.trim();
if (!trimmed) return null;
return {
id: `${run.id}:${nextId}`,
ts,
runId: run.id,
agentId: run.agentId,
agentName: run.agentName,
text: trimmed.slice(0, 220),
tone,
};
}
function parseStdoutChunk(
run: LiveRunForIssue,
chunk: string,
ts: string,
pendingByRun: Map<string, string>,
nextIdRef: MutableRefObject<number>,
): FeedItem[] {
const pendingKey = `${run.id}:stdout`;
const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`;
const split = combined.split(/\r?\n/);
pendingByRun.set(pendingKey, split.pop() ?? "");
const adapter = getUIAdapter(run.adapterType);
const items: FeedItem[] = [];
for (const line of split.slice(-8)) {
const trimmed = line.trim();
if (!trimmed) continue;
const parsed = adapter.parseStdoutLine(trimmed, ts);
if (parsed.length === 0) {
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
if (fallback) items.push(fallback);
continue;
}
for (const entry of parsed) {
const summary = summarizeEntry(entry);
if (!summary) continue;
const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++);
if (item) items.push(item);
}
}
return items;
}
function parseStderrChunk(
run: LiveRunForIssue,
chunk: string,
ts: string,
pendingByRun: Map<string, string>,
nextIdRef: MutableRefObject<number>,
): FeedItem[] {
const pendingKey = `${run.id}:stderr`;
const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`;
const split = combined.split(/\r?\n/);
pendingByRun.set(pendingKey, split.pop() ?? "");
const items: FeedItem[] = [];
for (const line of split.slice(-8)) {
const item = createFeedItem(run, ts, line, "error", nextIdRef.current++);
if (item) items.push(item);
}
return items;
}
interface ActiveAgentsPanelProps {
companyId: string;
}
interface AgentRunGroup {
agentId: string;
agentName: string;
runs: LiveRunForIssue[];
}
export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
const [feedByAgent, setFeedByAgent] = useState<Map<string, FeedItem[]>>(new Map());
const seenKeysRef = useRef(new Set<string>());
const pendingByRunRef = useRef(new Map<string, string>());
const nextIdRef = useRef(1);
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(companyId),
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
refetchInterval: 5000,
});
const runs = liveRuns ?? [];
const runById = useMemo(() => new Map(runs.map((r) => [r.id, r])), [runs]);
const activeRunIds = useMemo(() => new Set(runs.map((r) => r.id)), [runs]);
const agentGroups = useMemo(() => {
const map = new Map<string, AgentRunGroup>();
for (const run of runs) {
let group = map.get(run.agentId);
if (!group) {
group = { agentId: run.agentId, agentName: run.agentName, runs: [] };
map.set(run.agentId, group);
}
group.runs.push(run);
}
return Array.from(map.values());
}, [runs]);
// Clean up pending buffers for runs that ended
useEffect(() => {
const stillActive = new Set<string>();
for (const runId of activeRunIds) {
stillActive.add(`${runId}:stdout`);
stillActive.add(`${runId}:stderr`);
}
for (const key of pendingByRunRef.current.keys()) {
if (!stillActive.has(key)) {
pendingByRunRef.current.delete(key);
}
}
}, [activeRunIds]);
// WebSocket connection for streaming
useEffect(() => {
if (activeRunIds.size === 0) return;
let closed = false;
let reconnectTimer: number | null = null;
let socket: WebSocket | null = null;
const appendItems = (agentId: string, items: FeedItem[]) => {
if (items.length === 0) return;
setFeedByAgent((prev) => {
const next = new Map(prev);
const existing = next.get(agentId) ?? [];
next.set(agentId, [...existing, ...items].slice(-MAX_FEED_ITEMS));
return next;
});
};
const scheduleReconnect = () => {
if (closed) return;
reconnectTimer = window.setTimeout(connect, 1500);
};
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`;
socket = new WebSocket(url);
socket.onmessage = (message) => {
const raw = typeof message.data === "string" ? message.data : "";
if (!raw) return;
let event: LiveEvent;
try {
event = JSON.parse(raw) as LiveEvent;
} catch {
return;
}
if (event.companyId !== companyId) return;
const payload = event.payload ?? {};
const runId = readString(payload["runId"]);
if (!runId || !activeRunIds.has(runId)) return;
const run = runById.get(runId);
if (!run) return;
if (event.type === "heartbeat.run.event") {
const seq = typeof payload["seq"] === "number" ? payload["seq"] : null;
const eventType = readString(payload["eventType"]) ?? "event";
const messageText = readString(payload["message"]) ?? eventType;
const dedupeKey = `${runId}:event:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`;
if (seenKeysRef.current.has(dedupeKey)) return;
seenKeysRef.current.add(dedupeKey);
if (seenKeysRef.current.size > 2000) seenKeysRef.current.clear();
const tone = eventType === "error" ? "error" : eventType === "lifecycle" ? "warn" : "info";
const item = createFeedItem(run, event.createdAt, messageText, tone, nextIdRef.current++);
if (item) appendItems(run.agentId, [item]);
return;
}
if (event.type === "heartbeat.run.status") {
const status = readString(payload["status"]) ?? "updated";
const dedupeKey = `${runId}:status:${status}:${readString(payload["finishedAt"]) ?? ""}`;
if (seenKeysRef.current.has(dedupeKey)) return;
seenKeysRef.current.add(dedupeKey);
if (seenKeysRef.current.size > 2000) seenKeysRef.current.clear();
const tone = status === "failed" || status === "timed_out" ? "error" : "warn";
const item = createFeedItem(run, event.createdAt, `run ${status}`, tone, nextIdRef.current++);
if (item) appendItems(run.agentId, [item]);
return;
}
if (event.type === "heartbeat.run.log") {
const chunk = readString(payload["chunk"]);
if (!chunk) return;
const stream = readString(payload["stream"]) === "stderr" ? "stderr" : "stdout";
if (stream === "stderr") {
appendItems(run.agentId, parseStderrChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef));
return;
}
appendItems(run.agentId, parseStdoutChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef));
}
};
socket.onerror = () => {
socket?.close();
};
socket.onclose = () => {
scheduleReconnect();
};
};
connect();
return () => {
closed = true;
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
if (socket) {
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
socket.close(1000, "active_agents_panel_unmount");
}
};
}, [activeRunIds, companyId, runById]);
if (agentGroups.length === 0) return null;
return (
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Active Agents
</h3>
<div className="grid md:grid-cols-2 gap-4">
{agentGroups.map((group) => (
<AgentRunCard
key={group.agentId}
group={group}
feed={feedByAgent.get(group.agentId) ?? []}
/>
))}
</div>
</div>
);
}
function AgentRunCard({ group, feed }: { group: AgentRunGroup; feed: FeedItem[] }) {
const bodyRef = useRef<HTMLDivElement>(null);
const recent = feed.slice(-20);
const primaryRun = group.runs[0];
useEffect(() => {
const body = bodyRef.current;
if (!body) return;
body.scrollTo({ top: body.scrollHeight, behavior: "smooth" });
}, [feed.length]);
return (
<div className="rounded-lg border border-blue-500/30 bg-background/80 overflow-hidden shadow-[0_0_12px_rgba(59,130,246,0.08)]">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<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>
<Identity name={group.agentName} size="sm" />
<span className="text-[11px] font-medium text-blue-400">Live</span>
{group.runs.length > 1 && (
<span className="text-[10px] text-muted-foreground">
({group.runs.length} runs)
</span>
)}
</div>
{primaryRun && (
<Link
to={`/agents/${primaryRun.agentId}/runs/${primaryRun.id}`}
className="inline-flex items-center gap-1 text-[10px] text-blue-400 hover:text-blue-300"
>
Open run
<ExternalLink className="h-2.5 w-2.5" />
</Link>
)}
</div>
<div ref={bodyRef} className="max-h-[180px] overflow-y-auto p-2 font-mono text-[11px] space-y-1">
{recent.length === 0 && (
<div className="text-xs text-muted-foreground">Waiting for output...</div>
)}
{recent.map((item, index) => (
<div
key={item.id}
className={cn(
"flex gap-2 items-start",
index === recent.length - 1 && "animate-in fade-in slide-in-from-bottom-1 duration-300",
)}
>
<span className="text-[10px] text-muted-foreground shrink-0">{relativeTime(item.ts)}</span>
<span className={cn(
"min-w-0 break-words",
item.tone === "error" && "text-red-300",
item.tone === "warn" && "text-amber-300",
item.tone === "assistant" && "text-emerald-200",
item.tone === "tool" && "text-cyan-300",
item.tone === "info" && "text-foreground/80",
)}>
{item.text}
</span>
</div>
))}
</div>
{group.runs.length > 1 && (
<div className="border-t border-border/50 px-3 py-1.5 flex flex-wrap gap-2">
{group.runs.map((run) => (
<Link
key={run.id}
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 text-[10px] text-blue-400 hover:text-blue-300"
>
{run.id.slice(0, 8)}
<ExternalLink className="h-2.5 w-2.5" />
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -42,6 +42,7 @@ export const defaultCreateValues: CreateConfigValues = {
cwd: "",
promptTemplate: "",
model: "",
thinkingEffort: "",
dangerouslySkipPermissions: false,
search: false,
dangerouslyBypassSandbox: false,
@@ -126,6 +127,21 @@ function formatArgList(value: unknown): string {
return typeof value === "string" ? value : "";
}
const codexThinkingEffortOptions = [
{ id: "", label: "Auto" },
{ id: "minimal", label: "Minimal" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
] as const;
const claudeThinkingEffortOptions = [
{ id: "", label: "Auto" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
] as const;
function extractPickedDirectoryPath(handle: unknown): string | null {
if (typeof handle !== "object" || handle === null) return null;
@@ -269,6 +285,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// Popover states
const [modelOpen, setModelOpen] = useState(false);
const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false);
// Create mode helpers
const val = isCreate ? props.values : null;
@@ -281,6 +298,22 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? val!.model
: eff("adapterConfig", "model", String(config.model ?? ""));
const thinkingEffortKey = adapterType === "codex_local" ? "modelReasoningEffort" : "effort";
const thinkingEffortOptions =
adapterType === "codex_local" ? codexThinkingEffortOptions : claudeThinkingEffortOptions;
const currentThinkingEffort = isCreate
? val!.thinkingEffort
: adapterType === "codex_local"
? eff(
"adapterConfig",
"modelReasoningEffort",
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
)
: eff("adapterConfig", "effort", String(config.effort ?? ""));
const codexSearchEnabled = adapterType === "codex_local"
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
: false;
return (
<div className="relative">
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
@@ -342,7 +375,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
value={adapterType}
onChange={(t) => {
if (isCreate) {
set!({ adapterType: t });
set!({ adapterType: t, model: "", thinkingEffort: "" });
} else {
setOverlay((prev) => ({
...prev,
@@ -486,6 +519,25 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
open={modelOpen}
onOpenChange={setModelOpen}
/>
<ThinkingEffortDropdown
value={currentThinkingEffort}
options={thinkingEffortOptions}
onChange={(v) =>
isCreate
? set!({ thinkingEffort: v })
: mark("adapterConfig", thinkingEffortKey, v || undefined)
}
open={thinkingEffortOpen}
onOpenChange={setThinkingEffortOpen}
/>
{adapterType === "codex_local" &&
codexSearchEnabled &&
currentThinkingEffort === "minimal" && (
<p className="text-xs text-amber-400">
Codex may reject `minimal` thinking when search is enabled.
</p>
)}
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
{isCreate ? (
<AutoExpandTextarea
@@ -985,11 +1037,23 @@ function ModelDropdown({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [modelSearch, setModelSearch] = useState("");
const selected = models.find((m) => m.id === value);
const filteredModels = models.filter((m) => {
if (!modelSearch.trim()) return true;
const q = modelSearch.toLowerCase();
return m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q);
});
return (
<Field label="Model" hint={help.model}>
<Popover open={open} onOpenChange={onOpenChange}>
<Popover
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) setModelSearch("");
}}
>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>
@@ -999,6 +1063,13 @@ function ModelDropdown({
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
<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 models..."
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
@@ -1011,7 +1082,7 @@ function ModelDropdown({
>
Default
</button>
{models.map((m) => (
{filteredModels.map((m) => (
<button
key={m.id}
className={cn(
@@ -1027,6 +1098,56 @@ function ModelDropdown({
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
</button>
))}
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
)}
</PopoverContent>
</Popover>
</Field>
);
}
function ThinkingEffortDropdown({
value,
options,
onChange,
open,
onOpenChange,
}: {
value: string;
options: ReadonlyArray<{ id: string; label: string }>;
onChange: (id: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const selected = options.find((option) => option.id === value) ?? options[0];
return (
<Field label="Thinking effort" hint={help.thinkingEffort}>
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>{selected?.label ?? "Auto"}</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
{options.map((option) => (
<button
key={option.id || "auto"}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
option.id === value && "bg-accent",
)}
onClick={() => {
onChange(option.id);
onOpenChange(false);
}}
>
<span>{option.label}</span>
{option.id ? <span className="text-xs text-muted-foreground font-mono">{option.id}</span> : null}
</button>
))}
</PopoverContent>
</Popover>
</Field>

View File

@@ -16,7 +16,7 @@ function PayloadField({ label, value }: { label: string; value: unknown }) {
if (!value) return null;
return (
<div className="flex items-center gap-2">
<span className="text-muted-foreground w-24 shrink-0 text-xs">{label}</span>
<span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs">{label}</span>
<span>{String(value)}</span>
</div>
);
@@ -26,20 +26,20 @@ export function HireAgentPayload({ payload }: { payload: Record<string, unknown>
return (
<div className="mt-3 space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground w-24 shrink-0 text-xs">Name</span>
<span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs">Name</span>
<span className="font-medium">{String(payload.name ?? "—")}</span>
</div>
<PayloadField label="Role" value={payload.role} />
<PayloadField label="Title" value={payload.title} />
{!!payload.capabilities && (
<div className="flex items-start gap-2">
<span className="text-muted-foreground w-24 shrink-0 text-xs pt-0.5">Capabilities</span>
<span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs pt-0.5">Capabilities</span>
<span className="text-muted-foreground">{String(payload.capabilities)}</span>
</div>
)}
{!!payload.adapterType && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground w-24 shrink-0 text-xs">Adapter</span>
<span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs">Adapter</span>
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
{String(payload.adapterType)}
</span>

View File

@@ -1,5 +1,8 @@
import { Link } from "react-router-dom";
import { Menu } from "lucide-react";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { Button } from "@/components/ui/button";
import {
Breadcrumb,
BreadcrumbItem,
@@ -12,13 +15,26 @@ import { Fragment } from "react";
export function BreadcrumbBar() {
const { breadcrumbs } = useBreadcrumbs();
const { toggleSidebar, isMobile } = useSidebar();
if (breadcrumbs.length === 0) return null;
const menuButton = isMobile && (
<Button
variant="ghost"
size="icon-sm"
className="mr-2 shrink-0"
onClick={toggleSidebar}
>
<Menu className="h-5 w-5" />
</Button>
);
// Single breadcrumb = page title (uppercase)
if (breadcrumbs.length === 1) {
return (
<div className="border-b border-border px-6 py-4">
<div className="border-b border-border px-4 md:px-6 py-4 flex items-center">
{menuButton}
<h1 className="text-sm font-semibold uppercase tracking-wider">
{breadcrumbs[0].label}
</h1>
@@ -28,7 +44,8 @@ export function BreadcrumbBar() {
// Multiple breadcrumbs = breadcrumb trail
return (
<div className="border-b border-border px-6 py-3">
<div className="border-b border-border px-4 md:px-6 py-3 flex items-center">
{menuButton}
<Breadcrumb>
<BreadcrumbList>
{breadcrumbs.map((crumb, i) => {

View File

@@ -27,7 +27,7 @@ function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps)
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 * 20 + 12}px` }}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
onClick={() => onSelect?.(goal)}
>
{hasChildren ? (

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import type { Issue } from "@paperclip/shared";
import { useQuery } from "@tanstack/react-query";
@@ -8,9 +9,11 @@ import { queryKeys } from "../lib/queryKeys";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
import { formatDate } from "../lib/utils";
import { formatDate, cn } from "../lib/utils";
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";
interface IssuePropertiesProps {
issue: Issue;
@@ -26,16 +29,12 @@ function PropertyRow({ label, children }: { label: string; children: React.React
);
}
function statusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function priorityLabel(priority: string): string {
return priority.charAt(0).toUpperCase() + priority.slice(1);
}
export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
const { selectedCompanyId } = useCompany();
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [projectOpen, setProjectOpen] = useState(false);
const [projectSearch, setProjectSearch] = useState("");
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -46,7 +45,7 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && !!issue.projectId,
enabled: !!selectedCompanyId,
});
const agentName = (id: string | null) => {
@@ -72,41 +71,142 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
<StatusIcon
status={issue.status}
onChange={(status) => onUpdate({ status })}
showLabel
/>
<span className="text-sm">{statusLabel(issue.status)}</span>
</PropertyRow>
<PropertyRow label="Priority">
<PriorityIcon
priority={issue.priority}
onChange={(priority) => onUpdate({ priority })}
showLabel
/>
<span className="text-sm">{priorityLabel(issue.priority)}</span>
</PropertyRow>
<PropertyRow label="Assignee">
{assignee ? (
<Popover open={assigneeOpen} onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}>
<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">
{assignee ? (
<Identity name={assignee.name} size="sm" />
) : (
<>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Unassigned</span>
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 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 agents..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<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={() => { onUpdate({ assigneeAgentId: null }); setAssigneeOpen(false); }}
>
No assignee
</button>
{(agents ?? [])
.filter((a) => a.status !== "terminated")
.filter((a) => {
if (!assigneeSearch.trim()) return true;
const q = assigneeSearch.toLowerCase();
return a.name.toLowerCase().includes(q);
})
.map((a) => (
<button
key={a.id}
className={cn(
"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); }}
>
{a.name}
</button>
))}
</PopoverContent>
</Popover>
{issue.assigneeAgentId && (
<Link
to={`/agents/${assignee.id}`}
className="hover:underline"
to={`/agents/${issue.assigneeAgentId}`}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<Identity name={assignee.name} size="sm" />
<ArrowUpRight className="h-3 w-3" />
</Link>
) : (
<span className="text-sm text-muted-foreground">Unassigned</span>
)}
</PropertyRow>
{issue.projectId && (
<PropertyRow label="Project">
<PropertyRow label="Project">
<Popover open={projectOpen} onOpenChange={(open) => { setProjectOpen(open); if (!open) setProjectSearch(""); }}>
<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>
) : (
<>
<Hexagon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">No project</span>
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 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..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
autoFocus
/>
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.projectId && "bg-accent"
)}
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
>
No project
</button>
{(projects ?? [])
.filter((p) => {
if (!projectSearch.trim()) return true;
const q = projectSearch.toLowerCase();
return p.name.toLowerCase().includes(q);
})
.map((p) => (
<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",
p.id === issue.projectId && "bg-accent"
)}
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
>
{p.name}
</button>
))}
</PopoverContent>
</Popover>
{issue.projectId && (
<Link
to={`/projects/${issue.projectId}`}
className="text-sm hover:underline"
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
{projectName(issue.projectId)}
<ArrowUpRight className="h-3 w-3" />
</Link>
</PropertyRow>
)}
)}
</PropertyRow>
{issue.parentId && (
<PropertyRow label="Parent">

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { BreadcrumbBar } from "./BreadcrumbBar";
@@ -11,11 +11,12 @@ import { OnboardingWizard } from "./OnboardingWizard";
import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { cn } from "../lib/utils";
export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(true);
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
const { openNewIssue, openOnboarding } = useDialog();
const { panelContent, closePanel } = usePanel();
const { companies, loading: companiesLoading } = useCompany();
@@ -29,7 +30,6 @@ export function Layout() {
}
}, [companies, companiesLoading, openOnboarding]);
const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []);
const togglePanel = useCallback(() => {
if (panelContent) closePanel();
}, [panelContent, closePanel]);
@@ -42,18 +42,40 @@ export function Layout() {
return (
<div className="flex h-screen bg-background text-foreground overflow-hidden">
<div
className={cn(
"transition-all duration-200 ease-in-out shrink-0 h-full overflow-hidden",
sidebarOpen ? "w-60" : "w-0"
)}
>
<Sidebar />
</div>
{/* Mobile backdrop */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
{isMobile ? (
<div
className={cn(
"fixed inset-y-0 left-0 z-50 w-60 transition-transform duration-200 ease-in-out",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<Sidebar />
</div>
) : (
<div
className={cn(
"shrink-0 h-full overflow-hidden transition-all duration-200 ease-in-out",
sidebarOpen ? "w-60" : "w-0"
)}
>
<Sidebar />
</div>
)}
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 h-full">
<BreadcrumbBar />
<div className="flex flex-1 min-h-0">
<main className="flex-1 overflow-auto p-6">
<main className="flex-1 overflow-auto p-4 md:p-6">
<Outlet />
</main>
<PropertiesPanel />

View File

@@ -95,6 +95,7 @@ export function NewIssueDialog() {
const [statusOpen, setStatusOpen] = useState(false);
const [priorityOpen, setPriorityOpen] = useState(false);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [projectOpen, setProjectOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
@@ -341,14 +342,21 @@ export function NewIssueDialog() {
</Popover>
{/* Assignee chip */}
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
<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"}
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="start">
<PopoverContent className="w-52 p-1" align="start">
<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
/>
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
@@ -358,7 +366,14 @@ export function NewIssueDialog() {
>
No assignee
</button>
{(agents ?? []).map((a) => (
{(agents ?? [])
.filter((a) => a.status !== "terminated")
.filter((a) => {
if (!assigneeSearch.trim()) return true;
const q = assigneeSearch.toLowerCase();
return a.name.toLowerCase().includes(q);
})
.map((a) => (
<button
key={a.id}
className={cn(

View File

@@ -17,9 +17,10 @@ interface PriorityIconProps {
priority: string;
onChange?: (priority: string) => void;
className?: string;
showLabel?: boolean;
}
export function PriorityIcon({ priority, onChange, className }: PriorityIconProps) {
export function PriorityIcon({ priority, onChange, className, showLabel }: PriorityIconProps) {
const [open, setOpen] = useState(false);
const config = priorityConfig[priority] ?? priorityConfig.medium!;
const Icon = config.icon;
@@ -29,7 +30,7 @@ export function PriorityIcon({ priority, onChange, className }: PriorityIconProp
className={cn(
"inline-flex items-center justify-center shrink-0",
config.color,
onChange && "cursor-pointer",
onChange && !showLabel && "cursor-pointer",
className
)}
>
@@ -37,11 +38,18 @@ export function PriorityIcon({ priority, onChange, className }: PriorityIconProp
</span>
);
if (!onChange) return icon;
if (!onChange) return showLabel ? <span className="inline-flex items-center gap-1.5">{icon}<span className="text-sm">{config.label}</span></span> : icon;
const trigger = showLabel ? (
<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">
{icon}
<span className="text-sm">{config.label}</span>
</button>
) : icon;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{icon}</PopoverTrigger>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
{allPriorities.map((p) => {
const c = priorityConfig[p]!;

View File

@@ -5,6 +5,7 @@ import { Separator } from "@/components/ui/separator";
interface ProjectPropertiesProps {
project: Project;
onUpdate?: (data: Record<string, unknown>) => void;
}
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
@@ -16,7 +17,7 @@ function PropertyRow({ label, children }: { label: string; children: React.React
);
}
export function ProjectProperties({ project }: ProjectPropertiesProps) {
export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps) {
return (
<div className="space-y-4">
<div className="space-y-1">

View File

@@ -9,7 +9,7 @@ export function PropertiesPanel() {
if (!panelContent) return null;
return (
<aside className="w-80 border-l border-border bg-card flex flex-col shrink-0">
<aside className="hidden md:flex w-80 border-l border-border bg-card flex-col shrink-0">
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
<span className="text-sm font-medium">Properties</span>
<Button variant="ghost" size="icon-xs" onClick={closePanel}>

View File

@@ -77,6 +77,8 @@ export function Sidebar() {
label="Inbox"
icon={Inbox}
badge={sidebarBadges?.inbox}
badgeTone={sidebarBadges?.failedRuns ? "danger" : "default"}
alert={(sidebarBadges?.failedRuns ?? 0) > 0}
/>
</div>

View File

@@ -1,5 +1,6 @@
import { NavLink } from "react-router-dom";
import { cn } from "../lib/utils";
import { useSidebar } from "../context/SidebarContext";
import type { LucideIcon } from "lucide-react";
interface SidebarNavItemProps {
@@ -8,6 +9,8 @@ interface SidebarNavItemProps {
icon: LucideIcon;
end?: boolean;
badge?: number;
badgeTone?: "default" | "danger";
alert?: boolean;
}
export function SidebarNavItem({
@@ -16,11 +19,16 @@ export function SidebarNavItem({
icon: Icon,
end,
badge,
badgeTone = "default",
alert = false,
}: SidebarNavItemProps) {
const { isMobile, setSidebarOpen } = useSidebar();
return (
<NavLink
to={to}
end={end}
onClick={() => { if (isMobile) setSidebarOpen(false); }}
className={({ isActive }) =>
cn(
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
@@ -30,10 +38,22 @@ export function SidebarNavItem({
)
}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="relative shrink-0">
<Icon className="h-4 w-4" />
{alert && (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-red-500 shadow-[0_0_0_2px_hsl(var(--background))]" />
)}
</span>
<span className="flex-1 truncate">{label}</span>
{badge != null && badge > 0 && (
<span className="ml-auto text-xs bg-primary text-primary-foreground rounded-full px-1.5 py-0.5 leading-none">
<span
className={cn(
"ml-auto rounded-full px-1.5 py-0.5 text-xs leading-none",
badgeTone === "danger"
? "bg-red-600/90 text-red-50"
: "bg-primary text-primary-foreground",
)}
>
{badge}
</span>
)}

View File

@@ -10,6 +10,7 @@ const statusColors: Record<string, string> = {
achieved: "bg-green-900/50 text-green-300",
completed: "bg-green-900/50 text-green-300",
failed: "bg-red-900/50 text-red-300",
timed_out: "bg-orange-900/50 text-orange-300",
succeeded: "bg-green-900/50 text-green-300",
error: "bg-red-900/50 text-red-300",
pending_approval: "bg-amber-900/50 text-amber-300",

View File

@@ -23,9 +23,10 @@ interface StatusIconProps {
status: string;
onChange?: (status: string) => void;
className?: string;
showLabel?: boolean;
}
export function StatusIcon({ status, onChange, className }: StatusIconProps) {
export function StatusIcon({ status, onChange, className, showLabel }: StatusIconProps) {
const [open, setOpen] = useState(false);
const colorClass = statusColors[status] ?? "text-muted-foreground border-muted-foreground";
const isDone = status === "done";
@@ -35,7 +36,7 @@ export function StatusIcon({ status, onChange, className }: StatusIconProps) {
className={cn(
"relative inline-flex h-4 w-4 rounded-full border-2 shrink-0",
colorClass,
onChange && "cursor-pointer",
onChange && !showLabel && "cursor-pointer",
className
)}
>
@@ -45,11 +46,18 @@ export function StatusIcon({ status, onChange, className }: StatusIconProps) {
</span>
);
if (!onChange) return circle;
if (!onChange) return showLabel ? <span className="inline-flex items-center gap-1.5">{circle}<span className="text-sm">{statusLabel(status)}</span></span> : circle;
const trigger = showLabel ? (
<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">
{circle}
<span className="text-sm">{statusLabel(status)}</span>
</button>
) : circle;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{circle}</PopoverTrigger>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
{allStatuses.map((s) => (
<Button

View File

@@ -18,6 +18,7 @@ export const help: Record<string, string> = {
cwd: "The working directory where the agent operates. Use an absolute path on the machine running Paperclip.",
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
model: "Override the default model used by the adapter.",
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
search: "Enable Codex web search capability during runs.",