Compare commits

...

4 Commits

Author SHA1 Message Date
Dotta
bab5136645 Keep handoff resolution logging non-fatal 2026-05-05 12:16:30 -05:00
Dotta
09ed4e54cb Add operator QoL screenshots 2026-05-05 12:06:21 -05:00
Dotta
783f4d2f28 Address operator QoL PR feedback 2026-05-05 11:59:50 -05:00
Dotta
433326ffcb Improve operator workflow QoL
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-05 11:34:01 -05:00
50 changed files with 1984 additions and 263 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

View File

@@ -352,6 +352,8 @@ export type {
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,

View File

@@ -36,6 +36,11 @@ export interface IssueCostSummary {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
/** number of distinct heartbeat runs aggregated across the issue tree */
runCount: number;
/** sum of wall-clock duration of each run in the tree (ms);
* still-running runs contribute (now - startedAt) so this ticks up live */
runtimeMs: number;
}
export interface CostByAgent {

View File

@@ -140,6 +140,8 @@ export type {
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,

View File

@@ -162,6 +162,18 @@ export interface IssueProductivityReview {
updatedAt: Date;
}
export type SuccessfulRunHandoffStateKind = "required" | "resolved" | "escalated";
export interface SuccessfulRunHandoffState {
state: SuccessfulRunHandoffStateKind;
required: boolean;
sourceRunId: string | null;
correctiveRunId: string | null;
assigneeAgentId: string | null;
detectedProgressSummary: string | null;
createdAt: Date | string | null;
}
export interface IssueRelation {
id: string;
companyId: string;
@@ -324,6 +336,7 @@ export interface Issue {
blocks?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention;
productivityReview?: IssueProductivityReview | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
relatedWork?: IssueRelatedWorkSummary;
referencedIssueIdentifiers?: string[];
planDocument?: IssueDocument | null;

View File

@@ -3,7 +3,17 @@ import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { randomUUID } from "node:crypto";
import { createDb, companies, agents, costEvents, financeEvents, issues, projects } from "@paperclipai/db";
import {
createDb,
companies,
agents,
activityLog,
costEvents,
financeEvents,
heartbeatRuns,
issues,
projects,
} from "@paperclipai/db";
import { costService } from "../services/costs.ts";
import { financeService } from "../services/finance.ts";
import {
@@ -69,6 +79,8 @@ const mockCostService = vi.hoisted(() => ({
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
runCount: 0,
runtimeMs: 0,
}),
windowSpend: vi.fn().mockResolvedValue([]),
byProject: vi.fn().mockResolvedValue([]),
@@ -231,7 +243,9 @@ describe("cost routes", () => {
expect(res.status).toBe(200);
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1");
expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1");
expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1", {
excludeRoot: false,
});
expect(res.body).toEqual({
issueId: "issue-1",
issueCount: 1,
@@ -240,6 +254,8 @@ describe("cost routes", () => {
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
runCount: 0,
runtimeMs: 0,
});
});
@@ -393,6 +409,8 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
afterEach(async () => {
await db.delete(financeEvents);
await db.delete(costEvents);
await db.delete(activityLog);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(projects);
await db.delete(agents);
@@ -612,9 +630,173 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
inputTokens: 60,
cachedInputTokens: 6,
outputTokens: 12,
runCount: 0,
runtimeMs: 0,
});
});
it("aggregates run wall-clock duration across the recursive issue tree", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const rootIssueId = randomUUID();
const childIssueId = randomUUID();
const grandchildIssueId = randomUUID();
const siblingIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Run Agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values([
{
id: rootIssueId,
companyId,
title: "Root",
status: "in_progress",
priority: "medium",
issueNumber: 1,
identifier: "TST-1",
},
{
id: childIssueId,
companyId,
parentId: rootIssueId,
title: "Child",
status: "in_progress",
priority: "medium",
issueNumber: 2,
identifier: "TST-2",
},
{
id: grandchildIssueId,
companyId,
parentId: childIssueId,
title: "Grandchild",
status: "done",
priority: "medium",
issueNumber: 3,
identifier: "TST-3",
},
{
id: siblingIssueId,
companyId,
title: "Sibling",
status: "done",
priority: "medium",
issueNumber: 4,
identifier: "TST-4",
},
]);
const linkedViaContextRunId = randomUUID();
const linkedViaActivityRunId = randomUUID();
const grandchildRunId = randomUUID();
const siblingRunId = randomUUID();
const livePartialRunId = randomUUID();
await db.insert(heartbeatRuns).values([
// 60s run linked to root via contextSnapshot.issueId
{
id: linkedViaContextRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:00:00.000Z"),
finishedAt: new Date("2026-04-10T00:01:00.000Z"),
contextSnapshot: { issueId: rootIssueId },
},
// 120s run linked to child via activity_log
{
id: linkedViaActivityRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:05:00.000Z"),
finishedAt: new Date("2026-04-10T00:07:00.000Z"),
},
// 30s run linked to grandchild
{
id: grandchildRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:10:00.000Z"),
finishedAt: new Date("2026-04-10T00:10:30.000Z"),
contextSnapshot: { issueId: grandchildIssueId },
},
// sibling run NOT under root should be excluded
{
id: siblingRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:20:00.000Z"),
finishedAt: new Date("2026-04-10T00:21:00.000Z"),
contextSnapshot: { issueId: siblingIssueId },
},
// Still-running run on child (no finishedAt) should contribute (now - startedAt)
{
id: livePartialRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "running",
startedAt: new Date(Date.now() - 5_000),
contextSnapshot: { issueId: childIssueId },
},
]);
await db.insert(activityLog).values({
companyId,
runId: linkedViaActivityRunId,
actorType: "agent",
actorId: agentId,
agentId,
action: "issue.checked_out",
entityType: "issue",
entityId: childIssueId,
details: {},
});
const summary = await costs.issueTreeSummary(companyId, rootIssueId);
expect(summary.issueCount).toBe(3);
// 3 finished runs in tree (root, child via activity, grandchild) + 1 live run
expect(summary.runCount).toBe(4);
// 60s + 120s + 30s = 210s = 210_000ms from finished runs.
// Live run adds ~5_000ms; allow some slack so the assertion isn't flaky.
expect(summary.runtimeMs).toBeGreaterThanOrEqual(210_000 + 4_000);
expect(summary.runtimeMs).toBeLessThan(210_000 + 60_000);
// excludeRoot drops the root issue's own runs (the 60s contextSnapshot run)
// while keeping the child + grandchild runs and any live child run.
const descendantsOnly = await costs.issueTreeSummary(companyId, rootIssueId, {
excludeRoot: true,
});
expect(descendantsOnly.issueCount).toBe(2);
expect(descendantsOnly.runCount).toBe(3);
// 120s + 30s = 150s + ~5s live run
expect(descendantsOnly.runtimeMs).toBeGreaterThanOrEqual(150_000 + 4_000);
expect(descendantsOnly.runtimeMs).toBeLessThan(150_000 + 60_000);
});
it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => {
const companyId = randomUUID();

View File

@@ -109,7 +109,7 @@ function registerModuleMocks() {
}));
}
async function createApp() {
async function createApp(db: unknown = {}) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
@@ -126,7 +126,7 @@ async function createApp() {
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use("/api", issueRoutes(db as any, {} as any));
app.use(errorHandler);
return app;
}
@@ -266,6 +266,88 @@ describe("issue activity event routes", () => {
});
}, 15_000);
it("logs successful_run_handoff_resolved when an in_progress issue transitions to done with a pending required handoff", async () => {
const issue = { ...makeIssue(), status: "in_progress" };
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const handoffActivityRow = {
entityId: issue.id,
action: "issue.successful_run_handoff_required",
agentId: issue.assigneeAgentId,
runId: "run-1",
details: {
sourceRunId: "run-1",
correctiveRunId: "run-2",
},
createdAt: new Date("2026-05-01T00:00:00.000Z"),
};
const dbMock = {
select: () => ({
from: () => ({
where: () => ({
orderBy: async () => [handoffActivityRow],
}),
}),
}),
};
const res = await request(await createApp(dbMock))
.patch(`/api/issues/${issue.id}`)
.send({ status: "done" });
expect(res.status).toBe(200);
await vi.waitFor(() => {
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.successful_run_handoff_resolved",
entityId: issue.id,
details: expect.objectContaining({
identifier: "PAP-580",
sourceRunId: "run-1",
correctiveRunId: "run-2",
resolvedByStatus: "done",
}),
}),
);
});
});
it("does not log successful_run_handoff_resolved when status stays in_progress", async () => {
const issue = { ...makeIssue(), status: "in_progress" };
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const dbMock = {
select: () => ({
from: () => ({
where: () => ({
orderBy: async () => [],
}),
}),
}),
};
const res = await request(await createApp(dbMock))
.patch(`/api/issues/${issue.id}`)
.send({ title: "Updated title" });
expect(res.status).toBe(200);
expect(mockLogActivity).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "issue.successful_run_handoff_resolved" }),
);
});
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
const existingPolicy = normalizeIssueExecutionPolicy({
stages: [

View File

@@ -3134,6 +3134,130 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
expect(persisted?.healthStatus).toBe("unknown");
expect(persisted?.stoppedAt).toBeTruthy();
});
it("restarts a stopped auto-port service on the same port when it is available", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-port-reuse-"));
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Codex Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Runtime port reuse test",
status: "active",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Execution workspace port reuse test",
status: "active",
cwd: workspaceRoot,
providerType: "local_fs",
providerRef: workspaceRoot,
});
const actor = {
id: agentId,
name: "Codex Coder",
companyId,
};
const workspace = {
...buildWorkspace(workspaceRoot),
projectId,
workspaceId: null,
};
const config = {
workspaceRuntime: {
services: [
{
name: "web",
command:
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"",
port: { type: "auto" },
readiness: {
type: "http",
urlTemplate: "http://127.0.0.1:{{port}}",
timeoutSec: 10,
intervalMs: 100,
},
expose: {
type: "url",
urlTemplate: "http://127.0.0.1:{{port}}",
},
lifecycle: "shared",
reuseScope: "execution_workspace",
stopPolicy: {
type: "manual",
},
},
],
},
};
const first = await startRuntimeServicesForWorkspaceControl({
db,
actor,
issue: null,
workspace,
executionWorkspaceId,
config,
adapterEnv: {},
});
expect(first).toHaveLength(1);
expect(first[0]?.port).toBeGreaterThan(0);
await expect(fetch(first[0]!.url!)).resolves.toMatchObject({ ok: true });
await stopRuntimeServicesForExecutionWorkspace({
db,
executionWorkspaceId,
workspaceCwd: workspace.cwd,
});
await expect(fetch(first[0]!.url!)).rejects.toThrow();
const second = await startRuntimeServicesForWorkspaceControl({
db,
actor,
issue: null,
workspace,
executionWorkspaceId,
config,
adapterEnv: {},
});
expect(second).toHaveLength(1);
expect(second[0]?.id).toBe(first[0]?.id);
expect(second[0]?.port).toBe(first[0]?.port);
expect(second[0]?.url).toBe(first[0]?.url);
await expect(fetch(second[0]!.url!)).resolves.toMatchObject({ ok: true });
await stopRuntimeServicesForExecutionWorkspace({
db,
executionWorkspaceId,
workspaceCwd: workspace.cwd,
});
});
});
describe("normalizeAdapterManagedRuntimeServices", () => {

View File

@@ -145,7 +145,8 @@ export function costRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
const summary = await costs.issueTreeSummary(issue.companyId, issue.id);
const excludeRoot = req.query.excludeRoot === "true" || req.query.excludeRoot === "1";
const summary = await costs.issueTreeSummary(issue.companyId, issue.id, { excludeRoot });
res.json(summary);
});

