mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
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:
commit
bb80a847c4
385 files changed
+114493
-3058
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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)}`,
|
||||
{},
|
||||
),
|
||||
};
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -602,6 +602,7 @@ interface IssueChatThreadProps {
|
||||
reopen?: boolean,
|
||||
reassignment?: CommentReassignment,
|
||||
attachmentIds?: string[],
|
||||
clientRequestId?: string,
|
||||
) => Promise<void>;
|
||||
onReviewConversation?: () => Promise<void>;
|
||||
onCancelRun?: () => Promise<void>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 : (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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!),
|
||||
});
|
||||
}
|
||||
@@ -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("[]");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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% {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"teams-catalog",
|
||||
"org",
|
||||
"agents",
|
||||
"chats",
|
||||
"apps",
|
||||
"projects",
|
||||
"workspaces",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -242,6 +242,7 @@ function makeInstanceSettings({
|
||||
enableEnvironments,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableStreamlinedLeftNavigation: false,
|
||||
enableAgentChat: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableExperimentalFileViewer: false,
|
||||
|
||||
@@ -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
@@ -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">·</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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user