fix(ui): responsive tab bar, activity row wrapping, and layout tweaks
Make PageTabBar render a native select on mobile, allow ActivityRow text to wrap on narrow viewports, and minor layout adjustments in AgentDetail and Issues pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -105,7 +105,7 @@ export function ActivityRow({ event, agentMap, entityNameMap, className }: Activ
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-2 flex items-center justify-between gap-2 text-sm",
|
"px-4 py-2 flex flex-wrap items-center justify-between gap-x-2 gap-y-0.5 text-sm",
|
||||||
link && "cursor-pointer hover:bg-accent/50 transition-colors",
|
link && "cursor-pointer hover:bg-accent/50 transition-colors",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
|
|
||||||
export interface PageTabItem {
|
export interface PageTabItem {
|
||||||
value: string;
|
value: string;
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageTabBar({ items }: { items: PageTabItem[] }) {
|
interface PageTabBarProps {
|
||||||
|
items: PageTabItem[];
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageTabBar({ items, value, onValueChange }: PageTabBarProps) {
|
||||||
|
const { isMobile } = useSidebar();
|
||||||
|
|
||||||
|
if (isMobile && value !== undefined && onValueChange) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onValueChange(e.target.value)}
|
||||||
|
className="h-8 rounded-md border border-border bg-background px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<option key={item.value} value={item.value}>
|
||||||
|
{typeof item.label === "string" ? item.label : item.value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsList variant="line">
|
<TabsList variant="line">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { heartbeatsApi } from "../api/heartbeats";
|
|||||||
import { activityApi } from "../api/activity";
|
import { activityApi } from "../api/activity";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
@@ -47,6 +48,7 @@ import {
|
|||||||
EyeOff,
|
EyeOff,
|
||||||
Copy,
|
Copy,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ArrowLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
|
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
|
||||||
@@ -809,8 +811,59 @@ function ConfigurationTab({
|
|||||||
|
|
||||||
/* ---- Runs Tab ---- */
|
/* ---- Runs Tab ---- */
|
||||||
|
|
||||||
|
function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
||||||
|
const StatusIcon = statusInfo.icon;
|
||||||
|
const metrics = runMetrics(run);
|
||||||
|
const summary = run.resultJson
|
||||||
|
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
||||||
|
: run.error ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-1 w-full px-3 py-2.5 text-left border-b border-border last:border-b-0 transition-colors",
|
||||||
|
isSelected ? "bg-accent/40" : "hover:bg-accent/20",
|
||||||
|
)}
|
||||||
|
onClick={() => navigate(isSelected ? `/agents/${agentId}/runs` : `/agents/${agentId}/runs/${run.id}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusIcon className={cn("h-3.5 w-3.5 shrink-0", statusInfo.color, run.status === "running" && "animate-spin")} />
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{run.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium shrink-0",
|
||||||
|
run.invocationSource === "timer" ? "bg-blue-900/50 text-blue-300"
|
||||||
|
: run.invocationSource === "assignment" ? "bg-violet-900/50 text-violet-300"
|
||||||
|
: run.invocationSource === "on_demand" ? "bg-cyan-900/50 text-cyan-300"
|
||||||
|
: "bg-neutral-800 text-neutral-400"
|
||||||
|
)}>
|
||||||
|
{sourceLabels[run.invocationSource] ?? run.invocationSource}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-[11px] text-muted-foreground shrink-0">
|
||||||
|
{relativeTime(run.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{summary && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate pl-5.5">
|
||||||
|
{summary.slice(0, 60)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(metrics.totalTokens > 0 || metrics.cost > 0) && (
|
||||||
|
<div className="flex items-center gap-2 pl-5.5 text-[11px] text-muted-foreground">
|
||||||
|
{metrics.totalTokens > 0 && <span>{formatTokens(metrics.totalTokens)} tok</span>}
|
||||||
|
{metrics.cost > 0 && <span>${metrics.cost.toFixed(3)}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { runs: HeartbeatRun[]; companyId: string; agentId: string; selectedRunId: string | null; adapterType: string }) {
|
function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { runs: HeartbeatRun[]; companyId: string; agentId: string; selectedRunId: string | null; adapterType: string }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isMobile } = useSidebar();
|
||||||
|
|
||||||
if (runs.length === 0) {
|
if (runs.length === 0) {
|
||||||
return <p className="text-sm text-muted-foreground">No runs yet.</p>;
|
return <p className="text-sm text-muted-foreground">No runs yet.</p>;
|
||||||
@@ -821,10 +874,36 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
|
|||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-select latest run when no run is selected
|
// On mobile, don't auto-select so the list shows first; on desktop, auto-select latest
|
||||||
const effectiveRunId = selectedRunId ?? sorted[0]?.id ?? null;
|
const effectiveRunId = isMobile ? selectedRunId : (selectedRunId ?? sorted[0]?.id ?? null);
|
||||||
const selectedRun = sorted.find((r) => r.id === effectiveRunId) ?? null;
|
const selectedRun = sorted.find((r) => r.id === effectiveRunId) ?? null;
|
||||||
|
|
||||||
|
// Mobile: show either run list OR run detail with back button
|
||||||
|
if (isMobile) {
|
||||||
|
if (selectedRun) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => navigate(`/agents/${agentId}/runs`)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
Back to runs
|
||||||
|
</button>
|
||||||
|
<RunDetail key={selectedRun.id} run={selectedRun} adapterType={adapterType} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg">
|
||||||
|
{sorted.map((run) => (
|
||||||
|
<RunListItem key={run.id} run={run} isSelected={false} agentId={agentId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: side-by-side layout
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-0">
|
<div className="flex gap-0">
|
||||||
{/* Left: run list — border stretches full height, content sticks */}
|
{/* Left: run list — border stretches full height, content sticks */}
|
||||||
@@ -833,56 +912,9 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run
|
|||||||
selectedRun ? "w-72" : "w-full",
|
selectedRun ? "w-72" : "w-full",
|
||||||
)}>
|
)}>
|
||||||
<div className="sticky top-4 overflow-y-auto" style={{ maxHeight: "calc(100vh - 2rem)" }}>
|
<div className="sticky top-4 overflow-y-auto" style={{ maxHeight: "calc(100vh - 2rem)" }}>
|
||||||
{sorted.map((run) => {
|
{sorted.map((run) => (
|
||||||
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
|
<RunListItem key={run.id} run={run} isSelected={run.id === effectiveRunId} agentId={agentId} />
|
||||||
const StatusIcon = statusInfo.icon;
|
))}
|
||||||
const isSelected = run.id === effectiveRunId;
|
|
||||||
const metrics = runMetrics(run);
|
|
||||||
const summary = run.resultJson
|
|
||||||
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
|
||||||
: run.error ?? "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={run.id}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-1 w-full px-3 py-2.5 text-left border-b border-border last:border-b-0 transition-colors",
|
|
||||||
isSelected ? "bg-accent/40" : "hover:bg-accent/20",
|
|
||||||
)}
|
|
||||||
onClick={() => navigate(isSelected ? `/agents/${agentId}/runs` : `/agents/${agentId}/runs/${run.id}`)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusIcon className={cn("h-3.5 w-3.5 shrink-0", statusInfo.color, run.status === "running" && "animate-spin")} />
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
|
||||||
{run.id.slice(0, 8)}
|
|
||||||
</span>
|
|
||||||
<span className={cn(
|
|
||||||
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium shrink-0",
|
|
||||||
run.invocationSource === "timer" ? "bg-blue-900/50 text-blue-300"
|
|
||||||
: run.invocationSource === "assignment" ? "bg-violet-900/50 text-violet-300"
|
|
||||||
: run.invocationSource === "on_demand" ? "bg-cyan-900/50 text-cyan-300"
|
|
||||||
: "bg-neutral-800 text-neutral-400"
|
|
||||||
)}>
|
|
||||||
{sourceLabels[run.invocationSource] ?? run.invocationSource}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto text-[11px] text-muted-foreground shrink-0">
|
|
||||||
{relativeTime(run.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{summary && (
|
|
||||||
<span className="text-xs text-muted-foreground truncate pl-5.5">
|
|
||||||
{summary.slice(0, 60)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(metrics.totalTokens > 0 || metrics.cost > 0) && (
|
|
||||||
<div className="flex items-center gap-2 pl-5.5 text-[11px] text-muted-foreground">
|
|
||||||
{metrics.totalTokens > 0 && <span>{formatTokens(metrics.totalTokens)} tok</span>}
|
|
||||||
{metrics.cost > 0 && <span>${metrics.cost.toFixed(3)}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1753,7 +1785,7 @@ function KeysTab({ agentId }: { agentId: string }) {
|
|||||||
const revokedKeys = (keys ?? []).filter((k: AgentKey) => k.revokedAt);
|
const revokedKeys = (keys ?? []).filter((k: AgentKey) => k.revokedAt);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6">
|
||||||
{/* New token banner */}
|
{/* New token banner */}
|
||||||
{newToken && (
|
{newToken && (
|
||||||
<div className="border border-yellow-600/40 bg-yellow-500/5 rounded-lg p-4 space-y-2">
|
<div className="border border-yellow-600/40 bg-yellow-500/5 rounded-lg p-4 space-y-2">
|
||||||
|
|||||||
@@ -108,9 +108,9 @@ export function Issues() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as TabFilter)}>
|
<Tabs value={tab} onValueChange={(v) => setTab(v as TabFilter)}>
|
||||||
<PageTabBar items={[...issueTabItems]} />
|
<PageTabBar items={[...issueTabItems]} value={tab} onValueChange={(v) => setTab(v as TabFilter)} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Button size="sm" onClick={() => openNewIssue()}>
|
<Button size="sm" onClick={() => openNewIssue()}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
|||||||
Reference in New Issue
Block a user