Compare commits
4 Commits
master
...
pap-3598/o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bab5136645 | ||
|
|
09ed4e54cb | ||
|
|
783f4d2f28 | ||
|
|
433326ffcb |
BIN
docs/pr-screenshots/pr-5291/after-issue-management.png
Normal file
|
After Width: | Height: | Size: 701 KiB |
BIN
docs/pr-screenshots/pr-5291/after-navigation-layout.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
docs/pr-screenshots/pr-5291/after-projects-workspaces.png
Normal file
|
After Width: | Height: | Size: 694 KiB |
BIN
docs/pr-screenshots/pr-5291/after-status-language.png
Normal file
|
After Width: | Height: | Size: 546 KiB |
BIN
docs/pr-screenshots/pr-5291/before-issue-management.png
Normal file
|
After Width: | Height: | Size: 701 KiB |
BIN
docs/pr-screenshots/pr-5291/before-navigation-layout.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
docs/pr-screenshots/pr-5291/before-projects-workspaces.png
Normal file
|
After Width: | Height: | Size: 694 KiB |
@@ -352,6 +352,8 @@ export type {
|
||||
IssueBlockerAttentionState,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
SuccessfulRunHandoffState,
|
||||
SuccessfulRunHandoffStateKind,
|
||||
IssueReferenceSource,
|
||||
IssueRelatedWorkItem,
|
||||
IssueRelatedWorkSummary,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -140,6 +140,8 @@ export type {
|
||||
IssueBlockerAttentionState,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
SuccessfulRunHandoffState,
|
||||
SuccessfulRunHandoffStateKind,
|
||||
IssueReferenceSource,
|
||||
IssueRelatedWorkItem,
|
||||
IssueRelatedWorkSummary,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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[]);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 ?? {})) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
105
ui/src/components/IssueBlockedNotice.test.tsx
Normal 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("");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)");
|
||||
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||