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:
Forgotten
2026-02-20 10:33:18 -06:00
parent 6d0f58d559
commit 3ad421965c
4 changed files with 114 additions and 57 deletions

View File

@@ -105,7 +105,7 @@ export function ActivityRow({ event, agentMap, entityNameMap, className }: Activ
return (
<div
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",
className,
)}

View File

@@ -1,12 +1,37 @@
import type { ReactNode } from "react";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSidebar } from "../context/SidebarContext";
export interface PageTabItem {
value: string;
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 (
<TabsList variant="line">
{items.map((item) => (

View File

@@ -6,6 +6,7 @@ import { heartbeatsApi } from "../api/heartbeats";
import { activityApi } from "../api/activity";
import { issuesApi } from "../api/issues";
import { usePanel } from "../context/PanelContext";
import { useSidebar } from "../context/SidebarContext";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -47,6 +48,7 @@ import {
EyeOff,
Copy,
ChevronRight,
ArrowLeft,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
@@ -809,8 +811,59 @@ function ConfigurationTab({
/* ---- 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 }) {
const navigate = useNavigate();
const { isMobile } = useSidebar();
if (runs.length === 0) {
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()
);
// Auto-select latest run when no run is selected
const effectiveRunId = selectedRunId ?? sorted[0]?.id ?? null;
// On mobile, don't auto-select so the list shows first; on desktop, auto-select latest
const effectiveRunId = isMobile ? selectedRunId : (selectedRunId ?? sorted[0]?.id ?? 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 (
<div className="flex gap-0">
{/* 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",
)}>
<div className="sticky top-4 overflow-y-auto" style={{ maxHeight: "calc(100vh - 2rem)" }}>
{sorted.map((run) => {
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
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>
);
})}
{sorted.map((run) => (
<RunListItem key={run.id} run={run} isSelected={run.id === effectiveRunId} agentId={agentId} />
))}
</div>
</div>
@@ -1753,7 +1785,7 @@ function KeysTab({ agentId }: { agentId: string }) {
const revokedKeys = (keys ?? []).filter((k: AgentKey) => k.revokedAt);
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-6">
{/* New token banner */}
{newToken && (
<div className="border border-yellow-600/40 bg-yellow-500/5 rounded-lg p-4 space-y-2">

View File

@@ -108,9 +108,9 @@ export function Issues() {
return (
<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)}>
<PageTabBar items={[...issueTabItems]} />
<PageTabBar items={[...issueTabItems]} value={tab} onValueChange={(v) => setTab(v as TabFilter)} />
</Tabs>
<Button size="sm" onClick={() => openNewIssue()}>
<Plus className="h-4 w-4 mr-1" />