Improve operator workflow QoL (#5291)
## Thinking Path > - Paperclip is a control plane operators use repeatedly to supervise agent companies. > - Common operator workflows depend on fast scanning of inboxes, issue sidebars, workspaces, cost totals, and runtime services. > - Several small UI and service gaps made those workflows slower or less clear. > - This pull request groups the operator-facing QoL changes that can stand alone from recovery and adapter work. > - The benefit is a denser, clearer board experience for issue triage and workspace operation. ## What Changed - Added inbox assignee/project grouping and issue list token/runtime totals. - Improved issue properties with removable blocker chips and workspace task links. - Improved execution workspace layout, runtime controls, issues tab default, and stopped-port reuse behavior. - Added mobile markdown/routine dialog fixes, page title company names, sidebar polish, and dashboard run task label cleanup. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run ui/src/lib/inbox.test.ts ui/src/components/IssueProperties.test.tsx ui/src/components/WorkspaceRuntimeControls.test.tsx server/src/__tests__/workspace-runtime.test.ts server/src/__tests__/costs-service.test.ts` ## Risks - Medium UI risk because this touches several operator surfaces. The branch is intentionally grouped around workflow/QoL files and keeps the file count below the Greptile limit. ## Model Used - OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with shell/git/GitHub CLI tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
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 |
|
|
@ -36,6 +36,11 @@ export interface IssueCostSummary {
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
cachedInputTokens: number;
|
cachedInputTokens: number;
|
||||||
outputTokens: 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 {
|
export interface CostByAgent {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,17 @@ import request from "supertest";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { afterAll, afterEach, beforeAll } from "vitest";
|
import { afterAll, afterEach, beforeAll } from "vitest";
|
||||||
import { randomUUID } from "node:crypto";
|
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 { costService } from "../services/costs.ts";
|
||||||
import { financeService } from "../services/finance.ts";
|
import { financeService } from "../services/finance.ts";
|
||||||
import {
|
import {
|
||||||
|
|
@ -69,6 +79,8 @@ const mockCostService = vi.hoisted(() => ({
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
cachedInputTokens: 0,
|
cachedInputTokens: 0,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
|
runCount: 0,
|
||||||
|
runtimeMs: 0,
|
||||||
}),
|
}),
|
||||||
windowSpend: vi.fn().mockResolvedValue([]),
|
windowSpend: vi.fn().mockResolvedValue([]),
|
||||||
byProject: vi.fn().mockResolvedValue([]),
|
byProject: vi.fn().mockResolvedValue([]),
|
||||||
|
|
@ -231,7 +243,9 @@ describe("cost routes", () => {
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1");
|
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({
|
expect(res.body).toEqual({
|
||||||
issueId: "issue-1",
|
issueId: "issue-1",
|
||||||
issueCount: 1,
|
issueCount: 1,
|
||||||
|
|
@ -240,6 +254,8 @@ describe("cost routes", () => {
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
cachedInputTokens: 0,
|
cachedInputTokens: 0,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
|
runCount: 0,
|
||||||
|
runtimeMs: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -393,6 +409,8 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await db.delete(financeEvents);
|
await db.delete(financeEvents);
|
||||||
await db.delete(costEvents);
|
await db.delete(costEvents);
|
||||||
|
await db.delete(activityLog);
|
||||||
|
await db.delete(heartbeatRuns);
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
await db.delete(projects);
|
await db.delete(projects);
|
||||||
await db.delete(agents);
|
await db.delete(agents);
|
||||||
|
|
@ -612,9 +630,173 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
|
||||||
inputTokens: 60,
|
inputTokens: 60,
|
||||||
cachedInputTokens: 6,
|
cachedInputTokens: 6,
|
||||||
outputTokens: 12,
|
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 () => {
|
it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ function registerModuleMocks() {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createApp() {
|
async function createApp(db: unknown = {}) {
|
||||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||||
|
|
@ -126,7 +126,7 @@ async function createApp() {
|
||||||
};
|
};
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
app.use("/api", issueRoutes({} as any, {} as any));
|
app.use("/api", issueRoutes(db as any, {} as any));
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
@ -266,6 +266,88 @@ describe("issue activity event routes", () => {
|
||||||
});
|
});
|
||||||
}, 15_000);
|
}, 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 () => {
|
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
|
||||||
const existingPolicy = normalizeIssueExecutionPolicy({
|
const existingPolicy = normalizeIssueExecutionPolicy({
|
||||||
stages: [
|
stages: [
|
||||||
|
|
|
||||||
|
|
@ -3134,6 +3134,130 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||||
expect(persisted?.healthStatus).toBe("unknown");
|
expect(persisted?.healthStatus).toBe("unknown");
|
||||||
expect(persisted?.stoppedAt).toBeTruthy();
|
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", () => {
|
describe("normalizeAdapterManagedRuntimeServices", () => {
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,8 @@ export function costRoutes(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, issue.companyId);
|
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);
|
res.json(summary);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||||
import { Router, type Request, type Response } from "express";
|
import { Router, type Request, type Response } from "express";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { activityLog, issueExecutionDecisions } from "@paperclipai/db";
|
import { activityLog, issueExecutionDecisions } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
|
|
@ -189,25 +189,27 @@ async function listSuccessfulRunHandoffStates(
|
||||||
issueIds: string[],
|
issueIds: string[],
|
||||||
): Promise<Map<string, SuccessfulRunHandoffState>> {
|
): Promise<Map<string, SuccessfulRunHandoffState>> {
|
||||||
if (issueIds.length === 0) return new Map();
|
if (issueIds.length === 0) return new Map();
|
||||||
const result = await db.execute(sql`
|
const rows = await db
|
||||||
SELECT DISTINCT ON (${activityLog.entityId})
|
.select({
|
||||||
${activityLog.entityId} AS "entityId",
|
entityId: activityLog.entityId,
|
||||||
${activityLog.action} AS "action",
|
action: activityLog.action,
|
||||||
${activityLog.agentId} AS "agentId",
|
agentId: activityLog.agentId,
|
||||||
${activityLog.runId} AS "runId",
|
runId: activityLog.runId,
|
||||||
${activityLog.details} AS "details",
|
details: activityLog.details,
|
||||||
${activityLog.createdAt} AS "createdAt"
|
createdAt: activityLog.createdAt,
|
||||||
FROM ${activityLog}
|
})
|
||||||
WHERE ${activityLog.companyId} = ${companyId}
|
.from(activityLog)
|
||||||
AND ${activityLog.entityType} = 'issue'
|
.where(and(
|
||||||
AND ${activityLog.entityId} IN (${sql.join(issueIds.map((id) => sql`${id}`), sql`, `)})
|
eq(activityLog.companyId, companyId),
|
||||||
AND ${activityLog.action} IN (${sql.join(SUCCESSFUL_RUN_HANDOFF_ACTIONS.map((action) => sql`${action}`), sql`, `)})
|
eq(activityLog.entityType, "issue"),
|
||||||
ORDER BY ${activityLog.entityId}, ${activityLog.createdAt} DESC, ${activityLog.id} DESC
|
inArray(activityLog.entityId, issueIds),
|
||||||
`);
|
inArray(activityLog.action, [...SUCCESSFUL_RUN_HANDOFF_ACTIONS]),
|
||||||
const rows = Array.from(result as Iterable<SuccessfulRunHandoffActivityRow>);
|
))
|
||||||
|
.orderBy(activityLog.entityId, desc(activityLog.createdAt), desc(activityLog.id)) as SuccessfulRunHandoffActivityRow[];
|
||||||
|
|
||||||
const states = new Map<string, SuccessfulRunHandoffState>();
|
const states = new Map<string, SuccessfulRunHandoffState>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
if (states.has(row.entityId)) continue;
|
||||||
const state = successfulRunHandoffStateFromActivity(row);
|
const state = successfulRunHandoffStateFromActivity(row);
|
||||||
if (state) states.set(row.entityId, state);
|
if (state) states.set(row.entityId, state);
|
||||||
}
|
}
|
||||||
|
|
@ -2546,6 +2548,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)) {
|
if (Array.isArray(req.body.blockedByIssueIds)) {
|
||||||
const previousBlockedByIds = new Set((existingRelations?.blockedBy ?? []).map((relation) => relation.id));
|
const previousBlockedByIds = new Set((existingRelations?.blockedBy ?? []).map((relation) => relation.id));
|
||||||
const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]);
|
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 { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm";
|
||||||
import { alias } from "drizzle-orm/pg-core";
|
import { alias } from "drizzle-orm/pg-core";
|
||||||
import type { Db } from "@paperclipai/db";
|
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 { notFound, unprocessable } from "../errors.js";
|
||||||
import { budgetService, type BudgetServiceHooks } from "./budgets.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.
|
// 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.
|
// The route does that so zero counts are not mistaken for a missing root.
|
||||||
const childIssues = alias(issues, "child");
|
const childIssues = alias(issues, "child");
|
||||||
const issueTreeCondition = sql<boolean>`
|
|
||||||
${issues.id} IN (
|
// The seed of the recursive CTE: when excludeRoot is true, start from
|
||||||
WITH RECURSIVE issue_tree(id) AS (
|
// 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}
|
SELECT ${issues.id}
|
||||||
FROM ${issues}
|
FROM ${issues}
|
||||||
WHERE ${issues.companyId} = ${companyId}
|
WHERE ${issues.companyId} = ${companyId}
|
||||||
AND ${issues.id} = ${issueId}
|
AND ${issues.id} = ${issueId}
|
||||||
AND ${issues.hiddenAt} IS NULL
|
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
|
UNION ALL
|
||||||
SELECT ${childIssues.id}
|
SELECT ${childIssues.id}
|
||||||
FROM ${issues} ${childIssues}
|
FROM ${issues} ${childIssues}
|
||||||
|
|
@ -158,38 +193,80 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [row] = await db
|
const runSummarySql = sql`
|
||||||
.select({
|
WITH RECURSIVE issue_tree(id) AS (
|
||||||
issueCount: sql<number>`count(distinct ${issues.id})::int`,
|
${cteSeedText}
|
||||||
costCents: sumAsNumber(costEvents.costCents),
|
UNION ALL
|
||||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
SELECT (${childIssues.id})::text
|
||||||
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
|
FROM ${issues} ${childIssues}
|
||||||
outputTokens: sumAsNumber(costEvents.outputTokens),
|
JOIN issue_tree ON (${childIssues.parentId})::text = issue_tree.id
|
||||||
})
|
WHERE ${childIssues.companyId} = ${companyId}
|
||||||
.from(issues)
|
AND ${childIssues.hiddenAt} IS NULL
|
||||||
.leftJoin(
|
|
||||||
costEvents,
|
|
||||||
and(
|
|
||||||
eq(costEvents.companyId, companyId),
|
|
||||||
eq(costEvents.issueId, issues.id),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.where(
|
SELECT
|
||||||
and(
|
count(distinct ${heartbeatRuns.id})::int AS "runCount",
|
||||||
eq(issues.companyId, companyId),
|
coalesce(sum(extract(epoch from (coalesce(${heartbeatRuns.finishedAt}, now()) - ${heartbeatRuns.startedAt})) * 1000), 0)::double precision AS "runtimeMs"
|
||||||
isNull(issues.hiddenAt),
|
FROM ${heartbeatRuns}
|
||||||
issueTreeCondition,
|
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 {
|
return {
|
||||||
issueId,
|
issueId,
|
||||||
issueCount: Number(row?.issueCount ?? 0),
|
issueCount: Number(costRow?.issueCount ?? 0),
|
||||||
includeDescendants: true,
|
includeDescendants: true,
|
||||||
costCents: Number(row?.costCents ?? 0),
|
costCents: Number(costRow?.costCents ?? 0),
|
||||||
inputTokens: Number(row?.inputTokens ?? 0),
|
inputTokens: Number(costRow?.inputTokens ?? 0),
|
||||||
cachedInputTokens: Number(row?.cachedInputTokens ?? 0),
|
cachedInputTokens: Number(costRow?.cachedInputTokens ?? 0),
|
||||||
outputTokens: Number(row?.outputTokens ?? 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;
|
processGroupId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StoppedRuntimeServiceReuseCandidate = {
|
||||||
|
id: string;
|
||||||
|
port: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
|
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
|
||||||
const runtimeServicesByReuseKey = new Map<string, string>();
|
const runtimeServicesByReuseKey = new Map<string, string>();
|
||||||
const runtimeServiceLeasesByRun = 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) {
|
function clearIdleTimer(record: RuntimeServiceRecord) {
|
||||||
if (!record.idleTimer) return;
|
if (!record.idleTimer) return;
|
||||||
clearTimeout(record.idleTimer);
|
clearTimeout(record.idleTimer);
|
||||||
|
|
@ -1927,9 +1959,20 @@ async function startLocalRuntimeService(input: {
|
||||||
const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint;
|
const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint;
|
||||||
const explicitPort = identity.explicitPort;
|
const explicitPort = identity.explicitPort;
|
||||||
const identityPort = identity.identityPort;
|
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 =
|
const port =
|
||||||
asString(portConfig.type, "") === "auto"
|
asString(portConfig.type, "") === "auto"
|
||||||
? await allocatePort()
|
? (reusableStoppedPort ?? await allocatePort())
|
||||||
: explicitPort > 0
|
: explicitPort > 0
|
||||||
? explicitPort
|
? explicitPort
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -2073,7 +2116,7 @@ async function startLocalRuntimeService(input: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const record: RuntimeServiceRecord = {
|
const record: RuntimeServiceRecord = {
|
||||||
id: randomUUID(),
|
id: stoppedReuseCandidate?.id ?? randomUUID(),
|
||||||
companyId: input.agent.companyId,
|
companyId: input.agent.companyId,
|
||||||
projectId: input.workspace.projectId,
|
projectId: input.workspace.projectId,
|
||||||
projectWorkspaceId: input.workspace.workspaceId,
|
projectWorkspaceId: input.workspace.workspaceId,
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ function boardRoutes() {
|
||||||
<Route path="routines" element={<Routines />} />
|
<Route path="routines" element={<Routines />} />
|
||||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
<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/configuration" element={<ExecutionWorkspaceDetail />} />
|
||||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
|
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
|
||||||
<Route path="execution-workspaces/:workspaceId/issues" 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="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
|
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="execution-workspaces/:workspaceId" 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/configuration" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
|
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
|
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,10 @@ export const issuesApi = {
|
||||||
getComment: (id: string, commentId: string) =>
|
getComment: (id: string, commentId: string) =>
|
||||||
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
|
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
|
||||||
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
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>) => {
|
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(filters ?? {})) {
|
for (const [key, value] of Object.entries(filters ?? {})) {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const mockHeartbeatsApi = vi.hoisted(() => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockIssuesApi = vi.hoisted(() => ({
|
const mockIssuesApi = vi.hoisted(() => ({
|
||||||
list: vi.fn(),
|
get: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/router", () => ({
|
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) {
|
function createRun(index: number) {
|
||||||
return {
|
return {
|
||||||
id: `run-${index}`,
|
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", () => {
|
describe("ActiveAgentsPanel", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
|
@ -78,7 +123,7 @@ describe("ActiveAgentsPanel", () => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([1, 2, 3, 4, 5].map(createRun));
|
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([1, 2, 3, 4, 5].map(createRun));
|
||||||
mockIssuesApi.list.mockResolvedValue([]);
|
mockIssuesApi.get.mockRejectedValue(new Error("Issue not found"));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -149,4 +194,42 @@ describe("ActiveAgentsPanel", () => {
|
||||||
root.unmount();
|
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 { memo, useMemo } from "react";
|
||||||
import { Link } from "@/lib/router";
|
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 type { Issue } from "@paperclipai/shared";
|
||||||
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
||||||
import type { TranscriptEntry } from "../adapters";
|
import type { TranscriptEntry } from "../adapters";
|
||||||
|
|
@ -56,19 +56,28 @@ export function ActiveAgentsPanel({
|
||||||
const runs = liveRuns ?? [];
|
const runs = liveRuns ?? [];
|
||||||
const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]);
|
const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]);
|
||||||
const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length);
|
const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length);
|
||||||
const { data: issues } = useQuery({
|
const visibleIssueIds = useMemo(
|
||||||
queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
|
() => [...new Set(visibleRuns.map((run) => run.issueId).filter((issueId): issueId is string => Boolean(issueId)))],
|
||||||
queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }),
|
[visibleRuns],
|
||||||
enabled: visibleRuns.length > 0,
|
);
|
||||||
|
|
||||||
|
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 issueById = useMemo(() => {
|
||||||
const map = new Map<string, Issue>();
|
const map = new Map<string, Issue>();
|
||||||
for (const issue of issues ?? []) {
|
for (const query of issueQueries) {
|
||||||
map.set(issue.id, issue);
|
const issue = query.data;
|
||||||
|
if (issue) map.set(issue.id, issue);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [issues]);
|
}, [issueQueries]);
|
||||||
|
|
||||||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||||||
runs: visibleRuns,
|
runs: visibleRuns,
|
||||||
|
|
|
||||||
|
|
@ -60,4 +60,46 @@ describe("IssueBlockedNotice", () => {
|
||||||
expect(node.textContent).not.toContain("Work on this issue is blocked until");
|
expect(node.textContent).not.toContain("Work on this issue is blocked until");
|
||||||
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
|
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("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export function IssueBlockedNotice({
|
||||||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||||
agentName?: string | null;
|
agentName?: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
if (issueStatus === "done" || issueStatus === "cancelled") return null;
|
||||||
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
|
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
|
||||||
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
|
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -476,6 +476,59 @@ describe("IssueProperties", () => {
|
||||||
act(() => root.unmount());
|
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 () => {
|
it("shows a green service link above the workspace row for a live non-main workspace", async () => {
|
||||||
mockProjectsApi.list.mockResolvedValue([createProject()]);
|
mockProjectsApi.list.mockResolvedValue([createProject()]);
|
||||||
const serviceUrl = "http://127.0.0.1:62475";
|
const serviceUrl = "http://127.0.0.1:62475";
|
||||||
|
|
@ -530,7 +583,7 @@ describe("IssueProperties", () => {
|
||||||
(link) => link.textContent?.trim() === "View workspace",
|
(link) => link.textContent?.trim() === "View workspace",
|
||||||
);
|
);
|
||||||
expect(tasksLink).not.toBeUndefined();
|
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).not.toBeUndefined();
|
||||||
expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1");
|
expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { resolveIssueFilterWorkspaceId } from "../lib/issue-filters";
|
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
|
|
@ -32,9 +31,19 @@ import { Identity } from "./Identity";
|
||||||
import { IssueReferencePill } from "./IssueReferencePill";
|
import { IssueReferencePill } from "./IssueReferencePill";
|
||||||
import { formatDate, cn, projectUrl } from "../lib/utils";
|
import { formatDate, cn, projectUrl } from "../lib/utils";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
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 { Separator } from "@/components/ui/separator";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
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";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
|
|
||||||
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
|
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;
|
return runtimeServices?.find((service) => service.status === "running" && service.url?.trim()) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function issuesWorkspaceFilterHref(workspaceId: string) {
|
function executionWorkspaceIssuesHref(workspaceId: string) {
|
||||||
const params = new URLSearchParams();
|
return `/execution-workspaces/${workspaceId}/issues`;
|
||||||
params.append("workspace", workspaceId);
|
|
||||||
return `/issues?${params.toString()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDateTimeLocalValue(value: string | null | undefined) {
|
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). */
|
/** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */
|
||||||
function PropertyPicker({
|
function PropertyPicker({
|
||||||
inline,
|
inline,
|
||||||
|
|
@ -331,10 +419,10 @@ export function IssueProperties({
|
||||||
() => isMainIssueWorkspace({ issue, project: issueProject }),
|
() => isMainIssueWorkspace({ issue, project: issueProject }),
|
||||||
[issue, issueProject],
|
[issue, issueProject],
|
||||||
);
|
);
|
||||||
const workspaceFilterId = useMemo(() => {
|
const workspaceTasksExecutionWorkspaceId = useMemo(() => {
|
||||||
if (!isolatedWorkspacesEnabled) return null;
|
if (!isolatedWorkspacesEnabled) return null;
|
||||||
if (issueUsesMainWorkspace) return null;
|
if (issueUsesMainWorkspace) return null;
|
||||||
return resolveIssueFilterWorkspaceId(issue);
|
return issue.executionWorkspaceId ?? issue.currentExecutionWorkspace?.id ?? null;
|
||||||
}, [isolatedWorkspacesEnabled, issue, issueUsesMainWorkspace]);
|
}, [isolatedWorkspacesEnabled, issue, issueUsesMainWorkspace]);
|
||||||
const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace;
|
const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace;
|
||||||
const liveWorkspaceService = useMemo(() => {
|
const liveWorkspaceService = useMemo(() => {
|
||||||
|
|
@ -1137,6 +1225,9 @@ export function IssueProperties({
|
||||||
: [...blockedByIds, blockedByIssueId];
|
: [...blockedByIds, blockedByIssueId];
|
||||||
onUpdate({ blockedByIssueIds: nextBlockedByIds });
|
onUpdate({ blockedByIssueIds: nextBlockedByIds });
|
||||||
};
|
};
|
||||||
|
const removeBlockedBy = (blockedByIssueId: string) => {
|
||||||
|
onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) });
|
||||||
|
};
|
||||||
|
|
||||||
const blockedByContent = (
|
const blockedByContent = (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1284,7 +1375,7 @@ export function IssueProperties({
|
||||||
<div>
|
<div>
|
||||||
<PropertyRow label="Blocked by">
|
<PropertyRow label="Blocked by">
|
||||||
{(issue.blockedBy ?? []).map((relation) => (
|
{(issue.blockedBy ?? []).map((relation) => (
|
||||||
<IssueReferencePill key={relation.id} issue={relation} />
|
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
|
||||||
))}
|
))}
|
||||||
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
|
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
|
|
@ -1297,7 +1388,7 @@ export function IssueProperties({
|
||||||
) : (
|
) : (
|
||||||
<PropertyRow label="Blocked by">
|
<PropertyRow label="Blocked by">
|
||||||
{(issue.blockedBy ?? []).map((relation) => (
|
{(issue.blockedBy ?? []).map((relation) => (
|
||||||
<IssueReferencePill key={relation.id} issue={relation} />
|
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
|
||||||
))}
|
))}
|
||||||
<Popover
|
<Popover
|
||||||
open={blockedByOpen}
|
open={blockedByOpen}
|
||||||
|
|
@ -1448,10 +1539,10 @@ export function IssueProperties({
|
||||||
</Link>
|
</Link>
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
)}
|
)}
|
||||||
{workspaceFilterId && (
|
{workspaceTasksExecutionWorkspaceId && (
|
||||||
<PropertyRow label="Tasks">
|
<PropertyRow label="Tasks">
|
||||||
<Link
|
<Link
|
||||||
to={issuesWorkspaceFilterHref(workspaceFilterId)}
|
to={executionWorkspaceIssuesHref(workspaceTasksExecutionWorkspaceId)}
|
||||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
View workspace tasks
|
View workspace tasks
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ import {
|
||||||
resolveIssueWorkspaceName,
|
resolveIssueWorkspaceName,
|
||||||
type InboxIssueColumn,
|
type InboxIssueColumn,
|
||||||
} from "../lib/inbox";
|
} from "../lib/inbox";
|
||||||
import { cn } from "../lib/utils";
|
import { cn, formatDurationMs, formatTokens } from "../lib/utils";
|
||||||
import {
|
import {
|
||||||
InboxIssueMetaLeading,
|
InboxIssueMetaLeading,
|
||||||
InboxIssueTrailingColumns,
|
InboxIssueTrailingColumns,
|
||||||
|
|
@ -114,7 +114,7 @@ export type IssueSortField = "status" | "priority" | "title" | "created" | "upda
|
||||||
export type IssueViewState = IssueFilterState & {
|
export type IssueViewState = IssueFilterState & {
|
||||||
sortField: IssueSortField;
|
sortField: IssueSortField;
|
||||||
sortDir: "asc" | "desc";
|
sortDir: "asc" | "desc";
|
||||||
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
|
groupBy: "status" | "priority" | "assignee" | "project" | "workspace" | "parent" | "none";
|
||||||
viewMode: "list" | "board";
|
viewMode: "list" | "board";
|
||||||
nestingEnabled: boolean;
|
nestingEnabled: boolean;
|
||||||
collapsedGroups: string[];
|
collapsedGroups: string[];
|
||||||
|
|
@ -364,6 +364,12 @@ interface IssuesListProps {
|
||||||
createIssueLabel?: string;
|
createIssueLabel?: string;
|
||||||
defaultSortField?: IssueSortField;
|
defaultSortField?: IssueSortField;
|
||||||
showProgressSummary?: boolean;
|
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;
|
enableRoutineVisibilityFilter?: boolean;
|
||||||
hasMoreIssues?: boolean;
|
hasMoreIssues?: boolean;
|
||||||
isLoadingMoreIssues?: boolean;
|
isLoadingMoreIssues?: boolean;
|
||||||
|
|
@ -439,9 +445,11 @@ function IssueSearchInput({
|
||||||
function SubIssueProgressSummaryStrip({
|
function SubIssueProgressSummaryStrip({
|
||||||
summary,
|
summary,
|
||||||
issueLinkState,
|
issueLinkState,
|
||||||
|
parentIssueIdForCostSummary,
|
||||||
}: {
|
}: {
|
||||||
summary: SubIssueProgressSummary;
|
summary: SubIssueProgressSummary;
|
||||||
issueLinkState?: unknown;
|
issueLinkState?: unknown;
|
||||||
|
parentIssueIdForCostSummary?: string;
|
||||||
}) {
|
}) {
|
||||||
const target = summary.target;
|
const target = summary.target;
|
||||||
const targetIssue = target?.issue ?? null;
|
const targetIssue = target?.issue ?? null;
|
||||||
|
|
@ -451,6 +459,21 @@ function SubIssueProgressSummaryStrip({
|
||||||
.map((status) => ({ status, count: summary.countsByStatus[status] ?? 0 }))
|
.map((status) => ({ status, count: summary.countsByStatus[status] ?? 0 }))
|
||||||
.filter((entry) => entry.count > 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 (
|
return (
|
||||||
<div className="border border-border bg-background p-3">
|
<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">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
|
@ -465,6 +488,23 @@ function SubIssueProgressSummaryStrip({
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{summary.blockedCount} blocked
|
{summary.blockedCount} blocked
|
||||||
</span>
|
</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>
|
||||||
<div
|
<div
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
|
|
@ -536,6 +576,7 @@ export function IssuesList({
|
||||||
createIssueLabel,
|
createIssueLabel,
|
||||||
defaultSortField,
|
defaultSortField,
|
||||||
showProgressSummary = false,
|
showProgressSummary = false,
|
||||||
|
parentIssueIdForCostSummary,
|
||||||
enableRoutineVisibilityFilter = false,
|
enableRoutineVisibilityFilter = false,
|
||||||
hasMoreIssues = false,
|
hasMoreIssues = false,
|
||||||
isLoadingMoreIssues = false,
|
isLoadingMoreIssues = false,
|
||||||
|
|
@ -996,6 +1037,22 @@ export function IssuesList({
|
||||||
items: groups[key]!,
|
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") {
|
if (viewState.groupBy === "parent") {
|
||||||
const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent");
|
const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent");
|
||||||
return Object.keys(groups)
|
return Object.keys(groups)
|
||||||
|
|
@ -1036,6 +1093,7 @@ export function IssuesList({
|
||||||
workspaceNameMap,
|
workspaceNameMap,
|
||||||
issueTitleMap,
|
issueTitleMap,
|
||||||
companyUserLabelMap,
|
companyUserLabelMap,
|
||||||
|
projectById,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1131,6 +1189,7 @@ export function IssuesList({
|
||||||
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
|
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
|
||||||
else defaults.assigneeAgentId = groupKey;
|
else defaults.assigneeAgentId = groupKey;
|
||||||
}
|
}
|
||||||
|
else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey;
|
||||||
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
|
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
|
||||||
const parentIssue = issueById.get(groupKey);
|
const parentIssue = issueById.get(groupKey);
|
||||||
if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId));
|
if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId));
|
||||||
|
|
@ -1175,7 +1234,11 @@ export function IssuesList({
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} className="space-y-4">
|
<div ref={rootRef} className="space-y-4">
|
||||||
{progressSummary ? (
|
{progressSummary ? (
|
||||||
<SubIssueProgressSummaryStrip summary={progressSummary} issueLinkState={issueLinkState} />
|
<SubIssueProgressSummaryStrip
|
||||||
|
summary={progressSummary}
|
||||||
|
issueLinkState={issueLinkState}
|
||||||
|
parentIssueIdForCostSummary={parentIssueIdForCostSummary}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
|
|
@ -1307,6 +1370,7 @@ export function IssuesList({
|
||||||
["status", "Status"],
|
["status", "Status"],
|
||||||
["priority", "Priority"],
|
["priority", "Priority"],
|
||||||
["assignee", "Assignee"],
|
["assignee", "Assignee"],
|
||||||
|
["project", "Project"],
|
||||||
["workspace", "Workspace"],
|
["workspace", "Workspace"],
|
||||||
["parent", "Parent Issue"],
|
["parent", "Parent Issue"],
|
||||||
["none", "None"],
|
["none", "None"],
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,20 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"');
|
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", () => {
|
it("opens external links in a new tab with safe rel attributes", () => {
|
||||||
const html = renderMarkdown("[docs](https://example.com/docs)");
|
const html = renderMarkdown("[docs](https://example.com/docs)");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,11 @@ const scrollableBlockStyle: React.CSSProperties = {
|
||||||
overflowX: "auto",
|
overflowX: "auto",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tableCellWrapStyle: React.CSSProperties = {
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
wordBreak: "normal",
|
||||||
|
};
|
||||||
|
|
||||||
function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties {
|
function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties {
|
||||||
return {
|
return {
|
||||||
...wrapAnywhereStyle,
|
...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 {
|
function mergeScrollableBlockStyle(style?: React.CSSProperties): React.CSSProperties {
|
||||||
return {
|
return {
|
||||||
...scrollableBlockStyle,
|
...scrollableBlockStyle,
|
||||||
|
|
@ -514,13 +526,20 @@ export function MarkdownBody({
|
||||||
{blockquoteChildren}
|
{blockquoteChildren}
|
||||||
</blockquote>
|
</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: ({ 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}
|
{tableCellChildren}
|
||||||
</td>
|
</td>
|
||||||
),
|
),
|
||||||
th: ({ node: _node, style: tableHeaderStyle, children: tableHeaderChildren, ...tableHeaderProps }) => (
|
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}
|
{tableHeaderChildren}
|
||||||
</th>
|
</th>
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -411,6 +411,86 @@ describe("NewIssueDialog", () => {
|
||||||
act(() => root.unmount());
|
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");
|
||||||
|
await waitForAssertion(() => {
|
||||||
|
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 () => {
|
it("submits the latest locally typed title and description", async () => {
|
||||||
let resolveProjects: (projects: Array<{
|
let resolveProjects: (projects: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,21 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
|
||||||
return "shared_workspace";
|
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({
|
const IssueTitleTextarea = memo(function IssueTitleTextarea({
|
||||||
value,
|
value,
|
||||||
pending,
|
pending,
|
||||||
|
|
@ -686,9 +701,7 @@ export function NewIssueDialog() {
|
||||||
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
||||||
const defaultProjectWorkspaceId = newIssueDefaults.projectWorkspaceId
|
const defaultProjectWorkspaceId = newIssueDefaults.projectWorkspaceId
|
||||||
?? defaultProjectWorkspaceIdForProject(defaultProject);
|
?? defaultProjectWorkspaceIdForProject(defaultProject);
|
||||||
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
|
const defaultExecutionWorkspaceMode = defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject);
|
||||||
? "reuse_existing"
|
|
||||||
: (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject));
|
|
||||||
setIssueText(newIssueDefaults.title ?? "", newIssueDefaults.description ?? "");
|
setIssueText(newIssueDefaults.title ?? "", newIssueDefaults.description ?? "");
|
||||||
setStatus(newIssueDefaults.status ?? "todo");
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
setPriority(newIssueDefaults.priority ?? "");
|
setPriority(newIssueDefaults.priority ?? "");
|
||||||
|
|
@ -710,8 +723,9 @@ export function NewIssueDialog() {
|
||||||
setPriority(newIssueDefaults.priority ?? "");
|
setPriority(newIssueDefaults.priority ?? "");
|
||||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||||
|
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
||||||
setProjectId(defaultProjectId);
|
setProjectId(defaultProjectId);
|
||||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject));
|
||||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||||
setReviewerValue("");
|
setReviewerValue("");
|
||||||
setApproverValue("");
|
setApproverValue("");
|
||||||
|
|
@ -720,12 +734,17 @@ export function NewIssueDialog() {
|
||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
setAssigneeChrome(false);
|
setAssigneeChrome(false);
|
||||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
|
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject));
|
||||||
setSelectedExecutionWorkspaceId("");
|
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
||||||
executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null;
|
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
|
||||||
|
? defaultProjectId || null
|
||||||
|
: null;
|
||||||
} else if (draft && draft.title.trim()) {
|
} else if (draft && draft.title.trim()) {
|
||||||
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
|
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
|
||||||
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
|
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);
|
setIssueText(draft.title, draft.description);
|
||||||
setStatus(draft.status || "todo");
|
setStatus(draft.status || "todo");
|
||||||
setPriority(draft.priority);
|
setPriority(draft.priority);
|
||||||
|
|
@ -739,27 +758,40 @@ export function NewIssueDialog() {
|
||||||
setShowReviewerRow(!!(draft.reviewerValue));
|
setShowReviewerRow(!!(draft.reviewerValue));
|
||||||
setShowApproverRow(!!(draft.approverValue));
|
setShowApproverRow(!!(draft.approverValue));
|
||||||
setProjectId(restoredProjectId);
|
setProjectId(restoredProjectId);
|
||||||
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
|
setProjectWorkspaceId(
|
||||||
|
hasExplicitProjectWorkspaceId
|
||||||
|
? (newIssueDefaults.projectWorkspaceId ?? "")
|
||||||
|
: (draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject)),
|
||||||
|
);
|
||||||
setAssigneeModelLane(draft.assigneeModelLane ?? "primary");
|
setAssigneeModelLane(draft.assigneeModelLane ?? "primary");
|
||||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||||
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
||||||
setAssigneeChrome(draft.assigneeChrome ?? false);
|
setAssigneeChrome(draft.assigneeChrome ?? false);
|
||||||
setExecutionWorkspaceMode(
|
setExecutionWorkspaceMode(
|
||||||
draft.executionWorkspaceMode
|
hasExplicitExecutionWorkspaceId || hasExplicitExecutionWorkspaceMode
|
||||||
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject)),
|
? defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, restoredProject)
|
||||||
|
: (
|
||||||
|
draft.executionWorkspaceMode
|
||||||
|
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject))
|
||||||
|
),
|
||||||
);
|
);
|
||||||
setSelectedExecutionWorkspaceId(draft.selectedExecutionWorkspaceId ?? "");
|
setSelectedExecutionWorkspaceId(
|
||||||
executionWorkspaceDefaultProjectId.current = draft.projectWorkspaceId || restoredProject
|
hasExplicitExecutionWorkspaceId
|
||||||
|
? (newIssueDefaults.executionWorkspaceId ?? "")
|
||||||
|
: (draft.selectedExecutionWorkspaceId ?? ""),
|
||||||
|
);
|
||||||
|
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || hasExplicitExecutionWorkspaceId || draft.projectWorkspaceId || restoredProject
|
||||||
? restoredProjectId || null
|
? restoredProjectId || null
|
||||||
: null;
|
: null;
|
||||||
} else {
|
} else {
|
||||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||||
|
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
|
||||||
setIssueText("", "");
|
setIssueText("", "");
|
||||||
setStatus(newIssueDefaults.status ?? "todo");
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
setPriority(newIssueDefaults.priority ?? "");
|
setPriority(newIssueDefaults.priority ?? "");
|
||||||
setProjectId(defaultProjectId);
|
setProjectId(defaultProjectId);
|
||||||
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
|
setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject));
|
||||||
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
|
||||||
setReviewerValue("");
|
setReviewerValue("");
|
||||||
setApproverValue("");
|
setApproverValue("");
|
||||||
|
|
@ -768,9 +800,11 @@ export function NewIssueDialog() {
|
||||||
setAssigneeModelOverride("");
|
setAssigneeModelOverride("");
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
setAssigneeChrome(false);
|
setAssigneeChrome(false);
|
||||||
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
|
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject));
|
||||||
setSelectedExecutionWorkspaceId("");
|
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
||||||
executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null;
|
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
|
||||||
|
? defaultProjectId || null
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
}, [newIssueOpen, newIssueDefaults, orderedProjects, selectedCompanyId, setIssueText]);
|
}, [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 () => {
|
it("renders workspaceBranch as a read-only selected workspace value", async () => {
|
||||||
issueWorkspaceDraft = {
|
issueWorkspaceDraft = {
|
||||||
executionWorkspaceId: "workspace-1",
|
executionWorkspaceId: "workspace-1",
|
||||||
|
|
|
||||||
|
|
@ -335,8 +335,8 @@ export function RoutineRunVariablesDialog({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
|
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
|
||||||
<DialogContent className="max-w-xl">
|
<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>
|
<DialogHeader className="shrink-0 border-b border-border/60 px-6 pb-4 pr-12 pt-6">
|
||||||
{routineName && (
|
{routineName && (
|
||||||
<p className="text-muted-foreground text-sm">{routineName}</p>
|
<p className="text-muted-foreground text-sm">{routineName}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -346,7 +346,7 @@ export function RoutineRunVariablesDialog({
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Agent *</Label>
|
<Label className="text-xs">Agent *</Label>
|
||||||
|
|
@ -520,7 +520,10 @@ export function RoutineRunVariablesDialog({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 ? (
|
{!selection.assigneeAgentId ? (
|
||||||
<p className="mr-auto text-xs text-amber-600">Default agent required for this run.</p>
|
<p className="mr-auto text-xs text-amber-600">Default agent required for this run.</p>
|
||||||
) : missingRequired.length > 0 ? (
|
) : missingRequired.length > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@ vi.mock("@/context/CompanyContext", () => ({
|
||||||
brandColor: "#36a269",
|
brandColor: "#36a269",
|
||||||
status: "active",
|
status: "active",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "company-3",
|
||||||
|
issuePrefix: "ANA",
|
||||||
|
name: "Anachronist Wiki",
|
||||||
|
brandColor: "#a36a21",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selectedCompany: {
|
selectedCompany: {
|
||||||
id: "company-1",
|
id: "company-1",
|
||||||
|
|
@ -143,6 +150,7 @@ describe("SidebarCompanyMenu", () => {
|
||||||
|
|
||||||
expect(document.body.textContent).toContain("Switch workspace");
|
expect(document.body.textContent).toContain("Switch workspace");
|
||||||
expect(document.body.textContent).toContain("Strata");
|
expect(document.body.textContent).toContain("Strata");
|
||||||
|
expect(document.body.textContent).toContain("ANA");
|
||||||
expect(document.body.textContent).toContain("Add company...");
|
expect(document.body.textContent).toContain("Add company...");
|
||||||
expect(document.body.textContent).toContain("Invite people to Acme Labs");
|
expect(document.body.textContent).toContain("Invite people to Acme Labs");
|
||||||
expect(document.body.textContent).toContain("Company settings");
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, ChevronsUpDown, LogOut, Plus, Settings, UserPlus } from "lucide-react";
|
import { Check, ChevronsUpDown, LogOut, Plus, Settings, UserPlus } from "lucide-react";
|
||||||
import type { Company } from "@paperclipai/shared";
|
import type { Company } from "@paperclipai/shared";
|
||||||
|
|
@ -46,7 +46,10 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const open = controlledOpen ?? internalOpen;
|
const open = controlledOpen ?? internalOpen;
|
||||||
const setOpen = onOpenChange ?? setInternalOpen;
|
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({
|
const { data: session } = useQuery({
|
||||||
queryKey: queryKeys.auth.session,
|
queryKey: queryKeys.auth.session,
|
||||||
queryFn: () => authApi.getSession(),
|
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">
|
<DropdownMenuLabel className="px-2 py-1.5 text-[11px] font-semibold uppercase text-muted-foreground">
|
||||||
Switch workspace
|
Switch workspace
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<div className="max-h-72 overflow-y-auto">
|
<div className="max-h-96 overflow-y-auto">
|
||||||
{sidebarCompanies.map((company) => {
|
{sidebarCompanies.map((company) => {
|
||||||
const isSelected = company.id === selectedCompany?.id;
|
const isSelected = company.id === selectedCompany?.id;
|
||||||
return (
|
return (
|
||||||
|
|
@ -124,6 +127,9 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
||||||
>
|
>
|
||||||
<WorkspaceIcon company={company} />
|
<WorkspaceIcon company={company} />
|
||||||
<span className="min-w-0 flex-1 truncate">{company.name}</span>
|
<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}
|
{isSelected ? <Check className="size-4 text-muted-foreground" /> : null}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,36 @@ describe("StatusIcon", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).not.toContain('data-blocker-attention-state="covered"');
|
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).toContain("border-red-600");
|
||||||
expect(html).not.toContain("border-dashed");
|
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", () => {
|
it("renders stalled review chains with amber visual and stalled-leaf copy", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<StatusIcon
|
<StatusIcon
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,13 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blockerAttention.reason === "attention_required") {
|
if (blockerAttention.reason === "attention_required") {
|
||||||
const count = blockerAttention.unresolvedBlockerCount;
|
const count = blockerAttention.attentionBlockerCount || blockerAttention.unresolvedBlockerCount;
|
||||||
return `Blocked · ${count} unresolved ${count === 1 ? "blocker needs" : "blockers need"} attention`;
|
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";
|
return "Blocked";
|
||||||
|
|
@ -60,6 +65,8 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
|
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
|
||||||
const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled";
|
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
|
const colorClass = isCoveredBlocked
|
||||||
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
|
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
|
||||||
: isStalledBlocked
|
: isStalledBlocked
|
||||||
|
|
@ -71,7 +78,9 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
||||||
? "covered"
|
? "covered"
|
||||||
: isStalledBlocked
|
: isStalledBlocked
|
||||||
? "stalled"
|
? "stalled"
|
||||||
: undefined;
|
: isAttentionBlocked
|
||||||
|
? "needs_attention"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const circle = (
|
const circle = (
|
||||||
<span
|
<span
|
||||||
|
|
@ -91,6 +100,9 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
||||||
{isCoveredBlocked && (
|
{isCoveredBlocked && (
|
||||||
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
|
<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 && (
|
{isStalledBlocked && (
|
||||||
<span className="absolute inset-0 m-auto h-1.5 w-1.5 rounded-full bg-current" />
|
<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 {
|
import {
|
||||||
buildWorkspaceRuntimeControlItems,
|
buildWorkspaceRuntimeControlItems,
|
||||||
buildWorkspaceRuntimeControlSections,
|
buildWorkspaceRuntimeControlSections,
|
||||||
|
WorkspaceRuntimeQuickControls,
|
||||||
WorkspaceRuntimeControls,
|
WorkspaceRuntimeControls,
|
||||||
} from "./WorkspaceRuntimeControls";
|
} from "./WorkspaceRuntimeControls";
|
||||||
|
|
||||||
|
|
@ -293,6 +294,41 @@ describe("WorkspaceRuntimeControls", () => {
|
||||||
act(() => root.unmount());
|
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", () => {
|
it("shows disabled actions when local command prerequisites are missing", () => {
|
||||||
const sections = buildWorkspaceRuntimeControlSections({
|
const sections = buildWorkspaceRuntimeControlSections({
|
||||||
runtimeConfig: {
|
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(
|
function requestMatchesPending(
|
||||||
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined,
|
pendingRequest: WorkspaceRuntimeControlRequest | null | undefined,
|
||||||
nextRequest: WorkspaceRuntimeControlRequest,
|
nextRequest: WorkspaceRuntimeControlRequest,
|
||||||
|
|
@ -255,9 +264,8 @@ function CommandActionButtons({
|
||||||
variant={action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
|
variant={action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 w-full justify-start px-3 shadow-none sm:w-auto",
|
"w-full justify-start sm:w-auto",
|
||||||
square ? "rounded-none" : "rounded-xl",
|
square ? "rounded-none" : null,
|
||||||
action === "restart" ? "bg-background" : null,
|
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onAction(request)}
|
onClick={() => onAction(request)}
|
||||||
|
|
@ -451,3 +459,56 @@ export function WorkspaceRuntimeControls({
|
||||||
</div>
|
</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 { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
@ -58,4 +58,21 @@ describe("BreadcrumbContext", () => {
|
||||||
|
|
||||||
expect(renderCounts).toHaveLength(2);
|
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;
|
setMobileToolbar: (node: ReactNode | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
companyName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(null);
|
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(null);
|
||||||
|
|
||||||
function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) {
|
function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) {
|
||||||
|
|
@ -25,7 +30,16 @@ function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) {
|
||||||
return true;
|
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 [breadcrumbs, setBreadcrumbsState] = useState<Breadcrumb[]>([]);
|
||||||
const [mobileToolbar, setMobileToolbarState] = useState<ReactNode | null>(null);
|
const [mobileToolbar, setMobileToolbarState] = useState<ReactNode | null>(null);
|
||||||
|
|
||||||
|
|
@ -38,13 +52,8 @@ export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (breadcrumbs.length === 0) {
|
document.title = buildDocumentTitle(breadcrumbs, companyName);
|
||||||
document.title = "Paperclip";
|
}, [breadcrumbs, companyName]);
|
||||||
} else {
|
|
||||||
const parts = [...breadcrumbs].reverse().map((b) => b.label);
|
|
||||||
document.title = `${parts.join(" · ")} · Paperclip`;
|
|
||||||
}
|
|
||||||
}, [breadcrumbs]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BreadcrumbContext.Provider value={{ breadcrumbs, setBreadcrumbs, mobileToolbar, setMobileToolbar }}>
|
<BreadcrumbContext.Provider value={{ breadcrumbs, setBreadcrumbs, mobileToolbar, setMobileToolbar }}>
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,16 @@
|
||||||
background: oklch(0.5 0 0);
|
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 {
|
.scrollbar-auto-hide::-webkit-scrollbar {
|
||||||
width: 8px !important;
|
width: 8px !important;
|
||||||
|
height: 8px !important;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
.scrollbar-auto-hide::-webkit-scrollbar-track {
|
.scrollbar-auto-hide::-webkit-scrollbar-track {
|
||||||
|
|
@ -199,18 +206,25 @@
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
/* Light mode scrollbar on hover */
|
/* Light mode scrollbar on hover */
|
||||||
|
.scrollbar-auto-hide:hover {
|
||||||
|
scrollbar-color: oklch(0.7 0 0) transparent;
|
||||||
|
}
|
||||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
||||||
background: oklch(0.92 0 0) !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
||||||
background: oklch(0.7 0 0) !important;
|
background: oklch(0.7 0 0) !important;
|
||||||
|
border-radius: 999px !important;
|
||||||
}
|
}
|
||||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
|
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
|
||||||
background: oklch(0.6 0 0) !important;
|
background: oklch(0.6 0 0) !important;
|
||||||
}
|
}
|
||||||
/* Dark mode scrollbar on hover */
|
/* 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 {
|
.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 {
|
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
||||||
background: oklch(0.4 0 0) !important;
|
background: oklch(0.4 0 0) !important;
|
||||||
|
|
@ -747,7 +761,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||||
margin-bottom: 0;
|
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-top: 0.7rem;
|
||||||
margin-bottom: 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);
|
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--foreground) 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-markdown table {
|
.paperclip-markdown-table-scroll {
|
||||||
width: 100%;
|
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 {
|
.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");
|
saveInboxWorkItemGroupBy("workspace");
|
||||||
expect(loadInboxWorkItemGroupBy()).toBe("workspace");
|
expect(loadInboxWorkItemGroupBy()).toBe("workspace");
|
||||||
|
saveInboxWorkItemGroupBy("assignee");
|
||||||
|
expect(loadInboxWorkItemGroupBy()).toBe("assignee");
|
||||||
|
saveInboxWorkItemGroupBy("project");
|
||||||
|
expect(loadInboxWorkItemGroupBy()).toBe("project");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists collapsed inbox groups per company", () => {
|
it("persists collapsed inbox groups per company", () => {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
normalizeIssueFilterState,
|
normalizeIssueFilterState,
|
||||||
type IssueFilterState,
|
type IssueFilterState,
|
||||||
} from "./issue-filters";
|
} from "./issue-filters";
|
||||||
|
import { formatAssigneeUserLabel } from "./assignees";
|
||||||
|
|
||||||
export const RECENT_ISSUES_LIMIT = 100;
|
export const RECENT_ISSUES_LIMIT = 100;
|
||||||
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||||
|
|
@ -33,7 +34,7 @@ export type InboxCategoryFilter =
|
||||||
| "failed_runs"
|
| "failed_runs"
|
||||||
| "alerts";
|
| "alerts";
|
||||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||||
export type InboxWorkItemGroupBy = "none" | "type" | "workspace";
|
export type InboxWorkItemGroupBy = "none" | "type" | "assignee" | "project" | "workspace";
|
||||||
export const inboxIssueColumns = [
|
export const inboxIssueColumns = [
|
||||||
"status",
|
"status",
|
||||||
"id",
|
"id",
|
||||||
|
|
@ -137,6 +138,10 @@ export interface InboxWorkspaceGroupingOptions {
|
||||||
executionWorkspaceById?: ReadonlyMap<string, InboxExecutionWorkspaceLookup>;
|
executionWorkspaceById?: ReadonlyMap<string, InboxExecutionWorkspaceLookup>;
|
||||||
projectWorkspaceById?: ReadonlyMap<string, InboxProjectWorkspaceLookup>;
|
projectWorkspaceById?: ReadonlyMap<string, InboxProjectWorkspaceLookup>;
|
||||||
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
|
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 = {
|
const defaultInboxFilterPreferences: InboxFilterPreferences = {
|
||||||
|
|
@ -342,7 +347,7 @@ export function saveInboxIssueColumns(columns: InboxIssueColumn[]) {
|
||||||
export function loadInboxWorkItemGroupBy(): InboxWorkItemGroupBy {
|
export function loadInboxWorkItemGroupBy(): InboxWorkItemGroupBy {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(INBOX_GROUP_BY_KEY);
|
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 {
|
} catch {
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
@ -805,6 +810,86 @@ const inboxWorkItemKindLabels: Record<InboxWorkItem["kind"], string> = {
|
||||||
join_request: "Join requests",
|
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(
|
export function groupInboxWorkItems(
|
||||||
items: InboxWorkItem[],
|
items: InboxWorkItem[],
|
||||||
groupBy: InboxWorkItemGroupBy,
|
groupBy: InboxWorkItemGroupBy,
|
||||||
|
|
@ -815,41 +900,15 @@ export function groupInboxWorkItems(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupBy === "workspace") {
|
if (groupBy === "workspace") {
|
||||||
const groups = new Map<string, { label: string; items: InboxWorkItem[]; latestTimestamp: number }>();
|
return groupInboxWorkItemsByIssueGroup(items, (issue) => resolveIssueWorkspaceGroup(issue, options));
|
||||||
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 [...groups.entries()]
|
if (groupBy === "assignee") {
|
||||||
.map(([key, value]) => ({
|
return groupInboxWorkItemsByIssueGroup(items, (issue) => resolveIssueAssigneeGroup(issue, options));
|
||||||
key,
|
}
|
||||||
label: value.label,
|
|
||||||
items: value.items,
|
if (groupBy === "project") {
|
||||||
latestTimestamp: value.latestTimestamp,
|
return groupInboxWorkItemsByIssueGroup(items, (issue) => resolveIssueProjectGroup(issue, options));
|
||||||
}))
|
|
||||||
.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,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = new Map<InboxWorkItem["kind"], InboxWorkItem[]>();
|
const groups = new Map<InboxWorkItem["kind"], InboxWorkItem[]>();
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,10 @@ export const queryKeys = {
|
||||||
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
comments: (issueId: string) => ["issues", "comments", issueId] as const,
|
||||||
interactions: (issueId: string) => ["issues", "interactions", issueId] as const,
|
interactions: (issueId: string) => ["issues", "interactions", issueId] as const,
|
||||||
feedbackVotes: (issueId: string) => ["issues", "feedback-votes", 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,
|
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||||
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
||||||
document: (issueId: string, key: string) => ["issues", "document", issueId, key] 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);
|
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. */
|
/** Map a raw provider slug to a display-friendly name. */
|
||||||
export function providerDisplayName(provider: string): string {
|
export function providerDisplayName(provider: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "@/lib/router";
|
import { BrowserRouter } from "@/lib/router";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { CompanyProvider } from "./context/CompanyContext";
|
import { CompanyProvider, useCompany } from "./context/CompanyContext";
|
||||||
import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider";
|
import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider";
|
||||||
import { BreadcrumbProvider } from "./context/BreadcrumbContext";
|
import { BreadcrumbProvider } from "./context/BreadcrumbContext";
|
||||||
import { PanelProvider } from "./context/PanelContext";
|
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(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|
@ -47,7 +52,7 @@ createRoot(document.getElementById("root")!).render(
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<LiveUpdatesProvider>
|
<LiveUpdatesProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<BreadcrumbProvider>
|
<CompanyAwareBreadcrumbProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<PanelProvider>
|
<PanelProvider>
|
||||||
<PluginLauncherProvider>
|
<PluginLauncherProvider>
|
||||||
|
|
@ -57,7 +62,7 @@ createRoot(document.getElementById("root")!).render(
|
||||||
</PluginLauncherProvider>
|
</PluginLauncherProvider>
|
||||||
</PanelProvider>
|
</PanelProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</BreadcrumbProvider>
|
</CompanyAwareBreadcrumbProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</LiveUpdatesProvider>
|
</LiveUpdatesProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router";
|
import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, RoutineListItem } from "@paperclipai/shared";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
} from "../components/RoutineRunVariablesDialog";
|
} from "../components/RoutineRunVariablesDialog";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceRuntimeControlSections,
|
buildWorkspaceRuntimeControlSections,
|
||||||
|
WorkspaceRuntimeQuickControls,
|
||||||
WorkspaceRuntimeControls,
|
WorkspaceRuntimeControls,
|
||||||
type WorkspaceRuntimeControlRequest,
|
type WorkspaceRuntimeControlRequest,
|
||||||
} from "../components/WorkspaceRuntimeControls";
|
} from "../components/WorkspaceRuntimeControls";
|
||||||
|
|
@ -53,13 +54,14 @@ type WorkspaceFormState = {
|
||||||
workspaceRuntime: string;
|
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 {
|
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null {
|
||||||
const segments = pathname.split("/").filter(Boolean);
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
const executionWorkspacesIndex = segments.indexOf("execution-workspaces");
|
const executionWorkspacesIndex = segments.indexOf("execution-workspaces");
|
||||||
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
|
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
|
||||||
const tab = segments[executionWorkspacesIndex + 2];
|
const tab = segments[executionWorkspacesIndex + 2];
|
||||||
|
if (tab === "services") return "services";
|
||||||
if (tab === "issues") return "issues";
|
if (tab === "issues") return "issues";
|
||||||
if (tab === "routines") return "routines";
|
if (tab === "routines") return "routines";
|
||||||
if (tab === "runtime-logs") return "runtime_logs";
|
if (tab === "runtime-logs") return "runtime_logs";
|
||||||
|
|
@ -72,6 +74,16 @@ function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceT
|
||||||
return `/execution-workspaces/${workspaceId}/${segment}`;
|
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) {
|
function isSafeExternalUrl(value: string | null | undefined) {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
try {
|
try {
|
||||||
|
|
@ -259,14 +271,14 @@ function WorkspaceLink({
|
||||||
|
|
||||||
function ExecutionWorkspaceIssuesList({
|
function ExecutionWorkspaceIssuesList({
|
||||||
companyId,
|
companyId,
|
||||||
workspaceId,
|
workspace,
|
||||||
issues,
|
issues,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
project,
|
project,
|
||||||
}: {
|
}: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
workspaceId: string;
|
workspace: ExecutionWorkspace;
|
||||||
issues: Issue[];
|
issues: Issue[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
|
|
@ -292,7 +304,7 @@ function ExecutionWorkspaceIssuesList({
|
||||||
const updateIssue = useMutation({
|
const updateIssue = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
|
||||||
onSuccess: () => {
|
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) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||||
if (project?.id) {
|
if (project?.id) {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, 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 ? [{ id: project.id, name: project.name, workspaces: project.workspaces ?? [] }] : undefined),
|
||||||
[project],
|
[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 (
|
return (
|
||||||
<IssuesList
|
<IssuesList
|
||||||
|
|
@ -315,6 +336,7 @@ function ExecutionWorkspaceIssuesList({
|
||||||
liveIssueIds={liveIssueIds}
|
liveIssueIds={liveIssueIds}
|
||||||
projectId={project?.id}
|
projectId={project?.id}
|
||||||
viewStateKey="paperclip:execution-workspace-issues-view"
|
viewStateKey="paperclip:execution-workspace-issues-view"
|
||||||
|
baseCreateIssueDefaults={createIssueDefaults}
|
||||||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -663,25 +685,10 @@ export function ExecutionWorkspaceDetail() {
|
||||||
const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null;
|
const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null;
|
||||||
|
|
||||||
if (workspaceId && activeTab === null) {
|
if (workspaceId && activeTab === null) {
|
||||||
let cachedTab: ExecutionWorkspaceTab = "configuration";
|
return <LegacyWorkspaceTabRedirect workspaceId={workspaceId} />;
|
||||||
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 />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTabChange = (tab: ExecutionWorkspaceTab) => {
|
const handleTabChange = (tab: ExecutionWorkspaceTab) => {
|
||||||
try {
|
|
||||||
localStorage.setItem(`paperclip:execution-workspace-tab:${workspace.id}`, tab);
|
|
||||||
} catch {}
|
|
||||||
navigate(executionWorkspaceTabPath(workspace.id, tab));
|
navigate(executionWorkspaceTabPath(workspace.id, tab));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -707,43 +714,39 @@ export function ExecutionWorkspaceDetail() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-4 overflow-hidden sm:space-y-6">
|
<div className="space-y-4 overflow-hidden sm:space-y-6">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<div className="min-w-0 space-y-2">
|
||||||
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
|
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
Execution workspace
|
||||||
Back to all workspaces
|
</div>
|
||||||
</Link>
|
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
|
||||||
</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>
|
</div>
|
||||||
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
|
<WorkspaceRuntimeQuickControls
|
||||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
sections={runtimeControlSections}
|
||||||
Configure the concrete runtime workspace that Paperclip reuses for this issue flow.
|
isPending={controlRuntimeServices.isPending}
|
||||||
<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>
|
pendingRequest={pendingRuntimeAction}
|
||||||
</p>
|
onAction={(request) => controlRuntimeServices.mutate(request)}
|
||||||
|
/>
|
||||||
</div>
|
</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">
|
<Tabs value={activeTab ?? "issues"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
|
||||||
<CardHeader>
|
<PageTabBar
|
||||||
<CardTitle>Services and jobs</CardTitle>
|
items={[
|
||||||
<CardDescription>
|
{ value: "issues", label: "Issues" },
|
||||||
Source: {runtimeConfigSource === "execution_workspace"
|
{ value: "services", label: "Services" },
|
||||||
? "execution workspace override"
|
{ value: "configuration", label: "Configuration" },
|
||||||
: runtimeConfigSource === "project_workspace"
|
{ value: "runtime_logs", label: "Runtime logs" },
|
||||||
? "project workspace default"
|
{ value: "routines", label: "Routines" },
|
||||||
: "none"}
|
]}
|
||||||
</CardDescription>
|
align="start"
|
||||||
</CardHeader>
|
value={activeTab ?? "issues"}
|
||||||
<CardContent>
|
onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{activeTab === "services" ? (
|
||||||
<WorkspaceRuntimeControls
|
<WorkspaceRuntimeControls
|
||||||
sections={runtimeControlSections}
|
sections={runtimeControlSections}
|
||||||
isPending={controlRuntimeServices.isPending}
|
isPending={controlRuntimeServices.isPending}
|
||||||
|
|
@ -761,26 +764,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
}
|
}
|
||||||
onAction={(request) => controlRuntimeServices.mutate(request)}
|
onAction={(request) => controlRuntimeServices.mutate(request)}
|
||||||
/>
|
/>
|
||||||
{runtimeActionErrorMessage ? <p className="mt-4 text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
|
) : activeTab === "configuration" ? (
|
||||||
{!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" ? (
|
|
||||||
<div className="space-y-4 sm:space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<Card className="rounded-none">
|
<Card className="rounded-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -792,7 +776,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full rounded-none sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
onClick={() => setCloseDialogOpen(true)}
|
onClick={() => setCloseDialogOpen(true)}
|
||||||
disabled={workspace.status === "archived"}
|
disabled={workspace.status === "archived"}
|
||||||
>
|
>
|
||||||
|
|
@ -1138,7 +1122,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
) : activeTab === "issues" ? (
|
) : activeTab === "issues" ? (
|
||||||
<ExecutionWorkspaceIssuesList
|
<ExecutionWorkspaceIssuesList
|
||||||
companyId={workspace.companyId}
|
companyId={workspace.companyId}
|
||||||
workspaceId={workspace.id}
|
workspace={workspace}
|
||||||
issues={linkedIssues}
|
issues={linkedIssues}
|
||||||
isLoading={linkedIssuesQuery.isLoading}
|
isLoading={linkedIssuesQuery.isLoading}
|
||||||
error={linkedIssuesQuery.error as Error | null}
|
error={linkedIssuesQuery.error as Error | null}
|
||||||
|
|
|
||||||
|
|
@ -982,11 +982,23 @@ export function Inbox() {
|
||||||
}, [executionWorkspaces]);
|
}, [executionWorkspaces]);
|
||||||
const inboxWorkspaceGrouping = useMemo<InboxWorkspaceGroupingOptions>(
|
const inboxWorkspaceGrouping = useMemo<InboxWorkspaceGroupingOptions>(
|
||||||
() => ({
|
() => ({
|
||||||
|
agentById,
|
||||||
executionWorkspaceById,
|
executionWorkspaceById,
|
||||||
projectWorkspaceById,
|
projectWorkspaceById,
|
||||||
defaultProjectWorkspaceIdByProjectId,
|
defaultProjectWorkspaceIdByProjectId,
|
||||||
|
projectById,
|
||||||
|
userLabelById: companyUserLabelMap,
|
||||||
|
currentUserId,
|
||||||
}),
|
}),
|
||||||
[defaultProjectWorkspaceIdByProjectId, executionWorkspaceById, projectWorkspaceById],
|
[
|
||||||
|
agentById,
|
||||||
|
companyUserLabelMap,
|
||||||
|
currentUserId,
|
||||||
|
defaultProjectWorkspaceIdByProjectId,
|
||||||
|
executionWorkspaceById,
|
||||||
|
projectById,
|
||||||
|
projectWorkspaceById,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
|
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
|
||||||
const availableIssueColumns = useMemo(
|
const availableIssueColumns = useMemo(
|
||||||
|
|
@ -1990,6 +2002,8 @@ export function Inbox() {
|
||||||
{([
|
{([
|
||||||
["none", "None"],
|
["none", "None"],
|
||||||
["type", "Type"],
|
["type", "Type"],
|
||||||
|
["assignee", "Assignee"],
|
||||||
|
["project", "Project"],
|
||||||
...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []),
|
...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []),
|
||||||
] as const).map(([value, label]) => (
|
] as const).map(([value, label]) => (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ import {
|
||||||
} from "../lib/optimistic-issue-comments";
|
} from "../lib/optimistic-issue-comments";
|
||||||
import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
|
import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
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 { ApprovalCard } from "../components/ApprovalCard";
|
||||||
import { InlineEditor } from "../components/InlineEditor";
|
import { InlineEditor } from "../components/InlineEditor";
|
||||||
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
|
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
|
||||||
|
|
@ -966,8 +966,11 @@ function IssueDetailActivityTab({
|
||||||
let output = 0;
|
let output = 0;
|
||||||
let cached = 0;
|
let cached = 0;
|
||||||
let cost = 0;
|
let cost = 0;
|
||||||
|
let runtimeMs = 0;
|
||||||
|
let runCount = 0;
|
||||||
let hasCost = false;
|
let hasCost = false;
|
||||||
let hasTokens = false;
|
let hasTokens = false;
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
for (const run of linkedRuns ?? []) {
|
for (const run of linkedRuns ?? []) {
|
||||||
const usage = asRecord(run.usageJson);
|
const usage = asRecord(run.usageJson);
|
||||||
|
|
@ -987,6 +990,15 @@ function IssueDetailActivityTab({
|
||||||
output += runOutput;
|
output += runOutput;
|
||||||
cached += runCached;
|
cached += runCached;
|
||||||
cost += runCost;
|
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 {
|
return {
|
||||||
|
|
@ -997,6 +1009,9 @@ function IssueDetailActivityTab({
|
||||||
totalTokens: input + output,
|
totalTokens: input + output,
|
||||||
hasCost,
|
hasCost,
|
||||||
hasTokens,
|
hasTokens,
|
||||||
|
runtimeMs,
|
||||||
|
runCount,
|
||||||
|
hasRuntime: runtimeMs > 0,
|
||||||
};
|
};
|
||||||
}, [linkedRuns]);
|
}, [linkedRuns]);
|
||||||
const issueTreeCostTokens =
|
const issueTreeCostTokens =
|
||||||
|
|
@ -1006,6 +1021,7 @@ function IssueDetailActivityTab({
|
||||||
&& (issueTreeCostSummary.costCents > 0
|
&& (issueTreeCostSummary.costCents > 0
|
||||||
|| issueTreeCostTokens > 0
|
|| issueTreeCostTokens > 0
|
||||||
|| issueTreeCostSummary.cachedInputTokens > 0
|
|| issueTreeCostSummary.cachedInputTokens > 0
|
||||||
|
|| issueTreeCostSummary.runtimeMs > 0
|
||||||
|| issueTreeCostSummary.issueCount > 1);
|
|| issueTreeCostSummary.issueCount > 1);
|
||||||
const shouldShowCostSummary =
|
const shouldShowCostSummary =
|
||||||
(linkedRuns && linkedRuns.length > 0) || hasIssueTreeCost;
|
(linkedRuns && linkedRuns.length > 0) || hasIssueTreeCost;
|
||||||
|
|
@ -1038,7 +1054,13 @@ function IssueDetailActivityTab({
|
||||||
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
|
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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>
|
<span>No direct cost data.</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1058,6 +1080,12 @@ function IssueDetailActivityTab({
|
||||||
? ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)}, cached ${formatTokens(issueTreeCostSummary.cachedInputTokens)})`
|
? ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)}, cached ${formatTokens(issueTreeCostSummary.cachedInputTokens)})`
|
||||||
: ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)})`}
|
: ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)})`}
|
||||||
</span>
|
</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>
|
<span>{issueTreeCostSummary.issueCount} issue{issueTreeCostSummary.issueCount === 1 ? "" : "s"}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -3466,6 +3494,7 @@ export function IssueDetail() {
|
||||||
createIssueLabel="Sub-issue"
|
createIssueLabel="Sub-issue"
|
||||||
defaultSortField="workflow"
|
defaultSortField="workflow"
|
||||||
showProgressSummary
|
showProgressSummary
|
||||||
|
parentIssueIdForCostSummary={issue.id}
|
||||||
onUpdateIssue={handleChildIssueUpdate}
|
onUpdateIssue={handleChildIssueUpdate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||