fix(ui): bundle connector, toolbar, empty-state and agent roster fixes (#18716)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators use connector setup, collection headers, empty states, and
agent lists to manage that work.
> - Provider redirects replace the setup page, and collection controls
use different sizes.
> - Agent selection inserts a bar above the list. The row link
highlights only part of the row.
> - This pull request keeps sign-in in a separate window and applies the
Tasks control sizes to collection and audit headers.
> - It also reuses artifact artwork for empty collections and gives
agent selection a fixed action card.
> - These changes preserve the operator's place and keep controls and
rows stable.

## Linked Issues or Issue Description

**What happened?**

Connector sign-in replaced the current page. Inline collection controls
had different heights. Projects and Artifacts used small placeholder
empty states. Agent selection moved the list, hover covered only the
identity cell, and chat/status shared that cell without matching
headings.

**Expected behavior**

Provider sign-in opens in a new tab or window. Collection controls match
Tasks. Empty collections use the large artifact graphics. Agent actions
float above the page while rows remain aligned.

**Steps to reproduce**

Start connector sign-in from a page or dialog. Compare collection and
Audit headers. Open an empty Projects or Artifacts collection. Select an
agent, then hover across its row.

Related public work: #13584 covers an earlier connector new-tab
implementation, including same-page fallbacks that this bundle does not
use. #8528 covers Tasks toggle sizing. #17742 covers scoped agent Audit
sections. This bundle does not change server callback or audit
authorization contracts.

## What Changed

- Reserve a separate authorization window during the click. Use
validated sign-in URLs, retain a new-tab fallback, and close blank
windows after failed requests. Store Cloud reauthentication state in the
destination window.
- Use the Tasks 44px control foundation for shared collection headers
and Audit, Costs, Timeline, Secrets, Teams, Gateways, and agent skill
controls.
- Use a large artifact stack with an off-white Paperclip image with a
dark icon for Projects and artifact Tasks empty states. Use one matching
artifact for other types. Set 20px gaps and use the label “Create
project”. Action hover and keyboard focus animate the artwork.
- Portal agent bulk actions to a fixed card 48px above the bottom safe
area. Use quintic entry/exit motion and respect reduced motion. Retain
card contents during exit.
- Give chat and status separate cells, add a Status heading, use the
MessagesSquare chat action with a hover tooltip, and highlight the
entire row.
- Add linked agent chips and a manager picker to the agent profile.
Block self and descendant manager choices. Keep the name crown and
custom title inline during edits.
- Update design guidance, the connection maps, regression tests, and
empty-state Storybook fixtures.

## Verification

- Final commit `a41a6c6494a839deaaec1ef7cc50c90078ab6381`: all 55 checks
passed, three skipped. [Canonical CI
run](https://github.com/paperclipai/paperclip/actions/runs/41999792769)
includes all eight browser shards and all server/runner/workspace
groups. Greptile: 5/5, zero unresolved review findings.
- Workspace typecheck/build passed. Local UI suite passed 11,006 tests
in 880 files, plus 542 CLI tests. Subsequent connector callback changes
passed all 245 AppsConnect tests, UI typecheck, token gates, and
query-error policy checks. Manager cache/error regressions passed four
tests. Map checks passed.
- Local connector browser suite: 20 passed. All six chat integration
shards: 1,931 passed. Workspace B groups passed; database tests passed
313 cases across 72 files with one worker.
- Browser preview verified agent manager changes, overview links, inline
name/title geometry, empty-state colors, row actions and the chat
tooltip. Live provider consent and post-deployment feature verification
remain separate from these fixtures.
- Preserved failures: ten bulk-roster tests initially missed the
portaled action card; their selectors were fixed and all twelve roster
tests passed. A nested fixture selected pnpm 11 and removed local
dependencies; the local shim now pins pnpm 9. Embedded PostgreSQL native
links were restored. A Python 3.9 fixture failure passed all 19 tests
with the current runtime. Concurrent local database startup failures
passed on the one-worker rerun.
- The broad local serialized server run passed 3,791 tests with one
scheduling assertion failure, which passed in isolation. The local
general-server run reproduced a native recovery provider/model fixture
failure and has not completed. These server sources are unchanged by
this bundle; all final canonical CI server shards passed. This is not a
claim that every broad local run passed.
- [Earlier CI
run](https://github.com/paperclipai/paperclip/actions/runs/41995119880)
failed a browser test during cleanup when heartbeat-run cancellation hit
a PostgreSQL lock timeout. All final browser shards passed without
weakening that test.
- Review follow-ups cover Slack popup/fallback recovery, a single
artwork hover transform, shared manager error rendering, detached
standalone OAuth windows, and verified task-bound callbacks in both
hosts.

## Risks

- Browser popup policy and provider callback behavior can affect sign-in
completion. The original setup page stays open; blocked windows have an
explicit recovery path.
- Shared toolbar styles affect several surfaces. Menu content remains in
its existing portals; canonical label tabs and search clear buttons
retain their own geometry.
- No schema, server, API authorization, or migration change.
- Required CI and review are complete. Deployment provenance and hosted
feature verification are checked separately after merge.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools, test
execution, and browser inspection. The exact model ID and context-window
size were not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run relevant tests locally and they pass (broader local
exceptions are recorded above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
TonioandPaperclip authored and GitHub committed 2026-11-05 01:23:53 -08:00
1 parent beb73381ca
commit 56517dda6c
60 files changed
+1123 -325

No files matched your search

+1
View File
@@ -36,6 +36,7 @@ export function CollectionToolbar({
return (
<div
data-slot="collection-toolbar"
data-canonical-toolbar="collection"
className={cn("flex flex-col gap-2", className)}
role="toolbar"
aria-label={ariaLabel}
+7 -3
View File
@@ -1,9 +1,12 @@
import { Plus } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
interface EmptyStateProps {
icon: LucideIcon;
/** Large decorative artwork; shares hover/focus with the action below. */
graphic?: ReactNode;
/** Optional bold heading rendered above the message. */
title?: string;
message: string;
@@ -17,6 +20,7 @@ interface EmptyStateProps {
export function EmptyState({
icon: Icon,
graphic,
title,
message,
description,
@@ -25,10 +29,10 @@ export function EmptyState({
hideActionIcon = false,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="bg-muted/50 p-4 mb-4">
<div className={graphic ? "empty-state-artwork flex flex-col items-center justify-center gap-5 py-16 text-center" : "flex flex-col items-center justify-center py-16 text-center"}>
{graphic ? <div className="empty-state-graphic" aria-hidden="true">{graphic}</div> : <div className="bg-muted/50 p-4 mb-4">
<Icon className="h-10 w-10 text-muted-foreground/50" />
</div>
</div>}
{title ? (
<>
<p className="text-base font-semibold text-foreground mb-1.5">{title}</p>
+11 -2
View File
@@ -17,6 +17,8 @@ interface InlineEditorProps {
className?: string;
placeholder?: string;
multiline?: boolean;
/** Keep a short heading's editor as wide as its live text, preserving adjacent controls. */
fitContent?: boolean;
imageUploadHandler?: (file: File) => Promise<string>;
/** Called when a non-image file is dropped onto the editor. */
onDropFile?: (file: File) => Promise<void>;
@@ -82,6 +84,7 @@ function OwnedInlineEditor({
className,
placeholder = "Click to edit...",
multiline = false,
fitContent = false,
nullable = false,
imageUploadHandler,
onDropFile,
@@ -504,14 +507,14 @@ function OwnedInlineEditor({
}
if (editing) {
return (
const editor = (
<textarea
ref={inputRef}
value={draft}
aria-invalid={autosaveState === "error" || undefined}
aria-description={autosaveState === "error" ? autosave.label ?? undefined : undefined}
rows={1}
aria-label={placeholder}
onChange={(e) => {
changeDraft(e.target.value);
autoSize(e.target);
@@ -527,6 +530,12 @@ function OwnedInlineEditor({
)}
/>
);
return fitContent ? (
<span className="inline-editor-fit">
<span aria-hidden className={cn("invisible whitespace-pre", pad, className)}>{draft || " "}</span>
{editor}
</span>
) : editor;
}
// Use div instead of Tag when rendering markdown to avoid invalid nesting
+1 -1
View File
@@ -64,7 +64,7 @@ export function ProjectsToolbar({ query, onQueryChange, sortField, sortDir, onSo
<div className="design-toolbar-actions">
<Button size="sm" variant="outline" data-toolbar-primary onClick={openNewProject}>
<Plus className="h-4 w-4 mr-1" />
Add Project
Create project
</Button>
</div>
</div>;
+11 -5
View File
@@ -1,5 +1,5 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useState } from "react";
import { useState, type ReactElement } from "react";
import type { Agent } from "@paperclipai/shared";
import {
Popover,
@@ -18,6 +18,8 @@ export function ReportsToPicker({
excludeAgentIds = [],
disabledEmptyLabel = "Reports to: N/A (CEO)",
chooseLabel = "Reports to...",
trigger,
disabledAgentIds = [],
}: {
agents: Agent[];
value: string | null;
@@ -26,6 +28,8 @@ export function ReportsToPicker({
excludeAgentIds?: string[];
disabledEmptyLabel?: string;
chooseLabel?: string;
trigger?: ReactElement;
disabledAgentIds?: string[];
}) {
const [open, setOpen] = useState(false);
const exclude = new Set(excludeAgentIds);
@@ -39,7 +43,7 @@ export function ReportsToPicker({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
{trigger ?? <button
type="button"
className={cn(
"inline-flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
@@ -73,9 +77,9 @@ export function ReportsToPicker({
</span>
</>
)}
</button>
</button>}
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="start">
<PopoverContent className={trigger ? "w-64 max-h-80 overflow-y-auto p-1" : "w-48 p-1"} align="start" aria-label="Choose manager">
<button
type="button"
className={cn(
@@ -106,8 +110,10 @@ export function ReportsToPicker({
<button
type="button"
key={a.id}
disabled={disabledAgentIds.includes(a.id)}
title={disabledAgentIds.includes(a.id) ? "Would create a reporting loop" : undefined}
className={cn(
"flex items-center gap-2 w-full min-w-0 px-2 py-1.5 text-xs rounded hover:bg-accent/50 overflow-hidden",
"flex items-center gap-2 w-full min-w-0 px-2 py-1.5 text-xs rounded hover:bg-accent/50 overflow-hidden disabled:opacity-50 disabled:cursor-not-allowed",
a.id === value && "bg-accent",
)}
onClick={() => {
@@ -55,7 +55,7 @@ describe("pending agent roster display", () => {
return agent;
}
const editButton = () => [...container.querySelectorAll<HTMLButtonElement>("button")].find(b => b.textContent?.includes("Edit connection & model"))!;
const editButton = () => [...document.querySelectorAll<HTMLButtonElement>('[aria-label="Bulk actions"] button')].find(b => b.textContent?.includes("Edit connection & model"))!;
it("loads complete current agents before opening bulk edits from a restored roster", async () => {
const display = renderEditable();
@@ -101,7 +101,7 @@ describe("pending agent roster display", () => {
api.get.mockReturnValue(new Promise<Agent>((resolve, fail) => { finish = resolve; reject = fail; }));
await act(async () => editButton().click());
expect(api.get).toHaveBeenCalledTimes(1);
await act(async () => container.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!.click());
await act(async () => document.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!.click());
await act(async () => {
if (outcome === "success") finish(display);
else reject(new TypeError("Failed to fetch"));
@@ -130,7 +130,7 @@ describe("pending agent roster display", () => {
it("retains the pending harness label while keeping the placeholder unavailable for bulk editing", () => {
const row = render({ setupAdapterPending: true,
setupAdapterChoice: { adapterType: "paperclip_runner", runnerProvider: "antigravity", runner: "auto" } });
expect([...row.querySelectorAll("td")].slice(2).map(cell => cell.textContent)).toEqual([
expect([...row.querySelectorAll("td")].slice(3).map(cell => cell.textContent)).toEqual([
"Google Antigravity", "Not configured", "Not available yet",
]);
const checkbox = row.querySelector<HTMLButtonElement>('[role="checkbox"]');
@@ -141,7 +141,7 @@ describe("pending agent roster display", () => {
it("preserves ordinary process display without a saved setup choice", () => {
const row = render({ setupAdapterPending: true });
expect([...row.querySelectorAll("td")].slice(2).map(cell => cell.textContent)).toEqual([
expect([...row.querySelectorAll("td")].slice(3).map(cell => cell.textContent)).toEqual([
"Process", "Managed externally", "Harness default",
]);
const checkbox = row.querySelector<HTMLButtonElement>('[role="checkbox"]');
+24 -13
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useRef, useState, type ReactNode, type SetStateAction } from "react";
import { lazy, Suspense, useEffect, useMemo, useRef, useState, type ReactNode, type SetStateAction } from "react";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Filter, Pencil, X } from "lucide-react";
import type { Agent } from "@paperclipai/shared";
@@ -9,6 +10,7 @@ import { aiConnectionsApi } from "@/api/ai-connections";
import { getAdapterLabel } from "@/adapters/adapter-display-registry";
import { agentConnection, agentDefaultModel, bulkAgentDisabledReason, bulkAgentHarness, bulkConnectionName } from "@/lib/bulk-agent-settings";
import { pendingAgentSetupDisplayAdapter } from "@/lib/agent-setup-choice";
import { AgentStatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -17,8 +19,10 @@ import { cn } from "@/lib/utils";
const AgentBulkSettingsSheet = lazy(() => import("./AgentBulkSettingsSheet").then(m => ({ default: m.AgentBulkSettingsSheet })));
/** Shared by both roster layouts. Mount with a company key to isolate selection. */
export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent }: {
export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent, renderChat, renderStatus }: {
companyId: string; agents: Agent[]; visibleAgents: Agent[]; renderAgent: (agent: Agent) => ReactNode;
renderChat?: (agent: Agent) => ReactNode;
renderStatus?: (agent: Agent) => ReactNode;
}) {
const client = useQueryClient();
const openingRef = useRef(false);
@@ -36,12 +40,16 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent
const accounts = useQuery({ queryKey: ["ai-connections", companyId], queryFn: () => aiConnectionsApi.list(companyId) });
const visible = visibleAgents.filter(a => filter === "all" || bulkAgentHarness(a) === filter);
const selectable = visible.filter(a => !bulkAgentDisabledReason(a));
const selectedAgents = agents.filter(a => selected.includes(a.id) && !bulkAgentDisabledReason(a));
const selectedAgents = useMemo(() => agents.filter(a => selected.includes(a.id) && !bulkAgentDisabledReason(a)), [agents, selected]);
const [lastSelection, setLastSelection] = useState<Agent[]>([]);
useEffect(() => { if (selectedAgents.length) setLastSelection(selectedAgents); }, [selectedAgents]);
// Retain the card contents while it drops out, so its width does not collapse mid-animation.
const actionAgents = selectedAgents.length ? selectedAgents : lastSelection;
const harnesses = [...new Set(agents.map(bulkAgentHarness))].sort((a, b) => getAdapterLabel(a).localeCompare(getAdapterLabel(b)));
const groups = [...new Set(selectedAgents.map(bulkAgentHarness))];
const groups = [...new Set(actionAgents.map(bulkAgentHarness))];
const all = selectable.length > 0 && selectable.every(a => selected.includes(a.id));
const some = selectable.some(a => selected.includes(a.id));
const hidden = selectedAgents.filter(a => !visible.includes(a)).length;
const hidden = actionAgents.filter(a => !visible.includes(a)).length;
const toggleAll = () => changeSelection(all ? selected.filter(id => !selectable.some(a => a.id === id)) : [...new Set([...selected, ...selectable.map(a => a.id)])]);
async function openEditor(harness: string) {
@@ -68,7 +76,7 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent
}
return <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div data-canonical-toolbar="agent-filters" role="toolbar" aria-label="Agent filters" className="flex flex-wrap items-center justify-between gap-3">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Filter agents" title={filter === "all" ? "Filter" : `Filter: ${getAdapterLabel(filter)}`}
@@ -93,28 +101,31 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent
</Popover>
<span className="text-xs text-muted-foreground">{visible.length} {visible.length === 1 ? "agent" : "agents"}</span>
</div>
{selectedAgents.length > 0 && <div role="region" aria-label="Bulk actions" className="sticky top-0 z-10 flex flex-wrap items-center gap-3 rounded-md border bg-muted p-3">
<span className="text-sm font-medium">{selectedAgents.length} selected</span>
{createPortal(<div role="region" aria-label="Bulk actions" aria-hidden={selectedAgents.length === 0} inert={selectedAgents.length === 0}
data-state={selectedAgents.length > 0 ? "open" : "closed"} className="agent-selection-bar flex flex-wrap items-center gap-3 rounded-lg border bg-background p-3">
<span className="text-sm font-medium">{actionAgents.length} selected</span>
{hidden > 0 && <span className="text-xs text-muted-foreground">{hidden} hidden by filters</span>}
{groups.length > 1 && <span className="text-xs text-muted-foreground">Choose a harness to edit</span>}
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">{groups.map(h => <Button key={h} size="sm" variant="outline" disabled={opening} onClick={() => void openEditor(h)}>
<Pencil className="size-3.5" aria-hidden />{groups.length === 1 ? "Edit connection & model" : `Edit ${selectedAgents.filter(a => bulkAgentHarness(a) === h).length} ${getAdapterLabel(h)}`}
<Pencil className="size-3.5" aria-hidden />{groups.length === 1 ? "Edit connection & model" : `Edit ${actionAgents.filter(a => bulkAgentHarness(a) === h).length} ${getAdapterLabel(h)}`}
</Button>)}<Button size="icon-sm" variant="ghost" aria-label="Clear selection" onClick={() => changeSelection([])}><X className="size-4" /></Button></div>
</div>}
</div>, document.body)}
{opening && <p role="status" className="text-sm text-muted-foreground">Loading current agent settings…</p>}
{openError && <p role="alert" className="text-sm text-destructive">{openError}</p>}
{notice && <p role="status" className="flex items-center gap-2 text-sm"><Check className="size-4" aria-hidden />{notice}</p>}
{accounts.error && <p className="text-xs text-destructive">Connection names couldn’t be loaded. <Button variant="link" size="sm" onClick={() => void accounts.refetch()}>Retry</Button></p>}
<div className="overflow-x-auto"><table aria-label="Agent roster" className="w-full text-left text-sm">
<div className="overflow-x-auto"><table aria-label="Agent roster" className="agent-roster w-full text-left text-sm">
<thead className="text-xs text-muted-foreground [&_th]:px-3 [&_th]:py-3 [&_th]:font-normal"><tr>
<th className="w-10"><Checkbox aria-label="Select all visible editable agents" checked={all ? true : some ? "indeterminate" : false} disabled={!selectable.length} onCheckedChange={toggleAll} /></th>
<th>Agent</th><th>Harness</th><th>Connection</th><th>Default model</th>
<th scope="col">Agent</th>{renderChat && <th scope="col"><span className="sr-only">Chat</span></th>}<th scope="col">Status</th><th scope="col">Harness</th><th scope="col">Connection</th><th scope="col">Default model</th>
</tr></thead>
<tbody>{visible.map(agent => {
const pendingSetup = pendingAgentSetupDisplayAdapter(agent);
return <tr key={agent.id} data-state={selected.includes(agent.id) ? "selected" : undefined} className="border-t border-border data-[state=selected]:bg-accent/30 [&_td]:px-3">
return <tr key={agent.id} data-state={selected.includes(agent.id) ? "selected" : undefined} className="border-t border-border data-[state=selected]:bg-accent/30 hover:bg-accent/50 focus-within:bg-accent/50 [&_td]:px-3 [&_td]:align-middle">
<td title={bulkAgentDisabledReason(agent)}><Checkbox aria-label={`Select ${agent.name}`} checked={selectedAgents.some(a => a.id === agent.id)} disabled={!!bulkAgentDisabledReason(agent)} onCheckedChange={value => changeSelection(value ? [...new Set([...selected, agent.id])] : selected.filter(id => id !== agent.id))} /></td>
<td className="min-w-64 p-0">{renderAgent(agent)}</td>
{renderChat && <td>{renderChat(agent)}</td>}
<td>{renderStatus ? renderStatus(agent) : <AgentStatusBadge status={agent.status} />}</td>
<td className="whitespace-nowrap text-xs">{getAdapterLabel(pendingSetup ?? bulkAgentHarness(agent))}</td>
<td className="text-xs">{pendingSetup ? "Not configured" : ["http", "process"].includes(agent.adapterType) ? "Managed externally" : bulkConnectionName(agentConnection(agent), accounts.data)}</td>
<td className="font-mono text-xs">{pendingSetup ? "Not available yet" : agentDefaultModel(agent) ?? "Harness default"}</td>
@@ -9,7 +9,7 @@ import { getAdapterLabel } from "@/adapters/adapter-display-registry";
import { agentConnection, agentDefaultModel, bulkAgentHarness, bulkConnectionName, mapBulkAgents, resolveBulkConnection, sameBinding } from "@/lib/bulk-agent-settings";
import { nativeTaskModelCatalogOptions } from "@/lib/native-model-catalog";
import { queryKeys } from "@/lib/queryKeys";
import { AgentAvatar } from "@/components/AgentAvatar";
import { AgentIdentityChipContent, agentIdentityChipClass } from "./AgentIdentityChip";
import { ModelDropdown } from "@/components/AgentConfigForm";
import { AiConnectionField, aiProviderForAdapter } from "@/components/ai-connections/AiConnectionField";
import { bindingProblem } from "@/components/ai-connections/model";
@@ -134,7 +134,7 @@ export function AgentBulkSettingsSheet({ companyId, agents, totalSelected, onClo
return <Sheet open onOpenChange={open => { if (!open) close(); }}><SheetContent className="w-full sm:max-w-lg" showCloseButton={!saving} onEscapeKeyDown={event => { if (savingRef.current) event.preventDefault(); }} onInteractOutside={event => { if (savingRef.current) event.preventDefault(); }}>
<SheetHeader className="px-6 pt-6 pb-0"><SheetTitle>Update {agents.length} {label} {agents.length === 1 ? "agent" : "agents"}</SheetTitle><SheetDescription>{totalSelected > agents.length ? `${otherSelected} other selected ${otherSelected === 1 ? "agent" : "agents"} won’t change.` : "Change their connection, default model, or both."}</SheetDescription></SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 py-2">
<div className="flex flex-wrap gap-2" aria-label="Agents to update">{agents.map(a => <span key={a.id} className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs"><AgentAvatar agent={a} size={20} />{a.name}</span>)}</div>
<div className="flex flex-wrap gap-2" aria-label="Agents to update">{agents.map(a => <span key={a.id} className={agentIdentityChipClass}><AgentIdentityChipContent agent={a} /></span>)}</div>
{failures && <div role="alert" className="space-y-2 rounded-md border border-destructive/30 p-3 text-sm"><p className="font-medium">{updatedIds.length} updated · {failures.length} couldn’t be saved</p>{failures.map(f => <p key={f.agent.id} className="text-muted-foreground">{f.agent.name}: {f.message}</p>)}<p className="text-xs text-muted-foreground">Retry updates only the remaining agents. Close and reopen to review changed settings.</p></div>}
<fieldset disabled={saving || failures !== null} className="flex flex-col gap-6 disabled:opacity-60">
<section className="space-y-3"><label className="flex cursor-pointer items-start gap-3"><Checkbox checked={changeConnection} disabled={!aiProviderForAdapter(harness)} onCheckedChange={value => setChangeConnection(value === true)} aria-label="Change connection" className="mt-0.5" /><span className="space-y-1"><span className="block text-sm font-medium">Change connection</span><span className="block text-xs text-muted-foreground">{currentConnections.length === 1 ? currentConnections[0] : `${currentConnections.length} different connections`}{!changeConnection && " · Keep current"}</span></span></label>
@@ -0,0 +1,8 @@
import type { Agent } from "@paperclipai/shared";
import { AgentAvatar } from "@/components/AgentAvatar";
export const agentIdentityChipClass = "agent-identity-chip inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs";
export function AgentIdentityChipContent({ agent }: { agent: Agent }) {
return <><AgentAvatar agent={agent} size={20} /><span className="truncate">{agent.name}</span></>;
}
@@ -0,0 +1,12 @@
import { ArtifactFront } from "./ArtifactFront";
import { ArtifactStack } from "./ArtifactStack";
import type { ArtifactKind, ArtifactSubject } from "./kinds";
import brandPreview from "./assets/empty-brand.svg";
/** Reuses the full-size artifact artwork and its hover fan for empty collections. */
export function ArtifactEmptyGraphic({ kind = "file", stack = false }: { kind?: ArtifactKind; stack?: boolean }) {
const subject: ArtifactSubject = stack
? { kind: "image", title: "", thumbnailUrl: brandPreview, aspect: 1.5 }
: { kind, title: "", ...(kind === "document" ? { ext: "md" } : {}) };
return stack ? <ArtifactStack front={subject} /> : <ArtifactFront subject={subject} />;
}
@@ -50,7 +50,7 @@ export function ArtifactFront({
case "media-fallback":
return (
<div data-artifact-front className="shrink-0" style={style}>
<IconSheet subject={subject} play />
<IconSheet subject={subject} play={subject.kind !== "image"} />
</div>
);
default:
@@ -66,6 +66,7 @@ describe("the Artifacts page tiles", () => {
expect(print?.style.height).toBe("163.33px");
const front = container.querySelector<HTMLElement>("[data-artifact-front]");
expect(front).not.toBeNull();
expect(container.querySelectorAll("[data-artifact-front]")).toHaveLength(1);
expect(front?.contains(print)).toBe(true);
expect(front?.style.position).toBe("absolute");
expect(print?.style.position).toBe("");
@@ -83,6 +84,7 @@ describe("the Artifacts page tiles", () => {
);
const front = container.querySelector("[data-artifact-front]");
expect(front).not.toBeNull();
expect(container.querySelectorAll("[data-artifact-front]")).toHaveLength(1);
expect(front?.querySelector("[data-artifact-print]")).not.toBeNull();
});
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320" viewBox="0 0 480 320"><rect width="480" height="320" fill="#f5f5f4"/><g transform="translate(192 112) scale(4)" fill="none" stroke="#242424" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 6 l-8.414 8.586 a2 2 0 0 0 2.828 2.828 l8.414 -8.586 a4 4 0 1 0 -5.657 -5.657 l-8.379 8.551 a6 6 0 1 0 8.485 8.485 l8.379 -8.551"/></g></svg>

After

Width:  |  Height:  |  Size: 425 B

@@ -206,7 +206,7 @@ export function CoordinatorNeedsYou() {
</SheetContent>
</Sheet> : null}
<header className="flex items-center justify-between gap-3"><h1 className="text-2xl font-medium">Needs you</h1><Button variant="ghost" onClick={showHistory}><History className="size-4" />Decision history</Button></header>
<div role="toolbar" aria-label="Decisions controls" className="flex flex-wrap items-center gap-2">
<div data-canonical-toolbar="coordinator-decisions" role="toolbar" aria-label="Decisions controls" className="flex flex-wrap items-center gap-2">
<ToolbarSearch query={query} onQueryChange={setQuery} label="Search decisions" placeholder="Search decisions or task IDs…" />
<NativeSelect aria-label="Filter decisions by agent" value={agentFilter} onChange={(event) => setAgentFilter(event.target.value)} className="w-auto"><option value="">All agents</option>{(agentQuery.data ?? []).map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</NativeSelect>
<NativeSelect aria-label="Filter decisions by project" value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)} className="w-auto"><option value="">All projects</option>{projects.map(([id, name]) => <option key={id} value={id}>{name}</option>)}</NativeSelect>