Merge origin/master into fix/exact-detail-blocker-attention
This commit is contained in:
commit
77d1c16bb4
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -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); } };
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -3645,7 +3645,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
breadcrumbStatus ? (
|
||||
<StatusIcon
|
||||
status={breadcrumbStatus}
|
||||
size="lg"
|
||||
className="size-3"
|
||||
blockerAttention={breadcrumbBlockerAttention}
|
||||
/>
|
||||
) : undefined,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue