Merge origin/master into fix/exact-detail-blocker-attention

This commit is contained in:
CTO 2026-09-11 19:06:23 -05:00
commit 77d1c16bb4
35 changed files with 48038 additions and 200 deletions

View File

@ -386,3 +386,14 @@ pnpm secrets:migrate-inline-env --apply
```
Hosted AWS provider notes live in [SECRETS-AWS-PROVIDER.md](./SECRETS-AWS-PROVIDER.md).
## Legacy controller ownership
Legacy run claims atomically record `controller_boot_id`, a database-clock
`controller_lease_expires_at`, and `execution_stage` before workspace provisioning.
The lease renews independently of output. A different container must not infer
controller death from its own process map or numeric PIDs. Expiration grants
cleanup authority; it does not prove that remote inference has stopped. Recovery
revokes the previous boot identity with a conditional update. Its own claim also
expires so another sweep can finish cleanup after a restart. Historical rows keep
null ownership fields and follow the previous recovery path.

View File

@ -1261,6 +1261,11 @@ Scheduler must skip invocation when:
- an existing run is active
- hard budget limit has been hit
Legacy execution records a renewable controller lease when claiming a queued run,
before provisioning. A live lease protects the run during overlapping service
deployments. An expired controller loses dispatch authority; a recovery worker
must establish that the previous execution stopped before starting a successor.
## 11.7 Durable agent session goals
Runner Protocol v2 negotiates a required `sessionGoals` capability and typed

View File

@ -0,0 +1,3 @@
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "controller_boot_id" uuid;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "controller_lease_expires_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "execution_stage" text;

File diff suppressed because it is too large Load Diff

View File

@ -1898,6 +1898,13 @@
"when": 1789137216452,
"tag": "0272_light_kate_bishop",
"breakpoints": true
},
{
"idx": 273,
"version": "7",
"when": 1789164595203,
"tag": "0273_aromatic_moondragon",
"breakpoints": true
}
]
}

View File

@ -67,6 +67,10 @@ export const heartbeatRuns = pgTable(
stderrExcerpt: text("stderr_excerpt"),
errorCode: text("error_code"),
externalRunId: text("external_run_id"),
// Legacy controller lease. A PID alone is not an identity across containers.
controllerBootId: uuid("controller_boot_id"),
controllerLeaseExpiresAt: timestamp("controller_lease_expires_at", { withTimezone: true }),
executionStage: text("execution_stage"),
processPid: integer("process_pid"),
processGroupId: integer("process_group_id"),
processStartedAt: timestamp("process_started_at", { withTimezone: true }),

View File

@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
@ -311,8 +311,14 @@ describeEmbeddedPostgres("heartbeat task-drain admission release", () => {
expect(status.activeRuns).toBe(0);
expect(status.quiescent).toBe(true);
// The run's row is still "running", so the orphan reaper finds it,
// finalizes it, and releases the issue lock on its own cycle.
// Missing local tracking cannot override the durable controller lease.
// Once that unrenewed lease expires, the reaper finalizes the orphan and
// releases the issue lock on its own cycle.
const beforeExpiry = await heartbeat.reapOrphanedRuns();
expect(beforeExpiry.runIds).not.toContain(runId);
await db.update(heartbeatRuns).set({
controllerLeaseExpiresAt: sql`clock_timestamp() - interval '1 second'`,
}).where(eq(heartbeatRuns.id, runId));
const reapResult = await heartbeat.reapOrphanedRuns();
expect(reapResult.runIds).toContain(runId);

View File

@ -1,4 +1,5 @@
import { PROCESS_IDENTITY_RECORDED, recordNativeLocalProcessStop } from "./native-local-process-stop.js";
import { legacyControllerBootId, legacyControllerClaim, hasLiveLegacyController, revokeExpiredLegacyController, watchLegacyControllerLease } from "./legacy-controller-lease.js";
import { completeTerminatedRemoteNativeSessionCleanup } from "../vendor/paperclip-runner/index.js";
import { remoteExecutionHasStopped, remoteTerminationReceipt, stoppedRemoteCleanupScopes } from "./remote-execution-termination.js";
import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "./connector-runtime.js";
@ -14560,6 +14561,10 @@ export function heartbeatService(
const restartSuspendedRunIds: string[] = [];
for (const { run, agent } of activeRuns) {
// Shutdown owns only this boot's legacy executions. Expired foreign
// owners belong to the reaper, not another container's drain.
if (run.runtimeMode === "legacy" && run.controllerBootId &&
run.controllerBootId !== legacyControllerBootId) continue;
if (isNativeRunnerOwnershipHeld(run)) continue;
if (
run.runtimeMode === "native" &&
@ -16921,6 +16926,7 @@ export function heartbeatService(
.set({
status: "running",
runnerProfileJson: sql`(case when jsonb_typeof(${heartbeatRuns.runnerProfileJson}) = 'object' then ${heartbeatRuns.runnerProfileJson} else '{}'::jsonb end) || ${JSON.stringify({ adapterDispatch: { adapterType: agent.adapterType } })}::jsonb`,
...legacyControllerClaim(run.runtimeMode),
responsibleUserId,
startedAt: lockedRun.startedAt ?? claimedAt,
updatedAt: claimedAt,
@ -17018,6 +17024,7 @@ export function heartbeatService(
.set({
status: "running",
runnerProfileJson: sql`(case when jsonb_typeof(${heartbeatRuns.runnerProfileJson}) = 'object' then ${heartbeatRuns.runnerProfileJson} else '{}'::jsonb end) || ${JSON.stringify({ adapterDispatch: { adapterType: agent.adapterType } })}::jsonb`,
...legacyControllerClaim(run.runtimeMode),
responsibleUserId,
startedAt: lockedRun.startedAt ?? claimedAt,
contextSnapshot: withQueuedCommentIdsInRunContext(
@ -17085,6 +17092,7 @@ export function heartbeatService(
.set({
status: "running",
runnerProfileJson: sql`(case when jsonb_typeof(${heartbeatRuns.runnerProfileJson}) = 'object' then ${heartbeatRuns.runnerProfileJson} else '{}'::jsonb end) || ${JSON.stringify({ adapterDispatch: { adapterType: agent.adapterType } })}::jsonb`,
...legacyControllerClaim(run.runtimeMode),
responsibleUserId,
startedAt: run.startedAt ?? claimedAt,
updatedAt: claimedAt,
@ -18371,6 +18379,7 @@ export function heartbeatService(
}
if (resumedRunIds.has(run.id)) continue;
if (locallyTracked) continue;
if (await hasLiveLegacyController(db, run)) continue;
// Apply staleness threshold to avoid false positives
if (staleThresholdMs > 0) {
@ -18442,6 +18451,7 @@ export function heartbeatService(
((tracksLegacyLocalChild &&
(!!run.processPid || !!run.processGroupId)) ||
monitorDispatchLostWithoutFutureWake);
if (!(await revokeExpiredLegacyController(db, run))) continue;
const baseMessage = buildProcessLossMessage(run);
const conversationContinuationEligible = await runUsedConversationAdapter(db, run);
@ -19275,8 +19285,11 @@ export function heartbeatService(
}
}
if (run.runtimeMode === "legacy" && run.controllerBootId &&
run.controllerBootId !== legacyControllerBootId) return;
activeRunExecutions.add(run.id);
const executionControl = createAdapterExecutionControl();
const controllerLease = watchLegacyControllerLease(db, run, executionControl.controller);
let runScratch: HeartbeatRunScratch | null = null;
let githubLauncherLocation:
Parameters<typeof cleanupGitHubOperationLaunchers>[0] | null = null;
@ -21100,6 +21113,7 @@ export function heartbeatService(
ReturnType<typeof envOrchestrator.acquireForRun>
>;
try {
await controllerLease.assertOwned();
acquiredEnvironment = await envOrchestrator.acquireForRun({
companyId: agent.companyId,
selectedEnvironmentId,
@ -21111,6 +21125,7 @@ export function heartbeatService(
persistedExecutionWorkspace,
executionWorkspaceSettings: environmentExecutionWorkspaceSettings,
});
await controllerLease.assertOwned();
nativeRunnerPreparationSpans.push({
name: "environment.acquire",
parentName: "task.run",
@ -21250,6 +21265,7 @@ export function heartbeatService(
): Promise<
{ dispatched: true; resultPromise: Promise<T> } | { dispatched: false }
> => {
await controllerLease.assertOwned("dispatching");
// Recheck after workspace/credential preparation, immediately before the
// provider handoff. Never hold validation locks while adapter code runs.
await authorizeFailedChatRetryExecution();
@ -22631,6 +22647,7 @@ export function heartbeatService(
})
.onConflictDoNothing();
});
controllerLease.stop();
nativeWorkspaceSync = await prepareNativeWorkspaceSync({
db,
runId: run.id,
@ -24922,6 +24939,7 @@ export function heartbeatService(
logger.error({ err, runId: run.id }, "failed to promote interrupted comment queue after cleanup");
});
}
controllerLease.stop();
activeRunExecutions.delete(run.id);
// A failed owned Stop remains visible until this exact executor settles,
// including a graceful exit result arriving after the cancellation error.

View File

@ -0,0 +1,120 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { heartbeatService } from "./heartbeat.js";
import { hasLiveLegacyController, legacyControllerBootId, legacyControllerClaim,
renewLegacyControllerLease, revokeExpiredLegacyController, watchLegacyControllerLease } from "./legacy-controller-lease.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("durable legacy controller ownership", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("legacy-controller-");
db = createDb(database.connectionString);
}, 30000);
afterAll(async () => { await database?.cleanup(); });
async function seed() {
const companyId = randomUUID(), agentId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Controller test", issuePrefix: `C${companyId.slice(0, 7)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Agent", role: "general", adapterType: "claude_local", status: "idle" });
const [queued] = await db.insert(heartbeatRuns).values({ companyId, agentId }).returning();
const [run] = await db.update(heartbeatRuns).set({ status: "running", ...legacyControllerClaim("legacy") })
.where(eq(heartbeatRuns.id, queued.id)).returning();
return run;
}
async function expire(id: string) {
await db.update(heartbeatRuns).set({ controllerLeaseExpiresAt: sql`clock_timestamp() - interval '1 second'` })
.where(eq(heartbeatRuns.id, id));
}
it("commits ownership with the queued claim before provisioning or logs exist", async () => {
const run = await seed();
expect(run).toMatchObject({ status: "running", controllerBootId: legacyControllerBootId, executionStage: "preparing", processPid: null });
expect(await hasLiveLegacyController(db, run)).toBe(true);
expect(await revokeExpiredLegacyController(db, run)).toBe(false);
});
it("another deployment's startup reaper preserves an unexpired controller", async () => {
const run = await seed();
await db.update(heartbeatRuns).set({ controllerBootId: randomUUID() }).where(eq(heartbeatRuns.id, run.id));
await heartbeatService(db).reapOrphanedRuns({ staleThresholdMs: 0 });
const [saved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
expect(saved.status).toBe("running");
expect(saved.errorCode).toBeNull();
});
it.each([false, true])("shutdown preserves a foreign controller (expired: %s)", async expired => {
const run = await seed();
await db.update(heartbeatRuns).set({ controllerBootId: randomUUID() }).where(eq(heartbeatRuns.id, run.id));
if (expired) await expire(run.id);
const result = await heartbeatService(db).drainRunningRunsForShutdown("SIGTERM", new Date(), [run.id]);
expect(result.interruptedRunIds).toEqual([]);
const [saved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
expect(saved.status).toBe("running");
});
it("a current controller renews and records the dispatch boundary", async () => {
const run = await seed();
expect(await renewLegacyControllerLease(db, run, "dispatching")).toBe(true);
const [saved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
expect(saved.executionStage).toBe("dispatching");
expect(await revokeExpiredLegacyController(db, run)).toBe(false);
});
it("an expired controller cannot renew or dispatch even before a reaper claims it", async () => {
const run = await seed();
await expire(run.id);
expect(await renewLegacyControllerLease(db, run, "dispatching")).toBe(false);
const controller = new AbortController();
const watch = watchLegacyControllerLease(db, run, controller);
try {
await expect(watch.assertOwned("dispatching")).rejects.toThrow("lease lost");
expect(controller.signal.aborted).toBe(true);
} finally { watch.stop(); }
});
it("only one competing recovery revokes the observed expired owner", async () => {
const run = await seed();
await expire(run.id);
const attempts = await Promise.all([revokeExpiredLegacyController(db, run), revokeExpiredLegacyController(db, run)]);
expect(attempts.filter(Boolean)).toHaveLength(1);
expect(await renewLegacyControllerLease(db, run)).toBe(false);
});
it("a crash after revocation permits a later recovery claim", async () => {
const run = await seed();
await expire(run.id);
expect(await revokeExpiredLegacyController(db, run)).toBe(true);
const [claimed] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
expect(await hasLiveLegacyController(db, claimed)).toBe(true);
expect(await revokeExpiredLegacyController(db, claimed)).toBe(false);
await expire(run.id);
const [saved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
expect(await revokeExpiredLegacyController(db, saved)).toBe(true);
});
it("rejects foreign company renewal and revocation", async () => {
const run = await seed();
expect(await renewLegacyControllerLease(db, { ...run, companyId: randomUUID() })).toBe(false);
await expire(run.id);
expect(await revokeExpiredLegacyController(db, { ...run, companyId: randomUUID() })).toBe(false);
});
it("never turns a terminal run back into owned execution", async () => {
const run = await seed();
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, run.id));
expect(await renewLegacyControllerLease(db, run)).toBe(false);
expect(await revokeExpiredLegacyController(db, run)).toBe(false);
});
it("rejects dispatch at the lease deadline even if the database query never settles", async () => {
const run = await seed();
const hungDb = { update: () => ({ set: () => ({ where: () => ({ returning: () => new Promise(() => {}) }) }) }) } as unknown as typeof db;
vi.useFakeTimers();
const controller = new AbortController();
const watch = watchLegacyControllerLease(hungDb, { ...run, controllerLeaseExpiresAt: new Date(Date.now() + 100) }, controller);
try {
const checked = expect(watch.assertOwned("dispatching")).rejects.toThrow("lease lost");
await vi.advanceTimersByTimeAsync(101);
await checked;
expect(controller.signal.aborted).toBe(true);
} finally { watch.stop(); vi.useRealTimers(); }
});
it("leaves native controller ownership to the native coordinator", () => {
expect(legacyControllerClaim("native")).toEqual({});
});
});

