mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
fix(ui): align chat connection and recovery cards (#15771)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task pages show external chat connections and recovery notices.
> - These cards use different widths, colors, and spacing.
> - The difference makes related task information harder to scan.
> - This pull request gives these cards one shared surface.
> - The benefit is a consistent task page for chat and email users.
## Linked Issues or Issue Description
**What happened?**
The external chat banner and recovery notice have different backgrounds,
padding, and widths. The first banner needs more space above it.
**Expected behavior**
Connection, recovery, workspace repair, and task email cards use the
same surface. The chat cards have the same width.
**Steps to reproduce**
Open a task with an external Slack conversation and a recovery notice.
Compare the two cards. Open the Tasks / Context notices story to compare
the updated cards with an AgentMail email card.
## What Changed
- Add a shared token-based surface for task context cards.
- Put chat recovery notices in the same width container as the
connection banner.
- Add space above the connection banner.
- Apply the surface to task email cards and workspace export repair.
- Add a Storybook view with per-card width, color, padding, and corner
checks.
- Give the workspace recovery card its own React key to prevent
duplicate connection banners.
## Verification
- Passed `pnpm check:token-gates`.
- Passed 57 tests for ExecutionBlockerNotice, WorkspaceExportRecovery,
and the external chat banner.
- Passed UI typecheck and UI production build.
- Captured desktop and mobile browser screenshots from the real
components with fixture data. The three cards have equal widths,
backgrounds, padding, and corners.
- Full local typecheck needs Cargo, which is absent in the task
environment. Full tests and build could not complete within the
environment memory limit. Remote typecheck, build, all test shards, and
security checks pass on commit e225a8e47. The Canary Dry Run also
passes. Storybook visual/deploy jobs are skipped by workflow policy.
## Risks
- Low risk. Recovery notices move outside the chat panel. Their actions
and API calls stay the same.
- Verify narrow screens and both task layout modes.
## Model Used
- OpenAI GPT-6 through Codex. Tool use and code execution. The runtime
does not expose an exact model variant or context window size.
## 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 tests locally and they pass
- [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:
1 parent
07fee85eaf
commit
40bb466d78
9 files changed
+103
-19
No files matched your search
@@ -42,7 +42,9 @@ export function EmailMessageCard({
|
||||
message,
|
||||
publication,
|
||||
issueId,
|
||||
contextNotice = false,
|
||||
}: {
|
||||
contextNotice?: boolean;
|
||||
message: EmailMessage;
|
||||
publication?: EmailPublicationSummary;
|
||||
issueId: string;
|
||||
@@ -57,7 +59,7 @@ export function EmailMessageCard({
|
||||
aria-label={
|
||||
message.direction === "inbound" ? "Email received" : "Email sent"
|
||||
}
|
||||
className="space-y-4 rounded-xl border border-border bg-card p-5"
|
||||
className={contextNotice ? "task-context-notice space-y-3" : "space-y-4 rounded-xl border border-border bg-card p-5"}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold">
|
||||
|
||||
@@ -36,6 +36,7 @@ export function EmailTaskActivity({
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<EmailMessageCard
|
||||
contextNotice
|
||||
key={m.id}
|
||||
issueId={issueId}
|
||||
message={m}
|
||||
@@ -78,7 +79,7 @@ function EmailDelivery({
|
||||
{p.request && !p.providerMessageId && (
|
||||
<article
|
||||
aria-label="Email send intent"
|
||||
className="space-y-3 rounded-lg border border-border p-4"
|
||||
className="task-context-notice space-y-3"
|
||||
>
|
||||
<p className="font-semibold">{p.request.subject ?? "Email reply"}</p>
|
||||
{p.request.to && <p>To: {p.request.to.join(", ")}</p>}
|
||||
|
||||
@@ -36,8 +36,7 @@ describe("stopped task recovery notice", () => {
|
||||
const notice = container.querySelector('[role="status"][aria-label="Task recovery"]')!;
|
||||
expect(notice.textContent).toContain("Recorded work is preserved");
|
||||
expect(notice.textContent).toContain("Retry");
|
||||
expect(notice.classList.contains("border")).toBe(true);
|
||||
expect(notice.classList.contains("bg-muted")).toBe(true);
|
||||
expect(notice.classList.contains("task-context-notice")).toBe(true);
|
||||
expect(notice.querySelector("a")?.getAttribute("href")).toBe("/agents/agent/runs/failed-run");
|
||||
});
|
||||
it.each([false, true])("explains cancelled runs and saved input; continue eligibility %s", async canContinue => {
|
||||
|
||||
@@ -4,9 +4,11 @@ import { agentsApi } from "../api/agents";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Link } from "../lib/router";
|
||||
|
||||
export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried }: {
|
||||
export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried, className }: {
|
||||
className?: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
blocker: ExecutionBlocker;
|
||||
@@ -33,7 +35,7 @@ export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div role="status" aria-label="Task recovery" className="mx-(--sz-execution-blocker-inline) my-(--sz-execution-blocker-block) flex flex-wrap items-center justify-between execution-blocker-notice border border-border bg-muted text-foreground">
|
||||
<div role="status" aria-label="Task recovery" className={cn("task-context-notice flex flex-wrap items-center justify-between gap-2", className)}>
|
||||
<div className="min-w-0 flex-1 break-words">
|
||||
<p>{modelRejected ? "Model unavailable." : "Recovery needed."}{blocker.runError ? ` ${blocker.runError}` : ""}</p>
|
||||
{modelRejected && <p>Choose a supported model or clear the task's model override, then retry.</p>}
|
||||
|
||||
@@ -3,12 +3,14 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import type { IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
/** Copyback repair preserves the accepted result and never wakes the provider. */
|
||||
export function WorkspaceExportRecovery({ issueId, action, canManage, onQueued }: {
|
||||
export function WorkspaceExportRecovery({ issueId, action, canManage, onQueued, className }: {
|
||||
className?: string;
|
||||
issueId: string; action: IssueRecoveryAction | null; canManage: boolean; onQueued: () => void;
|
||||
}) {
|
||||
const noteId = useId();
|
||||
@@ -23,7 +25,7 @@ export function WorkspaceExportRecovery({ issueId, action, canManage, onQueued }
|
||||
if (!action || !isNativeWorkspaceExportRepairCause(action.cause) || action.ownerType !== "board"
|
||||
|| !["active", "escalated"].includes(action.status) || typeof action.evidence.runId !== "string") return null;
|
||||
const queued = queuedActionVersion === String(action.updatedAt) || action.wakePolicy?.kind === "resume_native_run";
|
||||
return <section aria-label="Workspace export repair" className="flex flex-col gap-2 p-4 text-sm">
|
||||
return <section aria-label="Workspace export repair" className={cn("task-context-notice flex flex-col gap-2", className)}>
|
||||
<p className="font-medium">Workspace export needs repair</p>
|
||||
{queued ? <p role="status">Export is queued for the saved result. The agent will not repeat its work.</p> : <>
|
||||
<p className="text-muted-foreground">{"Automatic workspace export retries stopped. Inspect the export failure, restore provider or destination availability, and preserve the saved files in the retained sandbox. Retry export here when the cause is resolved."}</p>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
@@ -124,6 +125,7 @@ export function useIssueChatBinding(companyId: string, issueId: string) {
|
||||
}
|
||||
|
||||
type ConnectedTaskProps = {
|
||||
className?: string;
|
||||
attachments?: IssueAttachment[];
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
@@ -148,6 +150,7 @@ export function ExternallyConnectedTaskBanner(props: ConnectedTaskProps) {
|
||||
}
|
||||
|
||||
function ConnectedTaskComposer({
|
||||
className,
|
||||
attachments = [],
|
||||
companyId,
|
||||
issueId,
|
||||
@@ -486,7 +489,7 @@ function ConnectedTaskComposer({
|
||||
return (
|
||||
<section
|
||||
aria-label="External conversation"
|
||||
className="space-y-3 rounded-lg border border-border bg-muted/40 p-3 text-sm"
|
||||
className={cn("task-context-notice space-y-3", className)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex min-w-0 flex-1 basis-64 items-center gap-3">
|
||||
|
||||
+7
-5
@@ -3120,11 +3120,13 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
||||
inset-inline: 0;
|
||||
}
|
||||
|
||||
/* Task recovery panel uses the shared spacing, radius, and type scales. */
|
||||
.execution-blocker-notice {
|
||||
gap: calc(var(--spacing) * 2);
|
||||
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
|
||||
border-radius: var(--radius-md);
|
||||
/* Connection and recovery cards share one surface and spacing scale. */
|
||||
.task-context-notice {
|
||||
padding: calc(var(--spacing) * 3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--muted);
|
||||
color: var(--foreground);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--text-sm--line-height);
|
||||
}
|
||||
|
||||
@@ -7642,6 +7642,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
{taskChatShellEnabled ? null : createdFromNote}
|
||||
|
||||
<ExternallyConnectedTaskBanner
|
||||
className={cn(shellSectionClass, "mt-3")}
|
||||
key={issue.id}
|
||||
attachments={attachments ?? []}
|
||||
companyId={issue.companyId}
|
||||
@@ -7649,6 +7650,15 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
issueCacheRefs={issueCacheRefs}
|
||||
/>
|
||||
|
||||
{resolvedDetailTab === "chat" && (
|
||||
<WorkspaceExportRecovery key={`workspace-export:${issue.activeRecoveryAction?.id ?? issue.id}`} issueId={issue.id}
|
||||
className={shellSectionClass}
|
||||
action={issue.activeRecoveryAction ?? null} canManage={canManageBoardRuntime} onQueued={invalidateIssueDetail} />
|
||||
)}
|
||||
{resolvedDetailTab === "chat" && issue.executionBlocker && (
|
||||
<ExecutionBlockerNotice className={shellSectionClass} companyId={issue.companyId} issueId={issue.id} blocker={issue.executionBlocker} onRetried={invalidateIssueDetail} />
|
||||
)}
|
||||
|
||||
{issue.status === "in_review" && issue.externalConversationState === "waiting" && (
|
||||
<p role="status" className="text-sm text-muted-foreground">Reply sent. Send a message to continue.</p>
|
||||
)}
|
||||
@@ -7952,11 +7962,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<WorkspaceExportRecovery key={issue.activeRecoveryAction?.id ?? issue.id} issueId={issue.id}
|
||||
action={issue.activeRecoveryAction ?? null} canManage={canManageBoardRuntime} onQueued={invalidateIssueDetail} />
|
||||
{issue.executionBlocker && (
|
||||
<ExecutionBlockerNotice companyId={issue.companyId} issueId={issue.id} blocker={issue.executionBlocker} onRetried={invalidateIssueDetail} />
|
||||
)}
|
||||
{resolvedDetailTab === "chat" ? (
|
||||
<WorkspaceBaseRefRecoveryProvider issue={issue} agentMap={agentMap} onRepaired={() => { invalidateIssueDetail(); invalidateIssueCollections(); }}
|
||||
unavailableReason={!canManageBoardRuntime || !canResolveBoardRecoveryAction ? "You don’t have permission to repair this task’s workspace."
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { ExternallyConnectedTaskBanner } from "@/components/chat/ExternallyConnectedTaskBanner";
|
||||
import { ExecutionBlockerNotice } from "@/components/ExecutionBlockerNotice";
|
||||
import { EmailMessageCard } from "@/components/EmailMessageCard";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const shellSectionClass = "mx-auto w-full max-w-(--tc-shell-max-w)";
|
||||
|
||||
function TaskContextNotices() {
|
||||
const [client] = useState(() => {
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
|
||||
cache.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: true });
|
||||
cache.setQueryData(["issue-chat-binding", "demo", "context-notices"], {
|
||||
endpointId: "slack-demo", provider: "slack", externalLabel: "#design · Card styling",
|
||||
externalUrl: "https://slack.com", conversationId: "demo-thread", assignedAgentLocked: true,
|
||||
});
|
||||
cache.setQueryData(queryKeys.issues.runs("context-notices"), []);
|
||||
return cache;
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="w-full space-y-3">
|
||||
<h1 className={`${shellSectionClass} text-xl font-semibold`}>Chat connection and recovery</h1>
|
||||
<ExternallyConnectedTaskBanner className={`${shellSectionClass} mt-3`} companyId="demo" issueId="context-notices" />
|
||||
<ExecutionBlockerNotice className={shellSectionClass} companyId="demo" issueId="context-notices" onRetried={() => {}}
|
||||
blocker={{ recoveryActionId: "demo-recovery", runId: null, agentId: null, cause: "action_outcome_unknown",
|
||||
nextAction: "Inspect the run before continuing. Recorded work is preserved." }} />
|
||||
<div className={shellSectionClass}>
|
||||
<EmailMessageCard contextNotice issueId="context-notices" message={{
|
||||
id: "email-demo", providerMessageId: "email-demo", direction: "inbound",
|
||||
from: "operator@example.com", to: ["agent@example.com"], subject: "Card styling",
|
||||
text: "Please keep the connection and recovery cards consistent.",
|
||||
fullText: "Please keep the connection and recovery cards consistent.",
|
||||
commentId: null, attachmentIds: [], timestamp: "2026-10-09T12:00:00Z", automatic: false,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Tasks/Context notices",
|
||||
component: TaskContextNotices,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof TaskContextNotices>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const ConnectionAndRecovery: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const connection = await canvas.findByRole("region", { name: "External conversation" });
|
||||
const recovery = canvas.getByRole("status", { name: "Task recovery" });
|
||||
const email = canvas.getByRole("article", { name: "Email received" });
|
||||
for (const card of [recovery, email]) {
|
||||
await expect(card.getBoundingClientRect().width).toBe(connection.getBoundingClientRect().width);
|
||||
await expect(getComputedStyle(card).backgroundColor).toBe(getComputedStyle(connection).backgroundColor);
|
||||
await expect(getComputedStyle(card).padding).toBe(getComputedStyle(connection).padding);
|
||||
await expect(getComputedStyle(card).borderRadius).toBe(getComputedStyle(connection).borderRadius);
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user