diff --git a/docs/pr-screenshots/pr-5291/after-issue-management.png b/docs/pr-screenshots/pr-5291/after-issue-management.png new file mode 100644 index 00000000..08f3bc25 Binary files /dev/null and b/docs/pr-screenshots/pr-5291/after-issue-management.png differ diff --git a/docs/pr-screenshots/pr-5291/after-navigation-layout.png b/docs/pr-screenshots/pr-5291/after-navigation-layout.png new file mode 100644 index 00000000..0b550691 Binary files /dev/null and b/docs/pr-screenshots/pr-5291/after-navigation-layout.png differ diff --git a/docs/pr-screenshots/pr-5291/after-projects-workspaces.png b/docs/pr-screenshots/pr-5291/after-projects-workspaces.png new file mode 100644 index 00000000..30a7a316 Binary files /dev/null and b/docs/pr-screenshots/pr-5291/after-projects-workspaces.png differ diff --git a/docs/pr-screenshots/pr-5291/after-status-language.png b/docs/pr-screenshots/pr-5291/after-status-language.png new file mode 100644 index 00000000..f37afc74 Binary files /dev/null and b/docs/pr-screenshots/pr-5291/after-status-language.png differ diff --git a/docs/pr-screenshots/pr-5291/before-issue-management.png b/docs/pr-screenshots/pr-5291/before-issue-management.png new file mode 100644 index 00000000..fa7e7462 Binary files /dev/null and b/docs/pr-screenshots/pr-5291/before-issue-management.png differ diff --git a/docs/pr-screenshots/pr-5291/before-navigation-layout.png b/docs/pr-screenshots/pr-5291/before-navigation-layout.png new file mode 100644 index 00000000..cedc6513 Binary files /dev/null and b/docs/pr-screenshots/pr-5291/before-navigation-layout.png differ diff --git a/docs/pr-screenshots/pr-5291/before-projects-workspaces.png b/docs/pr-screenshots/pr-5291/before-projects-workspaces.png new file mode 100644 index 00000000..7b3199a4 Binary files /dev/null and b/docs/pr-screenshots/pr-5291/before-projects-workspaces.png differ diff --git a/packages/shared/src/types/cost.ts b/packages/shared/src/types/cost.ts index 57e6be62..11d9a765 100644 --- a/packages/shared/src/types/cost.ts +++ b/packages/shared/src/types/cost.ts @@ -36,6 +36,11 @@ export interface IssueCostSummary { inputTokens: number; cachedInputTokens: number; outputTokens: number; + /** number of distinct heartbeat runs aggregated across the issue tree */ + runCount: number; + /** sum of wall-clock duration of each run in the tree (ms); + * still-running runs contribute (now - startedAt) so this ticks up live */ + runtimeMs: number; } export interface CostByAgent { diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index 38e1f671..4a2d58ee 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -3,7 +3,17 @@ import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll } from "vitest"; import { randomUUID } from "node:crypto"; -import { createDb, companies, agents, costEvents, financeEvents, issues, projects } from "@paperclipai/db"; +import { + createDb, + companies, + agents, + activityLog, + costEvents, + financeEvents, + heartbeatRuns, + issues, + projects, +} from "@paperclipai/db"; import { costService } from "../services/costs.ts"; import { financeService } from "../services/finance.ts"; import { @@ -69,6 +79,8 @@ const mockCostService = vi.hoisted(() => ({ inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, + runCount: 0, + runtimeMs: 0, }), windowSpend: vi.fn().mockResolvedValue([]), byProject: vi.fn().mockResolvedValue([]), @@ -231,7 +243,9 @@ describe("cost routes", () => { expect(res.status).toBe(200); expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1"); - expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1"); + expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1", { + excludeRoot: false, + }); expect(res.body).toEqual({ issueId: "issue-1", issueCount: 1, @@ -240,6 +254,8 @@ describe("cost routes", () => { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, + runCount: 0, + runtimeMs: 0, }); }); @@ -393,6 +409,8 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { afterEach(async () => { await db.delete(financeEvents); await db.delete(costEvents); + await db.delete(activityLog); + await db.delete(heartbeatRuns); await db.delete(issues); await db.delete(projects); await db.delete(agents); @@ -612,9 +630,173 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { inputTokens: 60, cachedInputTokens: 6, outputTokens: 12, + runCount: 0, + runtimeMs: 0, }); }); + it("aggregates run wall-clock duration across the recursive issue tree", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const rootIssueId = randomUUID(); + const childIssueId = randomUUID(); + const grandchildIssueId = randomUUID(); + const siblingIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Run Agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values([ + { + id: rootIssueId, + companyId, + title: "Root", + status: "in_progress", + priority: "medium", + issueNumber: 1, + identifier: "TST-1", + }, + { + id: childIssueId, + companyId, + parentId: rootIssueId, + title: "Child", + status: "in_progress", + priority: "medium", + issueNumber: 2, + identifier: "TST-2", + }, + { + id: grandchildIssueId, + companyId, + parentId: childIssueId, + title: "Grandchild", + status: "done", + priority: "medium", + issueNumber: 3, + identifier: "TST-3", + }, + { + id: siblingIssueId, + companyId, + title: "Sibling", + status: "done", + priority: "medium", + issueNumber: 4, + identifier: "TST-4", + }, + ]); + + const linkedViaContextRunId = randomUUID(); + const linkedViaActivityRunId = randomUUID(); + const grandchildRunId = randomUUID(); + const siblingRunId = randomUUID(); + const livePartialRunId = randomUUID(); + + await db.insert(heartbeatRuns).values([ + // 60s run linked to root via contextSnapshot.issueId + { + id: linkedViaContextRunId, + companyId, + agentId, + invocationSource: "on_demand", + status: "completed", + startedAt: new Date("2026-04-10T00:00:00.000Z"), + finishedAt: new Date("2026-04-10T00:01:00.000Z"), + contextSnapshot: { issueId: rootIssueId }, + }, + // 120s run linked to child via activity_log + { + id: linkedViaActivityRunId, + companyId, + agentId, + invocationSource: "on_demand", + status: "completed", + startedAt: new Date("2026-04-10T00:05:00.000Z"), + finishedAt: new Date("2026-04-10T00:07:00.000Z"), + }, + // 30s run linked to grandchild + { + id: grandchildRunId, + companyId, + agentId, + invocationSource: "on_demand", + status: "completed", + startedAt: new Date("2026-04-10T00:10:00.000Z"), + finishedAt: new Date("2026-04-10T00:10:30.000Z"), + contextSnapshot: { issueId: grandchildIssueId }, + }, + // sibling run NOT under root – should be excluded + { + id: siblingRunId, + companyId, + agentId, + invocationSource: "on_demand", + status: "completed", + startedAt: new Date("2026-04-10T00:20:00.000Z"), + finishedAt: new Date("2026-04-10T00:21:00.000Z"), + contextSnapshot: { issueId: siblingIssueId }, + }, + // Still-running run on child (no finishedAt) – should contribute (now - startedAt) + { + id: livePartialRunId, + companyId, + agentId, + invocationSource: "on_demand", + status: "running", + startedAt: new Date(Date.now() - 5_000), + contextSnapshot: { issueId: childIssueId }, + }, + ]); + + await db.insert(activityLog).values({ + companyId, + runId: linkedViaActivityRunId, + actorType: "agent", + actorId: agentId, + agentId, + action: "issue.checked_out", + entityType: "issue", + entityId: childIssueId, + details: {}, + }); + + const summary = await costs.issueTreeSummary(companyId, rootIssueId); + + expect(summary.issueCount).toBe(3); + // 3 finished runs in tree (root, child via activity, grandchild) + 1 live run + expect(summary.runCount).toBe(4); + // 60s + 120s + 30s = 210s = 210_000ms from finished runs. + // Live run adds ~5_000ms; allow some slack so the assertion isn't flaky. + expect(summary.runtimeMs).toBeGreaterThanOrEqual(210_000 + 4_000); + expect(summary.runtimeMs).toBeLessThan(210_000 + 60_000); + + // excludeRoot drops the root issue's own runs (the 60s contextSnapshot run) + // while keeping the child + grandchild runs and any live child run. + const descendantsOnly = await costs.issueTreeSummary(companyId, rootIssueId, { + excludeRoot: true, + }); + expect(descendantsOnly.issueCount).toBe(2); + expect(descendantsOnly.runCount).toBe(3); + // 120s + 30s = 150s + ~5s live run + expect(descendantsOnly.runtimeMs).toBeGreaterThanOrEqual(150_000 + 4_000); + expect(descendantsOnly.runtimeMs).toBeLessThan(150_000 + 60_000); + }); + it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => { const companyId = randomUUID(); diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index 0e385d1c..dbd15303 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -109,7 +109,7 @@ function registerModuleMocks() { })); } -async function createApp() { +async function createApp(db: unknown = {}) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/issues.js"), vi.importActual("../middleware/index.js"), @@ -126,7 +126,7 @@ async function createApp() { }; next(); }); - app.use("/api", issueRoutes({} as any, {} as any)); + app.use("/api", issueRoutes(db as any, {} as any)); app.use(errorHandler); return app; } @@ -266,6 +266,88 @@ describe("issue activity event routes", () => { }); }, 15_000); + it("logs successful_run_handoff_resolved when an in_progress issue transitions to done with a pending required handoff", async () => { + const issue = { ...makeIssue(), status: "in_progress" }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...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) => ({ + ...issue, + ...patch, + updatedAt: new Date(), + })); + + const dbMock = { + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: async () => [], + }), + }), + }), + }; + + const res = await request(await createApp(dbMock)) + .patch(`/api/issues/${issue.id}`) + .send({ title: "Updated title" }); + + expect(res.status).toBe(200); + expect(mockLogActivity).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: "issue.successful_run_handoff_resolved" }), + ); + }); + it("logs explicit reviewer and approver activity when execution policy participants change", async () => { const existingPolicy = normalizeIssueExecutionPolicy({ stages: [ diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 7f8a9971..c605e45b 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -3134,6 +3134,130 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { expect(persisted?.healthStatus).toBe("unknown"); expect(persisted?.stoppedAt).toBeTruthy(); }); + + it("restarts a stopped auto-port service on the same port when it is available", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-port-reuse-")); + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Codex Coder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Runtime port reuse test", + status: "active", + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Execution workspace port reuse test", + status: "active", + cwd: workspaceRoot, + providerType: "local_fs", + providerRef: workspaceRoot, + }); + + const actor = { + id: agentId, + name: "Codex Coder", + companyId, + }; + const workspace = { + ...buildWorkspace(workspaceRoot), + projectId, + workspaceId: null, + }; + const config = { + workspaceRuntime: { + services: [ + { + name: "web", + command: + "node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + expose: { + type: "url", + urlTemplate: "http://127.0.0.1:{{port}}", + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + stopPolicy: { + type: "manual", + }, + }, + ], + }, + }; + + const first = await startRuntimeServicesForWorkspaceControl({ + db, + actor, + issue: null, + workspace, + executionWorkspaceId, + config, + adapterEnv: {}, + }); + expect(first).toHaveLength(1); + expect(first[0]?.port).toBeGreaterThan(0); + await expect(fetch(first[0]!.url!)).resolves.toMatchObject({ ok: true }); + + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId, + workspaceCwd: workspace.cwd, + }); + await expect(fetch(first[0]!.url!)).rejects.toThrow(); + + const second = await startRuntimeServicesForWorkspaceControl({ + db, + actor, + issue: null, + workspace, + executionWorkspaceId, + config, + adapterEnv: {}, + }); + + expect(second).toHaveLength(1); + expect(second[0]?.id).toBe(first[0]?.id); + expect(second[0]?.port).toBe(first[0]?.port); + expect(second[0]?.url).toBe(first[0]?.url); + await expect(fetch(second[0]!.url!)).resolves.toMatchObject({ ok: true }); + + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId, + workspaceCwd: workspace.cwd, + }); + }); }); describe("normalizeAdapterManagedRuntimeServices", () => { diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts index 6e65e33f..8f7569d7 100644 --- a/server/src/routes/costs.ts +++ b/server/src/routes/costs.ts @@ -145,7 +145,8 @@ export function costRoutes( return; } assertCompanyAccess(req, issue.companyId); - const summary = await costs.issueTreeSummary(issue.companyId, issue.id); + const excludeRoot = req.query.excludeRoot === "true" || req.query.excludeRoot === "1"; + const summary = await costs.issueTreeSummary(issue.companyId, issue.id, { excludeRoot }); res.json(summary); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 51e7e0ec..4a298780 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { Router, type Request, type Response } from "express"; import multer from "multer"; import { z } from "zod"; -import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, issueExecutionDecisions } from "@paperclipai/db"; import { @@ -189,25 +189,27 @@ async function listSuccessfulRunHandoffStates( issueIds: string[], ): Promise> { if (issueIds.length === 0) return new Map(); - const result = await db.execute(sql` - SELECT DISTINCT ON (${activityLog.entityId}) - ${activityLog.entityId} AS "entityId", - ${activityLog.action} AS "action", - ${activityLog.agentId} AS "agentId", - ${activityLog.runId} AS "runId", - ${activityLog.details} AS "details", - ${activityLog.createdAt} AS "createdAt" - FROM ${activityLog} - WHERE ${activityLog.companyId} = ${companyId} - AND ${activityLog.entityType} = 'issue' - AND ${activityLog.entityId} IN (${sql.join(issueIds.map((id) => sql`${id}`), sql`, `)}) - AND ${activityLog.action} IN (${sql.join(SUCCESSFUL_RUN_HANDOFF_ACTIONS.map((action) => sql`${action}`), sql`, `)}) - ORDER BY ${activityLog.entityId}, ${activityLog.createdAt} DESC, ${activityLog.id} DESC - `); - const rows = Array.from(result as Iterable); + const rows = await db + .select({ + entityId: activityLog.entityId, + action: activityLog.action, + agentId: activityLog.agentId, + runId: activityLog.runId, + details: activityLog.details, + createdAt: activityLog.createdAt, + }) + .from(activityLog) + .where(and( + eq(activityLog.companyId, companyId), + eq(activityLog.entityType, "issue"), + inArray(activityLog.entityId, issueIds), + inArray(activityLog.action, [...SUCCESSFUL_RUN_HANDOFF_ACTIONS]), + )) + .orderBy(activityLog.entityId, desc(activityLog.createdAt), desc(activityLog.id)) as SuccessfulRunHandoffActivityRow[]; const states = new Map(); for (const row of rows) { + if (states.has(row.entityId)) continue; const state = successfulRunHandoffStateFromActivity(row); 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)) { const previousBlockedByIds = new Set((existingRelations?.blockedBy ?? []).map((relation) => relation.id)); const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]); diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index abea444e..05008d82 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -1,7 +1,7 @@ import { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import type { Db } from "@paperclipai/db"; -import { activityLog, agents, companies, costEvents, issues, projects } from "@paperclipai/db"; +import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; import { budgetService, type BudgetServiceHooks } from "./budgets.js"; @@ -135,18 +135,53 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { }; }, - issueTreeSummary: async (companyId: string, issueId: string) => { + issueTreeSummary: async ( + companyId: string, + issueId: string, + options: { excludeRoot?: boolean } = {}, + ) => { // Callers must resolve and authorize a visible root issue before invoking this. // The route does that so zero counts are not mistaken for a missing root. const childIssues = alias(issues, "child"); - const issueTreeCondition = sql` - ${issues.id} IN ( - WITH RECURSIVE issue_tree(id) AS ( + + // The seed of the recursive CTE: when excludeRoot is true, start from + // the direct children so the root issue itself is not counted. + const cteSeed = options.excludeRoot + ? sql` + SELECT ${issues.id} + FROM ${issues} + WHERE ${issues.companyId} = ${companyId} + AND ${issues.parentId} = ${issueId} + AND ${issues.hiddenAt} IS NULL + ` + : sql` SELECT ${issues.id} FROM ${issues} WHERE ${issues.companyId} = ${companyId} AND ${issues.id} = ${issueId} AND ${issues.hiddenAt} IS NULL + `; + + const cteSeedText = options.excludeRoot + ? sql` + SELECT (${issues.id})::text AS id + FROM ${issues} + WHERE ${issues.companyId} = ${companyId} + AND ${issues.parentId} = ${issueId} + AND ${issues.hiddenAt} IS NULL + ` + : sql` + SELECT (${issues.id})::text AS id + FROM ${issues} + WHERE ${issues.companyId} = ${companyId} + AND ${issues.id} = ${issueId} + AND ${issues.hiddenAt} IS NULL + `; + + const issueTreeCondition = sql` + ${issues.id} IN ( + WITH RECURSIVE issue_tree(id) AS ( + ${cteSeed} UNION ALL SELECT ${childIssues.id} FROM ${issues} ${childIssues} @@ -158,38 +193,80 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { ) `; - const [row] = await db - .select({ - issueCount: sql`count(distinct ${issues.id})::int`, - costCents: sumAsNumber(costEvents.costCents), - inputTokens: sumAsNumber(costEvents.inputTokens), - cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), - outputTokens: sumAsNumber(costEvents.outputTokens), - }) - .from(issues) - .leftJoin( - costEvents, - and( - eq(costEvents.companyId, companyId), - eq(costEvents.issueId, issues.id), - ), + const runSummarySql = sql` + WITH RECURSIVE issue_tree(id) AS ( + ${cteSeedText} + UNION ALL + SELECT (${childIssues.id})::text + FROM ${issues} ${childIssues} + JOIN issue_tree ON (${childIssues.parentId})::text = issue_tree.id + WHERE ${childIssues.companyId} = ${companyId} + AND ${childIssues.hiddenAt} IS NULL ) - .where( - and( - eq(issues.companyId, companyId), - isNull(issues.hiddenAt), - issueTreeCondition, + SELECT + count(distinct ${heartbeatRuns.id})::int AS "runCount", + coalesce(sum(extract(epoch from (coalesce(${heartbeatRuns.finishedAt}, now()) - ${heartbeatRuns.startedAt})) * 1000), 0)::double precision AS "runtimeMs" + FROM ${heartbeatRuns} + WHERE ${heartbeatRuns.companyId} = ${companyId} + AND ${heartbeatRuns.startedAt} IS NOT NULL + AND ( + ${heartbeatRuns.contextSnapshot} ->> 'issueId' IN (SELECT id FROM issue_tree) + OR EXISTS ( + SELECT 1 + FROM ${activityLog} + JOIN issue_tree ON ${activityLog.entityId} = issue_tree.id + WHERE ${activityLog.companyId} = ${companyId} + AND ${activityLog.entityType} = 'issue' + AND ${activityLog.runId} = ${heartbeatRuns.id} + ) + ) + `; + + // Run cost-event aggregation and run-duration aggregation in parallel. + // They're separate queries because cost_events fan out per-event and + // joining heartbeat_runs through them would double-count run durations. + const [costRowResult, runRowResult] = await Promise.all([ + db + .select({ + issueCount: sql`count(distinct ${issues.id})::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), + }) + .from(issues) + .leftJoin( + costEvents, + and( + eq(costEvents.companyId, companyId), + eq(costEvents.issueId, issues.id), + ), + ) + .where( + and( + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + issueTreeCondition, + ), ), - ); + db.execute(runSummarySql), + ]); + + const costRow = costRowResult[0]; + const runRow = Array.isArray(runRowResult) + ? (runRowResult[0] as { runCount?: number | string | null; runtimeMs?: number | string | null } | undefined) + : undefined; return { issueId, - issueCount: Number(row?.issueCount ?? 0), + issueCount: Number(costRow?.issueCount ?? 0), includeDescendants: true, - costCents: Number(row?.costCents ?? 0), - inputTokens: Number(row?.inputTokens ?? 0), - cachedInputTokens: Number(row?.cachedInputTokens ?? 0), - outputTokens: Number(row?.outputTokens ?? 0), + costCents: Number(costRow?.costCents ?? 0), + inputTokens: Number(costRow?.inputTokens ?? 0), + cachedInputTokens: Number(costRow?.cachedInputTokens ?? 0), + outputTokens: Number(costRow?.outputTokens ?? 0), + runCount: Number(runRow?.runCount ?? 0), + runtimeMs: Number(runRow?.runtimeMs ?? 0), }; }, diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 7ff44f7a..b288aa85 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -108,6 +108,11 @@ interface RuntimeServiceRecord extends RuntimeServiceRef { processGroupId: number | null; } +type StoppedRuntimeServiceReuseCandidate = { + id: string; + port: number | null; +}; + const runtimeServicesById = new Map(); const runtimeServicesByReuseKey = new Map(); const runtimeServiceLeasesByRun = new Map(); @@ -1815,6 +1820,33 @@ async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeSe }); } +async function findStoppedRuntimeServiceReuseCandidate(input: { + db?: Db; + companyId: string; + reuseKey: string | null; +}): Promise { + if (!input.db || !input.reuseKey) return null; + const row = await input.db + .select({ + id: workspaceRuntimeServices.id, + port: workspaceRuntimeServices.port, + }) + .from(workspaceRuntimeServices) + .where( + and( + eq(workspaceRuntimeServices.companyId, input.companyId), + eq(workspaceRuntimeServices.reuseKey, input.reuseKey), + eq(workspaceRuntimeServices.provider, "local_process"), + eq(workspaceRuntimeServices.status, "stopped"), + ), + ) + .orderBy(desc(workspaceRuntimeServices.updatedAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + + return row ?? null; +} + function clearIdleTimer(record: RuntimeServiceRecord) { if (!record.idleTimer) return; clearTimeout(record.idleTimer); @@ -1927,9 +1959,20 @@ async function startLocalRuntimeService(input: { const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint; const explicitPort = identity.explicitPort; const identityPort = identity.identityPort; + const stoppedReuseCandidate = await findStoppedRuntimeServiceReuseCandidate({ + db: input.db, + companyId: input.agent.companyId, + reuseKey: input.reuseKey, + }); + const reusableStoppedPort = + asString(portConfig.type, "") === "auto" && stoppedReuseCandidate?.port + ? (await readLocalServicePortOwner(stoppedReuseCandidate.port)) + ? null + : stoppedReuseCandidate.port + : null; const port = asString(portConfig.type, "") === "auto" - ? await allocatePort() + ? (reusableStoppedPort ?? await allocatePort()) : explicitPort > 0 ? explicitPort : null; @@ -2073,7 +2116,7 @@ async function startLocalRuntimeService(input: { } const record: RuntimeServiceRecord = { - id: randomUUID(), + id: stoppedReuseCandidate?.id ?? randomUUID(), companyId: input.agent.companyId, projectId: input.workspace.projectId, projectWorkspaceId: input.workspace.workspaceId, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c9637a9c..2facd9ec 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -107,6 +107,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -304,6 +305,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 4bf96222..3ed54a88 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -174,7 +174,10 @@ export const issuesApi = { getComment: (id: string, commentId: string) => api.get(`/issues/${id}/comments/${commentId}`), listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`), - getCostSummary: (id: string) => api.get(`/issues/${id}/cost-summary`), + getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => { + const qs = options.excludeRoot ? "?excludeRoot=true" : ""; + return api.get(`/issues/${id}/cost-summary${qs}`); + }, listFeedbackTraces: (id: string, filters?: Record) => { const params = new URLSearchParams(); for (const [key, value] of Object.entries(filters ?? {})) { diff --git a/ui/src/components/ActiveAgentsPanel.test.tsx b/ui/src/components/ActiveAgentsPanel.test.tsx index 5a528976..8918ae15 100644 --- a/ui/src/components/ActiveAgentsPanel.test.tsx +++ b/ui/src/components/ActiveAgentsPanel.test.tsx @@ -11,7 +11,7 @@ const mockHeartbeatsApi = vi.hoisted(() => ({ })); const mockIssuesApi = vi.hoisted(() => ({ - list: vi.fn(), + get: vi.fn(), })); vi.mock("@/lib/router", () => ({ @@ -55,6 +55,20 @@ async function flushReact() { }); } +async function waitForMicrotaskAssertion(assertion: () => void, attempts = 20) { + let lastError: unknown; + for (let index = 0; index < attempts; index += 1) { + await flushReact(); + try { + assertion(); + return; + } catch (error) { + lastError = error; + } + } + throw lastError; +} + function createRun(index: number) { return { id: `run-${index}`, @@ -71,6 +85,37 @@ function createRun(index: number) { }; } +function createIssueRun(index: number, issueId: string) { + return { + ...createRun(index), + issueId, + }; +} + +function createIssue(id: string, identifier: string, title: string) { + return { + id, + companyId: "company-1", + identifier, + title, + description: null, + status: "in_progress", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + parentId: null, + projectId: null, + projectWorkspaceId: null, + executionWorkspaceId: null, + goalId: null, + labels: [], + blockedByIssueIds: [], + blocksIssueIds: [], + createdAt: "2026-04-24T12:00:00.000Z", + updatedAt: "2026-04-24T12:00:00.000Z", + }; +} + describe("ActiveAgentsPanel", () => { let container: HTMLDivElement; @@ -78,7 +123,7 @@ describe("ActiveAgentsPanel", () => { container = document.createElement("div"); document.body.appendChild(container); mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([1, 2, 3, 4, 5].map(createRun)); - mockIssuesApi.list.mockResolvedValue([]); + mockIssuesApi.get.mockRejectedValue(new Error("Issue not found")); }); afterEach(() => { @@ -149,4 +194,42 @@ describe("ActiveAgentsPanel", () => { root.unmount(); }); }); + + it("loads exact visible run issues so task names render even when the issue list page would miss them", async () => { + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([ + createIssueRun(1, "65274215-0000-4000-8000-000000000000"), + ]); + mockIssuesApi.get.mockResolvedValue(createIssue( + "65274215-0000-4000-8000-000000000000", + "PAP-3562", + "Phase 4B: Implement LLM Wiki distillation UI", + )); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + 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(); + }); + }); }); diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 1b49624a..905c2147 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -1,6 +1,6 @@ import { memo, useMemo } from "react"; import { Link } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; +import { useQueries, useQuery } from "@tanstack/react-query"; import type { Issue } from "@paperclipai/shared"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; import type { TranscriptEntry } from "../adapters"; @@ -56,19 +56,28 @@ export function ActiveAgentsPanel({ const runs = liveRuns ?? []; const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]); const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length); - const { data: issues } = useQuery({ - queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"], - queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }), - enabled: visibleRuns.length > 0, + const visibleIssueIds = useMemo( + () => [...new Set(visibleRuns.map((run) => run.issueId).filter((issueId): issueId is string => Boolean(issueId)))], + [visibleRuns], + ); + + const issueQueries = useQueries({ + queries: visibleIssueIds.map((issueId) => ({ + queryKey: queryKeys.issues.detail(issueId), + queryFn: () => issuesApi.get(issueId), + staleTime: 30_000, + retry: false, + })), }); const issueById = useMemo(() => { const map = new Map(); - for (const issue of issues ?? []) { - map.set(issue.id, issue); + for (const query of issueQueries) { + const issue = query.data; + if (issue) map.set(issue.id, issue); } return map; - }, [issues]); + }, [issueQueries]); const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs: visibleRuns, diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index c09e9f64..725974bc 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -60,4 +60,46 @@ describe("IssueBlockedNotice", () => { expect(node.textContent).not.toContain("Work on this issue is blocked until"); expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull(); }); + + it("does not render when the issue is done even if a stale handoff state is required", () => { + const node = render( + , + ); + + expect(node.textContent).toBe(""); + }); + + it("does not render when the issue is cancelled even if blockers remain", () => { + const node = render( + , + ); + + expect(node.textContent).toBe(""); + }); }); diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx index e1283fd3..ba250597 100644 --- a/ui/src/components/IssueBlockedNotice.tsx +++ b/ui/src/components/IssueBlockedNotice.tsx @@ -17,6 +17,7 @@ export function IssueBlockedNotice({ successfulRunHandoff?: SuccessfulRunHandoffState | null; agentName?: string | null; }) { + if (issueStatus === "done" || issueStatus === "cancelled") return null; const showSuccessfulRunHandoff = successfulRunHandoff?.required === true; if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null; diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 927098b5..fa54fc39 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -476,6 +476,59 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("removes a blocked-by issue from the chip remove action after confirmation", async () => { + const onUpdate = vi.fn(); + const root = renderProperties(container, { + issue: createIssue({ + blockedBy: [ + { + id: "issue-2", + identifier: "PAP-2", + title: "Existing blocker", + status: "in_progress", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + { + id: "issue-4", + identifier: "PAP-4", + title: "Keep blocker", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + }, + ], + }), + childIssues: [], + onUpdate, + inline: true, + }); + await flush(); + + const removeButton = container.querySelector('button[aria-label="Remove PAP-2 as blocker"]'); + expect(removeButton).not.toBeNull(); + + await act(async () => { + removeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(document.body.textContent).toContain("Remove PAP-2: Existing blocker as a blocker for this issue."); + const confirmButton = Array.from(document.body.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Remove blocker")); + expect(confirmButton).not.toBeUndefined(); + + await act(async () => { + confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] }); + + act(() => root.unmount()); + }); + it("shows a green service link above the workspace row for a live non-main workspace", async () => { mockProjectsApi.list.mockResolvedValue([createProject()]); const serviceUrl = "http://127.0.0.1:62475"; @@ -530,7 +583,7 @@ describe("IssueProperties", () => { (link) => link.textContent?.trim() === "View workspace", ); expect(tasksLink).not.toBeUndefined(); - expect(tasksLink?.getAttribute("href")).toBe("/issues?workspace=workspace-1"); + expect(tasksLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1/issues"); expect(workspaceLink).not.toBeUndefined(); expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1"); diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index da36be76..9039ce7c 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -10,7 +10,6 @@ import { instanceSettingsApi } from "../api/instanceSettings"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; -import { resolveIssueFilterWorkspaceId } from "../lib/issue-filters"; import { queryKeys } from "../lib/queryKeys"; import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members"; import { useProjectOrder } from "../hooks/useProjectOrder"; @@ -32,9 +31,19 @@ import { Identity } from "./Identity"; import { IssueReferencePill } from "./IssueReferencePill"; import { formatDate, cn, projectUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, Clock } from "lucide-react"; +import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) { @@ -113,10 +122,8 @@ function runningRuntimeServiceWithUrl( return runtimeServices?.find((service) => service.status === "running" && service.url?.trim()) ?? null; } -function issuesWorkspaceFilterHref(workspaceId: string) { - const params = new URLSearchParams(); - params.append("workspace", workspaceId); - return `/issues?${params.toString()}`; +function executionWorkspaceIssuesHref(workspaceId: string) { + return `/execution-workspaces/${workspaceId}/issues`; } function toDateTimeLocalValue(value: string | null | undefined) { @@ -144,6 +151,87 @@ function PropertyRow({ label, children }: { label: string; children: React.React ); } +function RemovableIssueReferencePill({ + issue, + onRemove, +}: { + issue: NonNullable[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 = ( + <> + + {issueLabel} + + ); + const removeLabel = `Remove ${issueLabel} as blocker`; + const handleRemove = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsConfirmOpen(true); + }; + const confirmRemove = () => { + onRemove(issue.id); + setIsConfirmOpen(false); + }; + + return ( + <> + + + {issue.identifier ? ( + + {content} + + ) : ( + {content} + )} + + + + + Remove blocker? + + Remove {confirmLabel} as a blocker for this issue. + + + + + + + + + + + + ); +} + /** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */ function PropertyPicker({ inline, @@ -331,10 +419,10 @@ export function IssueProperties({ () => isMainIssueWorkspace({ issue, project: issueProject }), [issue, issueProject], ); - const workspaceFilterId = useMemo(() => { + const workspaceTasksExecutionWorkspaceId = useMemo(() => { if (!isolatedWorkspacesEnabled) return null; if (issueUsesMainWorkspace) return null; - return resolveIssueFilterWorkspaceId(issue); + return issue.executionWorkspaceId ?? issue.currentExecutionWorkspace?.id ?? null; }, [isolatedWorkspacesEnabled, issue, issueUsesMainWorkspace]); const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace; const liveWorkspaceService = useMemo(() => { @@ -1137,6 +1225,9 @@ export function IssueProperties({ : [...blockedByIds, blockedByIssueId]; onUpdate({ blockedByIssueIds: nextBlockedByIds }); }; + const removeBlockedBy = (blockedByIssueId: string) => { + onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) }); + }; const blockedByContent = ( <> @@ -1284,7 +1375,7 @@ export function IssueProperties({
{(issue.blockedBy ?? []).map((relation) => ( - + ))} {renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))} @@ -1297,7 +1388,7 @@ export function IssueProperties({ ) : ( {(issue.blockedBy ?? []).map((relation) => ( - + ))} )} - {workspaceFilterId && ( + {workspaceTasksExecutionWorkspaceId && ( View workspace tasks diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 523f4f45..4455853e 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -41,7 +41,7 @@ import { resolveIssueWorkspaceName, type InboxIssueColumn, } from "../lib/inbox"; -import { cn } from "../lib/utils"; +import { cn, formatDurationMs, formatTokens } from "../lib/utils"; import { InboxIssueMetaLeading, InboxIssueTrailingColumns, @@ -114,7 +114,7 @@ export type IssueSortField = "status" | "priority" | "title" | "created" | "upda export type IssueViewState = IssueFilterState & { sortField: IssueSortField; sortDir: "asc" | "desc"; - groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none"; + groupBy: "status" | "priority" | "assignee" | "project" | "workspace" | "parent" | "none"; viewMode: "list" | "board"; nestingEnabled: boolean; collapsedGroups: string[]; @@ -364,6 +364,12 @@ interface IssuesListProps { createIssueLabel?: string; defaultSortField?: IssueSortField; showProgressSummary?: boolean; + /** + * When set together with `showProgressSummary`, the progress strip fetches + * the recursive cost-summary for this parent issue and renders aggregate + * tokens + wall-clock runtime for every run in the tree. + */ + parentIssueIdForCostSummary?: string; enableRoutineVisibilityFilter?: boolean; hasMoreIssues?: boolean; isLoadingMoreIssues?: boolean; @@ -439,9 +445,11 @@ function IssueSearchInput({ function SubIssueProgressSummaryStrip({ summary, issueLinkState, + parentIssueIdForCostSummary, }: { summary: SubIssueProgressSummary; issueLinkState?: unknown; + parentIssueIdForCostSummary?: string; }) { const target = summary.target; const targetIssue = target?.issue ?? null; @@ -451,6 +459,21 @@ function SubIssueProgressSummaryStrip({ .map((status) => ({ status, count: summary.countsByStatus[status] ?? 0 })) .filter((entry) => entry.count > 0); + // Refresh fast enough that the runtime ticks up while a sub-issue is still + // running, but slow enough not to hammer the recursive CTE on idle trees. + const hasInProgress = summary.inProgressCount > 0; + const { data: costSummary } = useQuery({ + queryKey: queryKeys.issues.costSummary(parentIssueIdForCostSummary ?? "pending", { excludeRoot: true }), + queryFn: () => issuesApi.getCostSummary(parentIssueIdForCostSummary!, { excludeRoot: true }), + enabled: !!parentIssueIdForCostSummary, + refetchInterval: hasInProgress ? 5_000 : false, + }); + + const totalTokens = costSummary + ? costSummary.inputTokens + costSummary.cachedInputTokens + costSummary.outputTokens + : 0; + const showCostSummary = !!costSummary && (costSummary.runCount > 0 || totalTokens > 0); + return (
@@ -465,6 +488,23 @@ function SubIssueProgressSummaryStrip({ {summary.blockedCount} blocked + {showCostSummary && ( + <> + + {formatTokens(totalTokens)} tokens + + + {formatDurationMs(costSummary.runtimeMs)} runtime + + + )}
issue.projectId ?? "__no_project"); + return Object.keys(groups) + .sort((a, b) => { + if (a === "__no_project") return 1; + if (b === "__no_project") return -1; + const labelA = projectById.get(a)?.name ?? a; + const labelB = projectById.get(b)?.name ?? b; + return labelA.localeCompare(labelB); + }) + .map((key) => ({ + key, + label: key === "__no_project" ? "No Project" : (projectById.get(key)?.name ?? key.slice(0, 8)), + items: groups[key]!, + })); + } if (viewState.groupBy === "parent") { const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent"); return Object.keys(groups) @@ -1036,6 +1093,7 @@ export function IssuesList({ workspaceNameMap, issueTitleMap, companyUserLabelMap, + projectById, ]); useEffect(() => { @@ -1131,6 +1189,7 @@ export function IssuesList({ if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length); else defaults.assigneeAgentId = groupKey; } + else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey; else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") { const parentIssue = issueById.get(groupKey); if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId)); @@ -1175,7 +1234,11 @@ export function IssuesList({ return (
{progressSummary ? ( - + ) : null} {/* Toolbar */} @@ -1307,6 +1370,7 @@ export function IssuesList({ ["status", "Status"], ["priority", "Priority"], ["assignee", "Assignee"], + ["project", "Project"], ["workspace", "Workspace"], ["parent", "Parent Issue"], ["none", "None"], diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 9171fddd..78c7e07e 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -356,6 +356,20 @@ describe("MarkdownBody", () => { expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"'); }); + it("renders markdown tables in a horizontally scrollable region", () => { + const html = renderMarkdown([ + "| Time UTC | Source | Finding | Stalled leaf | Escalation |", + "| --- | --- | --- | --- | --- |", + "| 2026-04-30T14:31:35Z | PAP-2505 | in_review_without_action_path | PAP-2779 | PAP-2910 |", + ].join("\n")); + + expect(html).toContain('class="paperclip-markdown-table-scroll"'); + expect(html).toContain('aria-label="Scrollable table"'); + expect(html).toContain('tabindex="0"'); + expect(html).toContain(""); + expect(html).toContain('style="overflow-wrap:anywhere;word-break:normal"'); + }); + it("opens external links in a new tab with safe rel attributes", () => { const html = renderMarkdown("[docs](https://example.com/docs)"); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index d02d8a35..c70e750c 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -84,6 +84,11 @@ const scrollableBlockStyle: React.CSSProperties = { overflowX: "auto", }; +const tableCellWrapStyle: React.CSSProperties = { + overflowWrap: "anywhere", + wordBreak: "normal", +}; + function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties { return { ...wrapAnywhereStyle, @@ -91,6 +96,13 @@ function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties { }; } +function mergeTableCellStyle(style?: React.CSSProperties): React.CSSProperties { + return { + ...tableCellWrapStyle, + ...style, + }; +} + function mergeScrollableBlockStyle(style?: React.CSSProperties): React.CSSProperties { return { ...scrollableBlockStyle, @@ -514,13 +526,20 @@ export function MarkdownBody({ {blockquoteChildren} ), + table: ({ node: _node, style: tableStyle, children: tableChildren, ...tableProps }) => ( +
+
+ {tableChildren} +
+
+ ), td: ({ node: _node, style: tableCellStyle, children: tableCellChildren, ...tableCellProps }) => ( - + {tableCellChildren} ), th: ({ node: _node, style: tableHeaderStyle, children: tableHeaderChildren, ...tableHeaderProps }) => ( - + {tableHeaderChildren} ), diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 826482c2..9efadd21 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -411,6 +411,86 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("applies project and execution workspace defaults for normal new issues", async () => { + mockProjectsApi.list.mockResolvedValue([ + { + id: "project-1", + name: "Alpha", + description: null, + archivedAt: null, + color: "#445566", + workspaces: [ + { + id: "project-workspace-1", + name: "Primary", + isPrimary: true, + }, + { + id: "project-workspace-2", + name: "Isolated checkout", + isPrimary: false, + }, + ], + executionWorkspacePolicy: { + enabled: true, + defaultMode: "shared_workspace", + }, + }, + ]); + mockExecutionWorkspacesApi.list.mockResolvedValue([ + { + id: "workspace-1", + name: "PAP-100", + mode: "isolated_workspace", + status: "active", + branchName: "feature/pap-100", + cwd: "/tmp/workspace-1", + lastUsedAt: new Date("2026-04-06T16:00:00.000Z"), + }, + ]); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + dialogState.newIssueDefaults = { + title: "Follow-up issue", + projectId: "project-1", + projectWorkspaceId: "project-workspace-2", + executionWorkspaceId: "workspace-1", + }; + + const { root } = renderDialog(container); + await flush(); + + expect(container.textContent).toContain("New issue"); + expect(container.textContent).not.toContain("New sub-issue"); + 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 () => { let resolveProjects: (projects: Array<{ id: string; diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 6f8e7d31..3ff174b4 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -242,6 +242,21 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo return "shared_workspace"; } +function defaultExecutionWorkspaceModeForIssueDefaults( + defaults: { + executionWorkspaceId?: unknown; + executionWorkspaceMode?: unknown; + }, + project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined, +) { + if (typeof defaults.executionWorkspaceId === "string" && defaults.executionWorkspaceId.length > 0) { + return "reuse_existing"; + } + return typeof defaults.executionWorkspaceMode === "string" && defaults.executionWorkspaceMode.length > 0 + ? defaults.executionWorkspaceMode + : defaultExecutionWorkspaceModeForProject(project); +} + const IssueTitleTextarea = memo(function IssueTitleTextarea({ value, pending, @@ -686,9 +701,7 @@ export function NewIssueDialog() { const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined; const defaultProjectWorkspaceId = newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject); - const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId - ? "reuse_existing" - : (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject)); + const defaultExecutionWorkspaceMode = defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject); setIssueText(newIssueDefaults.title ?? "", newIssueDefaults.description ?? ""); setStatus(newIssueDefaults.status ?? "todo"); setPriority(newIssueDefaults.priority ?? ""); @@ -710,8 +723,9 @@ export function NewIssueDialog() { setPriority(newIssueDefaults.priority ?? ""); const defaultProjectId = newIssueDefaults.projectId ?? ""; const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId); + const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined; setProjectId(defaultProjectId); - setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject)); + setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject)); setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); setReviewerValue(""); setApproverValue(""); @@ -720,12 +734,17 @@ export function NewIssueDialog() { setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); - setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject)); - setSelectedExecutionWorkspaceId(""); - executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null; + setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject)); + setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? ""); + executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject + ? defaultProjectId || null + : null; } else if (draft && draft.title.trim()) { const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId; const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId); + const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined; + const hasExplicitExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId !== undefined; + const hasExplicitExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceMode !== undefined; setIssueText(draft.title, draft.description); setStatus(draft.status || "todo"); setPriority(draft.priority); @@ -739,27 +758,40 @@ export function NewIssueDialog() { setShowReviewerRow(!!(draft.reviewerValue)); setShowApproverRow(!!(draft.approverValue)); setProjectId(restoredProjectId); - setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject)); + setProjectWorkspaceId( + hasExplicitProjectWorkspaceId + ? (newIssueDefaults.projectWorkspaceId ?? "") + : (draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject)), + ); setAssigneeModelLane(draft.assigneeModelLane ?? "primary"); setAssigneeModelOverride(draft.assigneeModelOverride ?? ""); setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? ""); setAssigneeChrome(draft.assigneeChrome ?? false); setExecutionWorkspaceMode( - draft.executionWorkspaceMode - ?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject)), + hasExplicitExecutionWorkspaceId || hasExplicitExecutionWorkspaceMode + ? defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, restoredProject) + : ( + draft.executionWorkspaceMode + ?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject)) + ), ); - setSelectedExecutionWorkspaceId(draft.selectedExecutionWorkspaceId ?? ""); - executionWorkspaceDefaultProjectId.current = draft.projectWorkspaceId || restoredProject + setSelectedExecutionWorkspaceId( + hasExplicitExecutionWorkspaceId + ? (newIssueDefaults.executionWorkspaceId ?? "") + : (draft.selectedExecutionWorkspaceId ?? ""), + ); + executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || hasExplicitExecutionWorkspaceId || draft.projectWorkspaceId || restoredProject ? restoredProjectId || null : null; } else { const defaultProjectId = newIssueDefaults.projectId ?? ""; const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId); + const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined; setIssueText("", ""); setStatus(newIssueDefaults.status ?? "todo"); setPriority(newIssueDefaults.priority ?? ""); setProjectId(defaultProjectId); - setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject)); + setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject)); setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); setReviewerValue(""); setApproverValue(""); @@ -768,9 +800,11 @@ export function NewIssueDialog() { setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); - setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject)); - setSelectedExecutionWorkspaceId(""); - executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null; + setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject)); + setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? ""); + executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject + ? defaultProjectId || null + : null; } }, [newIssueOpen, newIssueDefaults, orderedProjects, selectedCompanyId, setIssueText]); diff --git a/ui/src/components/RoutineRunVariablesDialog.test.tsx b/ui/src/components/RoutineRunVariablesDialog.test.tsx index 58d0b170..301239e0 100644 --- a/ui/src/components/RoutineRunVariablesDialog.test.tsx +++ b/ui/src/components/RoutineRunVariablesDialog.test.tsx @@ -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( + + {}} + 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={() => {}} + /> + , + ); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const dialogContent = Array.from(document.body.querySelectorAll("div")).find((element) => + typeof element.className === "string" && element.className.includes("max-h-[calc(100dvh-2rem)]"), + ); + expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]"); + expect(dialogContent?.className).toContain("overflow-hidden"); + + const notesInput = document.querySelector("textarea"); + const formScrollRegion = Array.from(document.body.querySelectorAll("div")).find((element) => + typeof element.className === "string" && element.className.includes("overscroll-contain"), + ); + expect(formScrollRegion?.className).toContain("min-h-0"); + expect(formScrollRegion?.className).toContain("flex-1"); + expect(formScrollRegion?.className).toContain("overflow-y-auto"); + expect(formScrollRegion?.contains(notesInput)).toBe(true); + + const footer = Array.from(document.body.querySelectorAll("div")).find((element) => + typeof element.className === "string" && element.className.includes("pb-[calc(1rem+env(safe-area-inset-bottom))]"), + ); + expect(footer?.className).toContain("shrink-0"); + expect(footer?.contains(formScrollRegion ?? null)).toBe(false); + expect(footer?.textContent).toContain("Run routine"); + + await act(async () => { + root.unmount(); + }); + }); + it("renders workspaceBranch as a read-only selected workspace value", async () => { issueWorkspaceDraft = { executionWorkspaceId: "workspace-1", diff --git a/ui/src/components/RoutineRunVariablesDialog.tsx b/ui/src/components/RoutineRunVariablesDialog.tsx index 8129fb28..1dfb9491 100644 --- a/ui/src/components/RoutineRunVariablesDialog.tsx +++ b/ui/src/components/RoutineRunVariablesDialog.tsx @@ -335,8 +335,8 @@ export function RoutineRunVariablesDialog({ return ( !isPending && onOpenChange(next)}> - - + + {routineName && (