View File

@@ -2,8 +2,9 @@ import { randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import multer from "multer";
import { z } from "zod";
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { issueExecutionDecisions } from "@paperclipai/db";
import { activityLog, issueExecutionDecisions } from "@paperclipai/db";
import {
addIssueCommentSchema,
acceptIssueThreadInteractionSchema,
@@ -32,6 +33,7 @@ import {
isClosedIsolatedExecutionWorkspace,
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
type ExecutionWorkspace,
type SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
@@ -78,6 +80,7 @@ import { executionWorkspaceService as executionWorkspaceServiceDirect } from "..
import { feedbackService } from "../services/feedback.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { environmentService } from "../services/environments.js";
import { redactSensitiveText } from "../redaction.js";
import {
applyIssueExecutionPolicyTransition,
normalizeIssueExecutionPolicy,
@@ -113,6 +116,105 @@ type ExecutionStageWakeContext = {
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
allowedActions: string[];
};
type SuccessfulRunHandoffActivityRow = {
entityId: string;
action: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
};
const SUCCESSFUL_RUN_HANDOFF_ACTIONS = [
"issue.successful_run_handoff_required",
"issue.successful_run_handoff_resolved",
"issue.successful_run_handoff_escalated",
] as const;
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function successfulRunHandoffStateFromActivity(row: {
action: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
}): SuccessfulRunHandoffState | null {
const details = row.details ?? {};
const state =
row.action === "issue.successful_run_handoff_required"
? "required"
: row.action === "issue.successful_run_handoff_resolved"
? "resolved"
: row.action === "issue.successful_run_handoff_escalated"
? "escalated"
: null;
if (!state) return null;
const detectedProgressSummary =
readNonEmptyString(details.detectedProgressSummary)
?? readNonEmptyString(details.detected_progress_summary)
?? null;
return {
state,
required: state === "required",
sourceRunId:
readNonEmptyString(details.sourceRunId)
?? readNonEmptyString(details.source_run_id)
?? readNonEmptyString(details.resumeFromRunId)
?? row.runId
?? null,
correctiveRunId:
readNonEmptyString(details.correctiveRunId)
?? readNonEmptyString(details.corrective_run_id)
?? (state !== "required" ? row.runId : null),
assigneeAgentId:
readNonEmptyString(details.assigneeAgentId)
?? readNonEmptyString(details.agentId)
?? row.agentId
?? null,
detectedProgressSummary: detectedProgressSummary
? redactSensitiveText(detectedProgressSummary)
: null,
createdAt: row.createdAt,
};
}
async function listSuccessfulRunHandoffStates(
db: Db,
companyId: string,
issueIds: string[],
): Promise<Map<string, SuccessfulRunHandoffState>> {
if (issueIds.length === 0) return new Map();
const rows = await db
.select({
entityId: activityLog.entityId,
action: activityLog.action,
agentId: activityLog.agentId,
runId: activityLog.runId,
details: activityLog.details,
createdAt: activityLog.createdAt,
})
.from(activityLog)
.where(and(
eq(activityLog.companyId, companyId),
eq(activityLog.entityType, "issue"),
inArray(activityLog.entityId, issueIds),
inArray(activityLog.action, [...SUCCESSFUL_RUN_HANDOFF_ACTIONS]),
))
.orderBy(activityLog.entityId, desc(activityLog.createdAt), desc(activityLog.id)) as SuccessfulRunHandoffActivityRow[];
const states = new Map<string, SuccessfulRunHandoffState>();
for (const row of rows) {
if (states.has(row.entityId)) continue;
const state = successfulRunHandoffStateFromActivity(row);
if (state) states.set(row.entityId, state);
}
return states;
}
function executionPrincipalsEqual(
left: ParsedExecutionState["currentParticipant"] | null,
@@ -2435,6 +2537,33 @@ export function issueRoutes(
},
});
if (existing.status === "in_progress" && issue.status !== existing.status && issue.status !== "in_progress") {
await listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id])
.then(async (handoffStates) => {
const handoff = handoffStates.get(issue.id);
if (handoff?.state !== "required") return;
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.successful_run_handoff_resolved",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
sourceRunId: handoff.sourceRunId,
correctiveRunId: handoff.correctiveRunId,
resolvedByStatus: issue.status,
},
});
})
.catch((err) => {
logger.warn({ err, issueId: issue.id }, "failed to log successful run handoff resolution");
});
}
if (Array.isArray(req.body.blockedByIssueIds)) {
const previousBlockedByIds = new Set((existingRelations?.blockedBy ?? []).map((relation) => relation.id));
const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]);

View File