View File

@ -0,0 +1,111 @@
import { randomUUID } from "node:crypto";
import { and, eq, gt, lte, sql } from "drizzle-orm";
import { heartbeatRuns, type Db } from "@paperclipai/db";
// A boot UUID has meaning across containers; a numeric PID does not.
export const legacyControllerBootId = randomUUID();
export const LEGACY_CONTROLLER_LEASE_MS = 60_000;
export const LEGACY_CONTROLLER_RENEW_MS = 10_000;
type Run = typeof heartbeatRuns.$inferSelect;
/** Commit these fields in the same UPDATE that claims a queued run. */
export function legacyControllerClaim(runtimeMode: string) {
if (runtimeMode === "native") return {};
return {
controllerBootId: legacyControllerBootId,
controllerLeaseExpiresAt: sql`clock_timestamp() + interval '60 seconds'`,
executionStage: "preparing",
};
}
export async function renewLegacyControllerLease(
db: Db,
run: Pick<Run, "id" | "companyId" | "controllerBootId">,
stage?: "dispatching",
): Promise<boolean> {
const [renewed] = await db.update(heartbeatRuns).set({
controllerLeaseExpiresAt: sql`clock_timestamp() + interval '60 seconds'`,
...(stage ? { executionStage: stage } : {}),
}).where(and(
eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId),
eq(heartbeatRuns.runtimeMode, "legacy"), eq(heartbeatRuns.status, "running"),
eq(heartbeatRuns.controllerBootId, legacyControllerBootId),
gt(heartbeatRuns.controllerLeaseExpiresAt, sql`clock_timestamp()`),
)).returning({ id: heartbeatRuns.id });
return Boolean(renewed);
}
export async function hasLiveLegacyController(db: Db, run: Run): Promise<boolean> {
if (run.runtimeMode === "native" || !run.controllerBootId) return false;
const [owner] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId),
eq(heartbeatRuns.status, "running"),
gt(heartbeatRuns.controllerLeaseExpiresAt, sql`clock_timestamp()`),
));
return Boolean(owner);
}
/** Atomically revoke an expired controller. Renewal and revocation serialize on
* the run row. Expiry permits cleanup, never dispatch of a replacement agent. */
export async function revokeExpiredLegacyController(db: Db, run: Run): Promise<boolean> {
if (run.runtimeMode === "native" || !run.controllerBootId) return true;
const [revoked] = await db.update(heartbeatRuns).set({
controllerBootId: randomUUID(),
controllerLeaseExpiresAt: sql`clock_timestamp() + interval '60 seconds'`,
}).where(and(
eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId),
eq(heartbeatRuns.status, "running"),
eq(heartbeatRuns.controllerBootId, run.controllerBootId),
lte(heartbeatRuns.controllerLeaseExpiresAt, sql`clock_timestamp()`),
)).returning({ id: heartbeatRuns.id });
return Boolean(revoked);
}
/** Abort the adapter if the controller cannot renew. Bound each check by the
* lease duration even when the database connection never settles. */
export function watchLegacyControllerLease(db: Db, run: Run, controller: AbortController) {
if (run.runtimeMode === "native" || !run.controllerBootId) {
return { stop() {}, async assertOwned(_stage?: "dispatching") {} };
}
let stopped = false;
let pending = false;
const lost = () => { if (!stopped) controller.abort(new Error("Legacy controller lease lost")); };
let deadline = setTimeout(lost, Math.max(0,
(run.controllerLeaseExpiresAt?.getTime() ?? 0) - Date.now()));
deadline.unref();
const assertOwned = async (stage?: "dispatching") => {
if (stopped) return;
controller.signal.throwIfAborted();
const startedAt = Date.now();
let onAbort!: () => void;
const aborted = new Promise<never>((_, reject) => {
onAbort = () => reject(controller.signal.reason);
controller.signal.addEventListener("abort", onAbort, { once: true });
});
let renewed: boolean;
try {
renewed = await Promise.race([renewLegacyControllerLease(db, run, stage), aborted]);
} finally {
controller.signal.removeEventListener("abort", onAbort);
}
if (stopped) return;
if (!renewed) {
lost();
controller.signal.throwIfAborted();
}
controller.signal.throwIfAborted();
if (!stopped) {
clearTimeout(deadline);
deadline = setTimeout(lost, Math.max(0, LEGACY_CONTROLLER_LEASE_MS - (Date.now() - startedAt)));
deadline.unref();
}
};
const timer = setInterval(() => {
if (pending || stopped) return;
pending = true;
void assertOwned().catch(lost).finally(() => { pending = false; });
}, LEGACY_CONTROLLER_RENEW_MS);
timer.unref();
return { assertOwned, stop() { stopped = true; clearInterval(timer); clearTimeout(deadline); } };
}

View File

@ -4,7 +4,7 @@ import { act, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
import { ActiveAgentsPanel, AgentRunCard } from "./ActiveAgentsPanel";
const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForCompany: vi.fn(),
@ -30,10 +30,6 @@ vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("./Identity", () => ({
Identity: ({ name }: { name: string }) => <span>{name}</span>,
}));
vi.mock("./RunChatSurface", () => ({
RunChatSurface: () => <div>Run output</div>,
}));
@ -156,6 +152,7 @@ describe("ActiveAgentsPanel", () => {
anchor.textContent?.includes("more active/recent"),
);
expect(moreLink?.getAttribute("href")).toBe("/dashboard/live");
expect(container.textContent).not.toContain("Run output");
await act(async () => {
root.unmount();
@ -189,6 +186,7 @@ describe("ActiveAgentsPanel", () => {
limit: 50,
});
expect(container.textContent).not.toContain("more active/recent");
expect(container.textContent).not.toContain("Run output");
await act(async () => {
root.unmount();
@ -224,7 +222,8 @@ describe("ActiveAgentsPanel", () => {
const issueLink = [...container.querySelectorAll("a")].find((anchor) =>
anchor.textContent?.includes("Phase 4B"),
);
expect(issueLink?.textContent).toBe("PAP-3562 - Phase 4B: Implement LLM Wiki distillation UI");
expect(issueLink?.textContent).toContain("Phase 4B: Implement LLM Wiki distillation UI");
expect(issueLink?.textContent).toContain("PAP-3562");
expect(issueLink?.getAttribute("href")).toBe("/issues/PAP-3562");
});
@ -232,4 +231,65 @@ describe("ActiveAgentsPanel", () => {
root.unmount();
});
});
it("keeps run outcomes distinct from the linked task status", async () => {
const root = createRoot(container);
const statuses = ["running", "queued", "succeeded", "failed", "timed_out", "cancelled", "interrupted"];
await act(async () => {
root.render(<>{statuses.map((status, index) => (
<AgentRunCard
key={status}
companyId="company-1"
run={{ ...createIssueRun(index, "issue-1"), status }}
issue={{ title: "Review release notes", identifier: "PAP-559", status: "in_review" }}
/>
))}</>);
});
const headers = [...container.querySelectorAll('a[aria-label$=". View run"]')];
expect(headers.map((header) => header.getAttribute("aria-label"))).toEqual([
"Agent 0 — Running. View run", "Agent 1 — Queued. View run",
"Agent 2 — Succeeded. View run", "Agent 3 — Failed. View run",
"Agent 4 — Timed out. View run", "Agent 5 — Cancelled. View run",
"Agent 6 — Interrupted. View run",
]);
expect(headers.every((header) => header.querySelector("svg") === null)).toBe(true);
expect(container.querySelector(".status-chip")).toBeNull();
expect(container.querySelectorAll('[aria-label="Task in review"]')).toHaveLength(7);
expect(container.querySelectorAll(".motion-safe\\:animate-spin")).toHaveLength(0);
expect(container.querySelector('a[aria-label="Agent 0 — Running. View run"]')?.getAttribute("href"))
.toBe("/agents/agent-0/runs/run-0");
await act(async () => root.unmount());
});
it("keeps a failed task lookup navigable and shows a clear error", async () => {
const root = createRoot(container);
await act(async () => {
root.render(<AgentRunCard companyId="company-1" run={createIssueRun(1, "issue-missing")} issueLoadFailed />);
});
expect(container.textContent).toContain("Task unavailable");
expect(container.querySelector('a[href="/issues/issue-missing"]')).not.toBeNull();
await act(async () => root.unmount());
});
it("does not animate running records while execution is reconnecting", async () => {
const root = createRoot(container);
await act(async () => {
root.render(<AgentRunCard
companyId="company-1"
run={{
...createRun(0),
execution: {
phase: "reconnecting", label: "Reconnecting", cause: null,
lastConfirmedActivityAt: null, retryAt: null, attempt: 1, maxAttempts: 3,
recoveryOwner: "agent", nextAction: null, permittedActions: ["inspect_run"],
predecessorRunId: null, successorRunId: null,
},
}}
/>);
});
expect(container.querySelector('a[aria-label="Agent 0 — Running. View run"]')).not.toBeNull();
expect(container.querySelector(".status-chip")).toBeNull();
expect(container.querySelectorAll(".motion-safe\\:animate-spin")).toHaveLength(0);
await act(async () => root.unmount());
});
});

View File

@ -1,45 +1,18 @@
import { memo, useMemo } from "react";
import { Link } from "@/lib/router";
import { useQueries, useQuery } from "@tanstack/react-query";
import { requiresExecutionReconciliation, type Issue, type IssueRecoveryAction } from "@paperclipai/shared";
import type { Issue } from "@paperclipai/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import type { TranscriptEntry } from "../adapters";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import {
deriveActiveRecoveryDisplayState,
RECOVERY_CHIP_DEFAULT_TONE,
} from "../lib/recovery-display";
import { ExternalLink } from "lucide-react";
import { Clock3 } from "lucide-react";
import { Identity } from "./Identity";
import { StatusGlyph } from "./StatusGlyph";
import { RunChatSurface } from "./RunChatSurface";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { Badge } from "@/components/ui/badge";
function RunCardRecoveryChip({ action }: { action: IssueRecoveryAction }) {
const state = deriveActiveRecoveryDisplayState(action);
if (!state || requiresExecutionReconciliation(action.cause)) return null;
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
const Icon = tone.icon;
return (
<Badge variant="outline"
data-testid="active-agent-run-recovery-indicator"
data-recovery-state={state}
role="status"
aria-label={tone.label}
title={`${tone.label} — open the source task to act.`}
className={cn(
"gap-0.5 px-1.5 text-(length:--text-nano)",
tone.className,
)}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{tone.label}
</Badge>
);
}
const MIN_DASHBOARD_RUNS = 4;
const DASHBOARD_RUN_CARD_LIMIT = 4;
@ -47,10 +20,17 @@ const DASHBOARD_LOG_POLL_INTERVAL_MS = 15_000;
const DASHBOARD_LOG_READ_LIMIT_BYTES = 64_000;
const DASHBOARD_MAX_CHUNKS_PER_RUN = 40;
const EMPTY_TRANSCRIPT: TranscriptEntry[] = [];
const EMPTY_RUNS: LiveRunForIssue[] = [];
function isRunActive(run: LiveRunForIssue): boolean {
return run.status === "queued" || run.status === "running";
}
const runStatusLabels: Record<string, string> = {
running: "Running",
queued: "Queued",
succeeded: "Succeeded",
failed: "Failed",
timed_out: "Timed out",
cancelled: "Cancelled",
interrupted: "Interrupted",
};
interface ActiveAgentsPanelProps {
companyId: string;
@ -63,6 +43,7 @@ interface ActiveAgentsPanelProps {
emptyMessage?: string;
queryScope?: string;
showMoreLink?: boolean;
showTranscripts?: boolean;
}
export function ActiveAgentsPanel({
@ -76,6 +57,7 @@ export function ActiveAgentsPanel({
emptyMessage = "No recent agent runs.",
queryScope = "dashboard",
showMoreLink = true,
showTranscripts = false,
}: ActiveAgentsPanelProps) {
const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit }] as const;
const sharedLiveRuns = useSharedPollingQuery({
@ -119,7 +101,7 @@ export function ActiveAgentsPanel({
}, [issueQueries]);
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
runs: visibleRuns,
runs: showTranscripts ? visibleRuns : EMPTY_RUNS,
companyId,
maxChunksPerRun: DASHBOARD_MAX_CHUNKS_PER_RUN,
logPollIntervalMs: DASHBOARD_LOG_POLL_INTERVAL_MS,
@ -137,7 +119,7 @@ export function ActiveAgentsPanel({
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
</div>
) : (
<div className={cn("grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4", gridClassName)}>
<div className={cn("grid grid-cols-1 items-start gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4", gridClassName)}>
{visibleRuns.map((run) => (
<AgentRunCard
key={run.id}
@ -146,16 +128,19 @@ export function ActiveAgentsPanel({
issue={run.issueId ? issueById.get(run.issueId) : undefined}
transcript={transcriptByRun.get(run.id) ?? EMPTY_TRANSCRIPT}
hasOutput={hasOutputForRun(run.id)}
isActive={isRunActive(run)}
showTranscript={showTranscripts}
issueLoadFailed={issueQueries.some((query, index) => visibleIssueIds[index] === run.issueId && query.isError)}
className={cardClassName}
/>
))}
</div>
)}
{showMoreLink && hiddenRunCount > 0 && (
{showMoreLink && runs.length > 0 && (
<div className="mt-3 flex justify-end text-xs text-muted-foreground">
<Link to="/dashboard/live" className="hover:text-foreground hover:underline">
{hiddenRunCount} more active/recent run{hiddenRunCount === 1 ? "" : "s"}
{hiddenRunCount > 0
? `${hiddenRunCount} more active/recent run${hiddenRunCount === 1 ? "" : "s"}`
: "View all runs"}
</Link>
</div>
)}
@ -163,88 +148,94 @@ export function ActiveAgentsPanel({
);
}
const AgentRunCard = memo(function AgentRunCard({
export const AgentRunCard = memo(function AgentRunCard({
companyId,
run,
issue,
transcript,
hasOutput,
isActive,
transcript = EMPTY_TRANSCRIPT,
hasOutput = false,
showTranscript = false,
issueLoadFailed = false,
className,
}: {
companyId: string;
run: LiveRunForIssue;
issue?: Issue;
transcript: TranscriptEntry[];
hasOutput: boolean;
isActive: boolean;
issue?: Pick<Issue, "identifier" | "title" | "status">;
transcript?: TranscriptEntry[];
hasOutput?: boolean;
showTranscript?: boolean;
issueLoadFailed?: boolean;
className?: string;
}) {
const statusLabel = runStatusLabels[run.status] ?? run.status.replace(/[_-]/g, " ");
const runUrl = `/agents/${run.agentId}/runs/${run.id}`;
const timestamp = run.finishedAt
? `Finished ${relativeTime(run.finishedAt)}`
: run.startedAt ? `Started ${relativeTime(run.startedAt)}` : `Queued ${relativeTime(run.createdAt)}`;
const taskTitle = issue?.title ?? (issueLoadFailed ? "Task unavailable" : "Loading task…");
return (
<div className={cn(
"flex h-(--sz-320px) flex-col overflow-hidden rounded-xl border shadow-sm",
isActive
? "border-blue-500/25 bg-blue-500/[0.04] shadow-(--shadow-extract-1)"
"dashboard-agent-card flex min-w-0 flex-col overflow-hidden rounded-xl border",
showTranscript && "h-(--sz-320px)",
run.status === "running"
? "border-(--dashboard-run-border) bg-(--dashboard-run-background) shadow-(--shadow-extract-1)"
: "border-border bg-background/70",
className,
)}>
<div className="border-b border-border/60 px-3 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
{isActive && (!run.execution || run.execution.phase === "working") ? (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full motion-safe:animate-ping rounded-full bg-blue-400 opacity-70" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500" />
</span>
) : (
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-muted-foreground/35" />
)}
<Identity name={run.agentName} size="sm" className="[&>span:last-child]:!text-(length:--text-micro)" />
</div>
<div className="mt-2 flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<span>{isActive ? "Working" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}</span>
</div>
</div>
)} data-run-status={run.status}>
<div className={cn("flex shrink-0 flex-col gap-3 p-3", showTranscript && "border-b border-border/60")}>
<Link
to={runUrl}
title={`${run.agentName}${statusLabel} · ${timestamp}`}
aria-label={`${run.agentName}${statusLabel}. View run`}
className="flex min-w-0 items-center gap-2 rounded-md text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Identity name={run.agentName} className="gap-2 font-medium" />
</Link>
{run.issueId ? (
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2 py-1 text-(length:--text-nano) text-muted-foreground transition-colors hover:text-foreground"
to={`/issues/${issue?.identifier ?? run.issueId}`}
className="min-w-0 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-sm text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={issue ? `${issue.title} · ${issue.identifier}` : taskTitle}
>
<ExternalLink className="h-2.5 w-2.5" />
<span className="flex min-w-0 items-baseline gap-2">
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<StatusGlyph
status={issue?.status ?? "backlog"}
size="md"
className="self-center"
title={issue ? `Task ${issue.status.replace(/_/g, " ")}` : undefined}
/>
<span className="truncate">{taskTitle}</span>
</span>
<span className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">{issue?.identifier ?? run.issueId.slice(0, 8)}</span>
</span>
</Link>
) : (
<Link to={runUrl} className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Clock3 className="size-4 shrink-0" aria-hidden />
<span className="truncate">{run.invocationSource === "timer" ? "Scheduled heartbeat" : "No linked task"}</span>
</Link>
</div>
{run.issueId && (
<div className="mt-3 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-xs">
<Link
to={`/issues/${issue?.identifier ?? run.issueId}`}
className={cn(
"line-clamp-2 hover:underline",
isActive ? "text-blue-700 dark:text-blue-300" : "text-muted-foreground hover:text-foreground",
)}
title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)}
>
{issue?.identifier ?? run.issueId.slice(0, 8)}
{issue?.title ? ` - ${issue.title}` : ""}
</Link>
{issue?.activeRecoveryAction ? (
<div className="mt-1.5">
<RunCardRecoveryChip action={issue.activeRecoveryAction} />
</div>
) : null}
</div>
)}
<time
dateTime={run.finishedAt ?? run.startedAt ?? run.createdAt}
className="text-right font-sans text-xs text-muted-foreground/70"
>
{timestamp}
</time>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<RunChatSurface
run={run}
transcript={transcript}
hasOutput={hasOutput}
companyId={companyId}
/>
</div>
{showTranscript && (
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<RunChatSurface
run={run}
transcript={transcript}
hasOutput={hasOutput}
companyId={companyId}
/>
</div>
)}
</div>
);
});

View File

@ -20,9 +20,9 @@ function emptyRunDay(date: string): DashboardRunActivityDay {
}
const runSegmentColors = {
succeeded: "var(--hex-10b981)",
succeeded: "var(--status-task-icon-done)",
recovered: "var(--status-task-todo)",
failed: "var(--hex-ef4444)",
failed: "var(--status-task-icon-blocked)",
other: "var(--hex-737373)",
} as const;
@ -166,7 +166,7 @@ export function RunActivityChart(props: RunChartProps) {
}
const priorityColors: Record<string, string> = {
critical: "var(--hex-ef4444)",
critical: "var(--status-task-icon-blocked)",
high: "var(--hex-f97316)",
medium: "var(--hex-eab308)",
low: "var(--hex-6b7280)",
@ -223,14 +223,15 @@ export function PriorityChart({ issues }: { issues: { priority: string; createdA
// status vocabulary; badge, row, chart, and log agree). Previously an
// independent palette (todo blue, in_progress violet, etc.). `backlog`
// deliberately keeps --project-none (pre-B5, per user ruling); the
// priority series and success-rate tints below are not status hues and
// are left alone.
// non-red priority series and warning success-rate tints retain their own hues.
// Progress, done, and blocked use the icon hues so bars and legends match
// the task icons in each theme.
const statusColors: Record<string, string> = {
todo: "var(--status-task-todo)",
in_progress: "var(--status-task-in_progress)",
in_progress: "var(--status-task-icon-in_progress)",
in_review: "var(--status-task-in_review)",
done: "var(--status-task-done)",
blocked: "var(--status-task-blocked)",
done: "var(--status-task-icon-done)",
blocked: "var(--status-task-icon-blocked)",
cancelled: "var(--status-task-cancelled)",
backlog: "var(--project-none)",
};
@ -309,7 +310,7 @@ export function SuccessRateChart(props: RunChartProps) {
// rather than dragging it down as failures.
const effectiveSucceeded = entry.succeeded + entry.recovered;
const rate = entry.total > 0 ? effectiveSucceeded / entry.total : 0;
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "var(--hex-10b981)" : rate >= 0.5 ? "var(--hex-eab308)" : "var(--hex-ef4444)";
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "var(--status-task-icon-done)" : rate >= 0.5 ? "var(--hex-eab308)" : "var(--status-task-icon-blocked)";
return (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${entry.total > 0 ? Math.round(rate * 100) : 0}% (${effectiveSucceeded}/${entry.total})`}>
{entry.total > 0 ? (

View File

@ -53,27 +53,44 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en
const inner = (
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Avatar size="xs">
{actorAvatarUrl && <AvatarImage src={actorAvatarUrl} alt={actorName} />}
<AvatarFallback>{deriveInitials(actorName)}</AvatarFallback>
</Avatar>
<p className="min-w-0 flex-1 truncate">
<span>{actorName}</span>
<span className="text-muted-foreground"> {verb} </span>
{name && <span className="font-medium">{name}</span>}
{entityTitle && <span className="text-muted-foreground"> {entityTitle}</span>}
</p>
<div className="flex items-start gap-2 @xl:grid @xl:grid-cols-(--dashboard-activity-list-columns) @xl:items-baseline">
<Avatar size="sm" aria-hidden="true" className="@xl:self-center">
{actorAvatarUrl && <AvatarImage src={actorAvatarUrl} alt="" />}
<AvatarFallback>{deriveInitials(actorName)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-1 @xl:contents">
<div className="flex min-w-0 items-baseline gap-2 @xl:contents">
<p className="flex h-6 min-w-0 flex-1 items-center gap-1.5">
<span className="max-w-1/2 shrink-0 truncate" title={`${actorName} ${verb}`}>
<span>{actorName}</span>{" "}
<span className="text-muted-foreground">{verb}</span>
</span>
{event.entityType === "issue" ? (
<span className="min-w-0 flex-1 truncate" title={entityTitle}>{entityTitle}</span>
) : (
<span className="min-w-0 flex-1 truncate">
{name && <span className="font-medium">{name}</span>}
{entityTitle && <span className="text-muted-foreground"> {entityTitle}</span>}
</span>
)}
</p>
<span className="ml-auto shrink-0 truncate text-right font-mono text-(length:--text-micro) text-muted-foreground @xl:w-(--dashboard-list-id-width)">
{event.entityType === "issue" ? name : null}
</span>
</div>
<div className="flex min-h-6 min-w-0 items-center @xl:contents">
<span className="ml-auto w-(--dashboard-list-time-width) shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground">
{timeAgo(event.createdAt)}
</span>
</div>
</div>
<span className="text-xs text-muted-foreground shrink-0">{timeAgo(event.createdAt)}</span>
</div>
<IssueReferenceActivitySummary event={event} />
</div>
);
const classes = cn(
"px-4 py-2 text-sm",
"dashboard-list-row text-sm",
link && "cursor-pointer hover:bg-accent/50 transition-colors",
className,
);

View File

@ -24,7 +24,7 @@ type GlobalToolbarContext = { companyId: string | null; companyPrefix: string |
function CrumbIdentifier({ identifier }: { identifier?: string }) {
if (!identifier) return null;
return (
<span data-slot="task-title-identifier" className="shrink-0 font-mono text-muted-foreground">
<span data-slot="task-title-identifier" className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
{identifier}
</span>
);
@ -113,9 +113,9 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
return (
<div className="h-(--sz-60px) shrink-0 flex items-center border-b border-border px-4">
{menuButton}
<h1 className="flex min-w-0 flex-1 items-center gap-1.5 text-sm">
<h1 className="flex min-w-0 flex-1 items-baseline gap-1.5 text-sm">
{currentCrumb.leading ? (
<span className="flex shrink-0 items-center">{currentCrumb.leading}</span>
<span className="flex shrink-0 items-center self-center">{currentCrumb.leading}</span>
) : null}
<span className="min-w-0 truncate" title={currentCrumb.label}>{currentCrumb.label}</span>
<CrumbIdentifier identifier={currentCrumb.identifier} />
@ -137,9 +137,9 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
{isLast || !crumb.href ? (
crumb.leading || crumb.identifier ? (
<BreadcrumbPage className="flex min-w-0 items-center gap-1.5">
<BreadcrumbPage className="flex min-w-0 items-baseline gap-1.5">
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
<span className="flex shrink-0 items-center self-center">{crumb.leading}</span>
)}
{!taskDetailLayout ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
<span className="min-w-0 truncate">{crumb.label}</span>
@ -154,12 +154,12 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
<Link
to={crumb.href}
className={cn(
"flex min-w-0 items-center gap-1.5",
"flex min-w-0 items-baseline gap-1.5",
i === 0 && "font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground",
)}
>
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
<span className="flex shrink-0 items-center self-center">{crumb.leading}</span>
)}
{!taskDetailLayout ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
<span className="min-w-0 truncate">{crumb.label}</span>
@ -194,9 +194,9 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
{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>

View File

@ -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,
);

View File

@ -173,8 +173,7 @@ describe("Sidebar", () => {
const sidebar = container.querySelector("aside");
expect(sidebar?.classList).not.toContain("border-r");
expect(sidebar?.classList).not.toContain("border-border");
expect(sidebar?.classList).toContain("bg-border/50");
expect(sidebar?.classList).toContain("dark:bg-muted");
expect(sidebar?.classList).toContain("primary-sidebar-surface");
flushSync(() => {
root.unmount();

View File

@ -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

View File

@ -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"),
);

View File

@ -13,6 +13,7 @@ import type { DeploymentMode } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { authApi } from "@/api/auth";
import { queryKeys } from "@/lib/queryKeys";
import { useCloudInstance } from "@/hooks/useCloudInstance";
import { useSignOut } from "@/hooks/useSignOut";
import { useSidebar } from "../context/SidebarContext";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -112,6 +113,7 @@ export function SidebarAccountMenu({
onOpenChange,
forceExpanded = false,
}: SidebarAccountMenuProps) {
const isCloud = Boolean(useCloudInstance());
const [internalOpen, setInternalOpen] = useState(false);
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking && !forceExpanded;
@ -230,7 +232,7 @@ export function SidebarAccountMenu({
</div>
</PopoverContent>
</Popover>
{!rail ? (
{!rail && !isCloud ? (
<Tooltip>
<TooltipTrigger asChild>
<a

View File

@ -182,7 +182,7 @@ export function SidebarNavItem({
</Badge>
)}
{!rail && (hasLive || liveAccessory) && (
<span className="ml-auto flex items-center gap-1.5">
<span className="ml-auto flex shrink-0 items-center gap-1.5 whitespace-nowrap">
{liveAccessory}
{hasLive && (
<>

View File

@ -250,11 +250,11 @@ function RecentTasksList({
<>
<SidebarSection label="Recent Tasks">
{entries.map((entry) => (
<div key={entry.id} className="group/recent-task relative">
<div key={entry.id} className="sidebar-action-row group/recent-task relative">
<SidebarNavItem
to={`/issues/${entry.id}`}
label={entry.title}
className={rail ? undefined : "pr-10"}
className={rail ? undefined : "sidebar-action-link pointer-coarse:pr-8"}
liveCount={liveIssueIds.has(entry.id) ? 1 : undefined}
/>
{!rail ? (
@ -265,7 +265,7 @@ function RecentTasksList({
variant="ghost"
size="icon-xs"
aria-label={`More actions for ${entry.title}`}
className="absolute right-2 top-(--pct-50) z-10 -translate-y-(--pct-50) text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 pointer-coarse:opacity-100 group-hover/recent-task:opacity-100 group-focus-within/recent-task:opacity-100 data-[state=open]:bg-accent data-[state=open]:text-foreground data-[state=open]:opacity-100"
className="sidebar-action-menu absolute right-2 top-(--pct-50) z-10 -translate-y-(--pct-50) text-muted-foreground pointer-events-none opacity-0 transition-opacity hover:bg-sidebar-accent dark:hover:bg-sidebar-accent hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:before:hidden group-hover/recent-task:pointer-events-auto group-hover/recent-task:opacity-100 group-focus-within/recent-task:pointer-events-auto group-focus-within/recent-task:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:bg-sidebar-accent data-[state=open]:text-foreground data-[state=open]:opacity-100"
>
<MoreHorizontal aria-hidden="true" />
</Button>

View File

@ -16,7 +16,7 @@ import { taskStatusIconVar } from "../lib/status-colors";
const STATUS_ICON_CLASS: Record<string, string> = {
backlog: "lucide-circle-dashed",
todo: "lucide-circle",
in_progress: "lucide-rotate-cw",
in_progress: "lucide-task-progress-spinner",
in_review: "lucide-circle-dot",
done: "lucide-circle-check",
blocked: "lucide-circle-minus",
@ -29,14 +29,17 @@ describe("StatusGlyph", () => {
for (const status of Object.keys(taskStatusIconVar)) {
const html = renderToStaticMarkup(<StatusGlyph status={status} />);
expect(html).toContain('viewBox="0 0 24 24"');
expect(html).toContain('stroke-width="2"');
expect(html).toContain("<svg");
}
});
it("maps sm/md/lg to 14/16/20 px", () => {
expect(renderToStaticMarkup(<StatusGlyph status="todo" size="sm" />)).toContain('width="14"');
expect(renderToStaticMarkup(<StatusGlyph status="todo" size="md" />)).toContain('width="16"');
expect(renderToStaticMarkup(<StatusGlyph status="todo" size="lg" />)).toContain('width="20"');
for (const status of Object.keys(taskStatusIconVar)) {
expect(renderToStaticMarkup(<StatusGlyph status={status} size="sm" />)).toContain('width="14"');
expect(renderToStaticMarkup(<StatusGlyph status={status} size="md" />)).toContain('width="16"');
expect(renderToStaticMarkup(<StatusGlyph status={status} size="lg" />)).toContain('width="20"');
}
// Default size is md.
expect(renderToStaticMarkup(<StatusGlyph status="todo" />)).toContain('width="16"');
});
@ -61,6 +64,24 @@ describe("StatusGlyph", () => {
}
});
it("animates only in-progress task icons and respects reduced motion", () => {
for (const status of Object.keys(taskStatusIconVar)) {
const html = renderToStaticMarkup(<StatusGlyph status={status} />);
expect(html.includes("motion-safe:animate-spin")).toBe(status === "in_progress");
}
});
it("uses the same circle radius and stroke for the spinner as other task icons", () => {
for (const status of ["in_progress", "todo", "done", "blocked", "cancelled"]) {
const html = renderToStaticMarkup(<StatusGlyph status={status} />);
expect(html).toContain('<circle cx="12" cy="12" r="10"');
expect(html).toContain('stroke-width="2"');
}
const spinner = renderToStaticMarkup(<StatusGlyph status="in_progress" />);
expect(spinner).toContain('pathLength="100"');
expect(spinner).toContain('stroke-dasharray="80 20"');
});
it("gives todo the plain circle (not a compound circle icon)", () => {
const html = renderToStaticMarkup(<StatusGlyph status="todo" />);
expect(html).toContain("lucide-circle");

View File

@ -6,7 +6,7 @@ import {
CircleDashed,
CircleDot,
CircleMinus,
RotateCw,
createLucideIcon,
type LucideIcon,
} from "lucide-react";
import { cn } from "../lib/utils";
@ -18,10 +18,14 @@ import { taskStatusIconVar, taskStatusIconVarDefault } from "../lib/status-color
* `viewBox="0 0 24 24"` so they scale proportionally at any size), so the whole
* set reads as one consistent icon family:
*
* backlog circle-dashed · todo circle · in_progress rotate-cw ·
* backlog circle-dashed · todo circle · in_progress animated open circle ·
* in_review circle-dot · done circle-check · blocked circle-minus ·
* cancelled ban · in_queue circle-minus (blocked recoloured blue).
*
* The in-progress animation represents task workflow status, independently of
* run execution. It remains between runs until the task status changes; live
* indicators and run details report whether an agent is currently executing.
*
* Colour comes from the `--status-task-icon-*` CSS vars (AA-tuned, mode-aware;
* see `index.css`). The glyph paints in `currentColor`, and the component
* defaults `color` to the status' icon var so it renders correctly
@ -44,11 +48,17 @@ export type StatusGlyphStatus =
| "cancelled"
| "in_queue";
// LoaderCircle uses a 9-unit radius. Keep its open arc, but use the same
// 10-unit circle and unscaled stroke as the other task glyphs.
const TaskProgressSpinner = createLucideIcon("TaskProgressSpinner", [
["circle", { cx: "12", cy: "12", r: "10", pathLength: "100", strokeDasharray: "80 20", key: "progress" }],
]);
/** Status → Lucide icon. `in_queue` borrows the blocked icon; its colour var resolves to blue. */
const STATUS_ICON: Record<string, LucideIcon> = {
backlog: CircleDashed,
todo: Circle,
in_progress: RotateCw,
in_progress: TaskProgressSpinner,
in_review: CircleDot,
done: CircleCheck,
blocked: CircleMinus,
@ -78,7 +88,7 @@ export function StatusGlyph({ status, size = "md", className, title }: StatusGly
return (
<Icon
size={px}
className={cn("inline-block shrink-0 align-middle", className)}
className={cn("inline-block shrink-0 align-middle", status === "in_progress" && "motion-safe:animate-spin", className)}
style={{ color: `var(${cssVar})` } as CSSProperties}
{...a11y}
>

View File

@ -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

View File

@ -3,7 +3,7 @@
* Settings reuses this contract when it takes over the global sidebar.
*/
export const primarySidebarStyles = {
surface: "bg-border/50 dark:bg-muted",
surface: "primary-sidebar-surface",
nav: "flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto scrollbar-auto-hide px-3 py-2 pointer-coarse:gap-3",
group: "flex flex-col gap-0.5",
} as const;

View File

@ -3,7 +3,7 @@
@font-face {
font-family: "InterVariable";
src: url("../fonts/InterVariable.woff2") format("woff2");
src: url("/fonts/InterVariable.woff2") format("woff2");
font-display: swap;
font-style: normal;
font-weight: 100 900;
@ -11,7 +11,7 @@
@font-face {
font-family: "InterVariable";
src: url("../fonts/InterVariable-Italic.woff2") format("woff2");
src: url("/fonts/InterVariable-Italic.woff2") format("woff2");
font-display: swap;
font-style: italic;
font-weight: 100 900;
@ -174,6 +174,15 @@
--status-agent-running: #2563eb;
--status-agent-paused: #f59e0b;
--status-agent-error: #dc2626;
/* Dashboard agent card: translucent running treatment from the Paper design. */
--dashboard-run-accent: oklch(62.3% 0.214 259.815);
--dashboard-run-background: color-mix(in oklab, var(--dashboard-run-accent) 4%, transparent);
--dashboard-run-border: color-mix(in oklab, var(--dashboard-run-accent) 25%, transparent);
--dashboard-list-leading-width: calc(var(--spacing) * 6);
--dashboard-list-id-width: calc(var(--spacing) * 20);
--dashboard-list-time-width: calc(var(--spacing) * 16);
--dashboard-task-list-columns: var(--dashboard-list-leading-width) minmax(0, 1fr) calc(var(--spacing) * 36) var(--dashboard-list-id-width) var(--dashboard-list-time-width);
--dashboard-activity-list-columns: var(--dashboard-list-leading-width) minmax(0, 1fr) var(--dashboard-list-id-width) var(--dashboard-list-time-width);
--status-task-backlog: #a8aeb2;
--status-task-todo: #f59e0b;
--status-task-in_progress: #2563eb;
@ -191,15 +200,15 @@
gray / amber / green statuses are pinned to the board-approved AA hexes.
`in_queue` is the BLOCKED shape recoloured to the in_progress blue
(replaces the bespoke teal "covered" state). Light values here; the
`.dark` block below overrides the four that need a mode-specific hue. */
`.dark` block below overrides the hues that need a mode-specific value. */
--status-task-icon-backlog: #52585d; /* gray darkened — 7.21:1 on paper */
--status-task-icon-todo: #cc7a00; /* amber darkened — clears 3:1 */
--status-task-icon-in_progress: var(--status-task-in_progress); /* #2563eb both modes */
--status-task-icon-in_progress: var(--color-blue-600); /* matches nav dots and live labels */
--status-task-icon-in_review: var(--status-task-in_review); /* #7c3aed (light) */
--status-task-icon-done: #16a34a; /* green darkened — clears 3:1 */
--status-task-icon-blocked: var(--status-task-blocked); /* #dc2626 both modes */
--status-task-icon-cancelled: #52585d;
--status-task-icon-in_queue: var(--status-task-in_progress); /* blocked shape, blue */
--status-task-icon-in_queue: var(--status-task-icon-in_progress); /* blocked shape, same mode-aware blue */
--folder-color-indigo: #6366f1;
--folder-color-violet: #8b5cf6;
@ -286,6 +295,7 @@
--side-panel-tab-label-max-width: var(--sz-100px);
--side-panel-tab-label-expanded-max-width: calc(var(--side-panel-tab-label-max-width) + var(--sz-18px));
--side-panel-tab-label-fade-width: var(--sz-32px);
--sidebar-nav-action-fade-width: calc(var(--spacing) * 4);
--side-panel-tab-radius: var(--radius-xl);
--side-panel-control-radius: var(--radius-xl);
--side-panel-launcher-width: var(--sz-320px);
@ -405,11 +415,12 @@
--chip-match-identifier-bg: var(--muted);
--chip-match-identifier-fg: var(--muted-foreground);
--chip-match-identifier-border: var(--border);
/* Dark-mode AA overrides for the status-icon hues that need a mode-specific
value (PAP-238). in_progress / blocked stay identical (same blue / red both
modes) and in_queue tracks in_progress, so they need no override. */
/* Dark-mode status-icon hues. The lighter progress blue gives bare glyphs
contrast comparable to the neighboring gray, purple, and green icons.
Blocked retains its base red; in_queue follows the progress icon blue. */
--status-task-icon-backlog: #9a958a;
--status-task-icon-todo: #fbbf24;
--status-task-icon-in_progress: var(--color-blue-400); /* matches nav dots and live labels */
--status-task-icon-in_review: #9474f0; /* deeper than violet-400, reads purple */
--status-task-icon-done: #34d06f;
--status-task-icon-cancelled: #9a958a;
@ -985,6 +996,48 @@
transition-duration: var(--motion-side-panel-tab);
transition-timing-function: var(--motion-ease-standard);
}
/* Sidebar actions cover the trailing live label without moving row content,
fading into the row surface like the task-detail tab close control. */
.primary-sidebar-surface {
--sidebar-action-rest-surface: color-mix(in srgb, var(--border) 50%, var(--background));
background-color: var(--sidebar-action-rest-surface);
}
.dark .primary-sidebar-surface {
--sidebar-action-rest-surface: var(--muted);
}
.sidebar-action-row {
--sidebar-action-surface: var(--sidebar-action-rest-surface, var(--background));
}
.sidebar-action-row:is(:hover, :focus-within),
.sidebar-action-row:has(.sidebar-action-link[aria-current="page"]),
.sidebar-action-row:has(.sidebar-action-menu[data-state="open"]) {
--sidebar-action-surface: var(--sidebar-accent);
}
.sidebar-action-link {
background-color: var(--sidebar-action-surface);
}
.sidebar-action-row:is(:hover, :focus-within) .sidebar-action-link,
.sidebar-action-row:has(.sidebar-action-menu[data-state="open"]) .sidebar-action-link {
color: var(--sidebar-accent-foreground);
}
.sidebar-action-menu {
isolation: isolate;
}
.sidebar-action-menu::before {
content: "";
position: absolute;
inset: 0;
left: calc(-1 * var(--sidebar-nav-action-fade-width));
z-index: -1;
border-radius: inherit;
/* Animate a solid surface in sync with the link; gradient color stops switch
immediately and flash the resting sidebar color during the opacity fade. */
background-color: var(--sidebar-action-surface);
mask-image: linear-gradient(to right, transparent, black var(--sidebar-nav-action-fade-width));
transition: background-color var(--default-transition-duration) var(--default-transition-timing-function);
pointer-events: none;
}
.side-panel-tab-label-fade,
.side-panel-tab-label-close-fade[data-truncated="true"],
[data-appearance="streamlined-task"]:is(:hover, :focus-within) .side-panel-tab-label-close-fade {
@ -1145,6 +1198,12 @@
}
}
/* Shared rhythm for dashboard activity and recent-task rows. */
.dashboard-list-row {
min-height: calc(var(--spacing) * 12);
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
}
/* Dashboard activity row entry motion */
@keyframes dashboard-activity-enter {
0% {

View File

@ -8,6 +8,11 @@ describe("activity formatting", () => {
["agent-approver", { id: "agent-approver", name: "Approver Bot" } as Agent],
]);
it("uses readable verbs for task read-state changes", () => {
expect(formatActivityVerb("issue.read_marked")).toBe("read");
expect(formatActivityVerb("issue.read_unmarked")).toBe("marked unread");
});
it("formats blocker activity using linked issue identifiers", () => {
const details = {
addedBlockedByIssues: [

View File

@ -25,6 +25,8 @@ interface ActivityFormatOptions {
const ACTIVITY_ROW_VERBS: Record<string, string> = {
"issue.created": "created",
"issue.updated": "updated",
"issue.read_marked": "read",
"issue.read_unmarked": "marked unread",
"issue.checked_out": "checked out",
"issue.released": "released",
"issue.comment_added": "commented on",

View File

@ -18,7 +18,7 @@ describe("bundled UI font assets", () => {
expect(existsSync(fontPath), `${fileName} should exist in ui/public/fonts`).toBe(true);
expect(statSync(fontPath).isFile(), `${fileName} should be a file`).toBe(true);
expect(readFileSync(fontPath).subarray(0, 4).toString("ascii")).toBe("wOF2");
expect(css).toContain(`url("../fonts/${fileName}")`);
expect(css).toContain(`url("/fonts/${fileName}")`);
}
expect(css).toContain('--font-sans: "InterVariable"');

View File

@ -453,7 +453,7 @@ export function Dashboard() {
<SmokeLabDashboardCard companyId={selectedCompanyId!} />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className={cn("grid grid-cols-2 gap-4", SHOW_TASK_PRIORITY_UI ? "lg:grid-cols-4" : "lg:grid-cols-3")}>
<ChartCard title="Run Activity" subtitle="Last 14 days">
<RunActivityChart activity={data.runActivity} />
</ChartCard>
@ -486,7 +486,7 @@ export function Dashboard() {
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Recent Activity
</h3>
<Card className="block py-0 divide-y divide-border overflow-hidden">
<Card className="@container block py-0 divide-y divide-border overflow-hidden">
{recentActivity.map((event) => (
<ActivityRow
key={event.id}
@ -512,37 +512,36 @@ export function Dashboard() {
<p className="text-sm text-muted-foreground">No tasks yet.</p>
</Card>
) : (
<Card className="block py-0 divide-y divide-border overflow-hidden">
<Card className="@container block py-0 divide-y divide-border overflow-hidden">
{recentIssues.slice(0, 10).map((issue) => (
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
className="px-4 py-3 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
className="dashboard-list-row text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
>
<div className="flex items-start gap-2 sm:items-center sm:gap-3">
{/* Status icon - left column on mobile */}
<span className="shrink-0 sm:hidden">
<div className="flex items-start gap-2 @xl:grid @xl:grid-cols-(--dashboard-task-list-columns) @xl:items-baseline">
<span className="flex size-6 shrink-0 items-center justify-end @xl:self-center">
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />
</span>
{/* Right column on mobile: title + metadata stacked */}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} /></span>
<span className="text-xs font-mono text-muted-foreground">
<span className="flex min-w-0 flex-1 flex-col gap-1 @xl:contents">
<span className="flex min-w-0 items-baseline gap-2 @xl:contents">
<span className="min-w-0 flex-1 truncate text-sm leading-6" title={issue.title}>
{issue.title}
</span>
<span className="ml-auto shrink-0 truncate text-right font-mono text-(length:--text-micro) text-muted-foreground @xl:col-start-4 @xl:row-start-1 @xl:w-(--dashboard-list-id-width)">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
: null;
})()}
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>
<span className="text-xs text-muted-foreground shrink-0 sm:order-last">
</span>
<span className="flex min-h-6 min-w-0 items-center gap-2 @xl:contents">
<span className="flex min-w-0 flex-1 items-center @xl:col-start-3 @xl:row-start-1 @xl:self-center">
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <Identity name={name} size="sm" className="max-w-32" />
: null;
})()}
</span>
<span className="ml-auto w-(--dashboard-list-time-width) shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground">
{timeAgo(issue.updatedAt)}
</span>
</span>

View File

@ -53,8 +53,6 @@ export function DashboardLive() {
minRunCount={DASHBOARD_LIVE_RUN_LIMIT}
fetchLimit={DASHBOARD_LIVE_RUN_LIMIT}
cardLimit={DASHBOARD_LIVE_RUN_LIMIT}
gridClassName="gap-3 md:grid-cols-2 2xl:grid-cols-3"
cardClassName="h-(--sz-420px)"
emptyMessage="No active or recent agent runs."
queryScope="dashboard-live"
showMoreLink={false}

View File

@ -131,6 +131,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";
@ -1203,6 +1204,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>

View File

@ -3645,7 +3645,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
breadcrumbStatus ? (
<StatusIcon
status={breadcrumbStatus}
size="lg"
className="size-3"
blockerAttention={breadcrumbBlockerAttention}
/>
) : undefined,

View File

@ -456,7 +456,10 @@ function StorybookQueryFixtures({ children }: { children: ReactNode }) {
queryClient.setQueryData(queryKeys.adapters.all, adapterFixtures);
queryClient.setQueryData(queryKeys.issues.list(COMPANY_ID), storybookIssues);
queryClient.setQueryData([...queryKeys.issues.list(COMPANY_ID), "with-routine-executions"], storybookIssues);
queryClient.setQueryData([...queryKeys.liveRuns(COMPANY_ID), "dashboard"], liveRuns);
queryClient.setQueryData([...queryKeys.liveRuns(COMPANY_ID), "dashboard", { minRunCount: 4, fetchLimit: undefined }], liveRuns);
for (const issue of storybookIssues) {
queryClient.setQueryData(queryKeys.issues.detail(issue.id), issue);
}
queryClient.setQueryData(queryKeys.instance.generalSettings, { censorUsernameInLogs: false });
queryClient.setQueryData(queryKeys.agents.adapterModels(COMPANY_ID, "codex_local"), [
{ id: "gpt-5.4", label: "GPT-5.4" },