Merge current master and decision admission fixes from work-folders base

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* codex/work-folders-base-refresh: (25 commits)
  fix: retain saved messages while decisions are pending
  fix(server): stop reporting expected managed-cloud transients to Sentry (#13323)
  fix: promote review tasks when continuations start (#13318)
  fix: require explicit native completion reviews (#13314)
  fix(onboarding): make chief-of-staff hiring reliable (#13317)
  fix: fence native startup against cancellation (#13316)
  chore(skills): allow verified PR merges (#13313)
  feat: add experimental persistent agent chat (#13284)
  chore(lockfile): refresh pnpm-lock.yaml (#13279)
  fix(ui): improve mobile task spacing (#13304)
  fix(connections): repair and simplify Google Workspace setup (#13289)
  fix(ui): stabilize steered chat activity presentation (#13246)
  fix(ui): make feed cards fully clickable (#13294)
  ci: activate shared PR dependency cache restores (#13302)
  test: remove cold executable reads from runner integrity deadlines (#13301)
  ci: reuse dependency caches without per-PR uploads (#13300)
  fix(ui): hide profile feedback flag on Cloud (#13292)
  feat(ui): refine dashboard cards, charts, and recent lists (#13269)
  fix: protect starting runs during overlapping deployments (#13285)
  fix: reliably interrupt and resume legacy message queues (#13275)
  ...

# Conflicts:
#	packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs
This commit is contained in:
Dotta committed 2026-09-12 13:31:38 -05:00
commit bb80a847c4
385 files changed
+114493 -3058

No files matched your search

+2
View File
@@ -36,6 +36,7 @@ import { Workspaces } from "./pages/Workspaces";
import { Issues } from "./pages/Issues";
import { Search } from "./pages/Search";
import { IssueDetail } from "./pages/IssueDetail";
import { AgentChat } from "./pages/AgentChat";
import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf";
import { Routines } from "./pages/Routines";
import { Learnings, PipelineItemDetail, PipelineItemLegacyRedirect, Pipelines, ReviewQueue } from "./pages/Pipelines";
@@ -303,6 +304,7 @@ function boardRoutes(streamlinedUiEnabled: boolean) {
<Route path="issues/backlog" element={<Navigate to="/issues" replace />} />
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
<Route path="chats/:agentRef" element={<AgentChat />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
{import.meta.env.DEV ? (
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
+13
View File
@@ -0,0 +1,13 @@
import type { Issue } from "@paperclipai/shared";
import { api } from "./client";
export const agentChatsApi = {
get: (companyId: string, agentRef: string) =>
api.get<Issue | null>(
`/companies/${companyId}/chats/${encodeURIComponent(agentRef)}`,
),
ensure: (companyId: string, agentRef: string) =>
api.post<Issue>(
`/companies/${companyId}/chats/${encodeURIComponent(agentRef)}`,
{},
),
};
+6
View File
@@ -384,6 +384,10 @@ export const issuesApi = {
`/issues/${id}/queued-comments/order`,
data,
),
interruptQueuedComments: (
id: string,
data: { queueId: string; targetRunId: string; revision: string },
) => api.post<IssueQueuedCommentQueue>(`/issues/${id}/queued-comments/interrupt`, data),
steerQueuedComment: (
id: string,
commentId: string,
@@ -501,10 +505,12 @@ export const issuesApi = {
reopen?: boolean,
interrupt?: boolean,
attachmentIds?: string[],
clientRequestId?: string,
) =>
confirmedCommentResponse(
api.post<IssueComment>(`/issues/${id}/comments`, {
body,
...(clientRequestId ? { clientRequestId } : {}),
...(reopen === undefined ? {} : { reopen }),
...(interrupt === undefined ? {} : { interrupt }),
...(attachmentIds?.length ? { attachmentIds } : {}),
+66 -6
View File
@@ -4,7 +4,7 @@ import { act, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
import { ActiveAgentsPanel, AgentRunCard } from "./ActiveAgentsPanel";
const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForCompany: vi.fn(),
@@ -30,10 +30,6 @@ vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("./Identity", () => ({
Identity: ({ name }: { name: string }) => <span>{name}</span>,
}));
vi.mock("./RunChatSurface", () => ({
RunChatSurface: () => <div>Run output</div>,
}));
@@ -156,6 +152,7 @@ describe("ActiveAgentsPanel", () => {
anchor.textContent?.includes("more active/recent"),
);
expect(moreLink?.getAttribute("href")).toBe("/dashboard/live");
expect(container.textContent).not.toContain("Run output");
await act(async () => {
root.unmount();
@@ -189,6 +186,7 @@ describe("ActiveAgentsPanel", () => {
limit: 50,
});
expect(container.textContent).not.toContain("more active/recent");
expect(container.textContent).not.toContain("Run output");
await act(async () => {
root.unmount();
@@ -224,7 +222,8 @@ describe("ActiveAgentsPanel", () => {
const issueLink = [...container.querySelectorAll("a")].find((anchor) =>
anchor.textContent?.includes("Phase 4B"),
);
expect(issueLink?.textContent).toBe("PAP-3562 - Phase 4B: Implement LLM Wiki distillation UI");
expect(issueLink?.textContent).toContain("Phase 4B: Implement LLM Wiki distillation UI");
expect(issueLink?.textContent).toContain("PAP-3562");
expect(issueLink?.getAttribute("href")).toBe("/issues/PAP-3562");
});
@@ -232,4 +231,65 @@ describe("ActiveAgentsPanel", () => {
root.unmount();
});
});
it("keeps run outcomes distinct from the linked task status", async () => {
const root = createRoot(container);
const statuses = ["running", "queued", "succeeded", "failed", "timed_out", "cancelled", "interrupted"];
await act(async () => {
root.render(<>{statuses.map((status, index) => (
<AgentRunCard
key={status}
companyId="company-1"
run={{ ...createIssueRun(index, "issue-1"), status }}
issue={{ title: "Review release notes", identifier: "PAP-559", status: "in_review" }}
/>
))}</>);
});
const headers = [...container.querySelectorAll('a[aria-label$=". View run"]')];
expect(headers.map((header) => header.getAttribute("aria-label"))).toEqual([
"Agent 0 — Running. View run", "Agent 1 — Queued. View run",
"Agent 2 — Succeeded. View run", "Agent 3 — Failed. View run",
"Agent 4 — Timed out. View run", "Agent 5 — Cancelled. View run",
"Agent 6 — Interrupted. View run",
]);
expect(headers.every((header) => header.querySelector("svg") === null)).toBe(true);
expect(container.querySelector(".status-chip")).toBeNull();
expect(container.querySelectorAll('[aria-label="Task in review"]')).toHaveLength(7);
expect(container.querySelectorAll(".motion-safe\\:animate-spin")).toHaveLength(0);
expect(container.querySelector('a[aria-label="Agent 0 — Running. View run"]')?.getAttribute("href"))
.toBe("/agents/agent-0/runs/run-0");
await act(async () => root.unmount());
});
it("keeps a failed task lookup navigable and shows a clear error", async () => {
const root = createRoot(container);
await act(async () => {
root.render(<AgentRunCard companyId="company-1" run={createIssueRun(1, "issue-missing")} issueLoadFailed />);
});
expect(container.textContent).toContain("Task unavailable");
expect(container.querySelector('a[href="/issues/issue-missing"]')).not.toBeNull();
await act(async () => root.unmount());
});
it("does not animate running records while execution is reconnecting", async () => {
const root = createRoot(container);
await act(async () => {
root.render(<AgentRunCard
companyId="company-1"
run={{
...createRun(0),
execution: {
phase: "reconnecting", label: "Reconnecting", cause: null,
lastConfirmedActivityAt: null, retryAt: null, attempt: 1, maxAttempts: 3,
recoveryOwner: "agent", nextAction: null, permittedActions: ["inspect_run"],
predecessorRunId: null, successorRunId: null,
},
}}
/>);
});
expect(container.querySelector('a[aria-label="Agent 0 — Running. View run"]')).not.toBeNull();
expect(container.querySelector(".status-chip")).toBeNull();
expect(container.querySelectorAll(".motion-safe\\:animate-spin")).toHaveLength(0);
await act(async () => root.unmount());
});
});
+91 -100
View File
@@ -1,45 +1,18 @@
import { memo, useMemo } from "react";
import { Link } from "@/lib/router";
import { useQueries, useQuery } from "@tanstack/react-query";
import { requiresExecutionReconciliation, type Issue, type IssueRecoveryAction } from "@paperclipai/shared";
import type { Issue } from "@paperclipai/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import type { TranscriptEntry } from "../adapters";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import {
deriveActiveRecoveryDisplayState,
RECOVERY_CHIP_DEFAULT_TONE,
} from "../lib/recovery-display";
import { ExternalLink } from "lucide-react";
import { Clock3 } from "lucide-react";
import { Identity } from "./Identity";
import { StatusGlyph } from "./StatusGlyph";
import { RunChatSurface } from "./RunChatSurface";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { Badge } from "@/components/ui/badge";
function RunCardRecoveryChip({ action }: { action: IssueRecoveryAction }) {
const state = deriveActiveRecoveryDisplayState(action);
if (!state || requiresExecutionReconciliation(action.cause)) return null;
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
const Icon = tone.icon;
return (
<Badge variant="outline"
data-testid="active-agent-run-recovery-indicator"
data-recovery-state={state}
role="status"
aria-label={tone.label}
title={`${tone.label} — open the source task to act.`}
className={cn(
"gap-0.5 px-1.5 text-(length:--text-nano)",
tone.className,
)}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{tone.label}
</Badge>
);
}
const MIN_DASHBOARD_RUNS = 4;
const DASHBOARD_RUN_CARD_LIMIT = 4;
@@ -47,10 +20,17 @@ const DASHBOARD_LOG_POLL_INTERVAL_MS = 15_000;
const DASHBOARD_LOG_READ_LIMIT_BYTES = 64_000;
const DASHBOARD_MAX_CHUNKS_PER_RUN = 40;
const EMPTY_TRANSCRIPT: TranscriptEntry[] = [];
const EMPTY_RUNS: LiveRunForIssue[] = [];
function isRunActive(run: LiveRunForIssue): boolean {
return run.status === "queued" || run.status === "running";
}
const runStatusLabels: Record<string, string> = {
running: "Running",
queued: "Queued",
succeeded: "Succeeded",
failed: "Failed",
timed_out: "Timed out",
cancelled: "Cancelled",
interrupted: "Interrupted",
};
interface ActiveAgentsPanelProps {
companyId: string;
@@ -63,6 +43,7 @@ interface ActiveAgentsPanelProps {
emptyMessage?: string;
queryScope?: string;
showMoreLink?: boolean;
showTranscripts?: boolean;
}
export function ActiveAgentsPanel({
@@ -76,6 +57,7 @@ export function ActiveAgentsPanel({
emptyMessage = "No recent agent runs.",
queryScope = "dashboard",
showMoreLink = true,
showTranscripts = false,
}: ActiveAgentsPanelProps) {
const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit }] as const;
const sharedLiveRuns = useSharedPollingQuery({
@@ -119,7 +101,7 @@ export function ActiveAgentsPanel({
}, [issueQueries]);
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
runs: visibleRuns,
runs: showTranscripts ? visibleRuns : EMPTY_RUNS,
companyId,
maxChunksPerRun: DASHBOARD_MAX_CHUNKS_PER_RUN,
logPollIntervalMs: DASHBOARD_LOG_POLL_INTERVAL_MS,
@@ -137,7 +119,7 @@ export function ActiveAgentsPanel({
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
</div>
) : (
<div className={cn("grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4", gridClassName)}>
<div className={cn("grid grid-cols-1 items-start gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4", gridClassName)}>
{visibleRuns.map((run) => (
<AgentRunCard
key={run.id}
@@ -146,16 +128,19 @@ export function ActiveAgentsPanel({
issue={run.issueId ? issueById.get(run.issueId) : undefined}
transcript={transcriptByRun.get(run.id) ?? EMPTY_TRANSCRIPT}
hasOutput={hasOutputForRun(run.id)}
isActive={isRunActive(run)}
showTranscript={showTranscripts}
issueLoadFailed={issueQueries.some((query, index) => visibleIssueIds[index] === run.issueId && query.isError)}
className={cardClassName}
/>
))}
</div>
)}
{showMoreLink && hiddenRunCount > 0 && (
{showMoreLink && runs.length > 0 && (
<div className="mt-3 flex justify-end text-xs text-muted-foreground">
<Link to="/dashboard/live" className="hover:text-foreground hover:underline">
{hiddenRunCount} more active/recent run{hiddenRunCount === 1 ? "" : "s"}
{hiddenRunCount > 0
? `${hiddenRunCount} more active/recent run${hiddenRunCount === 1 ? "" : "s"}`
: "View all runs"}
</Link>
</div>
)}
@@ -163,88 +148,94 @@ export function ActiveAgentsPanel({
);
}
const AgentRunCard = memo(function AgentRunCard({
export const AgentRunCard = memo(function AgentRunCard({
companyId,
run,
issue,
transcript,
hasOutput,
isActive,
transcript = EMPTY_TRANSCRIPT,
hasOutput = false,
showTranscript = false,
issueLoadFailed = false,
className,
}: {
companyId: string;
run: LiveRunForIssue;
issue?: Issue;
transcript: TranscriptEntry[];
hasOutput: boolean;
isActive: boolean;
issue?: Pick<Issue, "identifier" | "title" | "status">;
transcript?: TranscriptEntry[];
hasOutput?: boolean;
showTranscript?: boolean;
issueLoadFailed?: boolean;
className?: string;
}) {
const statusLabel = runStatusLabels[run.status] ?? run.status.replace(/[_-]/g, " ");
const runUrl = `/agents/${run.agentId}/runs/${run.id}`;
const timestamp = run.finishedAt
? `Finished ${relativeTime(run.finishedAt)}`
: run.startedAt ? `Started ${relativeTime(run.startedAt)}` : `Queued ${relativeTime(run.createdAt)}`;
const taskTitle = issue?.title ?? (issueLoadFailed ? "Task unavailable" : "Loading task…");
return (
<div className={cn(
"flex h-(--sz-320px) flex-col overflow-hidden rounded-xl border shadow-sm",
isActive
? "border-blue-500/25 bg-blue-500/[0.04] shadow-(--shadow-extract-1)"
"dashboard-agent-card flex min-w-0 flex-col overflow-hidden rounded-xl border",
showTranscript && "h-(--sz-320px)",
run.status === "running"
? "border-(--dashboard-run-border) bg-(--dashboard-run-background) shadow-(--shadow-extract-1)"
: "border-border bg-background/70",
className,
)}>
<div className="border-b border-border/60 px-3 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
{isActive && (!run.execution || run.execution.phase === "working") ? (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full motion-safe:animate-ping rounded-full bg-blue-400 opacity-70" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500" />
</span>
) : (
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-muted-foreground/35" />
)}
<Identity name={run.agentName} size="sm" className="[&>span:last-child]:!text-(length:--text-micro)" />
</div>
<div className="mt-2 flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<span>{isActive ? "Working" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}</span>
</div>
</div>
)} data-run-status={run.status}>
<div className={cn("flex shrink-0 flex-col gap-3 p-3", showTranscript && "border-b border-border/60")}>
<Link
to={runUrl}
title={`${run.agentName} — ${statusLabel} · ${timestamp}`}
aria-label={`${run.agentName} — ${statusLabel}. View run`}
className="flex min-w-0 items-center gap-2 rounded-md text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Identity name={run.agentName} className="gap-2 font-medium" />
</Link>
{run.issueId ? (
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2 py-1 text-(length:--text-nano) text-muted-foreground transition-colors hover:text-foreground"
to={`/issues/${issue?.identifier ?? run.issueId}`}
className="min-w-0 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-sm text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={issue ? `${issue.title} · ${issue.identifier}` : taskTitle}
>
<ExternalLink className="h-2.5 w-2.5" />
<span className="flex min-w-0 items-baseline gap-2">
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<StatusGlyph
status={issue?.status ?? "backlog"}
size="md"
className="self-center"
title={issue ? `Task ${issue.status.replace(/_/g, " ")}` : undefined}
/>
<span className="truncate">{taskTitle}</span>
</span>
<span className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">{issue?.identifier ?? run.issueId.slice(0, 8)}</span>
</span>
</Link>
) : (
<Link to={runUrl} className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Clock3 className="size-4 shrink-0" aria-hidden />
<span className="truncate">{run.invocationSource === "timer" ? "Scheduled heartbeat" : "No linked task"}</span>
</Link>
</div>
{run.issueId && (
<div className="mt-3 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-xs">
<Link
to={`/issues/${issue?.identifier ?? run.issueId}`}
className={cn(
"line-clamp-2 hover:underline",
isActive ? "text-blue-700 dark:text-blue-300" : "text-muted-foreground hover:text-foreground",
)}
title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)}
>
{issue?.identifier ?? run.issueId.slice(0, 8)}
{issue?.title ? ` - ${issue.title}` : ""}
</Link>
{issue?.activeRecoveryAction ? (
<div className="mt-1.5">
<RunCardRecoveryChip action={issue.activeRecoveryAction} />
</div>
) : null}
</div>
)}
<time
dateTime={run.finishedAt ?? run.startedAt ?? run.createdAt}
className="text-right font-sans text-xs text-muted-foreground/70"
>
{timestamp}
</time>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<RunChatSurface
run={run}
transcript={transcript}
hasOutput={hasOutput}
companyId={companyId}
/>
</div>
{showTranscript && (
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<RunChatSurface
run={run}
transcript={transcript}
hasOutput={hasOutput}
companyId={companyId}
/>
</div>
)}
</div>
);
});
+10 -9
View File
@@ -20,9 +20,9 @@ function emptyRunDay(date: string): DashboardRunActivityDay {
}
const runSegmentColors = {
succeeded: "var(--hex-10b981)",
succeeded: "var(--status-task-icon-done)",
recovered: "var(--status-task-todo)",
failed: "var(--hex-ef4444)",
failed: "var(--status-task-icon-blocked)",
other: "var(--hex-737373)",
} as const;
@@ -166,7 +166,7 @@ export function RunActivityChart(props: RunChartProps) {
}
const priorityColors: Record<string, string> = {
critical: "var(--hex-ef4444)",
critical: "var(--status-task-icon-blocked)",
high: "var(--hex-f97316)",
medium: "var(--hex-eab308)",
low: "var(--hex-6b7280)",
@@ -223,14 +223,15 @@ export function PriorityChart({ issues }: { issues: { priority: string; createdA
// status vocabulary; badge, row, chart, and log agree). Previously an
// independent palette (todo blue, in_progress violet, etc.). `backlog`
// deliberately keeps --project-none (pre-B5, per user ruling); the
// priority series and success-rate tints below are not status hues and
// are left alone.
// non-red priority series and warning success-rate tints retain their own hues.
// Progress, done, and blocked use the icon hues so bars and legends match
// the task icons in each theme.
const statusColors: Record<string, string> = {
todo: "var(--status-task-todo)",
in_progress: "var(--status-task-in_progress)",
in_progress: "var(--status-task-icon-in_progress)",
in_review: "var(--status-task-in_review)",
done: "var(--status-task-done)",
blocked: "var(--status-task-blocked)",
done: "var(--status-task-icon-done)",
blocked: "var(--status-task-icon-blocked)",
cancelled: "var(--status-task-cancelled)",
backlog: "var(--project-none)",
};
@@ -309,7 +310,7 @@ export function SuccessRateChart(props: RunChartProps) {
// rather than dragging it down as failures.
const effectiveSucceeded = entry.succeeded + entry.recovered;
const rate = entry.total > 0 ? effectiveSucceeded / entry.total : 0;
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "var(--hex-10b981)" : rate >= 0.5 ? "var(--hex-eab308)" : "var(--hex-ef4444)";
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "var(--status-task-icon-done)" : rate >= 0.5 ? "var(--hex-eab308)" : "var(--status-task-icon-blocked)";
return (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${entry.total > 0 ? Math.round(rate * 100) : 0}% (${effectiveSucceeded}/${entry.total})`}>
{entry.total > 0 ? (
+31 -14
View File
@@ -53,27 +53,44 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en
const inner = (
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Avatar size="xs">
{actorAvatarUrl && <AvatarImage src={actorAvatarUrl} alt={actorName} />}
<AvatarFallback>{deriveInitials(actorName)}</AvatarFallback>
</Avatar>
<p className="min-w-0 flex-1 truncate">
<span>{actorName}</span>
<span className="text-muted-foreground"> {verb} </span>
{name && <span className="font-medium">{name}</span>}
{entityTitle && <span className="text-muted-foreground"> — {entityTitle}</span>}
</p>
<div className="flex items-start gap-2 @xl:grid @xl:grid-cols-(--dashboard-activity-list-columns) @xl:items-baseline">
<Avatar size="sm" aria-hidden="true" className="@xl:self-center">
{actorAvatarUrl && <AvatarImage src={actorAvatarUrl} alt="" />}
<AvatarFallback>{deriveInitials(actorName)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-1 @xl:contents">
<div className="flex min-w-0 items-baseline gap-2 @xl:contents">
<p className="flex h-6 min-w-0 flex-1 items-center gap-1.5">
<span className="max-w-1/2 shrink-0 truncate" title={`${actorName} ${verb}`}>
<span>{actorName}</span>{" "}
<span className="text-muted-foreground">{verb}</span>
</span>
{event.entityType === "issue" ? (
<span className="min-w-0 flex-1 truncate" title={entityTitle}>{entityTitle}</span>
) : (
<span className="min-w-0 flex-1 truncate">
{name && <span className="font-medium">{name}</span>}
{entityTitle && <span className="text-muted-foreground"> — {entityTitle}</span>}
</span>
)}
</p>
<span className="ml-auto shrink-0 truncate text-right font-mono text-(length:--text-micro) text-muted-foreground @xl:w-(--dashboard-list-id-width)">
{event.entityType === "issue" ? name : null}
</span>
</div>
<div className="flex min-h-6 min-w-0 items-center @xl:contents">
<span className="ml-auto w-(--dashboard-list-time-width) shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground">
{timeAgo(event.createdAt)}
</span>
</div>
</div>
<span className="text-xs text-muted-foreground shrink-0">{timeAgo(event.createdAt)}</span>
</div>
<IssueReferenceActivitySummary event={event} />
</div>
);
const classes = cn(
"px-4 py-2 text-sm",
"dashboard-list-row text-sm",
link && "cursor-pointer hover:bg-accent/50 transition-colors",
className,
);
+89
View File
@@ -0,0 +1,89 @@
import { useState } from "react";
import { Star, Users } from "lucide-react";
import { SidebarSection } from "@/components/SidebarSection";
import { SidebarNavItem } from "@/components/SidebarNavItem";
import { AgentIcon } from "@/components/AgentIconPicker";
import { Button } from "@/components/ui/button";
import { Link } from "@/lib/router";
import { useSidebar } from "@/context/SidebarContext";
import type { Agent } from "@paperclipai/shared";
import { agentRouteRef } from "@/lib/utils";
import { orderChatAgents } from "@/lib/recent-agent-chats";
export function AgentChatSidebar({
activeId,
starredIds,
recentIds,
onToggleStar,
agents,
href = (id: string) =>
`/chats/${encodeURIComponent(agentRouteRef(agents.find((agent) => agent.id === id)!))}`,
}: {
agents: Agent[];
href?: (id: string) => string;
activeId: string;
starredIds: string[];
recentIds: string[];
onToggleStar: (id: string) => void;
}) {
const [open, setOpen] = useState(true);
const { collapsed, peeking, isMobile, setSidebarOpen } = useSidebar();
const rail = collapsed && !peeking;
const ordered = orderChatAgents(agents, starredIds, recentIds);
const row = (agent: Agent) => {
const pinned = starredIds.includes(agent.id);
return (
<div key={agent.id} className="group/agent-chat relative">
<SidebarNavItem
to={href(agent.id)}
label={agent.name}
active={activeId === agent.id}
iconNode={
<AgentIcon icon={agent.icon} className="h-4 w-4 shrink-0" />
}
className={rail ? undefined : "pr-9"}
/>
{!rail && (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`${pinned ? "Unstar" : "Star"} ${agent.name}`}
aria-pressed={pinned}
title={pinned ? "Unstar agent" : "Star agent to pin"}
onClick={(event) => {
event.stopPropagation();
onToggleStar(agent.id);
}}
className="absolute right-2 top-(--pct-50) -translate-y-(--pct-50) text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/agent-chat:opacity-100 focus-visible:opacity-100"
>
<Star
aria-hidden="true"
className={pinned ? "fill-current" : undefined}
/>
</Button>
)}
</div>
);
};
return (
<>
<SidebarSection
label="Agents"
collapsible={{ open, onOpenChange: setOpen }}
>
{ordered.map((agent) => row(agent))}
<Link
to="/agents/all"
className="flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="See all agents"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
>
<Users className="h-4 w-4 shrink-0" />
{!rail && <span>See all agents</span>}
</Link>
</SidebarSection>
</>
);
}
+27
View File
@@ -113,6 +113,33 @@ describe("BreadcrumbBar", () => {
container.remove();
});
it("keeps a single task breadcrumb compact with adjacent identity and settings action", async () => {
const configure = vi.fn();
function AgentBreadcrumb() {
const { setBreadcrumbs } = useBreadcrumbs();
useEffect(() => {
setBreadcrumbs([{
label: "CodexCoder",
leading: <span aria-label="Agent avatar">CC</span>,
leadingKey: "codex-avatar",
trailing: <button aria-label="Configure CodexCoder" onClick={configure}>Settings</button>,
trailingKey: "codex-settings",
}]);
}, [setBreadcrumbs]);
return <BreadcrumbBar taskDetailLayout />;
}
await act(async () => root.render(<BreadcrumbProvider><AgentBreadcrumb /></BreadcrumbProvider>));
const label = container.querySelector('[data-slot="breadcrumb-page"]');
expect(label?.textContent).toBe("CCCodexCoder");
expect(container.querySelector("h1")).toBeNull();
const settings = container.querySelector<HTMLButtonElement>('button[aria-label="Configure CodexCoder"]');
expect(settings?.closest('[data-slot="breadcrumb-item"]')).toBe(label?.closest('[data-slot="breadcrumb-item"]'));
expect(settings?.closest('[aria-disabled="true"]')).toBeNull();
act(() => settings?.click());
expect(configure).toHaveBeenCalledOnce();
expect(container.querySelector('button[aria-label="Hide properties"]')).not.toBeNull();
});
it("shows only the title followed by its identifier for a company-scoped mobile task header", async () => {
viewport.isMobile = true;
await act(async () => {
+13 -11
View File
@@ -24,7 +24,7 @@ type GlobalToolbarContext = { companyId: string | null; companyPrefix: string |
function CrumbIdentifier({ identifier }: { identifier?: string }) {
if (!identifier) return null;
return (
<span data-slot="task-title-identifier" className="shrink-0 font-mono text-muted-foreground">
<span data-slot="task-title-identifier" className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
{identifier}
</span>
);
@@ -113,9 +113,9 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
return (
<div className="h-(--sz-60px) shrink-0 flex items-center border-b border-border px-4">
{menuButton}
<h1 className="flex min-w-0 flex-1 items-center gap-1.5 text-sm">
<h1 className="flex min-w-0 flex-1 items-baseline gap-1.5 text-sm">
{currentCrumb.leading ? (
<span className="flex shrink-0 items-center">{currentCrumb.leading}</span>
<span className="flex shrink-0 items-center self-center">{currentCrumb.leading}</span>
) : null}
<span className="min-w-0 truncate" title={currentCrumb.label}>{currentCrumb.label}</span>
<CrumbIdentifier identifier={currentCrumb.identifier} />
@@ -137,9 +137,9 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
{isLast || !crumb.href ? (
crumb.leading || crumb.identifier ? (
<BreadcrumbPage className="flex min-w-0 items-center gap-1.5">
<BreadcrumbPage className="flex min-w-0 items-baseline gap-1.5">
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
<span className="flex shrink-0 items-center self-center">{crumb.leading}</span>
)}
{!taskDetailLayout ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
<span className="min-w-0 truncate">{crumb.label}</span>
@@ -154,12 +154,12 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
<Link
to={crumb.href}
className={cn(
"flex min-w-0 items-center gap-1.5",
"flex min-w-0 items-baseline gap-1.5",
i === 0 && "font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground",
)}
>
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
<span className="flex shrink-0 items-center self-center">{crumb.leading}</span>
)}
{!taskDetailLayout ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
<span className="min-w-0 truncate">{crumb.label}</span>
@@ -178,6 +178,7 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
)}
</BreadcrumbLink>
)}
{crumb.trailing && <span className="flex shrink-0 items-center">{crumb.trailing}</span>}
</BreadcrumbItem>
</Fragment>
);
@@ -187,16 +188,17 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
</div>
);
// Single breadcrumb = page title (uppercase)
if (breadcrumbs.length === 1) {
// Task details use the same breadcrumb typography even with one item.
// Other single-crumb pages keep their existing page-title presentation.
if (breadcrumbs.length === 1 && !taskDetailLayout) {
return (
<div className="h-(--sz-60px) shrink-0 flex items-center border-b border-border px-4 md:px-6">
{menuButton}
<div className="min-w-0 overflow-hidden flex-1">
{breadcrumbs[0].leading || breadcrumbs[0].identifier ? (
<h1 className="flex items-center gap-1.5 text-sm font-semibold uppercase tracking-wider">
<h1 className="flex items-baseline gap-1.5 text-sm font-semibold uppercase tracking-wider">
{breadcrumbs[0].leading && (
<span className="flex shrink-0 items-center">{breadcrumbs[0].leading}</span>
<span className="flex shrink-0 items-center self-center">{breadcrumbs[0].leading}</span>
)}
<CrumbIdentifier identifier={breadcrumbs[0].identifier} />
<span className="truncate">{breadcrumbs[0].label}</span>
@@ -144,8 +144,7 @@ describe("CompanySettingsSidebar", () => {
expect(container.textContent).not.toContain("Settings");
expect(container.querySelector('[aria-label="Back from Settings"]')).toBeNull();
const settingsSurface = container.querySelector('[data-contextual-sidebar="settings"]');
expect(settingsSurface?.classList).toContain("bg-border/50");
expect(settingsSurface?.classList).toContain("dark:bg-muted");
expect(settingsSurface?.classList).toContain("primary-sidebar-surface");
expect(container.querySelector('[data-slot="contextual-sidebar-nav"]')?.className).toBe(
primarySidebarStyles.nav,
);
@@ -0,0 +1,48 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import type { ExecutionBlocker } from "@paperclipai/shared";
import { agentsApi } from "../api/agents";
import { activityApi } from "../api/activity";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "./ui/button";
export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried }: {
companyId: string;
issueId: string;
blocker: ExecutionBlocker;
onRetried: () => void;
}) {
const queryClient = useQueryClient();
const { data: runs } = useQuery({
queryKey: queryKeys.issues.runs(issueId),
queryFn: () => activityApi.runsForIssue(issueId),
});
const failedRun = runs?.find(run => run.runId === blocker.runId &&
["failed", "timed_out"].includes(run.status));
const retry = useMutation({
mutationFn: () => agentsApi.retryFailedRun(failedRun!.agentId, failedRun!.runId, companyId),
onSuccess: () => {
onRetried();
for (const queryKey of [queryKeys.issues.detail(issueId), queryKeys.issues.runs(issueId),
queryKeys.issues.liveRuns(issueId), queryKeys.issues.activeRun(issueId)]) {
void queryClient.invalidateQueries({ queryKey });
}
},
});
return (
<div role="status" className="px-(--sz-execution-blocker-inline) py-(--sz-execution-blocker-block) text-sm text-muted-foreground">
<span>Work cannot start. {blocker.nextAction}</span>{" "}
{failedRun && (
<Button variant="outline" size="sm" disabled={retry.isPending} onClick={() => retry.mutate()}>
{retry.isPending ? "Retrying…" : "Retry"}
</Button>
)}{" "}
{blocker.runId && blocker.agentId && (
<Link className="underline" to={`/agents/${blocker.agentId}/runs/${blocker.runId}`}>View stopped run</Link>
)}
{retry.isError && (
<p role="alert" className="text-destructive">{retry.error.message}</p>
)}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { ActivityEvent } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FeedCard } from "./FeedCard";
const navigate = vi.fn();
vi.mock("@/lib/router", () => ({
Link: ({ children, issueQuicklookSide: _, to, ...props }: React.ComponentProps<"a"> & { issueQuicklookSide?: string; to: string }) => (
<a {...props} href={to} onClick={navigate}>
{children}
</a>
),
}));
const event: ActivityEvent = {
id: "event-1",
companyId: "company-1",
actorType: "user",
actorId: "user-1",
action: "issue.updated",
entityType: "issue",
entityId: "issue-1",
agentId: null,
runId: null,
details: null,
createdAt: new Date("2026-09-11T12:00:00.000Z"),
};
describe("FeedCard", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
navigate.mockClear();
});
afterEach(() => {
container.remove();
});
it("uses the whole visible card as the entity link", () => {
const root = createRoot(container);
act(() => {
root.render(
<FeedCard
event={event}
agentMap={new Map()}
entityNameMap={new Map([["issue:issue-1", "PAP-1"]])}
entityTitleMap={new Map([["issue:issue-1", "Clickable card"]])}
/>,
);
});
const link = container.querySelector<HTMLAnchorElement>('[data-fc="link"]');
const card = container.querySelector<HTMLElement>('[data-fc="card"]');
expect(link).not.toBeNull();
expect(link?.className).toContain("w-full");
expect(card?.className).toContain("w-(--sz-calc-1)");
expect(card?.className).toContain("md:w-(--sz-calc-2)");
expect(link?.contains(card ?? null)).toBe(true);
card?.click();
expect(navigate).toHaveBeenCalledOnce();
act(() => root.unmount());
});
});
+3 -2
View File
@@ -437,7 +437,7 @@ export function FeedCard({
<Card
data-fc="card"
className={cn(
"flex-row group ml-3 mr-3 md:ml-0 my-2 items-center gap-2 p-(--sz-18px) text-xs",
"flex-row group ml-3 mr-3 md:ml-0 my-2 w-(--sz-calc-1) md:w-(--sz-calc-2) items-center gap-2 p-(--sz-18px) text-xs",
"transition-(--tp-background-color-border-color) duration-150",
content.link && "cursor-pointer hover:bg-accent hover:border-muted-foreground/30",
className,
@@ -480,7 +480,8 @@ export function FeedCard({
return (
<Link
to={content.link}
className="block no-underline text-inherit"
data-fc="link"
className="block w-full no-underline text-inherit"
issueQuicklookSide="left"
>
{card}
+1
View File
@@ -602,6 +602,7 @@ interface IssueChatThreadProps {
reopen?: boolean,
reassignment?: CommentReassignment,
attachmentIds?: string[],
clientRequestId?: string,
) => Promise<void>;
onReviewConversation?: () => Promise<void>;
onCancelRun?: () => Promise<void>;
+4 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { Sidebar } from "./Sidebar";
@@ -75,7 +75,7 @@ const RESERVED_APP_SUBPATHS = new Set([
"app",
]);
export function Layout() {
export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
const {
sidebarOpen,
setSidebarOpen,
@@ -654,7 +654,7 @@ export function Layout() {
{hasSecondarySidebar ? (
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
) : (
<Sidebar />
<Sidebar>{sidebarSections}</Sidebar>
)}
</div>
</div>
@@ -678,7 +678,7 @@ export function Layout() {
{replacesPrimarySidebar ? (
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
) : (
<Sidebar />
<Sidebar>{sidebarSections}</Sidebar>
)}
</div>
<SidebarAccountMenu
+1 -2
View File
@@ -173,8 +173,7 @@ describe("Sidebar", () => {
const sidebar = container.querySelector("aside");
expect(sidebar?.classList).not.toContain("border-r");
expect(sidebar?.classList).not.toContain("border-border");
expect(sidebar?.classList).toContain("bg-border/50");
expect(sidebar?.classList).toContain("dark:bg-muted");
expect(sidebar?.classList).toContain("primary-sidebar-surface");
flushSync(() => {
root.unmount();
+8 -2
View File
@@ -22,13 +22,15 @@ import {
LayoutGrid,
Users,
} from "lucide-react";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarAgents } from "./SidebarAgents";
import { SidebarProjects } from "./SidebarProjects";
import { SidebarStarredProjects } from "./SidebarStarredProjects";
import { SidebarAgentChats } from "./SidebarAgentChats";
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
import { SidebarRecentTasks } from "./SidebarRecentTasks";
import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
@@ -48,8 +50,9 @@ import { PluginLauncherOutlet } from "@/plugins/launchers";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
import { primarySidebarStyles } from "./primary-sidebar-styles";
export function Sidebar() {
export function Sidebar({ children }: { children?: ReactNode }) {
const { openNewIssue } = useDialogActions();
const { enabled: agentChatEnabled } = useAgentChatEnabled();
// Every labeled section is collapsible (session-scoped, default open) —
// one policy across static nav groups and the data-driven sections.
const [workOpen, setWorkOpen] = useState(true);
@@ -244,6 +247,9 @@ export function Sidebar() {
</SidebarSection>
) : null}
{children}
{agentChatEnabled && !children && <SidebarAgentChats />}
{streamlinedUiEnabled ? (
<SidebarRecentTasks companyId={selectedCompanyId} liveIssueIds={liveIssueIds} />
) : (
@@ -12,6 +12,7 @@ import type { DeploymentMode } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { authApi } from "@/api/auth";
import { queryKeys } from "@/lib/queryKeys";
import { useCloudInstance } from "@/hooks/useCloudInstance";
import { useSignOut } from "@/hooks/useSignOut";
import { useSidebar } from "../context/SidebarContext";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -106,6 +107,7 @@ export function SidebarAccountMenu({
open: controlledOpen,
onOpenChange,
}: SidebarAccountMenuProps) {
const isCloud = Boolean(useCloudInstance());
const [internalOpen, setInternalOpen] = useState(false);
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
@@ -227,7 +229,7 @@ export function SidebarAccountMenu({
</div>
</PopoverContent>
</Popover>
{!rail ? (
{!rail && !isCloud ? (
<Tooltip>
<TooltipTrigger asChild>
<a
@@ -214,6 +214,7 @@ describe("SidebarAccountMenu", () => {
await flushReact();
await flushReact();
expect(container.querySelector('a[aria-label="Share feedback"]')).not.toBeNull();
expect(container.textContent).toContain("Jane Example");
expect(container.textContent).not.toContain("jane@example.com");
@@ -274,7 +275,7 @@ describe("SidebarAccountMenu", () => {
});
});
it("navigates cloud-managed sign-out through the harness without calling local auth", async () => {
it.each([SidebarAccountMenu, ProductionSidebarAccountMenu])("hides cloud feedback and signs out through the harness (%#)", async (AccountMenu) => {
const root = createRoot(container);
const onOpenChange = vi.fn();
const queryClient = new QueryClient({
@@ -295,7 +296,7 @@ describe("SidebarAccountMenu", () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<SidebarAccountMenu
<AccountMenu
deploymentMode="authenticated"
open
onOpenChange={onOpenChange}
@@ -306,6 +307,8 @@ describe("SidebarAccountMenu", () => {
});
await flushReact();
expect(container.querySelector('a[aria-label="Share feedback"]')).toBeNull();
const signOutButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Sign out"),
);
+3 -1
View File
@@ -13,6 +13,7 @@ import type { DeploymentMode } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { authApi } from "@/api/auth";
import { queryKeys } from "@/lib/queryKeys";
import { useCloudInstance } from "@/hooks/useCloudInstance";
import { useSignOut } from "@/hooks/useSignOut";
import { useSidebar } from "../context/SidebarContext";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -112,6 +113,7 @@ export function SidebarAccountMenu({
onOpenChange,
forceExpanded = false,
}: SidebarAccountMenuProps) {
const isCloud = Boolean(useCloudInstance());
const [internalOpen, setInternalOpen] = useState(false);
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking && !forceExpanded;
@@ -230,7 +232,7 @@ export function SidebarAccountMenu({
</div>
</PopoverContent>
</Popover>
{!rail ? (
{!rail && !isCloud ? (
<Tooltip>
<TooltipTrigger asChild>
<a
+52
View File
@@ -0,0 +1,52 @@
import { useQuery } from "@tanstack/react-query";
import { agentsApi } from "@/api/agents";
import { authApi } from "@/api/auth";
import { useCompany } from "@/context/CompanyContext";
import {
useResourceMemberships,
useResourceMembershipMutation,
} from "@/hooks/useResourceMemberships";
import { useRecentAgentChats } from "@/lib/recent-agent-chats";
import { queryKeys } from "@/lib/queryKeys";
import { useLocation } from "@/lib/router";
import { agentRouteRef } from "@/lib/utils";
import { AgentChatSidebar } from "./AgentChatSidebar";
export function SidebarAgentChats() {
const { selectedCompanyId } = useCompany();
const { data: agents = [] } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const userId = session?.user?.id ?? session?.session?.userId;
const recentIds = useRecentAgentChats(selectedCompanyId ?? "", userId);
const memberships = useResourceMemberships(selectedCompanyId);
const mutation = useResourceMembershipMutation(selectedCompanyId);
const stars = memberships.data?.starredAgentIds ?? [];
const location = useLocation();
const activeRef = location.pathname.match(/\/chats\/([^/]+)/)?.[1];
const active = agents.find(
(agent) => agent.id === activeRef || agentRouteRef(agent) === activeRef,
);
return (
<AgentChatSidebar
agents={agents}
activeId={active?.id ?? ""}
starredIds={stars}
recentIds={recentIds}
onToggleStar={(id) => {
mutation.mutate({
resourceType: "agent",
resourceId: id,
resourceName:
agents.find((agent) => agent.id === id)?.name ?? "Agent",
starred: !stars.includes(id),
});
}}
/>
);
}
+1 -1
View File
@@ -182,7 +182,7 @@ export function SidebarNavItem({
</Badge>
)}
{!rail && (hasLive || liveAccessory) && (
<span className="ml-auto flex items-center gap-1.5">
<span className="ml-auto flex shrink-0 items-center gap-1.5 whitespace-nowrap">
{liveAccessory}
{hasLive && (
<>
+3 -3
View File
@@ -250,11 +250,11 @@ function RecentTasksList({
<>
<SidebarSection label="Recent Tasks">
{entries.map((entry) => (
<div key={entry.id} className="group/recent-task relative">
<div key={entry.id} className="sidebar-action-row group/recent-task relative">
<SidebarNavItem
to={`/issues/${entry.id}`}
label={entry.title}
className={rail ? undefined : "pr-10"}
className={rail ? undefined : "sidebar-action-link pointer-coarse:pr-8"}
liveCount={liveIssueIds.has(entry.id) ? 1 : undefined}
/>
{!rail ? (
@@ -265,7 +265,7 @@ function RecentTasksList({
variant="ghost"
size="icon-xs"
aria-label={`More actions for ${entry.title}`}
className="absolute right-2 top-(--pct-50) z-10 -translate-y-(--pct-50) text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 pointer-coarse:opacity-100 group-hover/recent-task:opacity-100 group-focus-within/recent-task:opacity-100 data-[state=open]:bg-accent data-[state=open]:text-foreground data-[state=open]:opacity-100"
className="sidebar-action-menu absolute right-2 top-(--pct-50) z-10 -translate-y-(--pct-50) text-muted-foreground pointer-events-none opacity-0 transition-opacity hover:bg-sidebar-accent dark:hover:bg-sidebar-accent hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:before:hidden group-hover/recent-task:pointer-events-auto group-hover/recent-task:opacity-100 group-focus-within/recent-task:pointer-events-auto group-focus-within/recent-task:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:bg-sidebar-accent data-[state=open]:text-foreground data-[state=open]:opacity-100"
>
<MoreHorizontal aria-hidden="true" />
</Button>
+25 -4
View File
@@ -16,7 +16,7 @@ import { taskStatusIconVar } from "../lib/status-colors";
const STATUS_ICON_CLASS: Record<string, string> = {
backlog: "lucide-circle-dashed",
todo: "lucide-circle",
in_progress: "lucide-rotate-cw",
in_progress: "lucide-task-progress-spinner",
in_review: "lucide-circle-dot",
done: "lucide-circle-check",
blocked: "lucide-circle-minus",
@@ -29,14 +29,17 @@ describe("StatusGlyph", () => {
for (const status of Object.keys(taskStatusIconVar)) {
const html = renderToStaticMarkup(<StatusGlyph status={status} />);
expect(html).toContain('viewBox="0 0 24 24"');
expect(html).toContain('stroke-width="2"');
expect(html).toContain("<svg");
}
});
it("maps sm/md/lg to 14/16/20 px", () => {
expect(renderToStaticMarkup(<StatusGlyph status="todo" size="sm" />)).toContain('width="14"');
expect(renderToStaticMarkup(<StatusGlyph status="todo" size="md" />)).toContain('width="16"');
expect(renderToStaticMarkup(<StatusGlyph status="todo" size="lg" />)).toContain('width="20"');
for (const status of Object.keys(taskStatusIconVar)) {
expect(renderToStaticMarkup(<StatusGlyph status={status} size="sm" />)).toContain('width="14"');
expect(renderToStaticMarkup(<StatusGlyph status={status} size="md" />)).toContain('width="16"');
expect(renderToStaticMarkup(<StatusGlyph status={status} size="lg" />)).toContain('width="20"');
}
// Default size is md.
expect(renderToStaticMarkup(<StatusGlyph status="todo" />)).toContain('width="16"');
});
@@ -61,6 +64,24 @@ describe("StatusGlyph", () => {
}
});
it("animates only in-progress task icons and respects reduced motion", () => {
for (const status of Object.keys(taskStatusIconVar)) {
const html = renderToStaticMarkup(<StatusGlyph status={status} />);
expect(html.includes("motion-safe:animate-spin")).toBe(status === "in_progress");
}
});
it("uses the same circle radius and stroke for the spinner as other task icons", () => {
for (const status of ["in_progress", "todo", "done", "blocked", "cancelled"]) {
const html = renderToStaticMarkup(<StatusGlyph status={status} />);
expect(html).toContain('<circle cx="12" cy="12" r="10"');
expect(html).toContain('stroke-width="2"');
}
const spinner = renderToStaticMarkup(<StatusGlyph status="in_progress" />);
expect(spinner).toContain('pathLength="100"');
expect(spinner).toContain('stroke-dasharray="80 20"');
});
it("gives todo the plain circle (not a compound circle icon)", () => {
const html = renderToStaticMarkup(<StatusGlyph status="todo" />);
expect(html).toContain("lucide-circle");
+14 -4
View File
@@ -6,7 +6,7 @@ import {
CircleDashed,
CircleDot,
CircleMinus,
RotateCw,
createLucideIcon,
type LucideIcon,
} from "lucide-react";
import { cn } from "../lib/utils";
@@ -18,10 +18,14 @@ import { taskStatusIconVar, taskStatusIconVarDefault } from "../lib/status-color
* `viewBox="0 0 24 24"` so they scale proportionally at any size), so the whole
* set reads as one consistent icon family:
*
* backlog → circle-dashed · todo → circle · in_progress → rotate-cw ·
* backlog → circle-dashed · todo → circle · in_progress → animated open circle ·
* in_review → circle-dot · done → circle-check · blocked → circle-minus ·
* cancelled → ban · in_queue → circle-minus (blocked recoloured blue).
*
* The in-progress animation represents task workflow status, independently of
* run execution. It remains between runs until the task status changes; live
* indicators and run details report whether an agent is currently executing.
*
* Colour comes from the `--status-task-icon-*` CSS vars (AA-tuned, mode-aware;
* see `index.css`). The glyph paints in `currentColor`, and the component
* defaults `color` to the status' icon var — so it renders correctly
@@ -44,11 +48,17 @@ export type StatusGlyphStatus =
| "cancelled"
| "in_queue";
// LoaderCircle uses a 9-unit radius. Keep its open arc, but use the same
// 10-unit circle and unscaled stroke as the other task glyphs.
const TaskProgressSpinner = createLucideIcon("TaskProgressSpinner", [
["circle", { cx: "12", cy: "12", r: "10", pathLength: "100", strokeDasharray: "80 20", key: "progress" }],
]);
/** Status → Lucide icon. `in_queue` borrows the blocked icon; its colour var resolves to blue. */
const STATUS_ICON: Record<string, LucideIcon> = {
backlog: CircleDashed,
todo: Circle,
in_progress: RotateCw,
in_progress: TaskProgressSpinner,
in_review: CircleDot,
done: CircleCheck,
blocked: CircleMinus,
@@ -78,7 +88,7 @@ export function StatusGlyph({ status, size = "md", className, title }: StatusGly
return (
<Icon
size={px}
className={cn("inline-block shrink-0 align-middle", className)}
className={cn("inline-block shrink-0 align-middle", status === "in_progress" && "motion-safe:animate-spin", className)}
style={{ color: `var(${cssVar})` } as CSSProperties}
{...a11y}
>
+14 -4
View File
@@ -423,9 +423,9 @@ describe("TaskChatThread draft pass-through", () => {
const thread = container.querySelector('[data-testid="task-chat-thread"]');
expect(thread?.classList).not.toContain("h-(--tc-thread-max-h)");
expect(thread?.classList).toContain("flex-1");
expect(dock?.classList).toContain("px-2");
expect(dock?.classList).toContain("px-1");
expect(dock?.classList).toContain("md:px-0");
expect(dock?.classList).not.toContain("px-1");
expect(dock?.classList).not.toContain("px-2");
expect(dock?.classList).not.toContain("-mt-(--radius-task-composer)");
expect(dock?.classList).not.toContain("pt-1");
expect(dock?.classList).toContain("md:pb-0");
@@ -1247,6 +1247,16 @@ describe("TaskChatThread runtime transcript selection", () => {
},
);
it("does not render an empty response notice for a conversation reset", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} linkedRuns={[{
runId: "chat-reset", status: "succeeded", startedAt: null, resultJson: { conversationReset: true },
agentId: "agent-1", agentName: "Claude", adapterType: "claude_local",
createdAt: "2026-09-11T18:00:00.000Z", finishedAt: "2026-09-11T18:00:01.000Z",
}]} />);
expect(container.textContent).not.toContain("The runner returned no user-facing response.");
expect(container.textContent).not.toContain("Run completed");
});
it.each([
["legacy", "issue_not_in_progress"],
["native", "issue_not_in_progress"],
@@ -1552,7 +1562,7 @@ describe("TaskChatThread runtime transcript selection", () => {
?.textContent,
).toContain(repeated);
expect(container.textContent).toContain(
`Queued ${new Date("2026-08-25T17:59:32.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} · Steered ${new Date("2026-08-25T18:00:02.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`,
new Date("2026-08-25T17:59:32.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }),
);
const turnHeaders = Array.from(
container.querySelectorAll('[data-testid="task-chat-turn-summary"]'),
@@ -1630,7 +1640,7 @@ describe("TaskChatThread runtime transcript selection", () => {
?.textContent,
).toContain("Continued after steering · Working for");
expect(container.textContent).toContain(
`Queued ${new Date("2026-08-25T17:59:32.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} · Steered ${new Date("2026-08-25T18:00:02.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`,
new Date("2026-08-25T17:59:32.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }),
);
});
+17 -3
View File
@@ -1,3 +1,5 @@
import type { ActivityEvent } from "@paperclipai/shared";
import { useProjectCreatedItems } from "@/hooks/useProjectCreatedItems";
import { requiresExecutionReconciliation } from "@paperclipai/shared";
import { TaskChatExpansionState } from "@/components/task-chat/expansion-state";
import { TaskChatScrollReady } from "@/components/task-chat/scroll-navigation";
@@ -394,6 +396,8 @@ function resolvedWithoutUserFacingResponse(value: unknown): boolean {
}
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread> & {
conversationMode?: boolean;
creationActivity?: ActivityEvent[];
initialHistoryPending?: boolean;
initialHistoryError?: boolean;
onRetryInitialHistory?: () => void;
@@ -499,6 +503,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
imageUploadHandler,
mentions,
enableReassign,
conversationMode,
reassignOptions,
currentAssigneeValue,
issueStatus,
@@ -536,6 +541,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
resumeAssigneePending = false,
} = props;
const queryClient = useQueryClient();
const createdProjectItems = useProjectCreatedItems(props.creationActivity ?? [], companyId);
const [pendingComposerAssignee, setPendingComposerAssignee] = useState<
string | null
>(null);
@@ -1266,10 +1272,14 @@ export function TaskChatThread(props: TaskChatThreadProps) {
},
});
}
for (const item of createdProjectItems) {
entries.push({ id: item.id, item, ms: toMs(item.timestamp), order: 2 });
}
return entries.sort(
(a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id),
);
}, [
createdProjectItems,
comments,
projectedComments,
commentItems,
@@ -1386,6 +1396,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
if (liveRun && source.id === liveRun.id) continue;
const entries = transcriptByRun.get(source.id) ?? [];
const meta = linkedRunMetaById.get(source.id);
// /new is represented by its durable comment boundary, not an empty
// model response or a completed-run notice.
if (meta?.resultJson?.conversationReset === true) { settledRunIds.add(source.id); continue; }
// A queued continuation can become unnecessary while another turn finishes
// the task. Keep that cancellation in the run log, not the conversation.
// Apply this before native stop markers are assembled as well.
@@ -1652,8 +1665,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
id,
kind: "marker",
variant: "turn_boundary",
label: "Run completed",
detail: "The runner returned no user-facing response.",
label: source.status === "cancelled" ? "Stopped" : "Run completed",
detail: source.status === "cancelled" ? "This turn was cancelled before it returned a response." : "The runner returned no user-facing response.",
},
});
}
@@ -2889,7 +2902,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
isMobile
? "bottom-(--tc-composer-bottom) z-20 transition-[bottom] duration-200 ease-out"
: "bottom-0 z-10",
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 px-2 pb-2 md:px-4",
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 px-1 pb-1 md:px-4 md:pb-2",
streamlinedUiEnabled && "md:px-0 md:pb-0",
(!streamlinedUiEnabled || isMobile) &&
"bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60 dark:bg-transparent dark:backdrop-blur-none dark:supports-[backdrop-filter]:bg-transparent",
@@ -2960,6 +2973,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
onImageUpload={imageUploadHandler}
mentions={mentions}
enableReassign={enableReassign}
conversationMode={conversationMode}
reassignOptions={reassignOptions}
agentMap={agentMap}
userProfileMap={userProfileMap}
@@ -2332,7 +2332,7 @@ export function IssueProperties({
<PropertyRow label="Status">
<StatusIcon
status={issue.status}
size="lg"
className="size-3"
blockerAttention={issue.blockerAttention}
onChange={(status) => onUpdate({ status })}
showLabel
+1 -1
View File
@@ -3,7 +3,7 @@
* Settings reuses this contract when it takes over the global sidebar.
*/
export const primarySidebarStyles = {
surface: "bg-border/50 dark:bg-muted",
surface: "primary-sidebar-surface",
nav: "flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto scrollbar-auto-hide px-3 py-2 pointer-coarse:gap-3",
group: "flex flex-col gap-0.5",
} as const;
@@ -1713,7 +1713,32 @@ describe("TaskChatComposer", () => {
});
});
it("gives separate identical chat submissions separate receipt identities", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} conversationMode workMode="standard" />);
typeText("Same message");
await act(async () => sendButton().click());
typeText("Same message");
await act(async () => sendButton().click());
expect(onAdd.mock.calls).toHaveLength(2);
expect(onAdd.mock.calls[0][4]).toEqual(expect.any(String));
expect(onAdd.mock.calls[1][4]).not.toBe(onAdd.mock.calls[0][4]);
});
describe("paused task takeover", () => {
it("allows only standalone /new to resume a paused conversation through the normal composer", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} conversationMode workMode="standard" pause={{ scope: "leaf" }} />);
typeText("Keep working");
expect(sendButton().disabled).toBe(true);
await act(async () => sendButton().click());
expect(onAdd).not.toHaveBeenCalled();
typeText("/new");
expect(sendButton().disabled).toBe(false);
await act(async () => sendButton().click());
expect(onAdd).toHaveBeenCalledWith("/new", undefined, undefined, undefined, expect.any(String));
});
it("preserves a typed draft and blocks sending until resume completes", async () => {
const onAdd = vi.fn();
const onResume = vi.fn();
@@ -102,6 +102,7 @@ interface TaskChatComposerProps {
reopen?: boolean,
reassignment?: CommentReassignment,
attachmentIds?: string[],
clientRequestId?: string,
) => Promise<void> | void;
onStop?: () => Promise<void>;
stopPending?: boolean;
@@ -118,6 +119,7 @@ interface TaskChatComposerProps {
/** Mentionable entities for the editor's @-autocomplete. */
mentions?: MentionOption[];
enableReassign?: boolean;
conversationMode?: boolean;
reassignOptions?: InlineEntityOption[];
agentMap?: ReadonlyMap<string, { icon?: string | null }>;
userProfileMap?: ReadonlyMap<
@@ -391,6 +393,7 @@ export function TaskChatComposer({
onImageUpload,
mentions,
enableReassign = false,
conversationMode = false,
reassignOptions,
agentMap,
userProfileMap,
@@ -591,6 +594,7 @@ export function TaskChatComposer({
const modeMeta = workModeMetaFor(pendingMode);
const canAcceptFiles =
!pause &&
!queuedEdit &&
!uncertainSubmission &&
Boolean(onAttachImage || onImageUpload);
@@ -797,7 +801,7 @@ export function TaskChatComposer({
const uploadPending = attachments.some((item) => item.status === "uploading");
const uploadFailed = attachments.some((item) => item.status === "error");
const takeoverVisible = Boolean(
takeover && !queuedEdit && !submitting && !uploadPending,
takeover && !pause && !queuedEdit && !submitting && !uploadPending,
);
const previousTakeoverVisibleRef = useRef(takeoverVisible);
useEffect(() => {
@@ -807,8 +811,10 @@ export function TaskChatComposer({
previousTakeoverVisibleRef.current = takeoverVisible;
}, [queuedEdit, takeoverVisible]);
const canResetPausedConversation = conversationMode && !queuedEdit && body.trim() === "/new" && attachments.length === 0;
async function submit() {
if (pause || disabled) return;
if (disabled || (pause && !canResetPausedConversation)) return;
const retained =
draftKey && !queuedEdit ? loadDraftSubmission(draftKey) : null;
if (retained && !submitting) {
@@ -822,6 +828,10 @@ export function TaskChatComposer({
const goalCommand = queuedEdit
? ({ matched: false } as const)
: parseRunnerGoalCommand(submittedBody);
if (goalCommand.matched && conversationMode) {
setActionError("Create a separate task for work that needs an ongoing execution goal.");
return;
}
if (goalCommand.matched) {
if ("error" in goalCommand) {
setActionError(goalCommand.error);
@@ -961,7 +971,9 @@ export function TaskChatComposer({
.map((item) => item.attachmentId!),
),
];
if (attachmentIds.length > 0)
if (conversationMode)
await onAdd(fullBody, reopen, reassignment, attachmentIds.length ? attachmentIds : undefined, attemptId);
else if (attachmentIds.length > 0)
await onAdd(fullBody, reopen, reassignment, attachmentIds);
else await onAdd(fullBody, reopen, reassignment);
if (mountedTaskKey.current !== draftKey) return;
@@ -1064,7 +1076,7 @@ export function TaskChatComposer({
</Button>
) : null;
if (pause) {
if (pause && (!conversationMode || queuedEdit)) {
return <TaskChatPausedTakeover {...pause} hasDraft={Boolean(body.trim() || attachments.length)} />;
}
@@ -1074,7 +1086,7 @@ export function TaskChatComposer({
streamlined
? "paperclip-task-chat-composer rounded-(--radius-task-composer) border border-border bg-card p-(--sz-18px) shadow-(--shadow-task-composer) dark:border-0 dark:bg-muted dark:shadow-none"
: "paperclip-task-chat-composer rounded-xl bg-card p-(--sz-18px)",
mobile && "p-3",
mobile && "p-2",
)}
onKeyDownCapture={(e) => {
// Capture mode shortcuts on the wrapper so they work while the rich
@@ -1243,6 +1255,12 @@ export function TaskChatComposer({
</span>
</button>
) : null}
{pause && conversationMode ? (
<div className="space-y-2">
<TaskChatPausedTakeover {...pause} hasDraft={Boolean(body.trim() || attachments.length)} />
<p className="text-xs text-muted-foreground">Send /new to start a fresh session and resume this conversation.</p>
</div>
) : null}
<div data-testid="task-chat-composer-input">
<MarkdownEditor
ref={editorRef}
@@ -1255,7 +1273,11 @@ export function TaskChatComposer({
}
readOnly={disabled || !!uncertainSubmission}
mentions={mentions}
actionCommands={[goalCommandOption]}
actionCommands={conversationMode ? [{
id: "action:new", kind: "action", command: "new", name: "New session",
description: "Start fresh context here, preserving conversation history.", aliases: ["new"],
disabled,
}] : [goalCommandOption]}
onSubmit={() => void submit()}
imageUploadHandler={
canAcceptFiles ? uploadInlineImage : undefined
@@ -1504,6 +1526,7 @@ export function TaskChatComposer({
showStop
? disabled || stopControl.stopping
: disabled ||
(Boolean(pause) && !canResetPausedConversation) ||
submitting ||
!!uncertainSubmission ||
uploadPending ||
@@ -72,4 +72,3 @@ export function TaskChatPausedTakeover({
</section>
);
}
@@ -0,0 +1,24 @@
import { FolderKanban, GitBranch } from "lucide-react";
import { Link } from "@/lib/router";
import type { TaskChatProjectCreatedItem } from "./task-chat-model";
export function TaskChatProjectCreatedCard({ item }: { item: TaskChatProjectCreatedItem }) {
return (
<article className="rounded-lg border border-border bg-card p-3" aria-label={`Project created: ${item.name}`}>
<div className="flex items-start gap-3">
<FolderKanban className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1 space-y-1">
<p className="text-xs text-muted-foreground">Project created</p>
<Link to={`/projects/${item.projectId}`} className="break-words text-sm font-medium hover:underline focus-visible:underline">{item.name}</Link>
{item.description && <p className="line-clamp-3 text-sm text-muted-foreground">{item.description}</p>}
{item.repositories.length > 0 && <ul className="space-y-1 pt-1">
{item.repositories.map(repo => <li key={repo.id} className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<GitBranch className="size-3 shrink-0" aria-hidden />
<a className="truncate hover:underline focus-visible:underline" href={repo.url} target="_blank" rel="noreferrer">{repo.name}</a>
</li>)}
</ul>}
</div>
</div>
</article>
);
}
@@ -324,7 +324,7 @@ describe("TaskChatQueuedMessages", () => {
),
).not.toBeNull();
expect(container.textContent).toContain(
"Active turn interrupted. Message remains queued.",
"Interruption requested. Queued messages will continue after the active turn stops.",
);
});
});
@@ -147,7 +147,7 @@ function SortableQueuedMessage({
type="button"
onClick={onInterrupt}
disabled={busy || !queue.targetRunId || !onInterrupt}
title="Interrupt the active turn; this message stays queued"
title="Interrupt the active turn and send queued messages"
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
data-testid={`task-chat-queued-interrupt-${entry.comment.id}`}
>
@@ -334,7 +334,7 @@ export function TaskChatQueuedMessages({
action === "steer"
? "Message steered into the active turn."
: action === "interrupt"
? "Active turn interrupted. Message remains queued."
? "Interruption requested. Queued messages will continue after the active turn stops."
: "Queued message discarded.",
);
} catch (error) {
@@ -140,12 +140,13 @@ describe("TaskChatRunnerActivityGroup", () => {
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(container.textContent).toContain("output-one");
act(() => toggle().click());
expect(viewport().textContent).toContain("command-two");
expect(toggle().textContent).toContain("Ran commands");
expect(container.textContent).not.toContain("command-two");
});
it("keeps failures discoverable after later activity, with neutral detail and no X", () => {
render([tool("failed", "failed"), tool("next")]);
expect(toggle().textContent).toContain("1 failed");
expect(toggle().textContent).not.toMatch(/\d+ failed/);
act(() => toggle().click());
expect(container.querySelector("li")?.textContent).toContain("failed");
act(() => container.querySelector<HTMLButtonElement>("li button")!.click());
@@ -203,8 +204,9 @@ describe("TaskChatRunnerActivityGroup", () => {
expect(container.textContent).toContain("Finished");
expect(container.querySelector(".text-destructive,.lucide-x")).toBeNull();
act(() => toggle().click());
expect(viewport().textContent).toContain("command-two");
expect(toggle().textContent).toContain("1 failed");
expect(toggle().textContent).toContain("Ran commands");
expect(container.textContent).not.toContain("command-two");
expect(toggle().textContent).not.toMatch(/\d+ failed/);
});
it("does not offer empty disclosures for sparse activities", () => {
@@ -239,6 +241,25 @@ describe("TaskChatRunnerActivityGroup", () => {
).toBeNull();
});
it("settles to a summary and can resume without losing the current activity", () => {
const items = [tool("one", "failed"), tool("two", "completed")];
render(items);
expect(viewport().textContent).toContain("command-two");
render(items, "live", false);
expect(
container.querySelector('[data-testid="task-chat-activity-viewport"]'),
).toBeNull();
expect(toggle().textContent).toBe("Ran commands");
expect(toggle().getAttribute("aria-label")).toContain("ran commands");
expect(container.textContent).not.toContain("command-two");
act(() => toggle().click());
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(toggle().textContent).not.toMatch(/\d+ failed/);
act(() => toggle().click());
render([...items, tool("three")]);
expect(viewport().textContent).toContain("command-three");
});
it("replaces immediately with reduced motion", () => {
motion.reduced = true;
render([tool("one")]);
@@ -1,4 +1,5 @@
import { useId, useState } from "react";
import { completedActivitySummary } from "./completed-activity-summary";
import {
Brain,
ChevronDown,
@@ -101,16 +102,6 @@ function presentation(item: Activity, active: boolean) {
};
}
function isFailure(item: Activity) {
return (
(item.kind === "tool" && item.status === "failed") ||
(item.kind === "protocol" &&
item.surface === "provider_activity" &&
item.status === "failed") ||
(item.kind === "marker" && item.tone === "error")
);
}
function ActivityContent({
item,
active,
@@ -330,7 +321,8 @@ export function TaskChatRunnerActivityGroup({
(activity) => presentation(activity, false) !== null,
);
const latest = activities.at(-1);
const failures = activities.filter(isFailure).length;
const summary = completedActivitySummary(activities);
const SummaryIcon = summary.icon;
const countLabel = `${activities.length} ${activities.length === 1 ? "activity" : "activities"}`;
return (
<section
@@ -358,9 +350,21 @@ export function TaskChatRunnerActivityGroup({
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls={expanded ? historyId : undefined}
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
aria-label={`${expanded ? "Collapse" : "Expand"} ${item.active ? countLabel : `${summary.fullLabel.toLowerCase()} (${countLabel})`}`}
>
{expanded ? (
{!item.active ? (
<span
className="flex h-8 min-w-0 flex-1 items-center gap-2 text-xs"
data-completed-summary
>
<span className="flex size-5 shrink-0 items-center justify-center">
<SummaryIcon className="size-3.5" aria-hidden="true" />
</span>
<span className="truncate" title={summary.fullLabel}>
{summary.label}
</span>
</span>
) : expanded ? (
<span className="flex min-h-8 flex-1 items-center gap-2 text-xs">
<span className="flex size-5 shrink-0 items-center justify-center">
<ChevronDown className="size-3.5" aria-hidden="true" />
@@ -370,16 +374,12 @@ export function TaskChatRunnerActivityGroup({
) : (
<RollingActivity item={latest} active={item.active} />
)}
{failures > 0 ? (
<span className="shrink-0 text-xs text-muted-foreground">
{failures} failed
</span>
) : null}
<span className="flex shrink-0 items-center gap-1 text-xs">
{expanded ? "Collapse" : activities.length}
{!expanded ? (
<ChevronRight className="size-3.5" aria-hidden="true" />
) : null}
{expanded ? countLabel : item.active ? activities.length : null}
<ChevronRight
className={cn("size-3.5", expanded && "rotate-90")}
aria-hidden="true"
/>
</span>
</button>
{expanded ? (
@@ -1409,7 +1409,7 @@ describe("TaskChatRunnerTurn", () => {
expect(rows[0]?.textContent).toContain("Read file");
expect(rows[2]?.textContent).toContain("Questions answered");
expect(rows[3]?.textContent).toContain("Searched the web");
expect(rows[5]?.textContent).toContain("Ran a command");
expect(rows[5]?.textContent).toContain("Ran commands");
const worked = container.querySelector(
'[data-testid="task-chat-turn-status-header"]',
);
@@ -77,6 +77,30 @@ describe("TaskChatStatusPill whimsy", () => {
expect(container.textContent).toContain("Terminal · ls -la");
});
it("centers the reconnecting caret and status dot in equal lead slots", () => {
act(() => {
root.render(
<TaskChatStatusPill
item={liveStatus({ label: "Reconnecting" })}
chevronOpen={false}
onToggle={() => undefined}
/>,
);
});
const caret = container.querySelector(
'[data-testid="task-chat-status-caret-slot"]',
);
const dot = container.querySelector(
'[data-testid="task-chat-status-dot-slot"]',
);
for (const slot of [caret, dot]) {
expect(slot?.classList.contains("h-3.5")).toBe(true);
expect(slot?.classList.contains("w-3.5")).toBe(true);
expect(slot?.classList.contains("items-center")).toBe(true);
expect(slot?.classList.contains("justify-center")).toBe(true);
}
});
it("keeps Queued copy untouched", () => {
render(liveStatus({ label: "Queued", detail: "Waiting to start" }));
expect(container.textContent).toContain("Queued…");
@@ -318,14 +318,22 @@ export function TaskChatStatusPill({
const statusLine = (
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
{chevronOpen !== undefined ? (
<ChevronRight
className={cn("h-3 w-3 shrink-0 transition-transform", chevronOpen ? "rotate-90" : null)}
aria-hidden
/>
<span
className="flex h-3.5 w-3.5 shrink-0 items-center justify-center"
data-testid="task-chat-status-caret-slot"
>
<ChevronRight
className={cn("h-3 w-3 transition-transform", chevronOpen ? "rotate-90" : null)}
aria-hidden
/>
</span>
) : null}
{/* Fixed-size lead slot keeps the label from moving as tool icons
come and go; the pulse dot renders unconditionally. */}
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<span
className="flex h-3.5 w-3.5 shrink-0 items-center justify-center"
data-testid="task-chat-status-dot-slot"
>
<span
aria-hidden
className="h-2 w-2 animate-pulse rounded-full bg-(--status-agent-running)"
@@ -1,3 +1,4 @@
import { TaskChatProjectCreatedCard } from "./TaskChatProjectCreatedCard";
import { useMemo, type ReactNode } from "react";
import type { IssueAttachment } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
@@ -93,6 +94,7 @@ function renderItem(
attachments: IssueAttachment[] = [],
) {
switch (item.kind) {
case "project_created": return <TaskChatProjectCreatedCard item={item} />;
case "message": {
// Compute the actions once: the bubble renders them for a runless reply
// (footer = actions + timestamp), while an attached turn hands them to
@@ -429,7 +431,7 @@ export function TaskChatThreadView({
const body = (
<div
className={cn(
"paperclip-mobile-thread mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col px-2 py-4 md:px-4",
"paperclip-mobile-thread mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col px-1 py-3 md:px-4 md:py-4",
streamlined ? "md:px-0" : "gap-5",
className,
)}
@@ -385,7 +385,10 @@ describe("TaskChatTurn", () => {
expect(header?.textContent).toContain("Editing files…");
expect(header?.textContent).toContain("Edit · server/src/routes/auth.ts");
expect(header?.getAttribute("aria-expanded")).toBe("false");
expect(header?.firstElementChild?.firstElementChild?.tagName).toBe("svg");
expect(
header?.querySelector('[data-testid="task-chat-status-caret-slot"] svg')
?.tagName,
).toBe("svg");
// All activity is folded behind it — no rows visible, no summary line.
expect(fold()?.getAttribute("data-folded")).toBe("true");
expect(summaryBtn()).toBeNull();
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { completedActivitySummary } from "./completed-activity-summary";
import type {
TaskChatActivityPhaseItem,
TaskChatProviderActivityItem,
TaskChatToolItem,
} from "./task-chat-model";
type Activity = TaskChatActivityPhaseItem["items"][number];
const tool = (
name: string,
status: TaskChatToolItem["status"] = "completed",
): TaskChatToolItem => ({
id: name,
kind: "tool",
name,
status,
target: "private-command-or-path",
detail: "private-output",
});
const provider = (
family: TaskChatProviderActivityItem["family"],
status: TaskChatProviderActivityItem["status"] = "completed",
): TaskChatProviderActivityItem => ({
id: family,
kind: "protocol",
surface: "provider_activity",
family,
status,
title: "provider internal title",
eventType: "activity",
details: [],
steps: [],
links: [],
children: [],
});
const thought: Activity = {
id: "thought",
kind: "thinking",
lines: ["Reasoning text"],
};
describe("completedActivitySummary", () => {
it.each([
"completed",
"failed",
"interrupted",
"pending",
"in_progress",
] as const)(
"describes command attempts without claiming success (%s)",
(status) => {
expect(
completedActivitySummary([
tool("exec_command", status),
tool("bash", status),
]).label,
).toBe("Ran commands");
},
);
it("merges retries and hides reasoning, raw targets and output from the summary", () => {
const summary = completedActivitySummary([
thought,
tool("read"),
tool("exec_command", "failed"),
tool("exec_command"),
]);
expect(summary.label).toBe("Read files, ran commands");
expect(summary.fullLabel).not.toMatch(/private|Reasoning|failed/);
});
it.each([
["read", "failed", "Checked files"],
["read", "interrupted", "Checked files"],
["apply_patch", "failed", "Worked on files"],
["apply_patch", "interrupted", "Worked on files"],
["read", "completed", "Read files"],
["apply_patch", "completed", "Edited files"],
] as const)(
"does not claim an unsuccessful %s completed",
(name, status, expected) => {
expect(completedActivitySummary([tool(name, status)]).label).toBe(
expected,
);
},
);
it("uses completed work once when a read or edit retry succeeds", () => {
expect(
completedActivitySummary([
tool("read", "failed"),
tool("apply_patch", "failed"),
tool("read"),
tool("apply_patch"),
]).label,
).toBe("Read files, edited files");
});
it("classifies native provider tools through the same taxonomy and keeps encounter order", () => {
const native = {
...provider("tool_execution", "failed"),
details: [{ label: "Name", value: "exec_command" }],
};
expect(
completedActivitySummary([
tool("read"),
provider("research"),
native,
tool("bash"),
]).label,
).toBe("Read files, searched the web, ran commands");
});
it("supports canonical operation identity when a native tool has no useful name", () => {
const native = {
...provider("tool_execution"),
details: [
{ label: "Name", value: "tool" },
{ label: "Operation", value: "execute" },
],
};
expect(completedActivitySummary([native]).label).toBe("Ran commands");
});
it("bounds many categories while keeping the complete accessible description", () => {
const summary = completedActivitySummary([
tool("read"),
tool("exec_command"),
tool("grep"),
tool("apply_patch"),
provider("research"),
]);
expect(summary.label).toBe("Read files, ran commands, and more");
expect(summary.fullLabel).toBe(
"Read files, ran commands, searched files, edited files, searched the web",
);
});
it("does not invent tool activity for thoughts-only groups or expose unknown identifiers", () => {
expect(completedActivitySummary([thought]).label).toBe(
"Thought through the task",
);
expect(completedActivitySummary([tool("opaque_8792")]).label).toBe(
"Used tools",
);
expect(
completedActivitySummary([tool("mcp__github__get_pull_request")]).label,
).toBe("Used connected tools");
});
});
@@ -0,0 +1,132 @@
import { Brain, CirclePause, Gauge, Layers3 } from "lucide-react";
import type { TaskChatActivityPhaseItem } from "./task-chat-model";
import {
toolActivityPresentation,
type ToolFamily,
type ToolIcon,
} from "./tool-taxonomy";
import { protocolActivityPresentation } from "./task-chat-activity-presentation";
type Activity = TaskChatActivityPhaseItem["items"][number];
const labels: Record<ToolFamily, string> = {
terminal: "Ran commands",
grep: "Searched files",
search: "Searched files",
read: "Read files",
edit: "Edited files",
web: "Searched the web",
plan: "Worked on a plan",
question: "Requested input",
agent: "Worked with agents",
safety: "Reviewed safety",
image: "Worked with images",
wait: "Waited",
mcp: "Used connected tools",
other: "Used tools",
};
/** Describe observed activities, never infer success from a finished group. */
export function completedActivitySummary(items: Activity[]) {
const categories = new Map<
string,
{ label: string; icon: ToolIcon; order: number }
>();
const add = (label: string, icon: ToolIcon, order = 0) => {
const existing = categories.get(label);
if (!existing || order < existing.order)
categories.set(label, { label, icon, order });
};
const tools: Array<{
family: ToolFamily;
icon: ToolIcon;
completed: boolean;
order: number;
}> = [];
for (const [order, item] of items.entries()) {
if (item.kind === "tool") {
const p = toolActivityPresentation({
name: item.rawName ?? item.name,
target: item.target,
});
tools.push({ ...p, order, completed: item.status === "completed" });
} else if (item.kind === "protocol") {
if (
item.surface === "provider_activity" &&
item.family === "tool_execution"
) {
const value = (label: string) =>
item.details.find((d) => d.label === label)?.value;
const p = toolActivityPresentation({
name: value("Name"),
operation: value("Operation"),
transport: value("Transport"),
namespace: value("Namespace"),
target: value("Target"),
});
tools.push({ ...p, order, completed: item.status === "completed" });
} else {
const p = protocolActivityPresentation(item);
if (!p) continue;
const family =
item.surface === "provider_activity" ? item.family : item.surface;
const label =
(
{
research: "Searched the web",
plan: "Worked on a plan",
delegation: "Worked with agents",
artifact: "Worked with artifacts",
context: "Managed context",
memory: "Checked memory",
model_identity: "Checked model settings",
review: "Worked in review mode",
hook: "Ran hooks",
safety: "Reviewed safety",
terminal: "Ran commands",
wait: "Waited",
provider_notice: "Received a provider update",
workspace_change: "Worked on files",
workspace_file: "Referenced files",
resource: "Added resources",
} as Record<string, string>
)[family] ?? "Used tools";
add(label, p.icon, order);
}
}
}
const completedFamilies = new Set(
tools.filter((tool) => tool.completed).map((tool) => tool.family),
);
// Merge repeated families and retries. Terminal is an action, not an exit-status claim.
for (const tool of tools) {
const succeeded = completedFamilies.has(tool.family);
const label =
tool.family === "read" && !succeeded
? "Checked files"
: tool.family === "edit" && !succeeded
? "Worked on files"
: labels[tool.family];
add(label, tool.icon, tool.order);
}
if (!categories.size) {
if (items.some((item) => item.kind === "thinking"))
add("Thought through the task", Brain);
else if (items.some((item) => item.kind === "marker"))
add("Activity stopped", CirclePause);
else add("Recorded usage", Gauge);
}
const values = [...categories.values()].sort((a, b) => a.order - b.order);
const join = (parts: string[]) =>
parts
.map((p, i) => (i ? p.charAt(0).toLowerCase() + p.slice(1) : p))
.join(", ");
const fullLabel = join(values.map((v) => v.label));
return {
label:
values.length > 3
? `${join(values.slice(0, 2).map((v) => v.label))}, and more`
: fullLabel,
fullLabel,
icon: values.length === 1 ? values[0].icon : Layers3,
};
}
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import type { ActivityEvent } from "@paperclipai/shared";
import { projectCreatedItems } from "./project-created-items";
const event = (overrides: Partial<ActivityEvent> = {}): ActivityEvent =>
({
id: "activity",
companyId: "company",
actorType: "agent",
actorId: "agent",
agentId: "agent",
runId: "run",
action: "project.created",
entityType: "project",
entityId: "project",
createdAt: new Date("2026-09-11T12:00:00Z"),
details: {
name: "Launch",
repositories: [
{ id: "1", name: "org/app", url: "https://github.com/org/app" },
{ id: "2", name: "org/docs", url: "https://github.com/org/docs" },
],
},
...overrides,
}) as ActivityEvent;
describe("durable project creation feed", () => {
it("deduplicates repeated receipts while retaining all repositories", () => {
const items = projectCreatedItems([event(), event({ id: "replay" })]);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
kind: "project_created",
projectId: "project",
name: "Launch",
});
expect(items[0].repositories).toHaveLength(2);
expect(projectCreatedItems(JSON.parse(JSON.stringify([event()])))).toEqual(
items,
);
});
it("does not turn a failed tool call or agent claim into a success card", () => {
expect(
projectCreatedItems([
event({ action: "runner.api_called" }),
event({ action: "issue.comment_added" }),
]),
).toEqual([]);
});
it("omits unsafe repository links and accepts projects without repositories", () => {
expect(
projectCreatedItems([
event({
details: {
name: "Research",
repositories: [
{ id: "unsafe", name: "unsafe", url: "javascript:alert(1)" },
],
},
}),
])[0].repositories,
).toEqual([]);
expect(
projectCreatedItems([event({ details: { name: "Research" } })])[0].name,
).toBe("Research");
});
});
describe("project creation repository hydration", () => {
const project = {
id: "project",
companyId: "company",
workspaces: [
{ id: "workspace-1", name: "App", repoUrl: "https://github.com/org/app" },
{
id: "workspace-2",
name: "Docs",
repoUrl: "https://github.com/org/docs",
},
],
} as import("@paperclipai/shared").Project;
it("includes repositories added after creation without adding another card", () => {
const receipt = event({
details: {
name: "Launch",
repositories: [
{ id: "1", name: "App", url: "https://github.com/org/app" },
],
},
});
const items = projectCreatedItems([receipt, receipt], [project]);
expect(items).toHaveLength(1);
expect(items[0].repositories.map((repo) => repo.url)).toEqual([
"https://github.com/org/app",
"https://github.com/org/docs",
]);
expect(projectCreatedItems([], [project])).toEqual([]);
expect(items[0].timestamp).toBe("2026-09-11T12:00:00.000Z");
});
it("reflects repository removal but falls back when current project data is unavailable", () => {
expect(
projectCreatedItems([event()], [{ ...project, workspaces: [] }])[0]
.repositories,
).toEqual([]);
expect(
projectCreatedItems(
[event()],
[{ ...project, companyId: "other-company" }],
),
).toEqual(projectCreatedItems([event()]));
});
it("deduplicates workspaces and rejects unsafe current repository links", () => {
const workspaces = [
project.workspaces[0],
project.workspaces[0],
{ ...project.workspaces[1], repoUrl: "javascript:alert(1)" },
];
expect(
projectCreatedItems([event()], [{ ...project, workspaces }])[0]
.repositories,
).toEqual([
{ id: "workspace-1", name: "App", url: "https://github.com/org/app" },
]);
});
});
@@ -0,0 +1,61 @@
import type { ActivityEvent, Project } from "@paperclipai/shared";
import type { TaskChatProjectCreatedItem } from "./task-chat-model";
export function projectCreatedItems(
events: readonly ActivityEvent[],
projects: readonly Pick<Project, "id" | "companyId" | "workspaces">[] = [],
): TaskChatProjectCreatedItem[] {
const seen = new Set<string>();
return events.flatMap((event) => {
if (
event.action !== "project.created" ||
event.entityType !== "project" ||
seen.has(event.entityId)
)
return [];
const details = event.details ?? {};
if (typeof details.name !== "string") return [];
seen.add(event.entityId);
const project = projects.find(
(candidate) =>
candidate.id === event.entityId &&
candidate.companyId === event.companyId,
);
const repositoryDetails = project
? project.workspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
url: workspace.repoUrl,
}))
: details.repositories;
const seenUrls = new Set<string>();
const repositories = Array.isArray(repositoryDetails)
? repositoryDetails.flatMap((value) => {
if (!value || typeof value !== "object") return [];
const repo = value as Record<string, unknown>;
if (
typeof repo.id !== "string" ||
typeof repo.name !== "string" ||
typeof repo.url !== "string" ||
!/^https:\/\//.test(repo.url)
)
return [];
if (seenUrls.has(repo.url)) return [];
seenUrls.add(repo.url);
return [{ id: repo.id, name: repo.name, url: repo.url }];
})
: [];
return [
{
id: `project-created:${event.entityId}`,
kind: "project_created" as const,
projectId: event.entityId,
name: details.name,
description:
typeof details.description === "string" ? details.description : null,
repositories,
timestamp: new Date(event.createdAt).toISOString(),
},
];
});
}
@@ -129,7 +129,7 @@ describe("commentsToTaskChatItems", () => {
expect(agent.createdAtIso).toBeUndefined();
});
it("shows both queue and steer times for a causally repositioned follow-up", () => {
it("keeps the regular comment time for a causally repositioned steered follow-up", () => {
const createdAt = "2026-09-04T14:09:33.000Z";
const conversationAnchorAt = "2026-09-04T14:10:14.000Z";
const [item] = commentsToTaskChatItems([
@@ -149,11 +149,11 @@ describe("commentsToTaskChatItems", () => {
expect(item).toMatchObject({
kind: "message",
timestamp: `Queued ${formatTaskChatTimestamp(createdAt)} · Steered ${formatTaskChatTimestamp(conversationAnchorAt)}`,
timestamp: formatTaskChatTimestamp(createdAt),
});
});
it("shows the successor-run delivery time for a queued follow-up", () => {
it("keeps the regular comment time for a successor-run follow-up", () => {
const createdAt = "2026-09-04T14:09:33.000Z";
const conversationAnchorAt = "2026-09-04T14:10:35.000Z";
const [item] = commentsToTaskChatItems([
@@ -172,7 +172,7 @@ describe("commentsToTaskChatItems", () => {
expect(item).toMatchObject({
kind: "message",
timestamp: `Queued ${formatTaskChatTimestamp(createdAt)} · Delivered ${formatTaskChatTimestamp(conversationAnchorAt)}`,
timestamp: formatTaskChatTimestamp(createdAt),
});
});
@@ -54,28 +54,12 @@ export function formatTaskChatTimestamp(value: unknown): string | undefined {
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
/**
* Follow-up inputs render at the causal slot where a runner consumed them.
* Keep their original submission time visible as well so the reordered bubble
* cannot look like it travelled backwards in the conversation.
*/
/** Keep every comment footer on the same compact, user-visible timestamp. */
export function formatTaskChatCommentTimestamp(
comment: IssueChatComment,
kind: TaskChatAuthorKind,
_kind: TaskChatAuthorKind,
): string | undefined {
const queuedAt = formatTaskChatTimestamp(comment.createdAt);
const deliveredAt = formatTaskChatTimestamp(comment.conversationAnchorAt);
const isDeliveredFollowUp = Boolean(
kind === "human" &&
comment.conversationAnchorAt &&
comment.consumedByRunId &&
(comment.followUpRequested || comment.steeredIntoRunId),
);
if (!isDeliveredFollowUp) return queuedAt;
if (!queuedAt || !deliveredAt) return queuedAt ?? deliveredAt;
const action = comment.steeredIntoRunId ? "Steered" : "Delivered";
return `Queued ${queuedAt} · ${action} ${deliveredAt}`;
return formatTaskChatTimestamp(comment.createdAt);
}
export function commentsToTaskChatItems(
@@ -85,6 +69,11 @@ export function commentsToTaskChatItems(
const items: TaskChatItem[] = [];
for (const comment of comments) {
if (comment.deletedAt) continue;
if (comment.conversationSessionGeneration != null) {
items.push({ id: comment.id, kind: "marker", variant: "session_start", label: "New session",
detail: "Earlier messages and files are still available.", createdAtIso: new Date(comment.createdAt).toISOString() });
continue;
}
const kind = authorKind(comment);
let authorName: string | undefined;
let agentIcon: string | null | undefined;
@@ -530,7 +530,18 @@ export interface TaskChatTurnItem {
};
}
export interface TaskChatProjectCreatedItem {
id: string;
kind: "project_created";
projectId: string;
name: string;
description?: string | null;
repositories: { id: string; name: string; url: string }[];
timestamp: string;
}
export type TaskChatItem =
| TaskChatProjectCreatedItem
| TaskChatMessageItem
| TaskChatThinkingItem
| TaskChatToolItem
@@ -250,7 +250,7 @@ export function TaskSidePanel({
const autoPlanHandledRef = useRef(restoredRef.current?.autoPlanHandled ?? false);
const initialState = useMemo(() => {
const restored = restoredRef.current?.state;
let tabs = restored?.tabs ?? [taskPanelPropertiesTab()];
let tabs = restored?.tabs ?? (issue.conversationAgentId ? [taskPanelArtifactsTab()] : [taskPanelPropertiesTab()]);
if (!initialSubtasksAvailableRef.current) {
tabs = tabs.filter((tab) => tab.payload.kind !== "subtasks");
} else if (!subtasksDismissedRef.current) {
+5
View File
@@ -16,6 +16,10 @@ export interface Breadcrumb {
* a primitive that changes only when the rendered `leading` should change.
*/
leadingKey?: string;
/** Optional action beside the label, outside the breadcrumb link. */
trailing?: ReactNode;
/** Stable identity for the action, following leadingKey semantics. */
trailingKey?: string;
}
interface BreadcrumbContextValue {
@@ -50,6 +54,7 @@ function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) {
|| left[index]?.href !== right[index]?.href
|| left[index]?.identifier !== right[index]?.identifier
|| left[index]?.leadingKey !== right[index]?.leadingKey
|| left[index]?.trailingKey !== right[index]?.trailingKey
) {
return false;
}
@@ -11,10 +11,40 @@ vi.mock("../api/issues", () => ({
}));
import { describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
import { queryKeys } from "../lib/queryKeys";
describe("LiveUpdatesProvider issue invalidation", () => {
it("connects trusted local boards without admitting signed-out authenticated users", () => {
const canConnect = __liveUpdatesTestUtils.canUseLiveSession;
expect(canConnect("success", false, "local_trusted")).toBe(true);
expect(canConnect("success", false, "authenticated")).toBe(false);
expect(canConnect("success", false, undefined)).toBe(false);
expect(canConnect("pending", false, "local_trusted")).toBe(false);
expect(canConnect("success", true, "authenticated")).toBe(true);
});
it("uses the current person's canonical chat for live updates and refreshes reset boundaries", () => {
const client = new QueryClient();
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
client.setQueryData(queryKeys.companies.list("user-1"), { companies: [{ id: "company-1", issuePrefix: "PAP" }], unauthorized: false });
client.setQueryData(queryKeys.agents.list("company-1"), [{ id: "agent-1", name: "Coder", urlKey: "coder" }]);
const chat = { id: "chat-1", companyId: "company-1", identifier: "PAP-1", assigneeAgentId: "agent-1" };
client.setQueryData(queryKeys.agentChats.detail("company-1", "user-1", "agent-1"), chat);
client.setQueryData(queryKeys.agentChats.detail("company-1", "user-2", "agent-1"), { ...chat, id: "other-chat" });
client.setQueryData(queryKeys.issues.detail("chat-1"), chat);
expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(client, "/PAP/chats/agent-1", { issueId: "chat-1", runId: "run-1" }, { isForegrounded: true })).toBe(true);
expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(client, "/PAP/chats/agent-1", { issueId: "other-chat", runId: "run-2" }, { isForegrounded: true })).toBe(false);
const invalidate = vi.spyOn(client, "invalidateQueries");
__liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", { entityType: "issue", entityId: "chat-1", action: "issue.conversation_session_started", actorType: "system" }, { userId: "user-1", agentId: null }, { pathname: "/PAP/chats/agent-1", isForegrounded: true });
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.comments("chat-1") });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["issues", "tree-control-state", "chat-1"] });
invalidate.mockClear();
__liveUpdatesTestUtils.invalidateVisibleIssueRunQueries(client, "/PAP/chats/agent-1", { agentId: "agent-1", runId: "run-1", status: "succeeded" }, { isForegrounded: true });
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.comments("chat-1") });
client.clear();
});
it("refreshes touched inbox queries and only the changed issue data for issue updates", () => {
const invalidations: unknown[] = [];
const queryClient = {
+53 -50
View File
@@ -34,6 +34,8 @@ import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import type { CompanyUserDirectoryResponse } from "../api/access";
import { issuesApi } from "../api/issues";
import { authApi } from "../api/auth";
import type { CompanyListResult } from "../api/companies-query";
import { healthApi } from "../api/health";
import { useCompany } from "./CompanyContext";
import type { ToastInput } from "./ToastContext";
import { useToastActions } from "./ToastContext";
@@ -43,8 +45,9 @@ import {
removeLiveRunById,
} from "../lib/optimistic-issue-runs";
import { queryKeys } from "../lib/queryKeys";
import { toCompanyRelativePath } from "../lib/company-routes";
import { extractCompanyPrefixFromPath, toCompanyRelativePath } from "../lib/company-routes";
import { useLocation } from "../lib/router";
import { agentRouteRef } from "../lib/utils";
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
@@ -296,11 +299,24 @@ function resolveVisibleIssueRouteContext(
const relativePath = toCompanyRelativePath(pathname);
const segments = relativePath.split("/").filter(Boolean);
if (segments[0] !== "issues" || !segments[1]) return null;
if (!["issues", "chats"].includes(segments[0]) || !segments[1]) return null;
const issueRef = decodeURIComponent(segments[1]);
const issue =
queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueRef)) ?? null;
let issueRef = decodeURIComponent(segments[1]);
if (segments[0] === "chats") {
const session = queryClient.getQueryData<Awaited<ReturnType<typeof authApi.getSession>>>(queryKeys.auth.session);
const userId = session?.user?.id ?? session?.session?.userId ?? null;
const companyPrefix = extractCompanyPrefixFromPath(pathname);
const company = queryClient.getQueryData<CompanyListResult>(queryKeys.companies.list(userId))
?.companies.find(item => item.issuePrefix.toUpperCase() === companyPrefix?.toUpperCase());
if (!company) return null;
const agent = queryClient.getQueryData<Agent[]>(queryKeys.agents.list(company.id))
?.find(item => item.id === issueRef || agentRouteRef(item) === issueRef);
if (!agent) return null;
const conversation = queryClient.getQueryData<Issue | null>(queryKeys.agentChats.detail(company.id, userId, agent.id));
if (!conversation) return null;
issueRef = conversation.id;
}
const issue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueRef)) ?? null;
const issueRefs = new Set<string>([issueRef]);
if (issue?.id) issueRefs.add(issue.id);
if (issue?.identifier) issueRefs.add(issue.identifier);
@@ -502,21 +518,17 @@ function invalidateVisibleIssueRunQueries(
}
for (const issueRef of context.issueRefs) {
queryClient.invalidateQueries({
queryKey: queryKeys.issues.detail(issueRef),
});
queryClient.invalidateQueries({
queryKey: queryKeys.issues.activity(issueRef),
});
queryClient.invalidateQueries({
queryKey: queryKeys.issues.runs(issueRef),
});
queryClient.invalidateQueries({
queryKey: queryKeys.issues.liveRuns(issueRef),
});
queryClient.invalidateQueries({
queryKey: queryKeys.issues.activeRun(issueRef),
});
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueRef) });
if (status && TERMINAL_RUN_STATUSES.has(status)) {
// A final comment can race the last in-flight history fetch. Reconcile
// persisted messages after the turn settles.
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueRef) });
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", issueRef] });
}
}
return true;
}
@@ -1318,19 +1330,14 @@ function invalidateActivityQueries(
visibleIssueCommentActivity
? { refetchType: "inactive" as const }
: undefined;
queryClient.invalidateQueries({
queryKey: queryKeys.issues.detail(ref),
...invalidationOptions,
});
queryClient.invalidateQueries({
queryKey: queryKeys.issues.activity(ref),
...invalidationOptions,
});
if (action === "issue.comment_added") {
queryClient.invalidateQueries({
queryKey: queryKeys.issues.comments(ref),
...invalidationOptions,
});
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref), ...invalidationOptions });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref), ...invalidationOptions });
if (action === "issue.comment_added" || action === "issue.conversation_session_started") {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref), ...invalidationOptions });
}
if (action === "issue.conversation_session_started") {
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", ref] });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref) });
}
if (action && ISSUE_DOCUMENT_ACTIVITY_ACTIONS.has(action)) {
const documentKey = readString(details?.key);
@@ -1417,13 +1424,10 @@ function invalidateActivityQueries(
}
if (entityType === "project") {
queryClient.invalidateQueries({
queryKey: queryKeys.projects.all(companyId),
});
if (entityId)
queryClient.invalidateQueries({
queryKey: queryKeys.projects.detail(entityId),
});
const sourceIssueId = readString((payload.details as Record<string, unknown> | undefined)?.sourceIssueId);
if (sourceIssueId) queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(sourceIssueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) });
if (entityId) queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(entityId) });
return;
}
@@ -1784,6 +1788,7 @@ export const __liveUpdatesTestUtils = {
invalidateVisibleIssueRunQueries,
readRunLiveStatusPatchFromPayload,
resolveLiveCompanyId,
canUseLiveSession,
shouldDeferIssueRefetchForVisibleAgentActivity,
shouldDeferVisibleIssueCommentActivity,
shouldSuppressActivityToastForVisibleIssue,
@@ -1791,6 +1796,10 @@ export const __liveUpdatesTestUtils = {
shouldSuppressAgentStatusToastForVisibleIssue,
};
function canUseLiveSession(sessionStatus: string, hasSession: boolean, deploymentMode?: string) {
return sessionStatus === "success" && (hasSession || deploymentMode === "local_trusted");
}
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const { visible } = usePageVisibility();
const wasHidden = useRef(!visible);
@@ -1808,18 +1817,12 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
queryFn: () => authApi.getSession(),
retry: false,
});
const { data: health } = useQuery({ queryKey: queryKeys.health, queryFn: healthApi.get });
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const socketAuthKey = session?.session?.id ?? currentUserId ?? "signed_out";
const liveCompanyId = resolveLiveCompanyId(
selectedCompanyId,
selectedCompany?.id ?? null,
);
const canConnectSocket =
sessionStatus === "success" && session !== null && liveCompanyId !== null;
const currentActorRef = useRef<{
userId: string | null;
agentId: string | null;
}>({
const liveCompanyId = resolveLiveCompanyId(selectedCompanyId, selectedCompany?.id ?? null);
const canConnectSocket = canUseLiveSession(sessionStatus, session != null, health?.deploymentMode) && liveCompanyId !== null;
const currentActorRef = useRef<{ userId: string | null; agentId: string | null }>({
userId: currentUserId,
agentId: null,
});
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowUpRight,
@@ -40,6 +40,7 @@ import {
getConnectableAppDefinition,
getAvailableConnectionMethods,
getRecommendedConnectionMethod,
isGoogleWorkspaceConnectorProfileId,
} from "@paperclipai/shared";
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
@@ -2204,7 +2205,13 @@ export function ConnectionSetupFlow({
methodKey={connectionMethodKey}
onMethodChange={(nextMethod) => {
setConnectionMethodKey(nextMethod?.key ?? "");
if (!reconnectGrantKind) {
// Capability/auth changes must not broaden the audience selected
// on Access (including choices restored after Cloud enrollment).
if (
!reconnectGrantKind
&& nextMethod?.grantKinds
&& !nextMethod.grantKinds.includes(grantKind)
) {
setGrantKind(defaultGrantKindFor(nextMethod, Boolean(requestedAgentId)));
}
setCredentials({});
@@ -3381,7 +3388,29 @@ function KeyStep({
{!capabilityKey && <p className="mt-2 text-xs text-muted-foreground">Choose an access level to continue.</p>}
</div>
) : null;
const authenticationSelection = capabilityMethods.length > 1 ? (
const managedGoogleMethod = capabilityMethods.find((candidate) =>
candidate.oauthStrategy === "paperclip_cloud_connector"
&& isGoogleWorkspaceConnectorProfileId(candidate.connectorProfile ?? ""),
);
const customerGoogleMethod = managedGoogleMethod && capabilityMethods.find((candidate) =>
connectionMethodAcceptsCustomerOAuthClient(candidate)
&& !connectionMethodSupportsAutomaticOAuth(candidate),
);
const usingCustomGoogleOAuth = method?.key === customerGoogleMethod?.key;
const googleOAuthFieldsId = useId();
const authenticationSelection = managedGoogleMethod && customerGoogleMethod && capabilityMethods.length === 2 ? (
<Button
type="button"
variant="link"
className="h-auto p-0 text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
aria-expanded={usingCustomGoogleOAuth}
aria-controls={googleOAuthFieldsId}
disabled={submitting}
onClick={() => onMethodChange(usingCustomGoogleOAuth ? managedGoogleMethod : customerGoogleMethod)}
>
{usingCustomGoogleOAuth ? "Use Paperclip instead" : "Use your own Google OAuth app"}
</Button>
) : capabilityMethods.length > 1 ? (
<div>
<label className="text-sm font-medium text-foreground">How do you want to connect?</label>
<RadioCardGroup
@@ -3572,16 +3601,18 @@ function KeyStep({
)}
{!usingVercel && method?.auth === "oauth" && customerOAuthClientRequired ? (
<OAuthClientFields
entry={entry}
method={method}
callbackUrl={oauthCallbackUrl}
clientId={oauthClientId}
onClientIdChange={onOAuthClientIdChange}
clientSecret={oauthClientSecret}
onClientSecretChange={onOAuthClientSecretChange}
required
/>
<div id={googleOAuthFieldsId} role="region" aria-label="Your OAuth app">
<OAuthClientFields
entry={entry}
method={method}
callbackUrl={oauthCallbackUrl}
clientId={oauthClientId}
onClientIdChange={onOAuthClientIdChange}
clientSecret={oauthClientSecret}
onClientSecretChange={onOAuthClientSecretChange}
required
/>
</div>
) : null}
{usingVercel || !method || fields.length === 0 ? null : (
+13
View File
@@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { queryKeys } from "@/lib/queryKeys";
export function useAgentChatEnabled() {
const query = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
return {
enabled: query.data?.enableAgentChat === true,
loaded: query.isFetched,
};
}
+1 -1
View File
@@ -13,7 +13,7 @@ import { queryKeys } from "@/lib/queryKeys";
export function useIssueDocuments(issueId: string | null | undefined) {
return useQuery<IssueDocument[]>({
queryKey: [...queryKeys.issues.documents(issueId ?? ""), "list"],
enabled: Boolean(issueId),
enabled: Boolean(issueId) && !issueId?.startsWith("chat:"),
queryFn: () => issuesApi.listDocuments(issueId!),
});
}
+1 -1
View File
@@ -14,7 +14,7 @@ import { queryKeys } from "@/lib/queryKeys";
export function useIssuePlanDocument(issueId: string | null | undefined) {
return useQuery<IssueDocument | null>({
queryKey: [...queryKeys.issues.documents(issueId ?? ""), "plan"],
enabled: Boolean(issueId),
enabled: Boolean(issueId) && !issueId?.startsWith("chat:"),
queryFn: async () => {
try {
return await issuesApi.getDocument(issueId!, "plan");
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ActivityEvent, Project } from "@paperclipai/shared";
import { projectsApi } from "@/api/projects";
import { queryKeys } from "@/lib/queryKeys";
import { useProjectCreatedItems } from "./useProjectCreatedItems";
vi.mock("@/api/projects", () => ({ projectsApi: { get: vi.fn() } }));
const receipt = {
id: "receipt",
companyId: "company",
action: "project.created",
entityType: "project",
entityId: "project",
createdAt: "2026-09-11T12:00:00Z",
details: {
name: "Launch",
repositories: [
{ id: "repo", name: "Original", url: "https://github.com/org/app" },
],
},
} as unknown as ActivityEvent;
const events = [receipt];
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
function Fixture({ activity = events }: { activity?: ActivityEvent[] }) {
const items = useProjectCreatedItems(activity, "company");
return <pre>{JSON.stringify(items)}</pre>;
}
function render(activity = events) {
act(() =>
root.render(
<QueryClientProvider client={client}>
<Fixture activity={activity} />
</QueryClientProvider>,
),
);
}
beforeEach(() => {
vi.clearAllMocks();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => {
act(() => root.unmount());
client.clear();
container.remove();
});
describe("shared project creation card queries", () => {
it("refreshes repositories through existing project invalidation", async () => {
const project = {
id: "project",
companyId: "company",
workspaces: [
{ id: "first", name: "App", repoUrl: "https://github.com/org/app" },
],
} as Project;
vi.mocked(projectsApi.get).mockResolvedValue(project);
render();
await vi.waitFor(() =>
expect(container.textContent).toContain('"name":"App"'),
);
expect(projectsApi.get).toHaveBeenCalledWith("project", "company");
vi.mocked(projectsApi.get).mockResolvedValue({
...project,
workspaces: [
...project.workspaces,
{
...project.workspaces[0],
id: "second",
name: "Docs",
repoUrl: "https://github.com/org/docs",
},
],
});
await act(async () => {
await client.invalidateQueries({
queryKey: queryKeys.projects.detail("project"),
});
});
await vi.waitFor(() =>
expect(container.textContent).toContain("https://github.com/org/docs"),
);
expect(JSON.parse(container.textContent!)).toHaveLength(1);
});
it("retains the creation receipt if the project is inaccessible", async () => {
vi.mocked(projectsApi.get).mockRejectedValue(new Error("Not found"));
render();
await vi.waitFor(() =>
expect(
client.getQueryState(queryKeys.projects.detail("project"))?.status,
).toBe("error"),
);
expect(container.textContent).toContain('"name":"Original"');
expect(projectsApi.get).toHaveBeenCalledTimes(1);
});
it("does not query projects or invent creation cards without receipts", () => {
render([]);
expect(projectsApi.get).not.toHaveBeenCalled();
expect(container.textContent).toBe("[]");
});
});
+33
View File
@@ -0,0 +1,33 @@
import { useMemo } from "react";
import { useQueries, type UseQueryResult } from "@tanstack/react-query";
import type { ActivityEvent, Project } from "@paperclipai/shared";
import { projectsApi } from "@/api/projects";
import { queryKeys } from "@/lib/queryKeys";
import { projectCreatedItems } from "@/components/task-chat/project-created-items";
function availableProjects(results: UseQueryResult<Project>[]) {
return results.flatMap((result) => (result.isSuccess ? [result.data] : []));
}
/** Creation receipts establish the cards; authorized project reads keep their
* repository lists current as the same run adds or edits workspaces.
*/
export function useProjectCreatedItems(
events: readonly ActivityEvent[],
companyId?: string | null,
) {
const receipts = useMemo(() => projectCreatedItems(events), [events]);
const projects = useQueries({
queries: receipts.map((receipt) => ({
queryKey: queryKeys.projects.detail(receipt.projectId),
queryFn: () => projectsApi.get(receipt.projectId, companyId!),
enabled: Boolean(companyId),
retry: false,
})),
combine: availableProjects,
});
return useMemo(
() => projectCreatedItems(events, projects),
[events, projects],
);
}
+67 -8
View File
@@ -3,7 +3,7 @@
@font-face {
font-family: "InterVariable";
src: url("../fonts/InterVariable.woff2") format("woff2");
src: url("/fonts/InterVariable.woff2") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 100 900;
@@ -11,7 +11,7 @@
@font-face {
font-family: "InterVariable";
src: url("../fonts/InterVariable-Italic.woff2") format("woff2");
src: url("/fonts/InterVariable-Italic.woff2") format("woff2");
font-display: swap;
font-style: italic;
font-weight: 100 900;
@@ -174,6 +174,15 @@
--status-agent-running: #2563eb;
--status-agent-paused: #f59e0b;
--status-agent-error: #dc2626;
/* Dashboard agent card: translucent running treatment from the Paper design. */
--dashboard-run-accent: oklch(62.3% 0.214 259.815);
--dashboard-run-background: color-mix(in oklab, var(--dashboard-run-accent) 4%, transparent);
--dashboard-run-border: color-mix(in oklab, var(--dashboard-run-accent) 25%, transparent);
--dashboard-list-leading-width: calc(var(--spacing) * 6);
--dashboard-list-id-width: calc(var(--spacing) * 20);
--dashboard-list-time-width: calc(var(--spacing) * 16);
--dashboard-task-list-columns: var(--dashboard-list-leading-width) minmax(0, 1fr) calc(var(--spacing) * 36) var(--dashboard-list-id-width) var(--dashboard-list-time-width);
--dashboard-activity-list-columns: var(--dashboard-list-leading-width) minmax(0, 1fr) var(--dashboard-list-id-width) var(--dashboard-list-time-width);
--status-task-backlog: #a8aeb2;
--status-task-todo: #f59e0b;
--status-task-in_progress: #2563eb;
@@ -191,15 +200,15 @@
gray / amber / green statuses are pinned to the board-approved AA hexes.
`in_queue` is the BLOCKED shape recoloured to the in_progress blue
(replaces the bespoke teal "covered" state). Light values here; the
`.dark` block below overrides the four that need a mode-specific hue. */
`.dark` block below overrides the hues that need a mode-specific value. */
--status-task-icon-backlog: #52585d; /* gray darkened — 7.21:1 on paper */
--status-task-icon-todo: #cc7a00; /* amber darkened — clears 3:1 */
--status-task-icon-in_progress: var(--status-task-in_progress); /* #2563eb both modes */
--status-task-icon-in_progress: var(--color-blue-600); /* matches nav dots and live labels */
--status-task-icon-in_review: var(--status-task-in_review); /* #7c3aed (light) */
--status-task-icon-done: #16a34a; /* green darkened — clears 3:1 */
--status-task-icon-blocked: var(--status-task-blocked); /* #dc2626 both modes */
--status-task-icon-cancelled: #52585d;
--status-task-icon-in_queue: var(--status-task-in_progress); /* blocked shape, blue */
--status-task-icon-in_queue: var(--status-task-icon-in_progress); /* blocked shape, same mode-aware blue */
--folder-color-indigo: #6366f1;
--folder-color-violet: #8b5cf6;
@@ -286,6 +295,7 @@
--side-panel-tab-label-max-width: var(--sz-100px);
--side-panel-tab-label-expanded-max-width: calc(var(--side-panel-tab-label-max-width) + var(--sz-18px));
--side-panel-tab-label-fade-width: var(--sz-32px);
--sidebar-nav-action-fade-width: calc(var(--spacing) * 4);
--side-panel-tab-radius: var(--radius-xl);
--side-panel-control-radius: var(--radius-xl);
--side-panel-launcher-width: var(--sz-320px);
@@ -405,11 +415,12 @@
--chip-match-identifier-bg: var(--muted);
--chip-match-identifier-fg: var(--muted-foreground);
--chip-match-identifier-border: var(--border);
/* Dark-mode AA overrides for the status-icon hues that need a mode-specific
value (PAP-238). in_progress / blocked stay identical (same blue / red both
modes) and in_queue tracks in_progress, so they need no override. */
/* Dark-mode status-icon hues. The lighter progress blue gives bare glyphs
contrast comparable to the neighboring gray, purple, and green icons.
Blocked retains its base red; in_queue follows the progress icon blue. */
--status-task-icon-backlog: #9a958a;
--status-task-icon-todo: #fbbf24;
--status-task-icon-in_progress: var(--color-blue-400); /* matches nav dots and live labels */
--status-task-icon-in_review: #9474f0; /* deeper than violet-400, reads purple */
--status-task-icon-done: #34d06f;
--status-task-icon-cancelled: #9a958a;
@@ -985,6 +996,48 @@
transition-duration: var(--motion-side-panel-tab);
transition-timing-function: var(--motion-ease-standard);
}
/* Sidebar actions cover the trailing live label without moving row content,
fading into the row surface like the task-detail tab close control. */
.primary-sidebar-surface {
--sidebar-action-rest-surface: color-mix(in srgb, var(--border) 50%, var(--background));
background-color: var(--sidebar-action-rest-surface);
}
.dark .primary-sidebar-surface {
--sidebar-action-rest-surface: var(--muted);
}
.sidebar-action-row {
--sidebar-action-surface: var(--sidebar-action-rest-surface, var(--background));
}
.sidebar-action-row:is(:hover, :focus-within),
.sidebar-action-row:has(.sidebar-action-link[aria-current="page"]),
.sidebar-action-row:has(.sidebar-action-menu[data-state="open"]) {
--sidebar-action-surface: var(--sidebar-accent);
}
.sidebar-action-link {
background-color: var(--sidebar-action-surface);
}
.sidebar-action-row:is(:hover, :focus-within) .sidebar-action-link,
.sidebar-action-row:has(.sidebar-action-menu[data-state="open"]) .sidebar-action-link {
color: var(--sidebar-accent-foreground);
}
.sidebar-action-menu {
isolation: isolate;
}
.sidebar-action-menu::before {
content: "";
position: absolute;
inset: 0;
left: calc(-1 * var(--sidebar-nav-action-fade-width));
z-index: -1;
border-radius: inherit;
/* Animate a solid surface in sync with the link; gradient color stops switch
immediately and flash the resting sidebar color during the opacity fade. */
background-color: var(--sidebar-action-surface);
mask-image: linear-gradient(to right, transparent, black var(--sidebar-nav-action-fade-width));
transition: background-color var(--default-transition-duration) var(--default-transition-timing-function);
pointer-events: none;
}
.side-panel-tab-label-fade,
.side-panel-tab-label-close-fade[data-truncated="true"],
[data-appearance="streamlined-task"]:is(:hover, :focus-within) .side-panel-tab-label-close-fade {
@@ -1145,6 +1198,12 @@
}
}
/* Shared rhythm for dashboard activity and recent-task rows. */
.dashboard-list-row {
min-height: calc(var(--spacing) * 12);
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
}
/* Dashboard activity row entry motion */
@keyframes dashboard-activity-enter {
0% {
+5
View File
@@ -8,6 +8,11 @@ describe("activity formatting", () => {
["agent-approver", { id: "agent-approver", name: "Approver Bot" } as Agent],
]);
it("uses readable verbs for task read-state changes", () => {
expect(formatActivityVerb("issue.read_marked")).toBe("read");
expect(formatActivityVerb("issue.read_unmarked")).toBe("marked unread");
});
it("formats blocker activity using linked issue identifiers", () => {
const details = {
addedBlockedByIssues: [
+2
View File
@@ -25,6 +25,8 @@ interface ActivityFormatOptions {
const ACTIVITY_ROW_VERBS: Record<string, string> = {
"issue.created": "created",
"issue.updated": "updated",
"issue.read_marked": "read",
"issue.read_unmarked": "marked unread",
"issue.checked_out": "checked out",
"issue.released": "released",
"issue.comment_added": "commented on",
+49
View File
@@ -0,0 +1,49 @@
import type { Agent, Issue, IssueWorkMode } from "@paperclipai/shared";
/** Ephemeral view model; never persisted until first send or upload. */
export function agentChatDraft(
agent: Agent,
workMode: IssueWorkMode = "standard",
): Issue {
return {
id: `chat:${agent.id}`,
companyId: agent.companyId,
title: `Chat with ${agent.name}`,
conversationAgentId: agent.id,
conversationUserId: "draft",
conversationState: "waiting",
status: "in_review",
workMode,
priority: "medium",
reviewPolicy: null,
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
description: null,
assigneeAgentId: agent.id,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
responsibleUserId: null,
issueNumber: null,
identifier: null,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date(0),
updatedAt: new Date(0),
documentSummaries: [],
labels: [],
};
}
+8
View File
@@ -0,0 +1,8 @@
/** Retire the legacy body-keyed retry cache; submission receipts now own identity. */
export function clearLegacyChatMessageRequests(scope: string) {
try {
localStorage.removeItem(`paperclip:agent-chat-pending:${scope}`);
} catch {
// Browser storage may be disabled.
}
}
+1
View File
@@ -6,6 +6,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"teams-catalog",
"org",
"agents",
"chats",
"apps",
"projects",
"workspaces",
+12
View File
@@ -22,6 +22,18 @@ describe("task draft upload receipts", () => {
contentPath: `/api/attachments/${id}/content`,
};
beforeEach(() => localStorage.clear());
it("keeps chat drafts and pending submission fences within the current tab", () => {
sessionStorage.clear();
const chatKey = "paperclip:agent-chat-draft:company:user:agent";
saveDraft(chatKey, "My chat draft");
saveDraftSubmission(chatKey, { attemptId: id, reviewed: false });
expect(loadDraft(chatKey)).toBe("My chat draft");
expect(loadDraftSubmission(chatKey)?.attemptId).toBe(id);
expect(localStorage.getItem(chatKey)).toBeNull();
expect(localStorage.getItem(`${chatKey}:submission:v1`)).toBeNull();
sessionStorage.clear();
expect(loadDraftSubmission(chatKey)).toBeNull();
});
it("retains a closed task-specific uncertainty marker and only settles the same attempt", () => {
saveDraftSubmission(key, { attemptId: id, reviewed: false });
expect(loadDraftSubmission(key)).toEqual({
+26 -19
View File
@@ -1,18 +1,25 @@
/**
* Per-task composer draft persistence, shared by the chat composers.
*
* Draft text is kept in localStorage under the caller-provided key. All
* Ordinary task drafts use localStorage; agent chat drafts use tab-scoped
* sessionStorage under the caller-provided key. All
* access is guarded so disabled or full storage never throws into React.
* Empty drafts remove the text key. Uploaded receipt metadata has a separate,
* versioned task-keyed record; legacy text drafts remain plain strings.
*/
/** Debounce before a keystroke lands in localStorage. */
/** Debounce before a keystroke lands in browser storage. */
export const DRAFT_DEBOUNCE_MS = 800;
// Chat drafts and uncertain submissions belong to this browser tab. Sharing a
// submission fence across tabs prevents intentional concurrent conversation turns.
function draftStorage(draftKey: string): Storage {
return draftKey.startsWith("paperclip:agent-chat-draft:") ? sessionStorage : localStorage;
}
export function loadDraft(draftKey: string): string {
try {
return localStorage.getItem(draftKey) ?? "";
return draftStorage(draftKey).getItem(draftKey) ?? "";
} catch {
return "";
}
@@ -27,23 +34,23 @@ export function saveDraft(draftKey: string, value: string, attemptId?: string) {
try {
if (!mayWriteDraft(draftKey, attemptId)) return;
if (value.trim()) {
localStorage.setItem(draftKey, value);
draftStorage(draftKey).setItem(draftKey, value);
} else {
localStorage.removeItem(draftKey);
draftStorage(draftKey).removeItem(draftKey);
}
} catch {
// Ignore localStorage failures.
// Ignore browser storage failures.
}
}
export function clearDraft(draftKey: string, attemptId?: string) {
try {
if (!mayWriteDraft(draftKey, attemptId)) return;
localStorage.removeItem(draftKey);
localStorage.removeItem(`${draftKey}:attachments:v1`);
localStorage.removeItem(`${draftKey}:submission:v1`);
draftStorage(draftKey).removeItem(draftKey);
draftStorage(draftKey).removeItem(`${draftKey}:attachments:v1`);
draftStorage(draftKey).removeItem(`${draftKey}:submission:v1`);
} catch {
// Ignore localStorage failures.
// Ignore browser storage failures.
}
}
@@ -58,7 +65,7 @@ export function loadDraftSubmission(
draftKey: string,
): ComposerDraftSubmission | null {
try {
const raw = localStorage.getItem(`${draftKey}:submission:v1`);
const raw = draftStorage(draftKey).getItem(`${draftKey}:submission:v1`);
if (!raw || raw.length > 2_048) return null;
const record = JSON.parse(raw);
return record?.version === 1 &&
@@ -82,7 +89,7 @@ export function saveDraftSubmission(
// An old completion/review must not replace a different retained intent.
// This is a local guard, not cross-tab atomicity or server idempotency.
if (!mayWriteDraft(draftKey, submission.attemptId)) return;
localStorage.setItem(
draftStorage(draftKey).setItem(
`${draftKey}:submission:v1`,
JSON.stringify({ version: 1, draftKey, ...submission }),
);
@@ -94,7 +101,7 @@ export function saveDraftSubmission(
export function clearDraftSubmission(draftKey: string, attemptId: string) {
try {
if (loadDraftSubmission(draftKey)?.attemptId === attemptId)
localStorage.removeItem(`${draftKey}:submission:v1`);
draftStorage(draftKey).removeItem(`${draftKey}:submission:v1`);
} catch {
/* Disabled browser storage is supported in memory. */
}
@@ -158,7 +165,7 @@ export function loadDraftAttachments(
draftKey: string,
): ComposerDraftAttachment[] {
try {
const raw = localStorage.getItem(`${draftKey}:attachments:v1`);
const raw = draftStorage(draftKey).getItem(`${draftKey}:attachments:v1`);
if (!raw || raw.length > 32_768) return [];
const value: unknown = JSON.parse(raw);
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
@@ -178,18 +185,18 @@ export function saveDraftAttachments(draftKey: string, attachments: unknown) {
if (!mayWriteDraft(draftKey)) return;
const selected = draftAttachments(attachments);
if (selected.length)
localStorage.setItem(
draftStorage(draftKey).setItem(
`${draftKey}:attachments:v1`,
JSON.stringify({ version: 1, draftKey, attachments: selected }),
);
else localStorage.removeItem(`${draftKey}:attachments:v1`);
else draftStorage(draftKey).removeItem(`${draftKey}:attachments:v1`);
} catch {
/* Disabled/full browser storage must not break the composer. */
}
}
export function loadStructuredDraft<T>(draftKey: string, fallback: T): T {
try {
const value = localStorage.getItem(draftKey);
const value = draftStorage(draftKey).getItem(draftKey);
return value ? (JSON.parse(value) as T) : fallback;
} catch {
return fallback;
@@ -198,8 +205,8 @@ export function loadStructuredDraft<T>(draftKey: string, fallback: T): T {
export function saveStructuredDraft(draftKey: string, value: unknown) {
try {
localStorage.setItem(draftKey, JSON.stringify(value));
draftStorage(draftKey).setItem(draftKey, JSON.stringify(value));
} catch {
// Ignore localStorage failures.
// Ignore browser storage failures.
}
}
+4
View File
@@ -1,4 +1,8 @@
export const queryKeys = {
agentChats: {
detail: (companyId: string | null, userId: string | null, agentId: string | undefined) =>
["agent-chat", companyId, userId, agentId] as const,
},
companies: {
/**
* Prefix for everything company-shaped. Matches the list, details and stats
+85
View File
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { clearLegacyChatMessageRequests } from "./chat-message-request";
import { describe, expect, it } from "vitest";
import {
orderChatAgents,
parseRecentAgentChats,
recordAgentChatVisit,
} from "./recent-agent-chats";
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
import type { IssueChatComment } from "./issue-chat-messages";
describe("agent chat navigation and session markers", () => {
it("sorts stars alphabetically, then limits recent unstarred agents to four", () => {
const agents = [
"Zulu",
"Alpha",
"Three",
"Four",
"Five",
"Six",
"Seven",
].map((name, i) => ({ id: String(i), name }));
expect(
orderChatAgents(agents, ["0", "1"], ["0", "6", "5", "4", "3", "2"]).map(
(agent) => agent.name,
),
).toEqual(["Alpha", "Zulu", "Seven", "Six", "Five", "Four"]);
expect(parseRecentAgentChats('["a","a",null,1,"b"]')).toEqual(["a", "b"]);
expect(parseRecentAgentChats("broken")).toEqual([]);
});
it("keeps visits personal and company scoped and moves only the visited agent", () => {
localStorage.clear();
recordAgentChatVisit("a", "user1", "agent1");
recordAgentChatVisit("a", "user1", "agent2");
recordAgentChatVisit("a", "user1", "agent1");
recordAgentChatVisit("b", "user1", "agent3");
recordAgentChatVisit("a", "user2", "agent4");
expect(
JSON.parse(localStorage.getItem("paperclip.recentAgentChats:a:user1")!),
).toEqual(["agent1", "agent2"]);
expect(
JSON.parse(localStorage.getItem("paperclip.recentAgentChats:b:user1")!),
).toEqual(["agent3"]);
expect(
JSON.parse(localStorage.getItem("paperclip.recentAgentChats:a:user2")!),
).toEqual(["agent4"]);
});
it("removes legacy plaintext retry records", () => {
const scope = "company:user:agent";
const key = `paperclip:agent-chat-pending:${scope}`;
localStorage.setItem(key, JSON.stringify([{ body: "private text", id: "old" }]));
clearLegacyChatMessageRequests(scope);
expect(localStorage.getItem(key)).toBeNull();
});
it("renders a processed /new as a divider without discarding earlier messages", () => {
const comment = {
id: "old",
body: "Earlier message",
authorType: "user",
createdAt: new Date(),
} as IssueChatComment;
const items = commentsToTaskChatItems([
comment,
{
...comment,
id: "reset",
body: "/new",
conversationSessionGeneration: 1,
},
{ ...comment, id: "next", body: "Fresh message" },
]);
expect(items.map((item) => item.kind)).toEqual([
"message",
"marker",
"message",
]);
expect(items[1]).toMatchObject({
label: "New session",
variant: "session_start",
});
expect(
commentsToTaskChatItems([{ ...comment, body: "/new" }])[0].kind,
).toBe("message");
});
});
+79
View File
@@ -0,0 +1,79 @@
import { useCallback, useSyncExternalStore } from "react";
import type { Agent } from "@paperclipai/shared";
const eventName = "paperclip:recent-agent-chats";
const key = (company: string, user?: string | null) =>
`paperclip.recentAgentChats:${company}:${user ?? "__local_board__"}`;
const memory = new Map<string, string>();
function read(storageKey: string): string {
try {
return (
window.localStorage.getItem(storageKey) ?? memory.get(storageKey) ?? "[]"
);
} catch {
return memory.get(storageKey) ?? "[]";
}
}
export function parseRecentAgentChats(raw: string): string[] {
try {
const ids: unknown = JSON.parse(raw);
return Array.isArray(ids)
? [
...new Set(ids.filter((id): id is string => typeof id === "string")),
].slice(0, 50)
: [];
} catch {
return [];
}
}
export function recordAgentChatVisit(
company: string,
user: string | null | undefined,
agentId: string,
) {
const storageKey = key(company, user);
const value = JSON.stringify(
[
agentId,
...parseRecentAgentChats(read(storageKey)).filter((id) => id !== agentId),
].slice(0, 50),
);
memory.set(storageKey, value);
try {
window.localStorage.setItem(storageKey, value);
} catch {
/* In-tab navigation still works without storage. */
}
window.dispatchEvent(new Event(eventName));
}
function subscribe(callback: () => void) {
window.addEventListener(eventName, callback);
window.addEventListener("storage", callback);
return () => {
window.removeEventListener(eventName, callback);
window.removeEventListener("storage", callback);
};
}
export function useRecentAgentChats(company: string, user?: string | null) {
const getSnapshot = useCallback(
() => read(key(company, user)),
[company, user],
);
return parseRecentAgentChats(
useSyncExternalStore(subscribe, getSnapshot, () => "[]"),
);
}
export function orderChatAgents<T extends Pick<Agent, "id" | "name">>(
agents: T[],
stars: string[],
recent: string[],
) {
return [
...agents
.filter((agent) => stars.includes(agent.id))
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)),
...recent
.filter((id) => !stars.includes(id))
.flatMap((id) => agents.filter((agent) => agent.id === id))
.slice(0, 4),
];
}
+2 -1
View File
@@ -78,10 +78,11 @@ export function writeRecentTasks(storageKey: string, entries: RecentTaskEntry[])
}
export function recordRecentTask(
issue: Pick<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "updatedAt">,
issue: Pick<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "updatedAt" | "conversationAgentId">,
userId: string | null | undefined,
recordedAt = new Date(issue.updatedAt).getTime(),
) {
if (issue.conversationAgentId) return;
const storageKey = getRecentTasksStorageKey(issue.companyId, userId);
const current = readRecentTasks(storageKey, issue.companyId);
const existing = current.find((candidate) => candidate.id === issue.id);
+1 -1
View File
@@ -41,7 +41,7 @@ export function classifyShellRoute(
return {
companySegments,
isTaskDetail: root === "issues" && companySegments.length >= 2,
isTaskDetail: (root === "issues" || root === "chats") && companySegments.length >= 2,
builtInContextualSurface: isCompanySettings
? "settings"
: root === "apps" || root === "tools"
+1 -1
View File
@@ -18,7 +18,7 @@ describe("bundled UI font assets", () => {
expect(existsSync(fontPath), `${fileName} should exist in ui/public/fonts`).toBe(true);
expect(statSync(fontPath).isFile(), `${fileName} should be a file`).toBe(true);
expect(readFileSync(fontPath).subarray(0, 4).toString("ascii")).toBe("wOF2");
expect(css).toContain(`url("../fonts/${fileName}")`);
expect(css).toContain(`url("/fonts/${fileName}")`);
}
expect(css).toContain('--font-sans: "InterVariable"');
+94
View File
@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { agentChatsApi } from "@/api/agentChats";
import { agentsApi } from "@/api/agents";
import { authApi } from "@/api/auth";
import { useCompany } from "@/context/CompanyContext";
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
import { recordAgentChatVisit } from "@/lib/recent-agent-chats";
import { queryKeys } from "@/lib/queryKeys";
import { useParams } from "@/lib/router";
import { agentRouteRef } from "@/lib/utils";
import { TaskDetailSurface } from "./IssueDetail";
import type { Issue } from "@paperclipai/shared";
export function AgentChat() {
const { agentRef = "" } = useParams<{ agentRef: string }>();
const { selectedCompanyId } = useCompany();
const { enabled, loaded } = useAgentChatEnabled();
const client = useQueryClient();
const agents = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const session = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const userId =
session.data?.user?.id ?? session.data?.session?.userId ?? null;
const agent = agents.data?.find(
(item) => item.id === agentRef || agentRouteRef(item) === agentRef,
);
const chatKey = queryKeys.agentChats.detail(selectedCompanyId, userId, agent?.id);
const chat = useQuery({
queryKey: chatKey,
queryFn: () => agentChatsApi.get(selectedCompanyId!, agent!.id),
enabled: enabled && !!agent && session.isFetched,
});
const creating = useRef<Promise<Issue> | null>(null);
useEffect(() => {
creating.current = null;
}, [selectedCompanyId, userId, agent?.id]);
useEffect(() => {
if (enabled && agent && session.isFetched)
recordAgentChatVisit(agent.companyId, userId, agent.id);
}, [enabled, agent?.id, agent?.companyId, userId, session.isFetched]);
const ensureIssue = useCallback(async () => {
if (!agent || !selectedCompanyId) throw new Error("Agent not found");
if (chat.data) return chat.data;
const promise = (creating.current ??= agentChatsApi.ensure(
selectedCompanyId,
agent.id,
));
try {
const issue = await promise;
client.setQueryData(queryKeys.issues.detail(issue.id), issue);
client.setQueryData(chatKey, issue);
return issue;
} catch (error) {
creating.current = null;
throw error;
}
}, [agent, selectedCompanyId, chat.data, client, userId]);
if (!loaded || agents.isPending || session.isPending)
return (
<p className="text-sm text-muted-foreground">Loading conversation…</p>
);
if (!enabled && !chat.data)
return (
<p className="text-sm text-muted-foreground">
Agent Chat is disabled. Enable it in Experimental settings. Existing
history remains available through task links.
</p>
);
if (agents.error || chat.error)
return (
<p className="text-sm text-destructive">
{(agents.error ?? chat.error)?.message}
</p>
);
if (!agent)
return <p className="text-sm text-destructive">Agent not found.</p>;
if (chat.isPending)
return (
<p className="text-sm text-muted-foreground">Loading conversation…</p>
);
return (
<TaskDetailSurface
key={`${agent.id}:${userId}`}
conversation={{ agent, issue: chat.data ?? null, ensureIssue }}
/>
);
}
+1
View File
@@ -242,6 +242,7 @@ function makeInstanceSettings({
enableEnvironments,
enableIsolatedWorkspaces: true,
enableStreamlinedLeftNavigation: false,
enableAgentChat: false,
enableConferenceRoomChat: false,
enableIssuePlanDecompositions: true,
enableExperimentalFileViewer: false,
+3
View File
@@ -1,3 +1,4 @@
import { useAgentChatEnabled } from "../hooks/useAgentChatEnabled";
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
import { Link, useNavigate, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
@@ -192,6 +193,7 @@ function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set<st
export type AgentsView = "list" | "org";
export function Agents({ initialView = "list" }: { initialView?: AgentsView } = {}) {
const agentChat = useAgentChatEnabled();
const { selectedCompanyId } = useCompany();
const { openNewAgent } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
@@ -424,6 +426,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
metaSpacerClassName="hidden @5xl:block"
trailing={
<div className="flex items-center gap-3">
{agentChat.enabled && <Button variant="ghost" size="sm" onClick={event => { event.preventDefault(); event.stopPropagation(); navigate(`/chats/${agentRouteRef(agent)}`); }}>Chat</Button>}
<div className="hidden sm:flex items-center gap-3">
{liveRunByAgent.has(agent.id) && (
<LiveRunIndicator
+23 -24
View File
@@ -453,7 +453,7 @@ export function Dashboard() {
<SmokeLabDashboardCard companyId={selectedCompanyId!} />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className={cn("grid grid-cols-2 gap-4", SHOW_TASK_PRIORITY_UI ? "lg:grid-cols-4" : "lg:grid-cols-3")}>
<ChartCard title="Run Activity" subtitle="Last 14 days">
<RunActivityChart activity={data.runActivity} />
</ChartCard>
@@ -486,7 +486,7 @@ export function Dashboard() {
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Recent Activity
</h3>
<Card className="block py-0 divide-y divide-border overflow-hidden">
<Card className="@container block py-0 divide-y divide-border overflow-hidden">
{recentActivity.map((event) => (
<ActivityRow
key={event.id}
@@ -512,37 +512,36 @@ export function Dashboard() {
<p className="text-sm text-muted-foreground">No tasks yet.</p>
</Card>
) : (
<Card className="block py-0 divide-y divide-border overflow-hidden">
<Card className="@container block py-0 divide-y divide-border overflow-hidden">
{recentIssues.slice(0, 10).map((issue) => (
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
className="px-4 py-3 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
className="dashboard-list-row text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
>
<div className="flex items-start gap-2 sm:items-center sm:gap-3">
{/* Status icon - left column on mobile */}
<span className="shrink-0 sm:hidden">
<div className="flex items-start gap-2 @xl:grid @xl:grid-cols-(--dashboard-task-list-columns) @xl:items-baseline">
<span className="flex size-6 shrink-0 items-center justify-end @xl:self-center">
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />
</span>
{/* Right column on mobile: title + metadata stacked */}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} /></span>
<span className="text-xs font-mono text-muted-foreground">
<span className="flex min-w-0 flex-1 flex-col gap-1 @xl:contents">
<span className="flex min-w-0 items-baseline gap-2 @xl:contents">
<span className="min-w-0 flex-1 truncate text-sm leading-6" title={issue.title}>
{issue.title}
</span>
<span className="ml-auto shrink-0 truncate text-right font-mono text-(length:--text-micro) text-muted-foreground @xl:col-start-4 @xl:row-start-1 @xl:w-(--dashboard-list-id-width)">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
: null;
})()}
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>
<span className="text-xs text-muted-foreground shrink-0 sm:order-last">
</span>
<span className="flex min-h-6 min-w-0 items-center gap-2 @xl:contents">
<span className="flex min-w-0 flex-1 items-center @xl:col-start-3 @xl:row-start-1 @xl:self-center">
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <Identity name={name} size="sm" className="max-w-32" />
: null;
})()}
</span>
<span className="ml-auto w-(--dashboard-list-time-width) shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground">
{timeAgo(issue.updatedAt)}
</span>
</span>
-2
View File
@@ -53,8 +53,6 @@ export function DashboardLive() {
minRunCount={DASHBOARD_LIVE_RUN_LIMIT}
fetchLimit={DASHBOARD_LIVE_RUN_LIMIT}
cardLimit={DASHBOARD_LIVE_RUN_LIMIT}
gridClassName="gap-3 md:grid-cols-2 2xl:grid-cols-3"
cardClassName="h-(--sz-420px)"
emptyMessage="No active or recent agent runs."
queryScope="dashboard-live"
showMoreLink={false}
+29
View File
@@ -1,3 +1,4 @@
import { TaskChatProjectCreatedCard } from "@/components/task-chat/TaskChatProjectCreatedCard";
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKeySelect";
import { RepositoryEditor } from "@/components/RepositoryEditor";
@@ -132,6 +133,7 @@ import {
AvatarGroupCount,
} from "@/components/ui/avatar";
import { AgentCapsule, AGENT_GRADIENT_COUNT } from "@/components/AgentCapsule";
import { AgentRunCard } from "@/components/ActiveAgentsPanel";
import { StatusBadge, IssueStatusBadge } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
import { EnforcementBanner } from "@/components/EnforcementBanner";
@@ -466,6 +468,7 @@ function TaskExecutionControlsExample() {
onCancel={() => setDialogMode("cancel")} onRestore={() => setDialogMode("restore")} />
</div>
<p className="text-sm text-muted-foreground">{running ? "Running: type to switch Stop to Send." : "Paused: resume from the menu."}</p>
<TaskChatProjectCreatedCard item={{ id: "design-project", kind: "project_created", projectId: "example-project", name: "Onboarding improvements", description: "Help new teams reach their first useful result.", timestamp: "2026-09-11T00:00:00Z", repositories: [{ id: "1", name: "paperclipai/paperclip", url: "https://github.com/paperclipai/paperclip" }] }} />
{!running ? <TaskChatMarker item={{ id: "design-cancelled", kind: "marker", variant: "interrupted", tone: "neutral", label: "Run cancelled", detail: "The run was cancelled before returning an answer.", collapsible: true }} /> : null}
<TaskChatComposer pause={!running ? { scope: "subtree", onResume: () => setDialogMode("resume") } : null} onAdd={async () => {}} workMode="standard" stopScope="subtree" onStop={running ? async () => setRunning(false) : undefined} />
<TaskTreeControlDialog open={dialogMode !== null} onOpenChange={(open) => { if (!open) setDialogMode(null); }}
@@ -639,6 +642,10 @@ export function DesignGuide() {
{ id: "design-runner-read", kind: "tool", name: "read", target: "TaskChatRunnerTurn.tsx", status: "completed", detail: "Found the activity groups." },
{ id: "design-runner-check", kind: "tool", name: "exec_command", target: "pnpm check:token-gates", status: "in_progress" },
] }} />
<TaskChatRunnerActivityGroup item={{ id: "design-runner-completed", kind: "activity_phase", active: false, summary: "", items: [
{ id: "design-completed-read", kind: "tool", name: "read", target: "TaskChatRunnerTurn.tsx", status: "completed", detail: "Read the activity groups." },
{ id: "design-completed-check", kind: "tool", name: "exec_command", target: "pnpm check:token-gates", status: "failed", detail: "A token check needs another pass." },
] }} />
</Section>
<Section title="Typography">
@@ -1208,6 +1215,23 @@ export function DesignGuide() {
{/* CARDS */}
{/* ============================================================ */}
<Section title="Cards">
<SubSection title="Dashboard agent runs">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{["running", "queued", "succeeded", "failed", "timed_out", "cancelled", "interrupted"].map((status) => (
<AgentRunCard
key={status}
companyId="design-guide"
run={{
id: `design-guide-${status}`, agentId: "design-guide-agent", agentName: "CodexCoder",
status, adapterType: "codex_local", invocationSource: "on_demand", triggerDetail: "manual",
startedAt: null, finishedAt: null, createdAt: "2026-09-11T12:00:00Z", issueId: "design-guide-task",
}}
issue={{ identifier: "PAP-559", title: "Recreate this wireframe on pages Paperclip", status: status === "succeeded" ? "done" : "in_progress" }}
/>
))}
</div>
<p className="text-xs text-muted-foreground">The dashboard and Live runs page use the same compact cards. In-progress task icons animate across the app, including between runs, to represent task workflow status. Live indicators report active execution. Open a run to view its status and transcript.</p>
</SubSection>
<SubSection title="Standard Card">
<Card>
<CardHeader>
@@ -1641,6 +1665,11 @@ export function DesignGuide() {
{/* ============================================================ */}
<Section title="Navigation Patterns">
<SubSection title="Sidebar nav items">
<p className="text-sm text-muted-foreground">
Layout accepts sidebarSections to compose additional SidebarSection groups inside the shared sidebar.
Use SidebarNavItem for each row, with sibling action buttons for starring or menus.
Starred agent conversations precede recent conversations without a divider. Stars appear on hover or keyboard focus. Task breadcrumbs support leading identity and trailing actions beside the label, including single-item task headers; see the Agent chat Storybook.
</p>
<Card className="block w-60 p-3 space-y-0.5">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium bg-accent text-accent-foreground">
<LayoutDashboard className="h-4 w-4" />
@@ -79,6 +79,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableChatConnectors: false,
enablePipelines: false,
enableCases: false,
enableAgentChat: false,
enableConferenceRoomChat: false,
enableClassicTaskInterface: false,
enableIssuePlanDecompositions: false,
@@ -312,6 +312,17 @@ export function InstanceExperimentalSettings() {
ariaLabel="Toggle cases experimental setting"
/>
<ExperimentalToggleCard
title="Agent Chat"
description="Talk to each agent in one ongoing conversation. Clarify goals and create tasks for execution."
footnote="Turning this off preserves conversations and lets active runs finish, but prevents new messages."
checked={experimentalQuery.data?.enableAgentChat ?? false}
onCheckedChange={(checked) => toggleMutation.mutate({ enableAgentChat: checked })}
disabled={toggleMutation.isPending}
settingKey="enableAgentChat"
managed={managedKeys.enableAgentChat}
ariaLabel="Toggle agent chat experimental setting"
/>
<ExperimentalToggleCard
title="Chat connectors"
description="Connect agents to Slack, GitHub, Discord, Microsoft Teams, and Telegram conversations."
+87 -8
View File
@@ -30,6 +30,7 @@ import {
canBoardManageRuntime,
canBoardResolveRecoveryAction,
IssueDetail,
TaskDetailSurface,
readRecoveryReconcileWorkspaceId,
shouldScrollIssueDetailToTopOnNavigation,
} from "./IssueDetail";
@@ -52,6 +53,7 @@ const mockIssuesApi = vi.hoisted(() => ({
listFeedbackVotes: vi.fn(),
listInteractions: vi.fn(),
getQueuedComments: vi.fn(),
interruptQueuedComments: vi.fn(),
editQueuedComment: vi.fn(),
reorderQueuedComments: vi.fn(),
steerQueuedComment: vi.fn(),
@@ -569,6 +571,7 @@ vi.mock("../components/ApprovalCard", () => ({
}));
vi.mock("../components/Identity", () => ({
deriveInitials: (name: string) => name.slice(0, 2),
Identity: ({ name, shape }: { name: string; shape?: string }) => (
<span data-shape={shape ?? "circle"}>{name}</span>
),
@@ -1321,6 +1324,7 @@ describe("IssueDetail", () => {
entries: [],
}),
);
mockIssuesApi.interruptQueuedComments.mockReset().mockResolvedValue(createQueuedCommentQueue());
mockIssuesApi.editQueuedComment.mockResolvedValue(
createQueuedCommentQueue(),
);
@@ -1413,6 +1417,74 @@ describe("IssueDetail", () => {
vi.restoreAllMocks();
});
it("keeps an existing conversation on its agent-addressed route", async () => {
const agent = createAgent();
const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1", conversationState: "waiting", status: "in_review" });
mockIssuesApi.get.mockResolvedValue(canonical);
await act(async () => {
root.render(<QueryClientProvider client={queryClient}><TaskDetailSurface conversation={{ agent, issue: canonical, ensureIssue: async () => canonical }} /></QueryClientProvider>);
});
await flushReact();
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockIssuesApi.markRead).toHaveBeenCalledWith(canonical.id);
});
it.each(["message", "attachment"])("creates an unused conversation only for the first %s and updates its canonical cache", async (kind) => {
mockIssuesApi.markRead.mockClear();
const agent = createAgent();
const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1", conversationState: "waiting", status: "in_review" });
const ensureIssue = vi.fn().mockResolvedValue(canonical);
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
mockIssuesApi.addComment.mockResolvedValue(createIssueComment({ body: "Clarify this goal" }));
mockIssuesApi.uploadAttachment.mockResolvedValue(createAttachment({ id: "first-upload" }));
await act(async () => {
root.render(<QueryClientProvider client={queryClient}><TaskDetailSurface conversation={{ agent, issue: null, ensureIssue }} /></QueryClientProvider>);
});
await flushReact();
expect(ensureIssue).not.toHaveBeenCalled();
expect(mockIssuesApi.markRead).not.toHaveBeenCalled();
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
onAdd: (body: string) => Promise<void>;
onAttachImage: (file: File) => Promise<IssueAttachment>;
};
if (kind === "message") {
await act(async () => { await props.onAdd("Clarify this goal"); });
expect(mockIssuesApi.addComment).toHaveBeenCalledWith(canonical.id, "Clarify this goal", undefined, undefined, undefined, expect.any(String));
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.comments(canonical.id) });
} else {
const file = new File(["image"], "first.png", { type: "image/png" });
await act(async () => { await props.onAttachImage(file); });
expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledWith(canonical.companyId, canonical.id, file);
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.attachments(canonical.id) });
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.detail(canonical.id) });
}
expect(ensureIssue).toHaveBeenCalledTimes(1);
});
it("retries the chosen initial chat mode after creation succeeded but mode persistence failed", async () => {
mockIssuesApi.addComment.mockClear();
mockIssuesApi.update.mockClear();
const agent = createAgent();
const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1", conversationState: "waiting", status: "in_review", workMode: "standard" });
const ensureIssue = vi.fn().mockResolvedValue(canonical);
mockIssuesApi.update.mockRejectedValueOnce(new Error("Mode save failed")).mockResolvedValue({ ...canonical, workMode: "ask" });
mockIssuesApi.addComment.mockResolvedValue(createIssueComment({ body: "Research only" }));
const renderChat = async (issue: Issue | null) => {
await act(async () => root.render(<QueryClientProvider client={queryClient}><TaskDetailSurface conversation={{ agent, issue, ensureIssue }} /></QueryClientProvider>));
await flushReact();
return mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { onWorkModeChange: (mode: string) => Promise<void>; onAdd: (body: string) => Promise<void> };
};
let props = await renderChat(null);
await act(async () => props.onWorkModeChange("ask"));
props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0];
await act(async () => { await expect(props.onAdd("Research only")).rejects.toThrow("Mode save failed"); });
expect(mockIssuesApi.addComment).not.toHaveBeenCalled();
props = await renderChat(canonical);
await act(async () => props.onAdd("Research only"));
expect(mockIssuesApi.update).toHaveBeenNthCalledWith(2, canonical.id, { workMode: "ask" });
expect(mockIssuesApi.addComment).toHaveBeenCalledOnce();
});
it("opens artifact cards in the shared gallery at the selected image without duplicating attachments", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.listAttachments.mockResolvedValue([
@@ -1608,6 +1680,7 @@ describe("IssueDetail", () => {
undefined,
undefined,
[id],
expect.any(String),
);
expect(mockIssuesApi.update).not.toHaveBeenCalled();
}
@@ -3675,14 +3748,19 @@ describe("IssueDetail", () => {
body: "Queued run message",
});
mockIssuesApi.getQueuedComments.mockResolvedValue(createQueuedCommentQueue({
targetRunId: "run-queued", protocol: "legacy", steeringDisposition: "unsupported",
}));
await act(async () => {
await persistedProps.onInterruptQueued(
persistedComment!.queueTargetRunId!,
);
});
expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-queued");
mockHeartbeatsApi.cancel.mockClear();
expect(mockIssuesApi.interruptQueuedComments).toHaveBeenCalledWith("PAP-1", {
queueId: "wake-queue-1", revision: "queue-revision-1", targetRunId: "run-queued",
});
expect(mockHeartbeatsApi.cancel).not.toHaveBeenCalled();
});
it("projects a native follow-up into the steering well before the post resolves", async () => {
@@ -3877,15 +3955,16 @@ describe("IssueDetail", () => {
queueTargetRunId: "run-original",
});
mockIssuesApi.getQueuedComments.mockResolvedValue(createQueuedCommentQueue({
targetRunId: "run-replacement", protocol: "legacy", steeringDisposition: "unsupported",
}));
await act(async () => {
await replacementProps.onInterruptQueued(
await expect(replacementProps.onInterruptQueued(
optimisticComment!.queueTargetRunId!,
);
)).rejects.toThrow("The queued messages changed");
});
expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-original");
expect(mockHeartbeatsApi.cancel).not.toHaveBeenCalledWith(
"run-replacement",
);
expect(mockIssuesApi.interruptQueuedComments).not.toHaveBeenCalled();
expect(mockHeartbeatsApi.cancel).not.toHaveBeenCalled();
await act(async () => {
postedComment.resolve(
+123 -178
View File
@@ -1,3 +1,9 @@
import { clearLegacyChatMessageRequests } from "@/lib/chat-message-request";
import { agentChatDraft } from "@/lib/agent-chat-draft";
import { Settings as ChatSettings } from "lucide-react";
import { agentDetailHref } from "./agent-detail-navigation";
import { deriveInitials } from "@/components/Identity";
import { ExecutionBlockerNotice } from "../components/ExecutionBlockerNotice";
import type { TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover";
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
import { EmailThreadProvider } from "../components/EmailMessageCard";
@@ -932,14 +938,14 @@ function IssueChatSkeleton() {
);
}
function useTaskDetailInterfaceMode() {
function useTaskDetailInterfaceMode(conversationMode = false) {
const {
enabled: classicTaskInterfacePreferenceEnabled,
loaded: classicTaskInterfaceLoaded,
} = useClassicTaskInterfaceEnabled();
const { enabled: streamlinedUiEnabled, loaded: streamlinedUiLoaded } =
useStreamlinedUiEnabled();
const classicTaskInterfaceEnabled = classicTaskInterfacePreferenceEnabled;
const classicTaskInterfaceEnabled = classicTaskInterfacePreferenceEnabled && !conversationMode;
const taskChatShellEnabled = !classicTaskInterfaceEnabled;
return {
@@ -1253,6 +1259,7 @@ type IssueDetailChatTabProps = {
currentAssigneeValue: string;
suggestedAssigneeValue: string;
mentions: MentionOption[];
conversationMode?: boolean;
composerPause?: TaskComposerPause | null;
composerDisabledReason: string | null;
composerHint: string | null;
@@ -1267,6 +1274,7 @@ type IssueDetailChatTabProps = {
reopen?: boolean,
reassignment?: CommentReassignment,
attachmentIds?: string[],
clientRequestId?: string,
) => Promise<void>;
onReviewConversation: () => Promise<void>;
onImageUpload: (file: File) => Promise<string>;
@@ -1375,6 +1383,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
currentAssigneeValue,
suggestedAssigneeValue,
mentions,
conversationMode,
composerPause,
composerDisabledReason,
composerHint,
@@ -1413,7 +1422,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
// Preserve master's Classic Task Interface seam: Streamlined UI changes the
// TaskChatThread presentation but never swaps it for IssueChatThread.
const { classicTaskInterfaceEnabled, streamlinedTaskDetailEnabled } =
useTaskDetailInterfaceMode();
useTaskDetailInterfaceMode(!!conversationMode);
const ThreadComponent = classicTaskInterfaceEnabled
? IssueChatThread
: TaskChatThread;
@@ -1429,6 +1438,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
} = useQuery({
queryKey: queryKeys.issues.activity(issueId),
queryFn: () => activityApi.forIssue(issueId),
enabled: !!issueId,
placeholderData: keepPreviousDataForSameQueryTail<ActivityEvent[]>(issueId),
});
const {
@@ -1439,6 +1449,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
} = useQuery({
queryKey: queryKeys.issues.liveRuns(issueId),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
enabled: !!issueId,
refetchInterval: 1000,
placeholderData:
keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
@@ -1485,7 +1496,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
const queuedCommentQueueEnabled =
!classicTaskInterfaceEnabled &&
runtimeSelectionKnown &&
Boolean(liveRuntimeRun || assigneeUsesPaperclipRunner);
Boolean(liveRuntimeRun || issueAssigneeAgentId);
const { data: authoritativeQueuedCommentQueue } = useQuery({
queryKey: queryKeys.issues.queuedComments(issueId),
queryFn: async () =>
@@ -1494,7 +1505,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
issueId,
),
enabled: queuedCommentQueueEnabled,
refetchInterval: queuedCommentQueueEnabled ? 1000 : false,
refetchInterval: (query) => queuedCommentQueueEnabled &&
(liveRuntimeRun || query.state.data?.entries.length) ? 1000 : false,
});
const [consumedQueuedCommentIds, setConsumedQueuedCommentIds] = useState<
ReadonlySet<string>
@@ -1524,6 +1536,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
} = useQuery({
queryKey: queryKeys.issues.runs(issueId),
queryFn: () => activityApi.runsForIssue(issueId),
enabled: !!issueId,
refetchInterval:
hasLiveRuns || issueStatus === "in_progress" ? 1000 : false,
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
@@ -2294,13 +2307,14 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
>
<EmailThreadProvider companyId={companyId} issueId={issueId}>
<ThreadComponent
key={issueId}
initialHistoryPending={
key={conversationMode ? draftKey : issueId}
{...(!classicTaskInterfaceEnabled ? { creationActivity: resolvedActivity } : {})}
initialHistoryPending={!!issueId && (
initialHistoryPending ||
commentsInitialLoading ||
activityPending ||
linkedRunsPending ||
!runtimeSelectionKnown
!runtimeSelectionKnown)
}
initialHistoryError={
initialHistoryError ||
@@ -2382,7 +2396,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
userLabelMap={userLabelMap}
userProfileMap={userProfileMap}
draftKey={draftKey}
enableReassign
conversationMode={conversationMode}
enableReassign={!conversationMode}
reassignOptions={reassignOptions}
currentAssigneeValue={currentAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
@@ -2822,11 +2837,17 @@ function IssueDetailActivityTab({
);
}
export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasksTab"] }) {
const { issueId, companyPrefix } = useParams<{
issueId: string;
companyPrefix: string;
}>();
export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasksTab"] }) { return <TaskDetailSurface tasksTab={tasksTab} />; }
/** One controller and surface for both task URLs and agent conversations. */
export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskSidePanelProps["tasksTab"]; conversation?: {
agent: Agent; issue: Issue | null; ensureIssue: () => Promise<Issue>;
} }) {
const { issueId: routeIssueId, companyPrefix } = useParams<{ issueId: string; companyPrefix: string }>();
const issueId = conversation ? conversation.issue?.id : routeIssueId;
const [draftWorkMode, setDraftWorkMode] = useState<IssueWorkMode>("standard");
const draftIssue = useMemo(() => conversation ? agentChatDraft(conversation.agent, draftWorkMode) : undefined, [conversation?.agent, draftWorkMode]);
const pendingDraftWorkMode = useRef<IssueWorkMode | null>(null);
const { companies, selectedCompanyId } = useCompany();
// Classic Task Interface remains the sole task-chat-vs-pre-chat switch from
// master. Streamlined UI only layers the new task-detail presentation onto
@@ -2837,7 +2858,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
streamlinedTaskDetailEnabled,
streamlinedUiEnabled,
loaded: taskInterfaceSettingsLoaded,
} = useTaskDetailInterfaceMode();
} = useTaskDetailInterfaceMode(!!conversation);
// Chat-style: the page wrapper spans the full center pane so the thread's
// scroll viewport (and its scrollbar) reaches the properties-pane border;
// every non-thread section re-centers itself at the 60rem shell cap instead.
@@ -2939,7 +2960,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
);
const {
data: issue,
data: queriedIssue,
isLoading,
isPlaceholderData,
error,
@@ -2954,6 +2975,17 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
}),
enabled: !!issueId,
});
const issue = queriedIssue ?? conversation?.issue ?? draftIssue;
const resolveWritableIssueId = async () => {
if (!conversation) return issueId!;
const resolved = await conversation.ensureIssue();
const requestedMode = pendingDraftWorkMode.current;
if (requestedMode !== null && requestedMode !== resolved.workMode) {
await issuesApi.update(resolved.id, { workMode: requestedMode });
}
pendingDraftWorkMode.current = null;
return resolved.id;
};
// A cached header seed can paint during navigation, but must not redirect
// or upload against the previous task while the requested task is loading.
const loadedIssue =
@@ -2968,14 +3000,14 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
const loadedIssueCompany = loadedIssue
? companies.find((company) => company.id === loadedIssue.companyId)
: undefined;
const taskRouteReady = Boolean(
const taskRouteReady = Boolean(conversation || (
loadedIssue &&
issueId === (loadedIssue.identifier ?? loadedIssue.id) &&
(!loadedIssueCompany || companyPrefix === loadedIssueCompany.issuePrefix) &&
!hasLegacyIssueDetailQuery(location.search),
);
!hasLegacyIssueDetailQuery(location.search)
));
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
const externalObjectsState = useIssueExternalObjects(issue?.id ?? null);
const externalObjectsState = useIssueExternalObjects(conversation && !conversation.issue ? null : issue?.id ?? null);
// A closed isolated workspace no longer blocks the composer. The server reopens
// the workspace when the next comment or resume arrives, so the composer stays
// enabled and a hint tells the user what happens.
@@ -3194,7 +3226,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
descendantOf: issue!.id,
includeBlockedBy: true,
}),
enabled: !!resolvedCompanyId && !!issue?.id,
enabled: !!resolvedCompanyId && !!issue?.id && !issue.id.startsWith("chat:"),
placeholderData: keepPreviousDataForSameQueryTail<Issue[]>(
issue?.id ?? "pending",
),
@@ -3644,7 +3676,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
breadcrumbStatus ? (
<StatusIcon
status={breadcrumbStatus}
size="lg"
className="size-3"
blockerAttention={breadcrumbBlockerAttention}
/>
) : undefined,
@@ -4363,18 +4395,12 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
});
const addComment = useMutation({
mutationFn: ({
body,
reopen,
interrupt,
attachmentIds,
}: {
body: string;
reopen?: boolean;
interrupt?: boolean;
attachmentIds?: string[];
}) =>
issuesApi.addComment(issueId!, body, reopen, interrupt, attachmentIds),
mutationFn: async ({ body, reopen, interrupt, attachmentIds, clientRequestId }: {
body: string; reopen?: boolean; interrupt?: boolean; attachmentIds?: string[]; clientRequestId?: string;
}) => {
if (issue?.conversationAgentId) clearLegacyChatMessageRequests(`${issue.companyId}:${currentUserId}:${issue.conversationAgentId}`);
return issuesApi.addComment(await resolveWritableIssueId(), body, reopen, interrupt, attachmentIds, clientRequestId ?? crypto.randomUUID());
},
onMutate: async ({ body, reopen, interrupt }) => {
// Start cache cancellation immediately but do not put it in front of the
// optimistic echo. The new-runner startup placeholder must paint in the
@@ -4439,7 +4465,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
),
);
try {
await issuesApi.cancelComment(issueId!, comment.id);
await issuesApi.cancelComment(comment.issueId, comment.id);
invalidateIssueDetail();
invalidateIssueThreadLazily();
invalidateIssueCollections();
@@ -4462,14 +4488,14 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
return next;
});
void queryClient.invalidateQueries({
queryKey: queryKeys.issues.queuedComments(issueId!),
queryKey: queryKeys.issues.queuedComments(issueId ?? comment.issueId),
});
}
if (context?.optimisticCommentId) {
commentRenderKeys.current.set(comment.id, context.optimisticCommentId);
}
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(issueId!),
queryKeys.issues.comments(issueId ?? comment.issueId),
(current) =>
current
? {
@@ -4519,7 +4545,8 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
tone: "error",
});
},
onSettled: (_result, _error, variables) => {
onSettled: (result, _error, variables) => {
if (result && !issueId) void queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(result.issueId) });
if (_error) void queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state"] });
invalidateIssueThreadLazily();
// Binding happens when the comment saves, after the upload's earlier
@@ -4925,93 +4952,14 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
});
const interruptQueuedComment = useMutation({
mutationFn: (runId: string) => heartbeatsApi.cancel(runId),
onMutate: async (runId) => {
await Promise.all(
issueCacheRefs.flatMap((ref) => [
queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(ref) }),
queryClient.cancelQueries({
queryKey: queryKeys.issues.liveRuns(ref),
}),
queryClient.cancelQueries({
queryKey: queryKeys.issues.activeRun(ref),
}),
queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(ref) }),
]),
);
const previousRunState = issueCacheRefs.map((ref) => ({
ref,
runs: queryClient.getQueryData<RunForIssue[]>(
queryKeys.issues.runs(ref),
),
liveRuns: queryClient.getQueryData<LiveRunForIssue[]>(
queryKeys.issues.liveRuns(ref),
),
activeRun: queryClient.getQueryData<ActiveRunForIssue | null>(
queryKeys.issues.activeRun(ref),
),
issue: queryClient.getQueryData<Issue>(queryKeys.issues.detail(ref)),
}));
const previousLocalQueuedCommentRunIds = locallyQueuedCommentRunIds;
const cachedActiveRun =
previousRunState.find((state) => state.activeRun?.id === runId)
?.activeRun ??
previousRunState.find((state) => state.activeRun)?.activeRun ??
null;
const liveRunList = dedupeLiveRunsById(
previousRunState.flatMap((state) => state.liveRuns ?? []),
);
const interruptibleIssueRun = resolveInterruptibleIssueRun(
cachedActiveRun,
liveRunList,
);
const targetRun =
cachedActiveRun?.id === runId
? cachedActiveRun
: (liveRunList?.find((run) => run.id === runId) ??
interruptibleIssueRun ??
null);
if (targetRun) {
const interruptedAt = new Date().toISOString();
for (const ref of issueCacheRefs) {
queryClient.setQueryData<RunForIssue[] | undefined>(
queryKeys.issues.runs(ref),
(current) =>
upsertInterruptedRun(current, targetRun, interruptedAt),
);
}
mutationFn: async (runId: string) => {
const queue = await issuesApi.getQueuedComments(issueId!);
if (!queue.queueId || queue.targetRunId !== runId) {
throw new Error("The queued messages changed. Refresh and try again.");
}
for (const ref of issueCacheRefs) {
queryClient.setQueryData(
queryKeys.issues.liveRuns(ref),
(current: LiveRunForIssue[] | undefined) =>
removeLiveRunById(current, runId),
);
queryClient.setQueryData(
queryKeys.issues.activeRun(ref),
(current: ActiveRunForIssue | null | undefined) =>
current?.id === runId ? null : current,
);
queryClient.setQueryData(
queryKeys.issues.detail(ref),
(current: Issue | undefined) =>
clearIssueExecutionRun(current, runId),
);
}
setLocallyQueuedCommentRunIds((current) => {
const next = new Map(
[...current].filter(([, targetRunId]) => targetRunId !== runId),
);
return next.size === current.size ? current : next;
return issuesApi.interruptQueuedComments(issueId!, {
queueId: queue.queueId, revision: queue.revision, targetRunId: runId,
});
return {
previousRunState,
previousLocalQueuedCommentRunIds,
};
},
onSuccess: () => {
invalidateIssueDetail();
@@ -5022,25 +4970,9 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
tone: "success",
});
},
onError: (err, _runId, context) => {
for (const state of context?.previousRunState ?? []) {
queryClient.setQueryData(queryKeys.issues.runs(state.ref), state.runs);
queryClient.setQueryData(
queryKeys.issues.liveRuns(state.ref),
state.liveRuns,
);
queryClient.setQueryData(
queryKeys.issues.activeRun(state.ref),
state.activeRun,
);
queryClient.setQueryData(
queryKeys.issues.detail(state.ref),
state.issue,
);
}
if (context?.previousLocalQueuedCommentRunIds) {
setLocallyQueuedCommentRunIds(context.previousLocalQueuedCommentRunIds);
}
onError: (err) => {
invalidateIssueDetail();
invalidateIssueRunState();
pushToast({
title: "Interrupt failed",
body:
@@ -5213,6 +5145,9 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
const uploadAttachment = useMutation({
mutationFn: async (file: File) => {
if (conversation) {
return issuesApi.uploadAttachment(conversation.agent.companyId, await resolveWritableIssueId(), file);
}
if (!loadedIssue)
throw new Error("Task details are still loading. Please try again.");
return issuesApi.uploadAttachment(
@@ -5221,12 +5156,13 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
file,
);
},
onSuccess: () => {
onSuccess: (result) => {
setAttachmentError(null);
queryClient.invalidateQueries({
queryKey: queryKeys.issues.attachments(issueId!),
queryKey: queryKeys.issues.attachments(issueId ?? result.issueId),
});
invalidateIssueDetail();
if (!issueId) void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(result.issueId) });
},
onError: (err) => {
setAttachmentError(err instanceof Error ? err.message : "Upload failed");
@@ -5242,18 +5178,19 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
const body = await file.text();
const inferredTitle = titleizeFilename(baseName);
const nextTitle = existing?.title ?? inferredTitle ?? null;
return issuesApi.upsertDocument(issueId!, key, {
return issuesApi.upsertDocument(await resolveWritableIssueId(), key, {
title: key === "plan" ? null : nextTitle,
format: "markdown",
body,
baseRevisionId: existing?.latestRevisionId ?? null,
});
},
onSuccess: () => {
onSuccess: (result) => {
setAttachmentError(null);
invalidateIssueDetail();
if (!issueId) void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(result.issueId) });
queryClient.invalidateQueries({
queryKey: queryKeys.issues.documents(issueId!),
queryKey: queryKeys.issues.documents(issueId ?? result.issueId),
});
},
onError: (err) => {
@@ -5348,7 +5285,18 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
},
});
const conversationAgent = conversation?.agent ?? agents?.find(agent => agent.id === issue?.conversationAgentId);
useEffect(() => {
if (conversationAgent) {
setBreadcrumbs([{
label: conversationAgent.name,
leading: <Avatar className="size-6 shrink-0"><AvatarFallback>{deriveInitials(conversationAgent.name)}</AvatarFallback></Avatar>,
leadingKey: `agent:${conversationAgent.id}`,
trailing: <Button variant="ghost" size="icon-xs" asChild aria-label={`Configure ${conversationAgent.name}`}><Link to={agentDetailHref(conversationAgent.id, "runtime")}><ChatSettings /></Link></Button>,
trailingKey: `configure:${conversationAgent.id}`,
}]);
return;
}
setBreadcrumbs([
sourceBreadcrumb,
{
@@ -5361,6 +5309,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
},
]);
}, [
conversationAgent,
breadcrumbTitle,
breadcrumbIdentifier,
hasLiveRuns,
@@ -5449,7 +5398,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
// Resolve external UUID links and wrong-prefix task links from the loaded
// task's company, not the organization that happened to be selected first.
useEffect(() => {
if (!loadedIssue) return;
if (conversation || !loadedIssue) return;
const nextState = resolvedIssueDetailState ?? location.state;
const taskCompany = loadedIssueCompany;
const canonicalRef = loadedIssue.identifier ?? loadedIssue.id;
@@ -5478,6 +5427,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
);
}
}, [
conversation,
loadedIssue,
loadedIssueCompany,
companyPrefix,
@@ -5490,7 +5440,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
]);
useEffect(() => {
if (!issue?.id) return;
if (!issueId || !issue?.id) return;
if (lastMarkedReadIssueIdRef.current === issue.id) return;
lastMarkedReadIssueIdRef.current = issue.id;
markIssueRead.mutate(issue.id);
@@ -5586,7 +5536,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
);
useLayoutEffect(() => {
if (!panelIssue || suppressPanelUntilPlan) {
if (!panelIssue || suppressPanelUntilPlan || (conversation && !conversation.issue)) {
closePanel();
return;
}
@@ -6176,6 +6126,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
reopen?: boolean,
reassignment?: CommentReassignment,
attachmentIds?: string[],
clientRequestId?: string,
) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({
@@ -6186,7 +6137,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
});
return;
}
await addComment.mutateAsync({ body, reopen, attachmentIds });
await addComment.mutateAsync({ body, reopen, attachmentIds, clientRequestId });
},
[addComment, addCommentAndReassign],
);
@@ -6837,7 +6788,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
/>
);
const issueHeaderBlock = (
const issueHeaderBlock = issue.conversationAgentId ? null : (
<div
data-testid="issue-detail-header"
className={cn(
@@ -6846,8 +6797,8 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
)}
>
{streamlinedTaskDetailEnabled ? (
<div className="flex min-w-0 items-center gap-2 pr-8">
{issueStatusControl}
<div className="flex min-w-0 items-start gap-2 md:items-center md:pr-8">
<div className="hidden md:block">{issueStatusControl}</div>
<div
data-slot="task-detail-title"
className="flex min-w-0 flex-1 items-baseline gap-2"
@@ -6860,7 +6811,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
/>
<span
data-slot="task-title-identifier"
className="shrink-0 font-mono text-sm text-muted-foreground"
className="hidden shrink-0 font-mono text-sm text-muted-foreground md:inline"
>
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
@@ -6871,9 +6822,17 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
<div
className={cn(
"flex min-w-0 flex-wrap items-center gap-2",
streamlinedTaskDetailEnabled && "gap-x-6 gap-y-2 pl-7",
streamlinedTaskDetailEnabled && "gap-x-3 gap-y-2 md:gap-x-6 md:pl-7",
)}
>
{streamlinedTaskDetailEnabled ? (
<div className="md:hidden">{issueStatusControl}</div>
) : null}
{streamlinedTaskDetailEnabled ? (
<span className="shrink-0 font-mono text-sm text-muted-foreground md:hidden">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
) : null}
{!streamlinedTaskDetailEnabled ? issueStatusControl : null}
{/* PAP-411: priority UI hidden behind SHOW_TASK_PRIORITY_UI. */}
{SHOW_TASK_PRIORITY_UI && (
@@ -7330,7 +7289,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
) : undefined;
return (
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
<FileViewerProvider issueId={conversation && !conversation.issue ? "" : issue.id} enabled={fileViewerEnabled}>
<IssueGalleryContext.Provider value={openIssueGallery}>
<div
data-task-chat-shell={taskChatShellEnabled ? "" : undefined}
@@ -7662,23 +7621,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
}
>
{issue.executionBlocker && (
<div
role="status"
className="px-(--sz-execution-blocker-inline) py-(--sz-execution-blocker-block) text-sm text-muted-foreground"
>
<span>
Work cannot start. {issue.executionBlocker.nextAction}
</span>{" "}
{issue.executionBlocker.runId &&
issue.executionBlocker.agentId && (
<Link
className="underline"
to={`/agents/${issue.executionBlocker.agentId}/runs/${issue.executionBlocker.runId}`}
>
View stopped run
</Link>
)}
</div>
<ExecutionBlockerNotice companyId={issue.companyId} issueId={issue.id} blocker={issue.executionBlocker} onRetried={invalidateIssueDetail} />
)}
{resolvedDetailTab === "chat" ? (
<IssueDetailChatTab
@@ -7687,7 +7630,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
// Suppress the seeded-description bubble for the onboarding first
// task: its description is agent instructions, not something the
// user typed. The user lands on a seeded agent greeting instead.
taskChatShellEnabled &&
taskChatShellEnabled && !issue.conversationAgentId &&
issue.originKind !== ONBOARDING_FIRST_TASK_ORIGIN_KIND
? {
description: issue.description ?? "",
@@ -7717,7 +7660,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
}
: undefined
}
issueId={issue.id}
issueId={conversation && !conversation.issue ? "" : issue.id}
companyId={issue.companyId}
projectId={issue.projectId ?? null}
issueStatus={issue.status}
@@ -7809,11 +7752,12 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
currentUserId={currentUserId}
userLabelMap={userLabelMap}
userProfileMap={userProfileMap}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
draftKey={conversationAgent ? `paperclip:agent-chat-draft:${issue.companyId}:${currentUserId}:${conversationAgent.id}` : `paperclip:issue-comment-draft:${issue.id}`}
reassignOptions={commentReassignOptions}
currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions}
conversationMode={!!issue.conversationAgentId}
composerPause={activePauseHold ? {
scope: activePauseHold.isRoot && childIssues.length === 0 ? "leaf" : "subtree",
pending: executeTreeControl.isPending && executeTreeControl.variables?.mode === "resume",
@@ -7825,7 +7769,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
} : undefined,
resumeHref: !activePauseHold.isRoot ? createIssueDetailPath(activePauseHoldRoot?.identifier ?? activePauseHold.rootIssueId) : undefined,
} : null}
composerDisabledReason={treeControlStatePending ? "Checking task status…" : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : null}
composerDisabledReason={issue.conversationAgentId && !instanceExperimentalSettings?.enableAgentChat ? "Agent Chat is disabled in Experimental settings." : issueId && treeControlStatePending ? "Checking task status…" : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : null}
composerHint={composerHint}
queuedCommentReason={queuedCommentReason}
onVote={handleCommentVote}
@@ -7870,6 +7814,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
const currentMode: IssueWorkMode =
issue.workMode ?? "standard";
if (currentMode === nextMode) return;
if (conversation && (!conversation.issue || pendingDraftWorkMode.current !== null)) { pendingDraftWorkMode.current = nextMode; setDraftWorkMode(nextMode); return; }
return updateIssue
.mutateAsync({ workMode: nextMode })
.then(() => undefined);
+69 -1
View File
@@ -3,7 +3,7 @@
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CONNECTABLE_APP_DEFINITIONS, getAppStoreDefinition } from "@paperclipai/shared";
import { CONNECTABLE_APP_DEFINITIONS, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, getAppStoreDefinition } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/api/client";
import { queryKeys } from "@/lib/queryKeys";
@@ -1187,6 +1187,74 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
}));
});
it.each([...new Set(Object.values(GOOGLE_WORKSPACE_CONNECTOR_PROFILES).map((profile) => profile.appSlug))]
.flatMap((slug) => [false, true].map((enrollmentReturn) => ({ slug, enrollmentReturn }))))(
"preserves personal Workspace access for $slug when changing method (enrollment return: $enrollmentReturn)",
async ({ slug, enrollmentReturn }) => {
const definition = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === slug)!;
const readMethod = definition.methods.find((method) => method.key === "paperclip-read")!;
mockSearch.value = enrollmentReturn
? `source=${slug}&stage=setup&cloud_connector=enrolled`
: `source=${slug}`;
if (enrollmentReturn) {
window.sessionStorage.setItem(`paperclip.connector-enrollment-access:${slug}`, JSON.stringify({
companyId: "company-1", grantKind: "user", installChoice: "all", agentIds: [],
}));
}
listGalleryMock.mockResolvedValue({ apps: [{
...definition, ownershipAvailability: { ...definition.ownershipAvailability, platform_shared: true },
}] });
await render();
if (!enrollmentReturn) {
await act(async () => {
radioContaining("Just me")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await passAccessStep();
}
if (definition.methods.some((method) => method.capabilityProfile?.key !== "read")) {
const readChoice = radioContaining(readMethod.capabilityProfile!.label);
expect(readChoice).not.toBeNull();
await act(async () => {
readChoice!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
await flushReact();
// Change auth methods too, including apps with only one capability.
expect(container.textContent).not.toContain("How do you want to connect?");
expect(container.textContent).not.toContain("Connect with Paperclip");
expect(container.textContent).not.toContain("Your OAuth app");
expect(buttonByText("Continue to sign in")?.disabled).toBe(false);
const customerAuth = buttonByText("Use your own Google OAuth app");
expect(customerAuth).toBeDefined();
expect(customerAuth?.getAttribute("aria-expanded")).toBe("false");
await act(async () => {
customerAuth!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Your OAuth app");
expect(container.textContent).toContain("Client ID");
expect(buttonByText("Continue to sign in")?.disabled).toBe(true);
const managedAuth = buttonByText("Use Paperclip instead");
expect(managedAuth).toBeDefined();
expect(managedAuth?.getAttribute("aria-expanded")).toBe("true");
const fieldsRegion = document.getElementById(managedAuth!.getAttribute("aria-controls")!);
expect(fieldsRegion?.getAttribute("role")).toBe("region");
expect(fieldsRegion?.textContent).toContain("Client ID");
await act(async () => {
managedAuth!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).not.toContain("Your OAuth app");
await act(async () => {
buttonByText("Continue to sign in")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
galleryKey: slug, connectionMethodKey: "paperclip-read", grantKind: "user",
}));
});
it("never renders self-host enrollment when the connector identity is already active", async () => {
mockSearch.value = "source=gmail&stage=setup";
listGalleryMock.mockResolvedValueOnce({