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:
402
ui/src/components/ActiveAgentsPanel.tsx
Normal file
402
ui/src/components/ActiveAgentsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user