@@ -1,7 +1,7 @@
import { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import type { Db } from "@paperclipai/db";
import { activityLog, agents, companies, costEvents, issues, projects } from "@paperclipai/db";
import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
import { budgetService, type BudgetServiceHooks } from "./budgets.js";
@@ -135,18 +135,53 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
};
},
issueTreeSummary: async (companyId: string, issueId: string) => {
issueTreeSummary: async (
companyId: string,
issueId: string,
options: { excludeRoot?: boolean } = {},
) => {
// Callers must resolve and authorize a visible root issue before invoking this.
// The route does that so zero counts are not mistaken for a missing root.
const childIssues = alias(issues, "child");
const issueTreeCondition = sql<boolean>`
${issues.id} IN (
WITH RECURSIVE issue_tree(id) AS (
// The seed of the recursive CTE: when excludeRoot is true, start from
// the direct children so the root issue itself is not counted.
const cteSeed = options.excludeRoot
? sql`
SELECT ${issues.id}
FROM ${issues}
WHERE ${issues.companyId} = ${companyId}
AND ${issues.parentId} = ${issueId}
AND ${issues.hiddenAt} IS NULL
`
: sql`
SELECT ${issues.id}
FROM ${issues}
WHERE ${issues.companyId} = ${companyId}
AND ${issues.id} = ${issueId}
AND ${issues.hiddenAt} IS NULL
`;
const cteSeedText = options.excludeRoot
? sql`
SELECT (${issues.id})::text AS id
FROM ${issues}
WHERE ${issues.companyId} = ${companyId}
AND ${issues.parentId} = ${issueId}
AND ${issues.hiddenAt} IS NULL
`
: sql`
SELECT (${issues.id})::text AS id
FROM ${issues}
WHERE ${issues.companyId} = ${companyId}
AND ${issues.id} = ${issueId}
AND ${issues.hiddenAt} IS NULL
`;
const issueTreeCondition = sql<boolean>`
${issues.id} IN (
WITH RECURSIVE issue_tree(id) AS (
${cteSeed}
UNION ALL
SELECT ${childIssues.id}
FROM ${issues} ${childIssues}
@@ -158,38 +193,80 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
)
`;
const [row] = await db
.select({
issueCount: sql<number>`count(distinct ${issues.id})::int`,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
})
.from(issues)
.leftJoin(
costEvents,
and(
eq(costEvents.companyId, companyId),
eq(costEvents.issueId, issues.id),
),
const runSummarySql = sql`
WITH RECURSIVE issue_tree(id) AS (
${cteSeedText}
UNION ALL
SELECT (${childIssues.id})::text
FROM ${issues} ${childIssues}
JOIN issue_tree ON (${childIssues.parentId})::text = issue_tree.id
WHERE ${childIssues.companyId} = ${companyId}
AND ${childIssues.hiddenAt} IS NULL
)
.where(
and(
eq(issues.companyId, companyId),
isNull(issues.hiddenAt),
issueTreeCondition,
SELECT
count(distinct ${heartbeatRuns.id})::int AS "runCount",
coalesce(sum(extract(epoch from (coalesce(${heartbeatRuns.finishedAt}, now()) - ${heartbeatRuns.startedAt})) * 1000), 0)::double precision AS "runtimeMs"
FROM ${heartbeatRuns}
WHERE ${heartbeatRuns.companyId} = ${companyId}
AND ${heartbeatRuns.startedAt} IS NOT NULL
AND (
${heartbeatRuns.contextSnapshot} ->> 'issueId' IN (SELECT id FROM issue_tree)
OR EXISTS (
SELECT 1
FROM ${activityLog}
JOIN issue_tree ON ${activityLog.entityId} = issue_tree.id
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.runId} = ${heartbeatRuns.id}
)
)
`;
// Run cost-event aggregation and run-duration aggregation in parallel.
// They're separate queries because cost_events fan out per-event and
// joining heartbeat_runs through them would double-count run durations.
const [costRowResult, runRowResult] = await Promise.all([
db
.select({
issueCount: sql<number>`count(distinct ${issues.id})::int`,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
})
.from(issues)
.leftJoin(
costEvents,
and(
eq(costEvents.companyId, companyId),
eq(costEvents.issueId, issues.id),
),
)
.where(
and(
eq(issues.companyId, companyId),
isNull(issues.hiddenAt),
issueTreeCondition,
),
),
);
db.execute(runSummarySql),
]);
const costRow = costRowResult[0];
const runRow = Array.isArray(runRowResult)
? (runRowResult[0] as { runCount?: number | string | null; runtimeMs?: number | string | null } | undefined)
: undefined;
return {
issueId,
issueCount: Number(row?.issueCount ?? 0),
issueCount: Number(costRow?.issueCount ?? 0),
includeDescendants: true,
costCents: Number(row?.costCents ?? 0),
inputTokens: Number(row?.inputTokens ?? 0),
cachedInputTokens: Number(row?.cachedInputTokens ?? 0),
outputTokens: Number(row?.outputTokens ?? 0),
costCents: Number(costRow?.costCents ?? 0),
inputTokens: Number(costRow?.inputTokens ?? 0),
cachedInputTokens: Number(costRow?.cachedInputTokens ?? 0),
outputTokens: Number(costRow?.outputTokens ?? 0),
runCount: Number(runRow?.runCount ?? 0),
runtimeMs: Number(runRow?.runtimeMs ?? 0),
};
},

View File

@@ -108,6 +108,11 @@ interface RuntimeServiceRecord extends RuntimeServiceRef {
processGroupId: number | null;
}
type StoppedRuntimeServiceReuseCandidate = {
id: string;
port: number | null;
};
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
const runtimeServicesByReuseKey = new Map<string, string>();
const runtimeServiceLeasesByRun = new Map<string, string[]>();
@@ -1815,6 +1820,33 @@ async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeSe
});
}
async function findStoppedRuntimeServiceReuseCandidate(input: {
db?: Db;
companyId: string;
reuseKey: string | null;
}): Promise<StoppedRuntimeServiceReuseCandidate | null> {
if (!input.db || !input.reuseKey) return null;
const row = await input.db
.select({
id: workspaceRuntimeServices.id,
port: workspaceRuntimeServices.port,
})
.from(workspaceRuntimeServices)
.where(
and(
eq(workspaceRuntimeServices.companyId, input.companyId),
eq(workspaceRuntimeServices.reuseKey, input.reuseKey),
eq(workspaceRuntimeServices.provider, "local_process"),
eq(workspaceRuntimeServices.status, "stopped"),
),
)
.orderBy(desc(workspaceRuntimeServices.updatedAt))
.limit(1)
.then((rows) => rows[0] ?? null);
return row ?? null;
}
function clearIdleTimer(record: RuntimeServiceRecord) {
if (!record.idleTimer) return;
clearTimeout(record.idleTimer);
@@ -1927,9 +1959,20 @@ async function startLocalRuntimeService(input: {
const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint;
const explicitPort = identity.explicitPort;
const identityPort = identity.identityPort;
const stoppedReuseCandidate = await findStoppedRuntimeServiceReuseCandidate({
db: input.db,
companyId: input.agent.companyId,
reuseKey: input.reuseKey,
});
const reusableStoppedPort =
asString(portConfig.type, "") === "auto" && stoppedReuseCandidate?.port
? (await readLocalServicePortOwner(stoppedReuseCandidate.port))
? null
: stoppedReuseCandidate.port
: null;
const port =
asString(portConfig.type, "") === "auto"
? await allocatePort()
? (reusableStoppedPort ?? await allocatePort())
: explicitPort > 0
? explicitPort
: null;
@@ -2073,7 +2116,7 @@ async function startLocalRuntimeService(input: {
}
const record: RuntimeServiceRecord = {
id: randomUUID(),
id: stoppedReuseCandidate?.id ?? randomUUID(),
companyId: input.agent.companyId,
projectId: input.workspace.projectId,
projectWorkspaceId: input.workspace.workspaceId,

View File

@@ -107,6 +107,7 @@ function boardRoutes() {
<Route path="routines" element={<Routines />} />
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
@@ -304,6 +305,7 @@ export function App() {
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/services" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />

View File

@@ -174,7 +174,10 @@ export const issuesApi = {
getComment: (id: string, commentId: string) =>
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
getCostSummary: (id: string) => api.get<IssueCostSummary>(`/issues/${id}/cost-summary`),
getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => {
const qs = options.excludeRoot ? "?excludeRoot=true" : "";
return api.get<IssueCostSummary>(`/issues/${id}/cost-summary${qs}`);
},
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters ?? {})) {

View File

@@ -11,7 +11,7 @@ const mockHeartbeatsApi = vi.hoisted(() => ({
}));
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
get: vi.fn(),
}));
vi.mock("@/lib/router", () => ({
@@ -55,6 +55,20 @@ async function flushReact() {
});
}
async function waitForMicrotaskAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let index = 0; index < attempts; index += 1) {
await flushReact();
try {
assertion();
return;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
function createRun(index: number) {
return {
id: `run-${index}`,
@@ -71,6 +85,37 @@ function createRun(index: number) {
};
}
function createIssueRun(index: number, issueId: string) {
return {
...createRun(index),
issueId,
};
}
function createIssue(id: string, identifier: string, title: string) {
return {
id,
companyId: "company-1",
identifier,
title,
description: null,
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
parentId: null,
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
goalId: null,
labels: [],
blockedByIssueIds: [],
blocksIssueIds: [],
createdAt: "2026-04-24T12:00:00.000Z",
updatedAt: "2026-04-24T12:00:00.000Z",
};
}
describe("ActiveAgentsPanel", () => {
let container: HTMLDivElement;
@@ -78,7 +123,7 @@ describe("ActiveAgentsPanel", () => {
container = document.createElement("div");
document.body.appendChild(container);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([1, 2, 3, 4, 5].map(createRun));
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.get.mockRejectedValue(new Error("Issue not found"));
});
afterEach(() => {
@@ -149,4 +194,42 @@ describe("ActiveAgentsPanel", () => {
root.unmount();
});
});
it("loads exact visible run issues so task names render even when the issue list page would miss them", async () => {
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
createIssueRun(1, "65274215-0000-4000-8000-000000000000"),
]);
mockIssuesApi.get.mockResolvedValue(createIssue(
"65274215-0000-4000-8000-000000000000",
"PAP-3562",
"Phase 4B: Implement LLM Wiki distillation UI",
));
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ActiveAgentsPanel companyId="company-1" />
</QueryClientProvider>,
);
});
await flushReact();
await waitForMicrotaskAssertion(() => {
expect(mockIssuesApi.get).toHaveBeenCalledWith("65274215-0000-4000-8000-000000000000");
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?.getAttribute("href")).toBe("/issues/PAP-3562");
});
await act(async () => {
root.unmount();
});
});
});

View File

@@ -1,6 +1,6 @@
import { memo, useMemo } from "react";
import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { useQueries, useQuery } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import type { TranscriptEntry } from "../adapters";
@@ -56,19 +56,28 @@ export function ActiveAgentsPanel({
const runs = liveRuns ?? [];
const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]);
const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length);
const { data: issues } = useQuery({
queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }),
enabled: visibleRuns.length > 0,
const visibleIssueIds = useMemo(
() => [...new Set(visibleRuns.map((run) => run.issueId).filter((issueId): issueId is string => Boolean(issueId)))],
[visibleRuns],
);
const issueQueries = useQueries({
queries: visibleIssueIds.map((issueId) => ({
queryKey: queryKeys.issues.detail(issueId),
queryFn: () => issuesApi.get(issueId),
staleTime: 30_000,
retry: false,
})),
});
const issueById = useMemo(() => {
const map = new Map<string, Issue>();
for (const issue of issues ?? []) {
map.set(issue.id, issue);
for (const query of issueQueries) {
const issue = query.data;
if (issue) map.set(issue.id, issue);
}
return map;
}, [issues]);
}, [issueQueries]);
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
runs: visibleRuns,

View File

@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
return container;
}
describe("IssueBlockedNotice", () => {
it("renders a successful-run next-step notice without requiring blockers", () => {
const node = render(
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.textContent).toContain("This issue still needs a next step.");
expect(node.textContent).toContain("Corrective wake queued for CodexCoder");
expect(node.textContent).toContain("Detected progress: Updated the plan");
expect(node.textContent).not.toContain("Work on this issue is blocked until");
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
});
it("does not render when the issue is done even if a stale handoff state is required", () => {
const node = render(
<IssueBlockedNotice
issueStatus="done"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.textContent).toBe("");
});
it("does not render when the issue is cancelled even if blockers remain", () => {
const node = render(
<IssueBlockedNotice
issueStatus="cancelled"
blockers={[
{
id: "blocker-1",
identifier: "PAP-123",
title: "Blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
]}
/>,
);
expect(node.textContent).toBe("");
});
});

View File

@@ -1,5 +1,6 @@
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
import type { IssueBlockerAttention, IssueRelationIssueSummary, SuccessfulRunHandoffState } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { Link } from "@/lib/router";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
@@ -7,12 +8,18 @@ export function IssueBlockedNotice({
issueStatus,
blockers,
blockerAttention,
successfulRunHandoff,
agentName,
}: {
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
agentName?: string | null;
}) {
if (blockers.length === 0 && issueStatus !== "blocked") return null;
if (issueStatus === "done" || issueStatus === "cancelled") return null;
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
@@ -61,39 +68,87 @@ export function IssueBlockedNotice({
return (
<div
data-blocker-attention-state={blockerAttention?.state}
data-successful-run-handoff={showSuccessfulRunHandoff ? "required" : undefined}
className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 space-y-1.5">
<p className="leading-5">
{blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
</div>
{showSuccessfulRunHandoff ? (
<>
<p className="font-medium leading-5">This issue still needs a next step.</p>
<p className="leading-5">
A run finished successfully, but this issue is still open in{" "}
<code className="rounded bg-amber-100 px-1 py-0.5 text-[12px] dark:bg-amber-400/15">
in_progress
</code>{" "}
with no clear owner for the next action.
</p>
<ul className="list-disc space-y-1 pl-5 text-xs leading-5 text-amber-900 dark:text-amber-100">
<li>Mark it done or cancelled.</li>
<li>Send it for review or ask for input.</li>
<li>Mark it blocked with a blocker owner.</li>
<li>Delegate follow-up work or queue a continuation.</li>
</ul>
<div className="flex flex-wrap gap-1.5 text-xs">
{successfulRunHandoff.sourceRunId && successfulRunHandoff.assigneeAgentId ? (
<Link
to={`/agents/${successfulRunHandoff.assigneeAgentId}/runs/${successfulRunHandoff.sourceRunId}`}
className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
>
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
</Link>
) : successfulRunHandoff.sourceRunId ? (
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
</span>
) : null}
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 text-amber-900 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
Corrective wake queued for {agentName ?? "the assignee"}
</span>
</div>
{successfulRunHandoff.detectedProgressSummary ? (
<p className="text-xs leading-5 text-amber-800 dark:text-amber-200">
Detected progress: {successfulRunHandoff.detectedProgressSummary}
</p>
) : null}
</>
) : null}
{showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Stalled in review
</span>
{stalledLeafBlockers.map(renderBlockerChip)}
</div>
) : terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (
<div className="border-t border-amber-300/60 pt-1.5 dark:border-amber-500/30" />
) : null}
{blockers.length > 0 || issueStatus === "blocked" ? (
<>
<p className="leading-5">
{blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
</div>
) : null}
{showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Stalled in review
</span>
{stalledLeafBlockers.map(renderBlockerChip)}
</div>
) : terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
) : null}
</>
) : null}
</div>
</div>

View File

@@ -476,6 +476,59 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("removes a blocked-by issue from the chip remove action after confirmation", async () => {
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Existing blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
{
id: "issue-4",
identifier: "PAP-4",
title: "Keep blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
}),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const removeButton = container.querySelector('button[aria-label="Remove PAP-2 as blocker"]');
expect(removeButton).not.toBeNull();
await act(async () => {
removeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(document.body.textContent).toContain("Remove PAP-2: Existing blocker as a blocker for this issue.");
const confirmButton = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Remove blocker"));
expect(confirmButton).not.toBeUndefined();
await act(async () => {
confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] });
act(() => root.unmount());
});
it("shows a green service link above the workspace row for a live non-main workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const serviceUrl = "http://127.0.0.1:62475";
@@ -530,7 +583,7 @@ describe("IssueProperties", () => {
(link) => link.textContent?.trim() === "View workspace",
);
expect(tasksLink).not.toBeUndefined();
expect(tasksLink?.getAttribute("href")).toBe("/issues?workspace=workspace-1");
expect(tasksLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1/issues");
expect(workspaceLink).not.toBeUndefined();
expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1");

View File

@@ -10,7 +10,6 @@ import { instanceSettingsApi } from "../api/instanceSettings";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { resolveIssueFilterWorkspaceId } from "../lib/issue-filters";
import { queryKeys } from "../lib/queryKeys";
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
import { useProjectOrder } from "../hooks/useProjectOrder";
@@ -32,9 +31,19 @@ import { Identity } from "./Identity";
import { IssueReferencePill } from "./IssueReferencePill";
import { formatDate, cn, projectUrl } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, Clock } from "lucide-react";
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
@@ -113,10 +122,8 @@ function runningRuntimeServiceWithUrl(
return runtimeServices?.find((service) => service.status === "running" && service.url?.trim()) ?? null;
}
function issuesWorkspaceFilterHref(workspaceId: string) {
const params = new URLSearchParams();
params.append("workspace", workspaceId);
return `/issues?${params.toString()}`;
function executionWorkspaceIssuesHref(workspaceId: string) {
return `/execution-workspaces/${workspaceId}/issues`;
}
function toDateTimeLocalValue(value: string | null | undefined) {
@@ -144,6 +151,87 @@ function PropertyRow({ label, children }: { label: string; children: React.React
);
}
function RemovableIssueReferencePill({
issue,
onRemove,
}: {
issue: NonNullable<Issue["blockedBy"]>[number];
onRemove: (issueId: string) => void;
}) {
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const issueLabel = issue.identifier ?? issue.title;
const confirmLabel = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title;
const content = (
<>
<StatusIcon status={issue.status} className="h-3 w-3 shrink-0" />
<span className="truncate">{issueLabel}</span>
</>
);
const removeLabel = `Remove ${issueLabel} as blocker`;
const handleRemove = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setIsConfirmOpen(true);
};
const confirmRemove = () => {
onRemove(issue.id);
setIsConfirmOpen(false);
};
return (
<>
<span
data-mention-kind="issue"
className={cn(
"paperclip-mention-chip paperclip-mention-chip--issue group",
"inline-flex items-center gap-1 rounded-full border border-border py-0.5 pl-1 pr-2 text-xs",
)}
title={issue.title}
aria-label={`Issue ${issueLabel}: ${issue.title}`}
>
<button
type="button"
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-colors transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-ring group-hover:opacity-100"
aria-label={removeLabel}
title={removeLabel}
onClick={handleRemove}
>
<X className="h-3 w-3" />
</button>
{issue.identifier ? (
<Link
to={`/issues/${issueLabel}`}
className="inline-flex min-w-0 items-center gap-1 no-underline hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
aria-label={`Issue ${issueLabel}: ${issue.title}`}
>
{content}
</Link>
) : (
<span className="inline-flex min-w-0 items-center gap-1">{content}</span>
)}
</span>
<Dialog open={isConfirmOpen} onOpenChange={setIsConfirmOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Remove blocker?</DialogTitle>
<DialogDescription>
Remove {confirmLabel} as a blocker for this issue.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">Cancel</Button>
</DialogClose>
<Button type="button" variant="destructive" onClick={confirmRemove}>
Remove blocker
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
/** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */
function PropertyPicker({
inline,
@@ -331,10 +419,10 @@ export function IssueProperties({
() => isMainIssueWorkspace({ issue, project: issueProject }),
[issue, issueProject],
);
const workspaceFilterId = useMemo(() => {
const workspaceTasksExecutionWorkspaceId = useMemo(() => {
if (!isolatedWorkspacesEnabled) return null;
if (issueUsesMainWorkspace) return null;
return resolveIssueFilterWorkspaceId(issue);
return issue.executionWorkspaceId ?? issue.currentExecutionWorkspace?.id ?? null;
}, [isolatedWorkspacesEnabled, issue, issueUsesMainWorkspace]);
const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace;
const liveWorkspaceService = useMemo(() => {
@@ -1137,6 +1225,9 @@ export function IssueProperties({
: [...blockedByIds, blockedByIssueId];
onUpdate({ blockedByIssueIds: nextBlockedByIds });
};
const removeBlockedBy = (blockedByIssueId: string) => {
onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) });
};
const blockedByContent = (
<>
@@ -1284,7 +1375,7 @@ export function IssueProperties({
<div>
<PropertyRow label="Blocked by">
{(issue.blockedBy ?? []).map((relation) => (
<IssueReferencePill key={relation.id} issue={relation} />
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
))}
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
</PropertyRow>
@@ -1297,7 +1388,7 @@ export function IssueProperties({
) : (
<PropertyRow label="Blocked by">
{(issue.blockedBy ?? []).map((relation) => (
<IssueReferencePill key={relation.id} issue={relation} />
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
))}
<Popover
open={blockedByOpen}
@@ -1448,10 +1539,10 @@ export function IssueProperties({
</Link>
</PropertyRow>
)}
{workspaceFilterId && (
{workspaceTasksExecutionWorkspaceId && (
<PropertyRow label="Tasks">
<Link
to={issuesWorkspaceFilterHref(workspaceFilterId)}
to={executionWorkspaceIssuesHref(workspaceTasksExecutionWorkspaceId)}
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
View workspace tasks

View File

@@ -41,7 +41,7 @@ import {
resolveIssueWorkspaceName,
type InboxIssueColumn,
} from "../lib/inbox";
import { cn } from "../lib/utils";
import { cn, formatDurationMs, formatTokens } from "../lib/utils";
import {
InboxIssueMetaLeading,
InboxIssueTrailingColumns,
@@ -113,7 +113,7 @@ export type IssueSortField = "status" | "priority" | "title" | "created" | "upda
export type IssueViewState = IssueFilterState & {
sortField: IssueSortField;
sortDir: "asc" | "desc";
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
groupBy: "status" | "priority" | "assignee" | "project" | "workspace" | "parent" | "none";
viewMode: "list" | "board";
nestingEnabled: boolean;
collapsedGroups: string[];
@@ -363,6 +363,12 @@ interface IssuesListProps {
createIssueLabel?: string;
defaultSortField?: IssueSortField;
showProgressSummary?: boolean;
/**
* When set together with `showProgressSummary`, the progress strip fetches
* the recursive cost-summary for this parent issue and renders aggregate
* tokens + wall-clock runtime for every run in the tree.
*/
parentIssueIdForCostSummary?: string;
enableRoutineVisibilityFilter?: boolean;
hasMoreIssues?: boolean;
isLoadingMoreIssues?: boolean;
@@ -438,9 +444,11 @@ function IssueSearchInput({
function SubIssueProgressSummaryStrip({
summary,
issueLinkState,
parentIssueIdForCostSummary,
}: {
summary: SubIssueProgressSummary;
issueLinkState?: unknown;
parentIssueIdForCostSummary?: string;
}) {
const target = summary.target;
const targetIssue = target?.issue ?? null;
@@ -450,6 +458,21 @@ function SubIssueProgressSummaryStrip({
.map((status) => ({ status, count: summary.countsByStatus[status] ?? 0 }))
.filter((entry) => entry.count > 0);
// Refresh fast enough that the runtime ticks up while a sub-issue is still
// running, but slow enough not to hammer the recursive CTE on idle trees.
const hasInProgress = summary.inProgressCount > 0;
const { data: costSummary } = useQuery({
queryKey: queryKeys.issues.costSummary(parentIssueIdForCostSummary ?? "pending", { excludeRoot: true }),
queryFn: () => issuesApi.getCostSummary(parentIssueIdForCostSummary!, { excludeRoot: true }),
enabled: !!parentIssueIdForCostSummary,
refetchInterval: hasInProgress ? 5_000 : false,
});
const totalTokens = costSummary
? costSummary.inputTokens + costSummary.cachedInputTokens + costSummary.outputTokens
: 0;
const showCostSummary = !!costSummary && (costSummary.runCount > 0 || totalTokens > 0);
return (
<div className="border border-border bg-background p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
@@ -464,6 +487,23 @@ function SubIssueProgressSummaryStrip({
<span className="text-muted-foreground">
{summary.blockedCount} blocked
</span>
{showCostSummary && (
<>
<span
className="text-muted-foreground tabular-nums"
title={`${costSummary.runCount.toLocaleString()} run${
costSummary.runCount === 1 ? "" : "s"
} across ${costSummary.issueCount} sub-issue${
costSummary.issueCount === 1 ? "" : "s"
}`}
>
{formatTokens(totalTokens)} tokens
</span>
<span className="text-muted-foreground tabular-nums">
{formatDurationMs(costSummary.runtimeMs)} runtime
</span>
</>
)}
</div>
<div
role="progressbar"
@@ -535,6 +575,7 @@ export function IssuesList({
createIssueLabel,
defaultSortField,
showProgressSummary = false,
parentIssueIdForCostSummary,
enableRoutineVisibilityFilter = false,
hasMoreIssues = false,
isLoadingMoreIssues = false,
@@ -995,6 +1036,22 @@ export function IssuesList({
items: groups[key]!,
}));
}
if (viewState.groupBy === "project") {
const groups = groupBy(filtered, (issue) => issue.projectId ?? "__no_project");
return Object.keys(groups)
.sort((a, b) => {
if (a === "__no_project") return 1;
if (b === "__no_project") return -1;
const labelA = projectById.get(a)?.name ?? a;
const labelB = projectById.get(b)?.name ?? b;
return labelA.localeCompare(labelB);
})
.map((key) => ({
key,
label: key === "__no_project" ? "No Project" : (projectById.get(key)?.name ?? key.slice(0, 8)),
items: groups[key]!,
}));
}
if (viewState.groupBy === "parent") {
const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent");
return Object.keys(groups)
@@ -1035,6 +1092,7 @@ export function IssuesList({
workspaceNameMap,
issueTitleMap,
companyUserLabelMap,
projectById,
]);
useEffect(() => {
@@ -1130,6 +1188,7 @@ export function IssuesList({
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
else defaults.assigneeAgentId = groupKey;
}
else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey;
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
const parentIssue = issueById.get(groupKey);
if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId));
@@ -1174,7 +1233,11 @@ export function IssuesList({
return (
<div ref={rootRef} className="space-y-4">
{progressSummary ? (
<SubIssueProgressSummaryStrip summary={progressSummary} issueLinkState={issueLinkState} />
<SubIssueProgressSummaryStrip
summary={progressSummary}
issueLinkState={issueLinkState}
parentIssueIdForCostSummary={parentIssueIdForCostSummary}
/>
) : null}
{/* Toolbar */}
@@ -1306,6 +1369,7 @@ export function IssuesList({
["status", "Status"],
["priority", "Priority"],
["assignee", "Assignee"],
["project", "Project"],
["workspace", "Workspace"],
["parent", "Parent Issue"],
["none", "None"],

View File

@@ -356,6 +356,20 @@ describe("MarkdownBody", () => {
expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"');
});
it("renders markdown tables in a horizontally scrollable region", () => {
const html = renderMarkdown([
"| Time UTC | Source | Finding | Stalled leaf | Escalation |",
"| --- | --- | --- | --- | --- |",
"| 2026-04-30T14:31:35Z | PAP-2505 | in_review_without_action_path | PAP-2779 | PAP-2910 |",
].join("\n"));
expect(html).toContain('class="paperclip-markdown-table-scroll"');
expect(html).toContain('aria-label="Scrollable table"');
expect(html).toContain('tabindex="0"');
expect(html).toContain("<table>");
expect(html).toContain('style="overflow-wrap:anywhere;word-break:normal"');
});
it("opens external links in a new tab with safe rel attributes", () => {
const html = renderMarkdown("[docs](https://example.com/docs)");

View File

@@ -84,6 +84,11 @@ const scrollableBlockStyle: React.CSSProperties = {
overflowX: "auto",
};
const tableCellWrapStyle: React.CSSProperties = {
overflowWrap: "anywhere",
wordBreak: "normal",
};
function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties {
return {
...wrapAnywhereStyle,
@@ -91,6 +96,13 @@ function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties {
};
}
function mergeTableCellStyle(style?: React.CSSProperties): React.CSSProperties {
return {
...tableCellWrapStyle,
...style,
};
}
function mergeScrollableBlockStyle(style?: React.CSSProperties): React.CSSProperties {
return {
...scrollableBlockStyle,
@@ -514,13 +526,20 @@ export function MarkdownBody({
{blockquoteChildren}
</blockquote>
),
table: ({ node: _node, style: tableStyle, children: tableChildren, ...tableProps }) => (
<div className="paperclip-markdown-table-scroll" role="region" aria-label="Scrollable table" tabIndex={0}>
<table {...tableProps} style={tableStyle as React.CSSProperties | undefined}>
{tableChildren}
</table>
</div>
),
td: ({ node: _node, style: tableCellStyle, children: tableCellChildren, ...tableCellProps }) => (
<td {...tableCellProps} style={mergeWrapStyle(tableCellStyle as React.CSSProperties | undefined)}>
<td {...tableCellProps} style={mergeTableCellStyle(tableCellStyle as React.CSSProperties | undefined)}>
{tableCellChildren}
</td>
),
th: ({ node: _node, style: tableHeaderStyle, children: tableHeaderChildren, ...tableHeaderProps }) => (
<th {...tableHeaderProps} style={mergeWrapStyle(tableHeaderStyle as React.CSSProperties | undefined)}>
<th {...tableHeaderProps} style={mergeTableCellStyle(tableHeaderStyle as React.CSSProperties | undefined)}>
{tableHeaderChildren}
</th>
),

View File

@@ -411,6 +411,84 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
it("applies project and execution workspace defaults for normal new issues", async () => {
mockProjectsApi.list.mockResolvedValue([
{
id: "project-1",
name: "Alpha",
description: null,
archivedAt: null,
color: "#445566",
workspaces: [
{
id: "project-workspace-1",
name: "Primary",
isPrimary: true,
},
{
id: "project-workspace-2",
name: "Isolated checkout",
isPrimary: false,
},
],
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
},
},
]);
mockExecutionWorkspacesApi.list.mockResolvedValue([
{
id: "workspace-1",
name: "PAP-100",
mode: "isolated_workspace",
status: "active",
branchName: "feature/pap-100",
cwd: "/tmp/workspace-1",
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
},
]);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
dialogState.newIssueDefaults = {
title: "Follow-up issue",
projectId: "project-1",
projectWorkspaceId: "project-workspace-2",
executionWorkspaceId: "workspace-1",
};
const { root } = renderDialog(container);
await flush();
expect(container.textContent).toContain("New issue");
expect(container.textContent).not.toContain("New sub-issue");
expect(container.textContent).toContain("Reusing PAP-100");
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Create Issue"));
expect(submitButton).not.toBeUndefined();
await act(async () => {
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockIssuesApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
title: "Follow-up issue",
projectId: "project-1",
projectWorkspaceId: "project-workspace-2",
executionWorkspaceId: "workspace-1",
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
}),
);
act(() => root.unmount());
});
it("submits the latest locally typed title and description", async () => {
let resolveProjects: (projects: Array<{
id: string;

View File

@@ -242,6 +242,21 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
return "shared_workspace";
}
function defaultExecutionWorkspaceModeForIssueDefaults(
defaults: {
executionWorkspaceId?: unknown;
executionWorkspaceMode?: unknown;
},
project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined,
) {
if (typeof defaults.executionWorkspaceId === "string" && defaults.executionWorkspaceId.length > 0) {
return "reuse_existing";
}
return typeof defaults.executionWorkspaceMode === "string" && defaults.executionWorkspaceMode.length > 0
? defaults.executionWorkspaceMode
: defaultExecutionWorkspaceModeForProject(project);
}
const IssueTitleTextarea = memo(function IssueTitleTextarea({
value,
pending,
@@ -686,9 +701,7 @@ export function NewIssueDialog() {
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
const defaultProjectWorkspaceId = newIssueDefaults.projectWorkspaceId
?? defaultProjectWorkspaceIdForProject(defaultProject);
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
? "reuse_existing"
: (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject));
const defaultExecutionWorkspaceMode = defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject);
setIssueText(newIssueDefaults.title ?? "", newIssueDefaults.description ?? "");
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
@@ -710,8 +723,9 @@ export function NewIssueDialog() {
setPriority(newIssueDefaults.priority ?? "");
const defaultProjectId = newIssueDefaults.projectId ?? "";
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
setProjectId(defaultProjectId);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject));
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setReviewerValue("");
setApproverValue("");
@@ -720,12 +734,17 @@ export function NewIssueDialog() {
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
setSelectedExecutionWorkspaceId("");
executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null;
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject));
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
? defaultProjectId || null
: null;
} else if (draft && draft.title.trim()) {
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
const hasExplicitExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId !== undefined;
const hasExplicitExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceMode !== undefined;
setIssueText(draft.title, draft.description);
setStatus(draft.status || "todo");
setPriority(draft.priority);
@@ -739,27 +758,40 @@ export function NewIssueDialog() {
setShowReviewerRow(!!(draft.reviewerValue));
setShowApproverRow(!!(draft.approverValue));
setProjectId(restoredProjectId);
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
setProjectWorkspaceId(
hasExplicitProjectWorkspaceId
? (newIssueDefaults.projectWorkspaceId ?? "")
: (draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject)),
);
setAssigneeModelLane(draft.assigneeModelLane ?? "primary");
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
setAssigneeChrome(draft.assigneeChrome ?? false);
setExecutionWorkspaceMode(
draft.executionWorkspaceMode
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject)),
hasExplicitExecutionWorkspaceId || hasExplicitExecutionWorkspaceMode
? defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, restoredProject)
: (
draft.executionWorkspaceMode
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject))
),
);
setSelectedExecutionWorkspaceId(draft.selectedExecutionWorkspaceId ?? "");
executionWorkspaceDefaultProjectId.current = draft.projectWorkspaceId || restoredProject
setSelectedExecutionWorkspaceId(
hasExplicitExecutionWorkspaceId
? (newIssueDefaults.executionWorkspaceId ?? "")
: (draft.selectedExecutionWorkspaceId ?? ""),
);
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || hasExplicitExecutionWorkspaceId || draft.projectWorkspaceId || restoredProject
? restoredProjectId || null
: null;
} else {
const defaultProjectId = newIssueDefaults.projectId ?? "";
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
setIssueText("", "");
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(defaultProjectId);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject));
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setReviewerValue("");
setApproverValue("");
@@ -768,9 +800,11 @@ export function NewIssueDialog() {
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
setSelectedExecutionWorkspaceId("");
executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null;
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject));
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
? defaultProjectId || null
: null;
}
}, [newIssueOpen, newIssueDefaults, orderedProjects, selectedCompanyId, setIssueText]);

View File

@@ -224,6 +224,74 @@ describe("RoutineRunVariablesDialog", () => {
});
});
it("keeps the mobile dialog bounded with an internal form scroll region", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<RoutineRunVariablesDialog
open
onOpenChange={() => {}}
companyId="company-1"
projects={[createProject()]}
agents={[createAgent()]}
defaultProjectId="project-1"
defaultAssigneeAgentId="agent-1"
variables={[
{
name: "notes",
label: "notes",
type: "textarea",
defaultValue: null,
required: false,
options: [],
},
]}
isPending={false}
onSubmit={() => {}}
/>
</QueryClientProvider>,
);
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
const dialogContent = Array.from(document.body.querySelectorAll("div")).find((element) =>
typeof element.className === "string" && element.className.includes("max-h-[calc(100dvh-2rem)]"),
);
expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]");
expect(dialogContent?.className).toContain("overflow-hidden");
const notesInput = document.querySelector("textarea");
const formScrollRegion = Array.from(document.body.querySelectorAll("div")).find((element) =>
typeof element.className === "string" && element.className.includes("overscroll-contain"),
);
expect(formScrollRegion?.className).toContain("min-h-0");
expect(formScrollRegion?.className).toContain("flex-1");
expect(formScrollRegion?.className).toContain("overflow-y-auto");
expect(formScrollRegion?.contains(notesInput)).toBe(true);
const footer = Array.from(document.body.querySelectorAll("div")).find((element) =>
typeof element.className === "string" && element.className.includes("pb-[calc(1rem+env(safe-area-inset-bottom))]"),
);
expect(footer?.className).toContain("shrink-0");
expect(footer?.contains(formScrollRegion ?? null)).toBe(false);
expect(footer?.textContent).toContain("Run routine");
await act(async () => {
root.unmount();
});
});
it("renders workspaceBranch as a read-only selected workspace value", async () => {
issueWorkspaceDraft = {
executionWorkspaceId: "workspace-1",

View File

@@ -335,8 +335,8 @@ export function RoutineRunVariablesDialog({
return (
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogContent className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] max-w-xl flex-col gap-0 overflow-hidden p-0 sm:h-auto sm:max-h-[min(calc(100dvh-2rem),42rem)]">
<DialogHeader className="shrink-0 border-b border-border/60 px-6 pb-4 pr-12 pt-6">
{routineName && (
<p className="text-muted-foreground text-sm">{routineName}</p>
)}
@@ -346,7 +346,7 @@ export function RoutineRunVariablesDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Agent *</Label>
@@ -520,7 +520,10 @@ export function RoutineRunVariablesDialog({
) : null}
</div>
<DialogFooter showCloseButton={false}>
<DialogFooter
showCloseButton={false}
className="shrink-0 border-t border-border/60 bg-background px-6 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4"
>
{!selection.assigneeAgentId ? (
<p className="mr-auto text-xs text-amber-600">Default agent required for this run.</p>
) : missingRequired.length > 0 ? (

View File

@@ -49,6 +49,13 @@ vi.mock("@/context/CompanyContext", () => ({
brandColor: "#36a269",
status: "active",
},
{
id: "company-3",
issuePrefix: "ANA",
name: "Anachronist Wiki",
brandColor: "#a36a21",
status: "active",
},
],
selectedCompany: {
id: "company-1",
@@ -143,6 +150,7 @@ describe("SidebarCompanyMenu", () => {
expect(document.body.textContent).toContain("Switch workspace");
expect(document.body.textContent).toContain("Strata");
expect(document.body.textContent).toContain("ANA");
expect(document.body.textContent).toContain("Add company...");
expect(document.body.textContent).toContain("Invite people to Acme Labs");
expect(document.body.textContent).toContain("Company settings");

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ChevronsUpDown, LogOut, Plus, Settings, UserPlus } from "lucide-react";
import type { Company } from "@paperclipai/shared";
@@ -46,7 +46,10 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
const navigate = useNavigate();
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const sidebarCompanies = companies.filter((company) => company.status !== "archived");
const sidebarCompanies = useMemo(
() => companies.filter((company) => company.status !== "archived"),
[companies],
);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
@@ -110,7 +113,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
<DropdownMenuLabel className="px-2 py-1.5 text-[11px] font-semibold uppercase text-muted-foreground">
Switch workspace
</DropdownMenuLabel>
<div className="max-h-72 overflow-y-auto">
<div className="max-h-96 overflow-y-auto">
{sidebarCompanies.map((company) => {
const isSelected = company.id === selectedCompany?.id;
return (
@@ -124,6 +127,9 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
>
<WorkspaceIcon company={company} />
<span className="min-w-0 flex-1 truncate">{company.name}</span>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{company.issuePrefix}
</span>
{isSelected ? <Check className="size-4 text-muted-foreground" /> : null}
</DropdownMenuItem>
);

View File

@@ -71,11 +71,36 @@ describe("StatusIcon", () => {
);
expect(html).not.toContain('data-blocker-attention-state="covered"');
expect(html).toContain('aria-label="Blocked · 1 unresolved blocker needs attention"');
expect(html).toContain('data-blocker-attention-state="needs_attention"');
expect(html).toContain('aria-label="Blocked · 1 blocker needs attention"');
expect(html).toContain("border-red-600");
expect(html).not.toContain("border-dashed");
});
it("shows active covered work on mixed attention-required blockers", () => {
const html = renderToStaticMarkup(
<StatusIcon
status="blocked"
blockerAttention={{
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 5,
coveredBlockerCount: 2,
stalledBlockerCount: 0,
attentionBlockerCount: 3,
sampleBlockerIdentifier: "PAP-3541",
sampleStalledBlockerIdentifier: null,
}}
/>,
);
expect(html).toContain('data-blocker-attention-state="needs_attention"');
expect(html).toContain('aria-label="Blocked · 3 blockers need attention; 2 covered by active work"');
expect(html).toContain("border-red-600");
expect(html).not.toContain("border-cyan-600");
expect(html).toContain("bg-cyan-600");
});
it("renders stalled review chains with amber visual and stalled-leaf copy", () => {
const html = renderToStaticMarkup(
<StatusIcon

View File

@@ -49,8 +49,13 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
}
if (blockerAttention.reason === "attention_required") {
const count = blockerAttention.unresolvedBlockerCount;
return `Blocked · ${count} unresolved ${count === 1 ? "blocker needs" : "blockers need"} attention`;
const count = blockerAttention.attentionBlockerCount || blockerAttention.unresolvedBlockerCount;
const attentionCopy = `${count} ${count === 1 ? "blocker needs" : "blockers need"} attention`;
const coveredCount = blockerAttention.coveredBlockerCount;
if (coveredCount > 0) {
return `Blocked · ${attentionCopy}; ${coveredCount} covered by active work`;
}
return `Blocked · ${attentionCopy}`;
}
return "Blocked";
@@ -60,6 +65,8 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
const [open, setOpen] = useState(false);
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled";
const isAttentionBlocked = status === "blocked" && blockerAttention?.state === "needs_attention";
const hasCoveredBlockedWork = isAttentionBlocked && (blockerAttention?.coveredBlockerCount ?? 0) > 0;
const colorClass = isCoveredBlocked
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
: isStalledBlocked
@@ -71,7 +78,9 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
? "covered"
: isStalledBlocked
? "stalled"
: undefined;
: isAttentionBlocked
? "needs_attention"
: undefined;
const circle = (
<span
@@ -91,6 +100,9 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
{isCoveredBlocked && (
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
)}
{hasCoveredBlockedWork && (
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-cyan-600 dark:bg-cyan-400" />
)}
{isStalledBlocked && (
<span className="absolute inset-0 m-auto h-1.5 w-1.5 rounded-full bg-current" />
)}

View File

@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildWorkspaceRuntimeControlItems,
buildWorkspaceRuntimeControlSections,
WorkspaceRuntimeQuickControls,
WorkspaceRuntimeControls,
} from "./WorkspaceRuntimeControls";
@@ -293,6 +294,41 @@ describe("WorkspaceRuntimeControls", () => {
act(() => root.unmount());
});
it("lets quick action buttons inherit the shared button shape tokens", () => {
const sections = buildWorkspaceRuntimeControlSections({
runtimeConfig: {
commands: [
{ id: "web", name: "web", kind: "service", command: "pnpm dev" },
],
},
runtimeServices: [
createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }),
],
canStartServices: true,
});
const root = createRoot(container);
act(() => {
root.render(
<WorkspaceRuntimeQuickControls
sections={sections}
onAction={vi.fn()}
/>,
);
});
const buttons = Array.from(container.querySelectorAll("button"));
expect(buttons).toHaveLength(2);
for (const button of buttons) {
expect(button.className).toContain("rounded-md");
expect(button.className).not.toContain("rounded-none");
expect(button.className).not.toContain("rounded-xl");
expect(button.className).not.toContain("shadow-none");
}
act(() => root.unmount());
});
it("shows disabled actions when local command prerequisites are missing", () => {
const sections = buildWorkspaceRuntimeControlSections({
runtimeConfig: {

View File

@@ -192,6 +192,15 @@ export function buildWorkspaceRuntimeControlItems(input: {
}));
}
export function getRunningRuntimeServiceUrl(
sections: WorkspaceRuntimeControlSections,
) {
const runningService = [...sections.services, ...sections.otherServices].find(
(item) => (item.statusLabel === "running" || item.statusLabel === "starting") && item.url,
);
return runningService?.url ?? null;
}
function requestMatchesPending(
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined,
nextRequest: WorkspaceRuntimeControlRequest,
@@ -255,9 +264,8 @@ function CommandActionButtons({
variant={action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
size="sm"
className={cn(
"h-9 w-full justify-start px-3 shadow-none sm:w-auto",
square ? "rounded-none" : "rounded-xl",
action === "restart" ? "bg-background" : null,
"w-full justify-start sm:w-auto",
square ? "rounded-none" : null,
)}
disabled={disabled}
onClick={() => onAction(request)}
@@ -451,3 +459,56 @@ export function WorkspaceRuntimeControls({
</div>
);
}
export function WorkspaceRuntimeQuickControls({
sections,
isPending = false,
pendingRequest = null,
onAction,
square,
}: {
sections: WorkspaceRuntimeControlSections;
isPending?: boolean;
pendingRequest?: WorkspaceRuntimeControlRequest | null;
onAction: (request: WorkspaceRuntimeControlRequest) => void;
square?: boolean;
}) {
const controlItems = sections.services.length > 0 ? sections.services : sections.otherServices;
const serviceUrl = getRunningRuntimeServiceUrl(sections);
if (controlItems.length === 0 && !serviceUrl) return null;
return (
<div className="flex min-w-0 flex-col items-stretch gap-2 sm:items-end">
{controlItems.length > 0 ? (
<div className="flex max-w-full flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
{controlItems.map((item) => (
<div key={item.key} className="flex min-w-0 flex-col gap-1 sm:items-end">
{controlItems.length > 1 ? (
<span className="truncate text-xs text-muted-foreground">{item.title}</span>
) : null}
<CommandActionButtons
item={item}
isPending={isPending}
pendingRequest={pendingRequest}
onAction={onAction}
square={square}
/>
</div>
))}
</div>
) : null}
{serviceUrl ? (
<a
href={serviceUrl}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1 self-start break-all text-xs text-muted-foreground hover:text-foreground hover:underline sm:self-end"
>
{serviceUrl}
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
</a>
) : null}
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BreadcrumbProvider, useBreadcrumbs } from "./BreadcrumbContext";
import { BreadcrumbProvider, buildDocumentTitle, useBreadcrumbs } from "./BreadcrumbContext";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -58,4 +58,21 @@ describe("BreadcrumbContext", () => {
expect(renderCounts).toHaveLength(2);
});
it("builds page titles with the selected company name before Paperclip", () => {
expect(buildDocumentTitle([{ label: "Inbox" }], "Anachronist Wiki")).toBe(
"Inbox • Anachronist Wiki • Paperclip",
);
expect(
buildDocumentTitle(
[{ label: "Issues", href: "/issues" }, { label: "PAP-3515" }],
"Anachronist Wiki",
),
).toBe("PAP-3515 • Issues • Anachronist Wiki • Paperclip");
});
it("omits blank company names from page titles", () => {
expect(buildDocumentTitle([{ label: "Inbox" }], " ")).toBe("Inbox • Paperclip");
expect(buildDocumentTitle([], null)).toBe("Paperclip");
});
});

View File

@@ -12,6 +12,11 @@ interface BreadcrumbContextValue {
setMobileToolbar: (node: ReactNode | null) => void;
}
interface BreadcrumbProviderProps {
children: ReactNode;
companyName?: string | null;
}
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(null);
function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) {
@@ -25,7 +30,16 @@ function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) {
return true;
}
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
export function buildDocumentTitle(breadcrumbs: Breadcrumb[], companyName?: string | null) {
const pageParts = breadcrumbs.length === 0
? []
: [...breadcrumbs].reverse().map((breadcrumb) => breadcrumb.label);
const companyPart = companyName?.trim() ? [companyName.trim()] : [];
const parts = [...pageParts, ...companyPart, "Paperclip"];
return parts.join(" • ");
}
export function BreadcrumbProvider({ children, companyName }: BreadcrumbProviderProps) {
const [breadcrumbs, setBreadcrumbsState] = useState<Breadcrumb[]>([]);
const [mobileToolbar, setMobileToolbarState] = useState<ReactNode | null>(null);
@@ -38,13 +52,8 @@ export function BreadcrumbProvider({ children }: { children: ReactNode }) {
}, []);
useEffect(() => {
if (breadcrumbs.length === 0) {
document.title = "Paperclip";
} else {
const parts = [...breadcrumbs].reverse().map((b) => b.label);
document.title = `${parts.join(" · ")} · Paperclip`;
}
}, [breadcrumbs]);
document.title = buildDocumentTitle(breadcrumbs, companyName);
}, [breadcrumbs, companyName]);
return (
<BreadcrumbContext.Provider value={{ breadcrumbs, setBreadcrumbs, mobileToolbar, setMobileToolbar }}>

View File

@@ -187,9 +187,16 @@
background: oklch(0.5 0 0);
}
/* Auto-hide scrollbar: always reserves space, thumb visible only on hover */
/* Auto-hide scrollbar: thin, stable gutter with the thumb visible only on hover */
.scrollbar-auto-hide {
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.scrollbar-auto-hide::-webkit-scrollbar {
width: 8px !important;
height: 8px !important;
background: transparent !important;
}
.scrollbar-auto-hide::-webkit-scrollbar-track {
@@ -199,18 +206,25 @@
background: transparent !important;
}
/* Light mode scrollbar on hover */
.scrollbar-auto-hide:hover {
scrollbar-color: oklch(0.7 0 0) transparent;
}
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
background: oklch(0.92 0 0) !important;
background: transparent !important;
}
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
background: oklch(0.7 0 0) !important;
border-radius: 999px !important;
}
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
background: oklch(0.6 0 0) !important;
}
/* Dark mode scrollbar on hover */
.dark .scrollbar-auto-hide:hover {
scrollbar-color: oklch(0.4 0 0) transparent;
}
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-track {
background: oklch(0.205 0 0) !important;
background: transparent !important;
}
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
background: oklch(0.4 0 0) !important;
@@ -747,7 +761,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
margin-bottom: 0;
}
.paperclip-markdown :where(p, ul, ol, blockquote, pre, table) {
.paperclip-markdown :where(p, ul, ol, blockquote, pre, .paperclip-markdown-table-scroll) {
margin-top: 0.7rem;
margin-bottom: 0.7rem;
}
@@ -855,8 +869,28 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--foreground) 10%, transparent);
}
.paperclip-markdown table {
width: 100%;
.paperclip-markdown-table-scroll {
max-width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
.paperclip-markdown-table-scroll:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.paperclip-markdown-table-scroll table {
width: max-content;
min-width: 100%;
margin: 0;
}
.paperclip-markdown-table-scroll :where(th, td) {
min-width: 8rem;
max-width: 18rem;
vertical-align: top;
}
.paperclip-markdown th {

View File

@@ -1322,9 +1322,69 @@ describe("inbox helpers", () => {
]);
});
it("persists workspace grouping preferences", () => {
it("groups assignee sections by latest issue activity while preserving non-issue sections", () => {
const agentIssue = makeIssue("agent", true);
agentIssue.assigneeAgentId = "agent-1";
const userIssue = makeIssue("user", false);
userIssue.assigneeUserId = "user-1";
const unassignedIssue = makeIssue("unassigned", false);
const items: InboxWorkItem[] = [
{ kind: "issue", timestamp: 5, issue: agentIssue },
{ kind: "approval", timestamp: 8, approval: makeApproval("pending") },
{ kind: "issue", timestamp: 7, issue: userIssue },
{ kind: "issue", timestamp: 2, issue: unassignedIssue },
];
expect(groupInboxWorkItems(items, "assignee", {
agentById: new Map([["agent-1", "Coder"]]),
userLabelById: new Map([["user-1", "Riley"]]),
})).toEqual([
{ key: "kind:approval", label: "Approvals", items: [items[1]] },
{ key: "assignee:user:user-1", label: "Riley", items: [items[2]] },
{ key: "assignee:agent:agent-1", label: "Coder", items: [items[0]] },
{ key: "assignee:none", label: "Unassigned", items: [items[3]] },
]);
});
it("groups project sections by latest issue activity while preserving non-issue sections", () => {
const paperclipIssue = makeIssue("paperclip", true);
paperclipIssue.projectId = "project-1";
const onboardingIssue = makeIssue("onboarding", false);
onboardingIssue.projectId = "project-2";
const noProjectIssue = makeIssue("no-project", false);
const items: InboxWorkItem[] = [
{ kind: "issue", timestamp: 9, issue: paperclipIssue },
{ kind: "issue", timestamp: 4, issue: onboardingIssue },
{ kind: "join_request", timestamp: 6, joinRequest: makeJoinRequest("join-1") },
{ kind: "issue", timestamp: 2, issue: noProjectIssue },
];
expect(groupInboxWorkItems(items, "project", {
projectById: new Map([
["project-1", { name: "Paperclip App" }],
["project-2", { name: "Onboarding" }],
]),
})).toEqual([
{ key: "project:project-1", label: "Paperclip App", items: [items[0]] },
{ key: "kind:join_request", label: "Join requests", items: [items[2]] },
{ key: "project:project-2", label: "Onboarding", items: [items[1]] },
{ key: "project:none", label: "No project", items: [items[3]] },
]);
});
it("persists inbox grouping preferences", () => {
saveInboxWorkItemGroupBy("workspace");
expect(loadInboxWorkItemGroupBy()).toBe("workspace");
saveInboxWorkItemGroupBy("assignee");
expect(loadInboxWorkItemGroupBy()).toBe("assignee");
saveInboxWorkItemGroupBy("project");
expect(loadInboxWorkItemGroupBy()).toBe("project");
});
it("persists collapsed inbox groups per company", () => {

View File

@@ -12,6 +12,7 @@ import {
normalizeIssueFilterState,
type IssueFilterState,
} from "./issue-filters";
import { formatAssigneeUserLabel } from "./assignees";
export const RECENT_ISSUES_LIMIT = 100;
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
@@ -33,7 +34,7 @@ export type InboxCategoryFilter =
| "failed_runs"
| "alerts";
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
export type InboxWorkItemGroupBy = "none" | "type" | "workspace";
export type InboxWorkItemGroupBy = "none" | "type" | "assignee" | "project" | "workspace";
export const inboxIssueColumns = [
"status",
"id",
@@ -137,6 +138,10 @@ export interface InboxWorkspaceGroupingOptions {
executionWorkspaceById?: ReadonlyMap<string, InboxExecutionWorkspaceLookup>;
projectWorkspaceById?: ReadonlyMap<string, InboxProjectWorkspaceLookup>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
projectById?: ReadonlyMap<string, { name: string | null | undefined }>;
agentById?: ReadonlyMap<string, string | null | undefined>;
userLabelById?: ReadonlyMap<string, string>;
currentUserId?: string | null;
}
const defaultInboxFilterPreferences: InboxFilterPreferences = {
@@ -342,7 +347,7 @@ export function saveInboxIssueColumns(columns: InboxIssueColumn[]) {
export function loadInboxWorkItemGroupBy(): InboxWorkItemGroupBy {
try {
const raw = localStorage.getItem(INBOX_GROUP_BY_KEY);
return raw === "type" || raw === "workspace" ? raw : "none";
return raw === "type" || raw === "assignee" || raw === "project" || raw === "workspace" ? raw : "none";
} catch {
return "none";
}
@@ -805,6 +810,86 @@ const inboxWorkItemKindLabels: Record<InboxWorkItem["kind"], string> = {
join_request: "Join requests",
};
function resolveIssueAssigneeGroup(
issue: Pick<Issue, "assigneeAgentId" | "assigneeUserId">,
{
agentById,
currentUserId,
userLabelById,
}: Pick<InboxWorkspaceGroupingOptions, "agentById" | "currentUserId" | "userLabelById">,
): { key: string; label: string } {
if (issue.assigneeAgentId) {
const agentName = agentById?.get(issue.assigneeAgentId)?.trim();
return {
key: `assignee:agent:${issue.assigneeAgentId}`,
label: agentName || issue.assigneeAgentId.slice(0, 8),
};
}
if (issue.assigneeUserId) {
return {
key: `assignee:user:${issue.assigneeUserId}`,
label: formatAssigneeUserLabel(issue.assigneeUserId, currentUserId, userLabelById) ?? "User",
};
}
return { key: "assignee:none", label: "Unassigned" };
}
function resolveIssueProjectGroup(
issue: Pick<Issue, "projectId">,
{ projectById }: Pick<InboxWorkspaceGroupingOptions, "projectById">,
): { key: string; label: string } {
if (!issue.projectId) return { key: "project:none", label: "No project" };
const projectName = projectById?.get(issue.projectId)?.name?.trim();
return {
key: `project:${issue.projectId}`,
label: projectName || issue.projectId.slice(0, 8),
};
}
function groupInboxWorkItemsByIssueGroup(
items: InboxWorkItem[],
resolveIssueGroup: (issue: Issue) => { key: string; label: string },
): InboxWorkItemGroup[] {
const groups = new Map<string, { label: string; items: InboxWorkItem[]; latestTimestamp: number }>();
for (const item of items) {
const resolvedGroup = item.kind === "issue"
? resolveIssueGroup(item.issue)
: { key: `kind:${item.kind}`, label: inboxWorkItemKindLabels[item.kind] };
const existing = groups.get(resolvedGroup.key);
if (existing) {
existing.items.push(item);
existing.latestTimestamp = Math.max(existing.latestTimestamp, item.timestamp);
} else {
groups.set(resolvedGroup.key, {
label: resolvedGroup.label,
items: [item],
latestTimestamp: item.timestamp,
});
}
}
return [...groups.entries()]
.map(([key, value]) => ({
key,
label: value.label,
items: value.items,
latestTimestamp: value.latestTimestamp,
}))
.sort((a, b) => {
const timestampDiff = b.latestTimestamp - a.latestTimestamp;
if (timestampDiff !== 0) return timestampDiff;
return a.label.localeCompare(b.label);
})
.map(({ key, label, items: groupItems }) => ({
key,
label,
items: groupItems,
}));
}
export function groupInboxWorkItems(
items: InboxWorkItem[],
groupBy: InboxWorkItemGroupBy,
@@ -815,41 +900,15 @@ export function groupInboxWorkItems(
}
if (groupBy === "workspace") {
const groups = new Map<string, { label: string; items: InboxWorkItem[]; latestTimestamp: number }>();
for (const item of items) {
const resolvedGroup = item.kind === "issue"
? resolveIssueWorkspaceGroup(item.issue, options)
: { key: `kind:${item.kind}`, label: inboxWorkItemKindLabels[item.kind] };
const existing = groups.get(resolvedGroup.key);
if (existing) {
existing.items.push(item);
existing.latestTimestamp = Math.max(existing.latestTimestamp, item.timestamp);
} else {
groups.set(resolvedGroup.key, {
label: resolvedGroup.label,
items: [item],
latestTimestamp: item.timestamp,
});
}
}
return groupInboxWorkItemsByIssueGroup(items, (issue) => resolveIssueWorkspaceGroup(issue, options));
}
return [...groups.entries()]
.map(([key, value]) => ({
key,
label: value.label,
items: value.items,
latestTimestamp: value.latestTimestamp,
}))
.sort((a, b) => {
const timestampDiff = b.latestTimestamp - a.latestTimestamp;
if (timestampDiff !== 0) return timestampDiff;
return a.label.localeCompare(b.label);
})
.map(({ key, label, items: groupItems }) => ({
key,
label,
items: groupItems,
}));
if (groupBy === "assignee") {
return groupInboxWorkItemsByIssueGroup(items, (issue) => resolveIssueAssigneeGroup(issue, options));
}
if (groupBy === "project") {
return groupInboxWorkItemsByIssueGroup(items, (issue) => resolveIssueProjectGroup(issue, options));
}
const groups = new Map<InboxWorkItem["kind"], InboxWorkItem[]>();

View File

@@ -53,7 +53,10 @@ export const queryKeys = {
comments: (issueId: string) => ["issues", "comments", issueId] as const,
interactions: (issueId: string) => ["issues", "interactions", issueId] as const,
feedbackVotes: (issueId: string) => ["issues", "feedback-votes", issueId] as const,
costSummary: (issueId: string) => ["issues", "cost-summary", issueId] as const,
costSummary: (issueId: string, options: { excludeRoot?: boolean } = {}) =>
options.excludeRoot
? (["issues", "cost-summary", issueId, "exclude-root"] as const)
: (["issues", "cost-summary", issueId] as const),
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
documents: (issueId: string) => ["issues", "documents", issueId] as const,
document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const,

View File

@@ -75,6 +75,24 @@ export function formatTokens(n: number): string {
return String(n);
}
/** Humanize a millisecond duration into a compact `1h 2m`, `45m 12s`, `12s` string. */
export function formatDurationMs(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return "0s";
const totalSeconds = Math.round(ms / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours < 24) {
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
}
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
}
/** Map a raw provider slug to a display-friendly name. */
export function providerDisplayName(provider: string): string {
const map: Record<string, string> = {

View File

@@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "@/lib/router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "./App";
import { CompanyProvider } from "./context/CompanyContext";
import { CompanyProvider, useCompany } from "./context/CompanyContext";
import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider";
import { BreadcrumbProvider } from "./context/BreadcrumbContext";
import { PanelProvider } from "./context/PanelContext";
@@ -37,6 +37,11 @@ const queryClient = new QueryClient({
},
});
function CompanyAwareBreadcrumbProvider({ children }: { children: React.ReactNode }) {
const { selectedCompany } = useCompany();
return <BreadcrumbProvider companyName={selectedCompany?.name ?? null}>{children}</BreadcrumbProvider>;
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
@@ -47,7 +52,7 @@ createRoot(document.getElementById("root")!).render(
<ToastProvider>
<LiveUpdatesProvider>
<TooltipProvider>
<BreadcrumbProvider>
<CompanyAwareBreadcrumbProvider>
<SidebarProvider>
<PanelProvider>
<PluginLauncherProvider>
@@ -57,7 +62,7 @@ createRoot(document.getElementById("root")!).render(
</PluginLauncherProvider>
</PanelProvider>
</SidebarProvider>
</BreadcrumbProvider>
</CompanyAwareBreadcrumbProvider>
</TooltipProvider>
</LiveUpdatesProvider>
</ToastProvider>

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, RoutineListItem } from "@paperclipai/shared";
import { ArrowLeft, Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react";
import { Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -25,6 +25,7 @@ import {
} from "../components/RoutineRunVariablesDialog";
import {
buildWorkspaceRuntimeControlSections,
WorkspaceRuntimeQuickControls,
WorkspaceRuntimeControls,
type WorkspaceRuntimeControlRequest,
} from "../components/WorkspaceRuntimeControls";
@@ -53,13 +54,14 @@ type WorkspaceFormState = {
workspaceRuntime: string;
};
type ExecutionWorkspaceTab = "configuration" | "runtime_logs" | "issues" | "routines";
type ExecutionWorkspaceTab = "services" | "configuration" | "runtime_logs" | "issues" | "routines";
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null {
const segments = pathname.split("/").filter(Boolean);
const executionWorkspacesIndex = segments.indexOf("execution-workspaces");
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
const tab = segments[executionWorkspacesIndex + 2];
if (tab === "services") return "services";
if (tab === "issues") return "issues";
if (tab === "routines") return "routines";
if (tab === "runtime-logs") return "runtime_logs";
@@ -72,6 +74,16 @@ function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceT
return `/execution-workspaces/${workspaceId}/${segment}`;
}
function LegacyWorkspaceTabRedirect({ workspaceId }: { workspaceId: string }) {
useEffect(() => {
try {
localStorage.removeItem(`paperclip:execution-workspace-tab:${workspaceId}`);
} catch {}
}, [workspaceId]);
return <Navigate to={executionWorkspaceTabPath(workspaceId, "issues")} replace />;
}
function isSafeExternalUrl(value: string | null | undefined) {
if (!value) return false;
try {
@@ -259,14 +271,14 @@ function WorkspaceLink({
function ExecutionWorkspaceIssuesList({
companyId,
workspaceId,
workspace,
issues,
isLoading,
error,
project,
}: {
companyId: string;
workspaceId: string;
workspace: ExecutionWorkspace;
issues: Issue[];
isLoading: boolean;
error: Error | null;
@@ -292,7 +304,7 @@ function ExecutionWorkspaceIssuesList({
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(companyId, workspaceId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(companyId, workspace.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
if (project?.id) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, project.id) });
@@ -304,6 +316,15 @@ function ExecutionWorkspaceIssuesList({
() => (project ? [{ id: project.id, name: project.name, workspaces: project.workspaces ?? [] }] : undefined),
[project],
);
const createIssueDefaults = useMemo(
() => ({
projectId: workspace.projectId,
...(workspace.projectWorkspaceId ? { projectWorkspaceId: workspace.projectWorkspaceId } : {}),
executionWorkspaceId: workspace.id,
executionWorkspaceMode: "reuse_existing",
}),
[workspace.id, workspace.projectId, workspace.projectWorkspaceId],
);
return (
<IssuesList
@@ -315,6 +336,7 @@ function ExecutionWorkspaceIssuesList({
liveIssueIds={liveIssueIds}
projectId={project?.id}
viewStateKey="paperclip:execution-workspace-issues-view"
baseCreateIssueDefaults={createIssueDefaults}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
/>
);
@@ -663,25 +685,10 @@ export function ExecutionWorkspaceDetail() {
const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null;
if (workspaceId && activeTab === null) {
let cachedTab: ExecutionWorkspaceTab = "configuration";
try {
const storedTab = localStorage.getItem(`paperclip:execution-workspace-tab:${workspaceId}`);
if (
storedTab === "issues" ||
storedTab === "routines" ||
storedTab === "configuration" ||
storedTab === "runtime_logs"
) {
cachedTab = storedTab;
}
} catch {}
return <Navigate to={executionWorkspaceTabPath(workspaceId, cachedTab)} replace />;
return <LegacyWorkspaceTabRedirect workspaceId={workspaceId} />;
}
const handleTabChange = (tab: ExecutionWorkspaceTab) => {
try {
localStorage.setItem(`paperclip:execution-workspace-tab:${workspace.id}`, tab);
} catch {}
navigate(executionWorkspaceTabPath(workspace.id, tab));
};
@@ -707,43 +714,39 @@ export function ExecutionWorkspaceDetail() {
return (
<>
<div className="space-y-4 overflow-hidden sm:space-y-6">
<div className="flex flex-wrap items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to all workspaces
</Link>
</Button>
<StatusPill>{workspace.mode}</StatusPill>
<StatusPill>{workspace.providerType}</StatusPill>
<StatusPill className={workspace.status === "active" ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : undefined}>
{workspace.status}
</StatusPill>
</div>
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Execution workspace
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Execution workspace
</div>
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
</div>
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Configure the concrete runtime workspace that Paperclip reuses for this issue flow.
<span className="hidden sm:inline"> These settings stay attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown, and runtime-service behavior in sync with the actual workspace being reused.</span>
</p>
<WorkspaceRuntimeQuickControls
sections={runtimeControlSections}
isPending={controlRuntimeServices.isPending}
pendingRequest={pendingRuntimeAction}
onAction={(request) => controlRuntimeServices.mutate(request)}
/>
</div>
{runtimeActionErrorMessage ? <p className="text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
<Card className="rounded-none">
<CardHeader>
<CardTitle>Services and jobs</CardTitle>
<CardDescription>
Source: {runtimeConfigSource === "execution_workspace"
? "execution workspace override"
: runtimeConfigSource === "project_workspace"
? "project workspace default"
: "none"}
</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab ?? "issues"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
<PageTabBar
items={[
{ value: "issues", label: "Issues" },
{ value: "services", label: "Services" },
{ value: "configuration", label: "Configuration" },
{ value: "runtime_logs", label: "Runtime logs" },
{ value: "routines", label: "Routines" },
]}
align="start"
value={activeTab ?? "issues"}
onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}
/>
</Tabs>
{activeTab === "services" ? (
<WorkspaceRuntimeControls
sections={runtimeControlSections}
isPending={controlRuntimeServices.isPending}
@@ -761,26 +764,7 @@ export function ExecutionWorkspaceDetail() {
}
onAction={(request) => controlRuntimeServices.mutate(request)}
/>
{runtimeActionErrorMessage ? <p className="mt-4 text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="mt-4 text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
</CardContent>
</Card>
<Tabs value={activeTab ?? "configuration"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
<PageTabBar
items={[
{ value: "configuration", label: "Configuration" },
{ value: "runtime_logs", label: "Runtime logs" },
{ value: "issues", label: "Issues" },
{ value: "routines", label: "Routines" },
]}
align="start"
value={activeTab ?? "configuration"}
onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}
/>
</Tabs>
{activeTab === "configuration" ? (
) : activeTab === "configuration" ? (
<div className="space-y-4 sm:space-y-6">
<Card className="rounded-none">
<CardHeader>
@@ -792,7 +776,7 @@ export function ExecutionWorkspaceDetail() {
<Button
variant="destructive"
size="sm"
className="w-full rounded-none sm:w-auto"
className="w-full sm:w-auto"
onClick={() => setCloseDialogOpen(true)}
disabled={workspace.status === "archived"}
>
@@ -1138,7 +1122,7 @@ export function ExecutionWorkspaceDetail() {
) : activeTab === "issues" ? (
<ExecutionWorkspaceIssuesList
companyId={workspace.companyId}
workspaceId={workspace.id}
workspace={workspace}
issues={linkedIssues}
isLoading={linkedIssuesQuery.isLoading}
error={linkedIssuesQuery.error as Error | null}

View File

@@ -982,11 +982,23 @@ export function Inbox() {
}, [executionWorkspaces]);
const inboxWorkspaceGrouping = useMemo<InboxWorkspaceGroupingOptions>(
() => ({
agentById,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
projectById,
userLabelById: companyUserLabelMap,
currentUserId,
}),
[defaultProjectWorkspaceIdByProjectId, executionWorkspaceById, projectWorkspaceById],
[
agentById,
companyUserLabelMap,
currentUserId,
defaultProjectWorkspaceIdByProjectId,
executionWorkspaceById,
projectById,
projectWorkspaceById,
],
);
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
const availableIssueColumns = useMemo(
@@ -1990,6 +2002,8 @@ export function Inbox() {
{([
["none", "None"],
["type", "Type"],
["assignee", "Assignee"],
["project", "Project"],
...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []),
] as const).map(([value, label]) => (
<button

View File

@@ -60,7 +60,7 @@ import {
} from "../lib/optimistic-issue-comments";
import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { ApprovalCard } from "../components/ApprovalCard";
import { InlineEditor } from "../components/InlineEditor";
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
@@ -957,8 +957,11 @@ function IssueDetailActivityTab({
let output = 0;
let cached = 0;
let cost = 0;
let runtimeMs = 0;
let runCount = 0;
let hasCost = false;
let hasTokens = false;
const nowMs = Date.now();
for (const run of linkedRuns ?? []) {
const usage = asRecord(run.usageJson);
@@ -978,6 +981,15 @@ function IssueDetailActivityTab({
output += runOutput;
cached += runCached;
cost += runCost;
if (run.startedAt) {
const startMs = new Date(run.startedAt).getTime();
const endMs = run.finishedAt ? new Date(run.finishedAt).getTime() : nowMs;
if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs) {
runtimeMs += endMs - startMs;
runCount += 1;
}
}
}
return {
@@ -988,6 +1000,9 @@ function IssueDetailActivityTab({
totalTokens: input + output,
hasCost,
hasTokens,
runtimeMs,
runCount,
hasRuntime: runtimeMs > 0,
};
}, [linkedRuns]);
const issueTreeCostTokens =
@@ -997,6 +1012,7 @@ function IssueDetailActivityTab({
&& (issueTreeCostSummary.costCents > 0
|| issueTreeCostTokens > 0
|| issueTreeCostSummary.cachedInputTokens > 0
|| issueTreeCostSummary.runtimeMs > 0
|| issueTreeCostSummary.issueCount > 1);
const shouldShowCostSummary =
(linkedRuns && linkedRuns.length > 0) || hasIssueTreeCost;
@@ -1029,7 +1045,13 @@ function IssueDetailActivityTab({
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
</span>
) : null}
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
{issueCostSummary.hasRuntime ? (
<span>
Runtime {formatDurationMs(issueCostSummary.runtimeMs)}
{` (${issueCostSummary.runCount} run${issueCostSummary.runCount === 1 ? "" : "s"})`}
</span>
) : null}
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens && !issueCostSummary.hasRuntime ? (
<span>No direct cost data.</span>
) : null}
</div>
@@ -1049,6 +1071,12 @@ function IssueDetailActivityTab({
? ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)}, cached ${formatTokens(issueTreeCostSummary.cachedInputTokens)})`
: ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)})`}
</span>
{issueTreeCostSummary.runCount > 0 ? (
<span>
Runtime {formatDurationMs(issueTreeCostSummary.runtimeMs)}
{` (${issueTreeCostSummary.runCount} run${issueTreeCostSummary.runCount === 1 ? "" : "s"})`}
</span>
) : null}
<span>{issueTreeCostSummary.issueCount} issue{issueTreeCostSummary.issueCount === 1 ? "" : "s"}</span>
</div>
) : null}
@@ -3448,6 +3476,7 @@ export function IssueDetail() {
createIssueLabel="Sub-issue"
defaultSortField="workflow"
showProgressSummary
parentIssueIdForCostSummary={issue.id}
onUpdateIssue={handleChildIssueUpdate}
/>
</div>