{routineName}

)} @@ -346,7 +346,7 @@ export function RoutineRunVariablesDialog({
-
+
@@ -520,7 +520,10 @@ export function RoutineRunVariablesDialog({ ) : null}
- + {!selection.assigneeAgentId ? (

Default agent required for this run.

) : missingRequired.length > 0 ? ( diff --git a/ui/src/components/SidebarCompanyMenu.test.tsx b/ui/src/components/SidebarCompanyMenu.test.tsx index 53bbc5ec..12634662 100644 --- a/ui/src/components/SidebarCompanyMenu.test.tsx +++ b/ui/src/components/SidebarCompanyMenu.test.tsx @@ -49,6 +49,13 @@ vi.mock("@/context/CompanyContext", () => ({ brandColor: "#36a269", status: "active", }, + { + id: "company-3", + issuePrefix: "ANA", + name: "Anachronist Wiki", + brandColor: "#a36a21", + status: "active", + }, ], selectedCompany: { id: "company-1", @@ -143,6 +150,7 @@ describe("SidebarCompanyMenu", () => { expect(document.body.textContent).toContain("Switch workspace"); expect(document.body.textContent).toContain("Strata"); + expect(document.body.textContent).toContain("ANA"); expect(document.body.textContent).toContain("Add company..."); expect(document.body.textContent).toContain("Invite people to Acme Labs"); expect(document.body.textContent).toContain("Company settings"); diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx index cfd39698..9cc4dc56 100644 --- a/ui/src/components/SidebarCompanyMenu.tsx +++ b/ui/src/components/SidebarCompanyMenu.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, ChevronsUpDown, LogOut, Plus, Settings, UserPlus } from "lucide-react"; import type { Company } from "@paperclipai/shared"; @@ -46,7 +46,10 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb const navigate = useNavigate(); const open = controlledOpen ?? internalOpen; const setOpen = onOpenChange ?? setInternalOpen; - const sidebarCompanies = companies.filter((company) => company.status !== "archived"); + const sidebarCompanies = useMemo( + () => companies.filter((company) => company.status !== "archived"), + [companies], + ); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), @@ -110,7 +113,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb Switch workspace -
+
{sidebarCompanies.map((company) => { const isSelected = company.id === selectedCompany?.id; return ( @@ -124,6 +127,9 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb > {company.name} + + {company.issuePrefix} + {isSelected ? : null} ); diff --git a/ui/src/components/StatusIcon.test.tsx b/ui/src/components/StatusIcon.test.tsx index 11922d73..3365c4c9 100644 --- a/ui/src/components/StatusIcon.test.tsx +++ b/ui/src/components/StatusIcon.test.tsx @@ -71,11 +71,36 @@ describe("StatusIcon", () => { ); expect(html).not.toContain('data-blocker-attention-state="covered"'); - expect(html).toContain('aria-label="Blocked · 1 unresolved blocker needs attention"'); + expect(html).toContain('data-blocker-attention-state="needs_attention"'); + expect(html).toContain('aria-label="Blocked · 1 blocker needs attention"'); expect(html).toContain("border-red-600"); expect(html).not.toContain("border-dashed"); }); + it("shows active covered work on mixed attention-required blockers", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-blocker-attention-state="needs_attention"'); + expect(html).toContain('aria-label="Blocked · 3 blockers need attention; 2 covered by active work"'); + expect(html).toContain("border-red-600"); + expect(html).not.toContain("border-cyan-600"); + expect(html).toContain("bg-cyan-600"); + }); + it("renders stalled review chains with amber visual and stalled-leaf copy", () => { const html = renderToStaticMarkup( 0) { + return `Blocked · ${attentionCopy}; ${coveredCount} covered by active work`; + } + return `Blocked · ${attentionCopy}`; } return "Blocked"; @@ -60,6 +65,8 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show const [open, setOpen] = useState(false); const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered"; const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled"; + const isAttentionBlocked = status === "blocked" && blockerAttention?.state === "needs_attention"; + const hasCoveredBlockedWork = isAttentionBlocked && (blockerAttention?.coveredBlockerCount ?? 0) > 0; const colorClass = isCoveredBlocked ? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400" : isStalledBlocked @@ -71,7 +78,9 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show ? "covered" : isStalledBlocked ? "stalled" - : undefined; + : isAttentionBlocked + ? "needs_attention" + : undefined; const circle = ( )} + {hasCoveredBlockedWork && ( + + )} {isStalledBlocked && ( )} diff --git a/ui/src/components/WorkspaceRuntimeControls.test.tsx b/ui/src/components/WorkspaceRuntimeControls.test.tsx index 717a6c7e..606556de 100644 --- a/ui/src/components/WorkspaceRuntimeControls.test.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.test.tsx @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildWorkspaceRuntimeControlItems, buildWorkspaceRuntimeControlSections, + WorkspaceRuntimeQuickControls, WorkspaceRuntimeControls, } from "./WorkspaceRuntimeControls"; @@ -293,6 +294,41 @@ describe("WorkspaceRuntimeControls", () => { act(() => root.unmount()); }); + it("lets quick action buttons inherit the shared button shape tokens", () => { + const sections = buildWorkspaceRuntimeControlSections({ + runtimeConfig: { + commands: [ + { id: "web", name: "web", kind: "service", command: "pnpm dev" }, + ], + }, + runtimeServices: [ + createRuntimeService({ id: "service-web", serviceName: "web", status: "running" }), + ], + canStartServices: true, + }); + + const root = createRoot(container); + act(() => { + root.render( + , + ); + }); + + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons).toHaveLength(2); + for (const button of buttons) { + expect(button.className).toContain("rounded-md"); + expect(button.className).not.toContain("rounded-none"); + expect(button.className).not.toContain("rounded-xl"); + expect(button.className).not.toContain("shadow-none"); + } + + act(() => root.unmount()); + }); + it("shows disabled actions when local command prerequisites are missing", () => { const sections = buildWorkspaceRuntimeControlSections({ runtimeConfig: { diff --git a/ui/src/components/WorkspaceRuntimeControls.tsx b/ui/src/components/WorkspaceRuntimeControls.tsx index b41e41da..cc03903e 100644 --- a/ui/src/components/WorkspaceRuntimeControls.tsx +++ b/ui/src/components/WorkspaceRuntimeControls.tsx @@ -192,6 +192,15 @@ export function buildWorkspaceRuntimeControlItems(input: { })); } +export function getRunningRuntimeServiceUrl( + sections: WorkspaceRuntimeControlSections, +) { + const runningService = [...sections.services, ...sections.otherServices].find( + (item) => (item.statusLabel === "running" || item.statusLabel === "starting") && item.url, + ); + return runningService?.url ?? null; +} + function requestMatchesPending( pendingRequest: WorkspaceRuntimeControlRequest | null | undefined, nextRequest: WorkspaceRuntimeControlRequest, @@ -255,9 +264,8 @@ function CommandActionButtons({ variant={action === "stop" ? "destructive" : action === "restart" ? "outline" : "default"} size="sm" className={cn( - "h-9 w-full justify-start px-3 shadow-none sm:w-auto", - square ? "rounded-none" : "rounded-xl", - action === "restart" ? "bg-background" : null, + "w-full justify-start sm:w-auto", + square ? "rounded-none" : null, )} disabled={disabled} onClick={() => onAction(request)} @@ -451,3 +459,56 @@ export function WorkspaceRuntimeControls({
); } + +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 ( +
+ {controlItems.length > 0 ? ( +
+ {controlItems.map((item) => ( +
+ {controlItems.length > 1 ? ( + {item.title} + ) : null} + +
+ ))} +
+ ) : null} + {serviceUrl ? ( + + {serviceUrl} + + + ) : null} +
+ ); +} diff --git a/ui/src/context/BreadcrumbContext.test.tsx b/ui/src/context/BreadcrumbContext.test.tsx index fb705dc9..ab2eb67b 100644 --- a/ui/src/context/BreadcrumbContext.test.tsx +++ b/ui/src/context/BreadcrumbContext.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { BreadcrumbProvider, useBreadcrumbs } from "./BreadcrumbContext"; +import { BreadcrumbProvider, buildDocumentTitle, useBreadcrumbs } from "./BreadcrumbContext"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -58,4 +58,21 @@ describe("BreadcrumbContext", () => { expect(renderCounts).toHaveLength(2); }); + + it("builds page titles with the selected company name before Paperclip", () => { + expect(buildDocumentTitle([{ label: "Inbox" }], "Anachronist Wiki")).toBe( + "Inbox • Anachronist Wiki • Paperclip", + ); + expect( + buildDocumentTitle( + [{ label: "Issues", href: "/issues" }, { label: "PAP-3515" }], + "Anachronist Wiki", + ), + ).toBe("PAP-3515 • Issues • Anachronist Wiki • Paperclip"); + }); + + it("omits blank company names from page titles", () => { + expect(buildDocumentTitle([{ label: "Inbox" }], " ")).toBe("Inbox • Paperclip"); + expect(buildDocumentTitle([], null)).toBe("Paperclip"); + }); }); diff --git a/ui/src/context/BreadcrumbContext.tsx b/ui/src/context/BreadcrumbContext.tsx index 0f156840..16ea448c 100644 --- a/ui/src/context/BreadcrumbContext.tsx +++ b/ui/src/context/BreadcrumbContext.tsx @@ -12,6 +12,11 @@ interface BreadcrumbContextValue { setMobileToolbar: (node: ReactNode | null) => void; } +interface BreadcrumbProviderProps { + children: ReactNode; + companyName?: string | null; +} + const BreadcrumbContext = createContext(null); function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) { @@ -25,7 +30,16 @@ function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) { return true; } -export function BreadcrumbProvider({ children }: { children: ReactNode }) { +export function buildDocumentTitle(breadcrumbs: Breadcrumb[], companyName?: string | null) { + const pageParts = breadcrumbs.length === 0 + ? [] + : [...breadcrumbs].reverse().map((breadcrumb) => breadcrumb.label); + const companyPart = companyName?.trim() ? [companyName.trim()] : []; + const parts = [...pageParts, ...companyPart, "Paperclip"]; + return parts.join(" • "); +} + +export function BreadcrumbProvider({ children, companyName }: BreadcrumbProviderProps) { const [breadcrumbs, setBreadcrumbsState] = useState([]); const [mobileToolbar, setMobileToolbarState] = useState(null); @@ -38,13 +52,8 @@ export function BreadcrumbProvider({ children }: { children: ReactNode }) { }, []); useEffect(() => { - if (breadcrumbs.length === 0) { - document.title = "Paperclip"; - } else { - const parts = [...breadcrumbs].reverse().map((b) => b.label); - document.title = `${parts.join(" · ")} · Paperclip`; - } - }, [breadcrumbs]); + document.title = buildDocumentTitle(breadcrumbs, companyName); + }, [breadcrumbs, companyName]); return ( diff --git a/ui/src/index.css b/ui/src/index.css index 5335e4f6..461bea99 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -187,9 +187,16 @@ background: oklch(0.5 0 0); } -/* Auto-hide scrollbar: always reserves space, thumb visible only on hover */ +/* Auto-hide scrollbar: thin, stable gutter with the thumb visible only on hover */ +.scrollbar-auto-hide { + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + .scrollbar-auto-hide::-webkit-scrollbar { width: 8px !important; + height: 8px !important; background: transparent !important; } .scrollbar-auto-hide::-webkit-scrollbar-track { @@ -199,18 +206,25 @@ background: transparent !important; } /* Light mode scrollbar on hover */ +.scrollbar-auto-hide:hover { + scrollbar-color: oklch(0.7 0 0) transparent; +} .scrollbar-auto-hide:hover::-webkit-scrollbar-track { - background: oklch(0.92 0 0) !important; + background: transparent !important; } .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb { background: oklch(0.7 0 0) !important; + border-radius: 999px !important; } .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover { background: oklch(0.6 0 0) !important; } /* Dark mode scrollbar on hover */ +.dark .scrollbar-auto-hide:hover { + scrollbar-color: oklch(0.4 0 0) transparent; +} .dark .scrollbar-auto-hide:hover::-webkit-scrollbar-track { - background: oklch(0.205 0 0) !important; + background: transparent !important; } .dark .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb { background: oklch(0.4 0 0) !important; @@ -747,7 +761,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { margin-bottom: 0; } -.paperclip-markdown :where(p, ul, ol, blockquote, pre, table) { +.paperclip-markdown :where(p, ul, ol, blockquote, pre, .paperclip-markdown-table-scroll) { margin-top: 0.7rem; margin-bottom: 0.7rem; } @@ -855,8 +869,28 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--foreground) 10%, transparent); } -.paperclip-markdown table { - width: 100%; +.paperclip-markdown-table-scroll { + max-width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; +} + +.paperclip-markdown-table-scroll:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.paperclip-markdown-table-scroll table { + width: max-content; + min-width: 100%; + margin: 0; +} + +.paperclip-markdown-table-scroll :where(th, td) { + min-width: 8rem; + max-width: 18rem; + vertical-align: top; } .paperclip-markdown th { diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 862c5c52..b82ac6c1 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -1322,9 +1322,69 @@ describe("inbox helpers", () => { ]); }); - it("persists workspace grouping preferences", () => { + it("groups assignee sections by latest issue activity while preserving non-issue sections", () => { + const agentIssue = makeIssue("agent", true); + agentIssue.assigneeAgentId = "agent-1"; + + const userIssue = makeIssue("user", false); + userIssue.assigneeUserId = "user-1"; + + const unassignedIssue = makeIssue("unassigned", false); + + const items: InboxWorkItem[] = [ + { kind: "issue", timestamp: 5, issue: agentIssue }, + { kind: "approval", timestamp: 8, approval: makeApproval("pending") }, + { kind: "issue", timestamp: 7, issue: userIssue }, + { kind: "issue", timestamp: 2, issue: unassignedIssue }, + ]; + + expect(groupInboxWorkItems(items, "assignee", { + agentById: new Map([["agent-1", "Coder"]]), + userLabelById: new Map([["user-1", "Riley"]]), + })).toEqual([ + { key: "kind:approval", label: "Approvals", items: [items[1]] }, + { key: "assignee:user:user-1", label: "Riley", items: [items[2]] }, + { key: "assignee:agent:agent-1", label: "Coder", items: [items[0]] }, + { key: "assignee:none", label: "Unassigned", items: [items[3]] }, + ]); + }); + + it("groups project sections by latest issue activity while preserving non-issue sections", () => { + const paperclipIssue = makeIssue("paperclip", true); + paperclipIssue.projectId = "project-1"; + + const onboardingIssue = makeIssue("onboarding", false); + onboardingIssue.projectId = "project-2"; + + const noProjectIssue = makeIssue("no-project", false); + + const items: InboxWorkItem[] = [ + { kind: "issue", timestamp: 9, issue: paperclipIssue }, + { kind: "issue", timestamp: 4, issue: onboardingIssue }, + { kind: "join_request", timestamp: 6, joinRequest: makeJoinRequest("join-1") }, + { kind: "issue", timestamp: 2, issue: noProjectIssue }, + ]; + + expect(groupInboxWorkItems(items, "project", { + projectById: new Map([ + ["project-1", { name: "Paperclip App" }], + ["project-2", { name: "Onboarding" }], + ]), + })).toEqual([ + { key: "project:project-1", label: "Paperclip App", items: [items[0]] }, + { key: "kind:join_request", label: "Join requests", items: [items[2]] }, + { key: "project:project-2", label: "Onboarding", items: [items[1]] }, + { key: "project:none", label: "No project", items: [items[3]] }, + ]); + }); + + it("persists inbox grouping preferences", () => { saveInboxWorkItemGroupBy("workspace"); expect(loadInboxWorkItemGroupBy()).toBe("workspace"); + saveInboxWorkItemGroupBy("assignee"); + expect(loadInboxWorkItemGroupBy()).toBe("assignee"); + saveInboxWorkItemGroupBy("project"); + expect(loadInboxWorkItemGroupBy()).toBe("project"); }); it("persists collapsed inbox groups per company", () => { diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 09614e1b..7eedb066 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -12,6 +12,7 @@ import { normalizeIssueFilterState, type IssueFilterState, } from "./issue-filters"; +import { formatAssigneeUserLabel } from "./assignees"; export const RECENT_ISSUES_LIMIT = 100; export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]); @@ -33,7 +34,7 @@ export type InboxCategoryFilter = | "failed_runs" | "alerts"; export type InboxApprovalFilter = "all" | "actionable" | "resolved"; -export type InboxWorkItemGroupBy = "none" | "type" | "workspace"; +export type InboxWorkItemGroupBy = "none" | "type" | "assignee" | "project" | "workspace"; export const inboxIssueColumns = [ "status", "id", @@ -137,6 +138,10 @@ export interface InboxWorkspaceGroupingOptions { executionWorkspaceById?: ReadonlyMap; projectWorkspaceById?: ReadonlyMap; defaultProjectWorkspaceIdByProjectId?: ReadonlyMap; + projectById?: ReadonlyMap; + agentById?: ReadonlyMap; + userLabelById?: ReadonlyMap; + currentUserId?: string | null; } const defaultInboxFilterPreferences: InboxFilterPreferences = { @@ -342,7 +347,7 @@ export function saveInboxIssueColumns(columns: InboxIssueColumn[]) { export function loadInboxWorkItemGroupBy(): InboxWorkItemGroupBy { try { const raw = localStorage.getItem(INBOX_GROUP_BY_KEY); - return raw === "type" || raw === "workspace" ? raw : "none"; + return raw === "type" || raw === "assignee" || raw === "project" || raw === "workspace" ? raw : "none"; } catch { return "none"; } @@ -805,6 +810,86 @@ const inboxWorkItemKindLabels: Record = { join_request: "Join requests", }; +function resolveIssueAssigneeGroup( + issue: Pick, + { + agentById, + currentUserId, + userLabelById, + }: Pick, +): { 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, + { projectById }: Pick, +): { 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(); + for (const item of items) { + const resolvedGroup = item.kind === "issue" + ? resolveIssueGroup(item.issue) + : { key: `kind:${item.kind}`, label: inboxWorkItemKindLabels[item.kind] }; + const existing = groups.get(resolvedGroup.key); + if (existing) { + existing.items.push(item); + existing.latestTimestamp = Math.max(existing.latestTimestamp, item.timestamp); + } else { + groups.set(resolvedGroup.key, { + label: resolvedGroup.label, + items: [item], + latestTimestamp: item.timestamp, + }); + } + } + + return [...groups.entries()] + .map(([key, value]) => ({ + key, + label: value.label, + items: value.items, + latestTimestamp: value.latestTimestamp, + })) + .sort((a, b) => { + const timestampDiff = b.latestTimestamp - a.latestTimestamp; + if (timestampDiff !== 0) return timestampDiff; + return a.label.localeCompare(b.label); + }) + .map(({ key, label, items: groupItems }) => ({ + key, + label, + items: groupItems, + })); +} + export function groupInboxWorkItems( items: InboxWorkItem[], groupBy: InboxWorkItemGroupBy, @@ -815,41 +900,15 @@ export function groupInboxWorkItems( } if (groupBy === "workspace") { - const groups = new Map(); - for (const item of items) { - const resolvedGroup = item.kind === "issue" - ? resolveIssueWorkspaceGroup(item.issue, options) - : { key: `kind:${item.kind}`, label: inboxWorkItemKindLabels[item.kind] }; - const existing = groups.get(resolvedGroup.key); - if (existing) { - existing.items.push(item); - existing.latestTimestamp = Math.max(existing.latestTimestamp, item.timestamp); - } else { - groups.set(resolvedGroup.key, { - label: resolvedGroup.label, - items: [item], - latestTimestamp: item.timestamp, - }); - } - } + return groupInboxWorkItemsByIssueGroup(items, (issue) => resolveIssueWorkspaceGroup(issue, options)); + } - return [...groups.entries()] - .map(([key, value]) => ({ - key, - label: value.label, - items: value.items, - latestTimestamp: value.latestTimestamp, - })) - .sort((a, b) => { - const timestampDiff = b.latestTimestamp - a.latestTimestamp; - if (timestampDiff !== 0) return timestampDiff; - return a.label.localeCompare(b.label); - }) - .map(({ key, label, items: groupItems }) => ({ - key, - label, - items: groupItems, - })); + if (groupBy === "assignee") { + return groupInboxWorkItemsByIssueGroup(items, (issue) => resolveIssueAssigneeGroup(issue, options)); + } + + if (groupBy === "project") { + return groupInboxWorkItemsByIssueGroup(items, (issue) => resolveIssueProjectGroup(issue, options)); } const groups = new Map(); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index c5064a34..b6e67cfb 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -53,7 +53,10 @@ export const queryKeys = { comments: (issueId: string) => ["issues", "comments", issueId] as const, interactions: (issueId: string) => ["issues", "interactions", issueId] as const, feedbackVotes: (issueId: string) => ["issues", "feedback-votes", issueId] as const, - costSummary: (issueId: string) => ["issues", "cost-summary", issueId] as const, + costSummary: (issueId: string, options: { excludeRoot?: boolean } = {}) => + options.excludeRoot + ? (["issues", "cost-summary", issueId, "exclude-root"] as const) + : (["issues", "cost-summary", issueId] as const), attachments: (issueId: string) => ["issues", "attachments", issueId] as const, documents: (issueId: string) => ["issues", "documents", issueId] as const, document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const, diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index ee47b146..064c0254 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -75,6 +75,24 @@ export function formatTokens(n: number): string { return String(n); } +/** Humanize a millisecond duration into a compact `1h 2m`, `45m 12s`, `12s` string. */ +export function formatDurationMs(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return "0s"; + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + if (hours < 24) { + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; + } + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`; +} + /** Map a raw provider slug to a display-friendly name. */ export function providerDisplayName(provider: string): string { const map: Record = { diff --git a/ui/src/main.tsx b/ui/src/main.tsx index e0efe15e..b37c5a08 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "@/lib/router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { App } from "./App"; -import { CompanyProvider } from "./context/CompanyContext"; +import { CompanyProvider, useCompany } from "./context/CompanyContext"; import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider"; import { BreadcrumbProvider } from "./context/BreadcrumbContext"; import { PanelProvider } from "./context/PanelContext"; @@ -37,6 +37,11 @@ const queryClient = new QueryClient({ }, }); +function CompanyAwareBreadcrumbProvider({ children }: { children: React.ReactNode }) { + const { selectedCompany } = useCompany(); + return {children}; +} + createRoot(document.getElementById("root")!).render( @@ -47,7 +52,7 @@ createRoot(document.getElementById("root")!).render( - + @@ -57,7 +62,7 @@ createRoot(document.getElementById("root")!).render( - + diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 60307348..f541616c 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, RoutineListItem } from "@paperclipai/shared"; -import { ArrowLeft, Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react"; +import { Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -25,6 +25,7 @@ import { } from "../components/RoutineRunVariablesDialog"; import { buildWorkspaceRuntimeControlSections, + WorkspaceRuntimeQuickControls, WorkspaceRuntimeControls, type WorkspaceRuntimeControlRequest, } from "../components/WorkspaceRuntimeControls"; @@ -53,13 +54,14 @@ type WorkspaceFormState = { workspaceRuntime: string; }; -type ExecutionWorkspaceTab = "configuration" | "runtime_logs" | "issues" | "routines"; +type ExecutionWorkspaceTab = "services" | "configuration" | "runtime_logs" | "issues" | "routines"; function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null { const segments = pathname.split("/").filter(Boolean); const executionWorkspacesIndex = segments.indexOf("execution-workspaces"); if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null; const tab = segments[executionWorkspacesIndex + 2]; + if (tab === "services") return "services"; if (tab === "issues") return "issues"; if (tab === "routines") return "routines"; if (tab === "runtime-logs") return "runtime_logs"; @@ -72,6 +74,16 @@ function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceT return `/execution-workspaces/${workspaceId}/${segment}`; } +function LegacyWorkspaceTabRedirect({ workspaceId }: { workspaceId: string }) { + useEffect(() => { + try { + localStorage.removeItem(`paperclip:execution-workspace-tab:${workspaceId}`); + } catch {} + }, [workspaceId]); + + return ; +} + function isSafeExternalUrl(value: string | null | undefined) { if (!value) return false; try { @@ -259,14 +271,14 @@ function WorkspaceLink({ function ExecutionWorkspaceIssuesList({ companyId, - workspaceId, + workspace, issues, isLoading, error, project, }: { companyId: string; - workspaceId: string; + workspace: ExecutionWorkspace; issues: Issue[]; isLoading: boolean; error: Error | null; @@ -292,7 +304,7 @@ function ExecutionWorkspaceIssuesList({ const updateIssue = useMutation({ mutationFn: ({ id, data }: { id: string; data: Record }) => issuesApi.update(id, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(companyId, workspaceId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(companyId, workspace.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); if (project?.id) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, project.id) }); @@ -304,6 +316,15 @@ function ExecutionWorkspaceIssuesList({ () => (project ? [{ id: project.id, name: project.name, workspaces: project.workspaces ?? [] }] : undefined), [project], ); + const createIssueDefaults = useMemo( + () => ({ + projectId: workspace.projectId, + ...(workspace.projectWorkspaceId ? { projectWorkspaceId: workspace.projectWorkspaceId } : {}), + executionWorkspaceId: workspace.id, + executionWorkspaceMode: "reuse_existing", + }), + [workspace.id, workspace.projectId, workspace.projectWorkspaceId], + ); return ( updateIssue.mutate({ id, data })} /> ); @@ -663,25 +685,10 @@ export function ExecutionWorkspaceDetail() { const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null; if (workspaceId && activeTab === null) { - let cachedTab: ExecutionWorkspaceTab = "configuration"; - try { - const storedTab = localStorage.getItem(`paperclip:execution-workspace-tab:${workspaceId}`); - if ( - storedTab === "issues" || - storedTab === "routines" || - storedTab === "configuration" || - storedTab === "runtime_logs" - ) { - cachedTab = storedTab; - } - } catch {} - return ; + return ; } const handleTabChange = (tab: ExecutionWorkspaceTab) => { - try { - localStorage.setItem(`paperclip:execution-workspace-tab:${workspace.id}`, tab); - } catch {} navigate(executionWorkspaceTabPath(workspace.id, tab)); }; @@ -707,43 +714,39 @@ export function ExecutionWorkspaceDetail() { return ( <>
-
- - {workspace.mode} - {workspace.providerType} - - {workspace.status} - -
- -
-
- Execution workspace +
+
+
+ Execution workspace +
+

{workspace.name}

-

{workspace.name}

-

- Configure the concrete runtime workspace that Paperclip reuses for this issue flow. - 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. -

+ controlRuntimeServices.mutate(request)} + />
+ {runtimeActionErrorMessage ?

{runtimeActionErrorMessage}

: null} + {!runtimeActionErrorMessage && runtimeActionMessage ?

{runtimeActionMessage}

: null} - - - Services and jobs - - Source: {runtimeConfigSource === "execution_workspace" - ? "execution workspace override" - : runtimeConfigSource === "project_workspace" - ? "project workspace default" - : "none"} - - - + handleTabChange(value as ExecutionWorkspaceTab)}> + handleTabChange(value as ExecutionWorkspaceTab)} + /> + + + {activeTab === "services" ? ( controlRuntimeServices.mutate(request)} /> - {runtimeActionErrorMessage ?

{runtimeActionErrorMessage}

: null} - {!runtimeActionErrorMessage && runtimeActionMessage ?

{runtimeActionMessage}

: null} -
-
- - handleTabChange(value as ExecutionWorkspaceTab)}> - handleTabChange(value as ExecutionWorkspaceTab)} - /> - - - {activeTab === "configuration" ? ( + ) : activeTab === "configuration" ? (
@@ -792,7 +776,7 @@ export function ExecutionWorkspaceDetail() {
@@ -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)})`} + {issueTreeCostSummary.runCount > 0 ? ( + + Runtime {formatDurationMs(issueTreeCostSummary.runtimeMs)} + {` (${issueTreeCostSummary.runCount} run${issueTreeCostSummary.runCount === 1 ? "" : "s"})`} + + ) : null} {issueTreeCostSummary.issueCount} issue{issueTreeCostSummary.issueCount === 1 ? "" : "s"}
) : null} @@ -3466,6 +3494,7 @@ export function IssueDetail() { createIssueLabel="Sub-issue" defaultSortField="workflow" showProgressSummary + parentIssueIdForCostSummary={issue.id} onUpdateIssue={handleChildIssueUpdate} />