diff --git a/releases/v2026.415.0.md b/releases/v2026.415.0.md new file mode 100644 index 0000000000..2286554a73 --- /dev/null +++ b/releases/v2026.415.0.md @@ -0,0 +1,27 @@ +# v2026.415.0 + +> Released: 2026-04-15 + +## Highlights + +- **Faster issues page first paint** — The issues list now renders the initial view significantly faster by deferring non-critical work and optimizing workspace lookups. +- **Issue detail performance** — Reduced unnecessary rerenders on the issue detail page and kept queued issue chat mounted to avoid layout thrash during agent runs. +- **Inbox search expansion** — Added an "Other results" section to inbox search, surfacing matches beyond the current filter scope. + +## Improvements + +- **Properties pane polish** — Workspace link, copy-on-click for identifiers, and an inline parent navigation arrow in the issue properties sidebar. +- **Routine UX** — Routine name now appears above the "Run routine" title in the run dialog; routine execution issues are shown in issue lists by default. +- **Filter label clarity** — Renamed the routine-run filter to "Hide routine runs" so the default state shows no active filter badge. +- **Stranded issue diagnostics** — Stranded issue comments now include the latest run failure message for faster triage. +- **Issues search responsiveness** — Debounce and rendering improvements make the issues page search feel snappier. +- **Live run refresh** — Visible issue runs now refresh automatically on status updates without a manual reload. +- **Heartbeat payload hygiene** — Raw `result_json` writes are preserved exactly as received; payloads are bounded to prevent oversized records. +- **Heartbeat log scoping** — Narrowed heartbeat log endpoint lookups to reduce query overhead. +- **Vite dev watch** — UI test files are now excluded from the Vite dev watcher, reducing unnecessary rebuilds during development. + +## Fixes + +- **Self-comment code block scrolling** — Fixed horizontal scrolling for code blocks inside an agent's own comment thread. +- **Markdown long-string wrapping** — Long unbroken strings in markdown comments now wrap correctly instead of overflowing the container. +- **Dev asset serving order** — Fixed dev mode to serve public assets before the HTML shell, preventing 404s on static files during local development. diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts new file mode 100644 index 0000000000..b2f54f9286 --- /dev/null +++ b/server/src/__tests__/company-skills-service.test.ts @@ -0,0 +1,92 @@ +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { companies, companySkills, createDb } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { companySkillService } from "../services/company-skills.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company skill service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("companySkillService.list", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + const cleanupDirs = new Set(); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skills-service-"); + db = createDb(tempDb.connectionString); + svc = companySkillService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(companySkills); + await db.delete(companies); + await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("lists skills without exposing markdown content", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-heavy-skill-")); + cleanupDirs.add(skillDir); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Heavy Skill\n", "utf8"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: `company/${companyId}/heavy-skill`, + slug: "heavy-skill", + name: "Heavy Skill", + description: "Large skill used for list projection regression coverage.", + markdown: `# Heavy Skill\n\n${"x".repeat(250_000)}`, + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "local_path" }, + }); + + const listed = await svc.list(companyId); + const skill = listed.find((entry) => entry.id === skillId); + + expect(skill).toBeDefined(); + expect(skill).not.toHaveProperty("markdown"); + expect(skill).toMatchObject({ + id: skillId, + key: `company/${companyId}/heavy-skill`, + slug: "heavy-skill", + name: "Heavy Skill", + sourceType: "local_path", + sourceLocator: skillDir, + attachedAgentCount: 0, + sourceBadge: "local", + editable: true, + }); + }); +}); diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index 5ee13e4c06..83e4e87b70 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -1,6 +1,15 @@ import express from "express"; 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, projects } from "@paperclipai/db"; +import { costService } from "../services/costs.ts"; +import { financeService } from "../services/finance.ts"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; function makeDb(overrides: Record = {}) { const selectChain = { @@ -230,3 +239,154 @@ describe("cost routes", () => { expect(mockAgentService.update).not.toHaveBeenCalled(); }); }); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { + let db!: ReturnType; + let costs!: ReturnType; + let finance!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-costs-service-"); + db = createDb(tempDb.connectionString); + costs = costService(db); + finance = financeService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(financeEvents); + await db.delete(costEvents); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("aggregates cost event sums above int32 without raising Postgres integer overflow", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = 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: "Cost Agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Overflow Project", + status: "active", + }); + + await db.insert(costEvents).values([ + { + companyId, + agentId, + projectId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 2_000_000_000, + cachedInputTokens: 0, + outputTokens: 200_000_000, + costCents: 2_000_000_000, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + agentId, + projectId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 2_000_000_000, + cachedInputTokens: 10, + outputTokens: 200_000_000, + costCents: 2_000_000_000, + occurredAt: new Date("2026-04-11T00:00:00.000Z"), + }, + ]); + + const range = { + from: new Date("2026-04-01T00:00:00.000Z"), + to: new Date("2026-04-15T23:59:59.999Z"), + }; + + const [byAgentRow] = await costs.byAgent(companyId, range); + const [byProjectRow] = await costs.byProject(companyId, range); + const [byAgentModelRow] = await costs.byAgentModel(companyId, range); + + expect(byAgentRow?.costCents).toBe(4_000_000_000); + expect(byAgentRow?.inputTokens).toBe(4_000_000_000); + expect(byProjectRow?.costCents).toBe(4_000_000_000); + expect(byAgentModelRow?.costCents).toBe(4_000_000_000); + }); + + it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => { + const companyId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(financeEvents).values([ + { + companyId, + biller: "openai", + eventKind: "invoice", + amountCents: 2_000_000_000, + currency: "USD", + direction: "debit", + estimated: false, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + biller: "openai", + eventKind: "invoice", + amountCents: 2_000_000_000, + currency: "USD", + direction: "debit", + estimated: true, + occurredAt: new Date("2026-04-11T00:00:00.000Z"), + }, + ]); + + const range = { + from: new Date("2026-04-01T00:00:00.000Z"), + to: new Date("2026-04-15T23:59:59.999Z"), + }; + + const summary = await finance.summary(companyId, range); + const [byKindRow] = await finance.byKind(companyId, range); + + expect(summary.debitCents).toBe(4_000_000_000); + expect(summary.estimatedDebitCents).toBe(2_000_000_000); + expect(byKindRow?.debitCents).toBe(4_000_000_000); + expect(byKindRow?.netCents).toBe(4_000_000_000); + }); +}); diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index f7382e8b9a..c20040840d 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -190,6 +190,10 @@ describe("issue goal context routes", () => { title: projectGoal.title, }), ); + expect(mockIssueService.findMentionedProjectIds).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + { includeCommentBodies: false }, + ); expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 0629686a15..75aefd6a36 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -22,6 +22,7 @@ import { } from "./helpers/embedded-postgres.js"; import { instanceSettingsService } from "../services/instance-settings.ts"; import { issueService } from "../services/issues.ts"; +import { buildProjectMentionHref } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -697,6 +698,39 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { "2026-03-26T10:00:00.000Z", ); }); + + it("trims list payload fields that can grow large on issue index routes", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const longDescription = "x".repeat(5_000); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Large issue", + description: longDescription, + status: "todo", + priority: "medium", + executionPolicy: { stages: Array.from({ length: 20 }, (_, index) => ({ index, kind: "review", notes: "y".repeat(400) })) }, + executionState: { history: Array.from({ length: 20 }, (_, index) => ({ index, body: "z".repeat(400) })) }, + executionWorkspaceSettings: { notes: "w".repeat(2_000) }, + }); + + const [result] = await svc.list(companyId); + + expect(result).toBeTruthy(); + expect(result?.description).toHaveLength(1200); + expect(result?.executionPolicy).toBeNull(); + expect(result?.executionState).toBeNull(); + expect(result?.executionWorkspaceSettings).toBeNull(); + }); }); describeEmbeddedPostgres("issueService.create workspace inheritance", () => { @@ -1182,3 +1216,84 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", }); }); }); + +describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-mentioned-projects-"); + db = createDb(tempDb.connectionString); + svc = issueService(db); + await ensureIssueRelationsTable(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueRelations); + await db.delete(issueInboxArchives); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("can skip comment-body scans for bounded issue detail reads", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const titleProjectId = randomUUID(); + const commentProjectId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(projects).values([ + { + id: titleProjectId, + companyId, + name: "Title project", + status: "in_progress", + }, + { + id: commentProjectId, + companyId, + name: "Comment project", + status: "in_progress", + }, + ]); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: `Link [Title](${buildProjectMentionHref(titleProjectId)})`, + description: null, + status: "todo", + priority: "medium", + }); + + await db.insert(issueComments).values({ + companyId, + issueId, + body: `Comment link [Comment](${buildProjectMentionHref(commentProjectId)})`, + }); + + expect(await svc.findMentionedProjectIds(issueId, { includeCommentBodies: false })).toEqual([titleProjectId]); + expect(await svc.findMentionedProjectIds(issueId)).toEqual([ + titleProjectId, + commentProjectId, + ]); + }); +}); diff --git a/server/src/log-redaction.ts b/server/src/log-redaction.ts index ab59b3e414..952d1553ad 100644 --- a/server/src/log-redaction.ts +++ b/server/src/log-redaction.ts @@ -112,6 +112,7 @@ export function redactCurrentUserText(input: string, opts?: CurrentUserRedaction let result = input; for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) { + if (!result.includes(homeDir)) continue; const lastSegment = splitPathSegments(homeDir).pop() ?? ""; const replacementDir = lastSegment ? replaceLastPathSegment(homeDir, maskUserNameForLogs(lastSegment, replacement)) @@ -120,6 +121,7 @@ export function redactCurrentUserText(input: string, opts?: CurrentUserRedaction } for (const userName of [...userNames].sort((a, b) => b.length - a.length)) { + if (!result.includes(userName)) continue; const pattern = new RegExp(`(?`coalesce(sum(${costEvents.costCents}), 0)::int`, + spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where( diff --git a/server/src/services/budgets.ts b/server/src/services/budgets.ts index 3e7d8b4e6c..4857543838 100644 --- a/server/src/services/budgets.ts +++ b/server/src/services/budgets.ts @@ -156,7 +156,7 @@ async function computeObservedAmount( const [row] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where(and(...conditions)); diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index dcdeb27dd3..89678572ca 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -76,10 +76,10 @@ export function companyService(db: Db) { if (companyIds.length === 0) return new Map(); const { start, end } = currentUtcMonthWindow(); const rows = await database - .select({ - companyId: costEvents.companyId, - spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - }) + .select({ + companyId: costEvents.companyId, + spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, + }) .from(costEvents) .where( and( diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 60fc06b40f..0985c2b622 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -36,6 +36,50 @@ import { projectService } from "./projects.js"; import { secretService } from "./secrets.js"; type CompanySkillRow = typeof companySkills.$inferSelect; +type CompanySkillListDbRow = Pick< + CompanySkillRow, + | "id" + | "companyId" + | "key" + | "slug" + | "name" + | "description" + | "sourceType" + | "sourceLocator" + | "sourceRef" + | "trustLevel" + | "compatibility" + | "fileInventory" + | "metadata" + | "createdAt" + | "updatedAt" +>; +type CompanySkillListRow = Pick< + CompanySkill, + | "id" + | "companyId" + | "key" + | "slug" + | "name" + | "description" + | "sourceType" + | "sourceLocator" + | "sourceRef" + | "trustLevel" + | "compatibility" + | "fileInventory" + | "metadata" + | "createdAt" + | "updatedAt" +>; +type SkillReferenceTarget = Pick; +type SkillSourceInfoTarget = Pick< + CompanySkill, + | "companyId" + | "sourceType" + | "sourceLocator" + | "metadata" +>; type ImportedSkill = { key: string; @@ -1150,6 +1194,28 @@ function toCompanySkill(row: CompanySkillRow): CompanySkill { }; } +function toCompanySkillListRow(row: CompanySkillListDbRow): CompanySkillListRow { + return { + ...row, + description: row.description ?? null, + sourceType: row.sourceType as CompanySkillSourceType, + sourceLocator: row.sourceLocator ?? null, + sourceRef: row.sourceRef ?? null, + trustLevel: row.trustLevel as CompanySkillTrustLevel, + compatibility: row.compatibility as CompanySkillCompatibility, + fileInventory: Array.isArray(row.fileInventory) + ? row.fileInventory.flatMap((entry) => { + if (!isPlainRecord(entry)) return []; + return [{ + path: String(entry.path ?? ""), + kind: (String(entry.kind ?? "other") as CompanySkillFileInventoryEntry["kind"]), + }]; + }) + : [], + metadata: isPlainRecord(row.metadata) ? row.metadata : null, + }; +} + function serializeFileInventory( fileInventory: CompanySkillFileInventoryEntry[], ): Array> { @@ -1159,14 +1225,14 @@ function serializeFileInventory( })); } -function getSkillMeta(skill: CompanySkill): SkillSourceMeta { +function getSkillMeta(skill: Pick): SkillSourceMeta { return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {}; } function resolveSkillReference( - skills: CompanySkill[], + skills: SkillReferenceTarget[], reference: string, -): { skill: CompanySkill | null; ambiguous: boolean } { +): { skill: SkillReferenceTarget | null; ambiguous: boolean } { const trimmed = reference.trim(); if (!trimmed) { return { skill: null, ambiguous: false }; @@ -1242,7 +1308,7 @@ function resolveRequestedSkillKeysOrThrow( } function resolveDesiredSkillKeys( - skills: CompanySkill[], + skills: SkillReferenceTarget[], config: Record, ) { const preference = readPaperclipSkillSyncPreference(config); @@ -1253,7 +1319,7 @@ function resolveDesiredSkillKeys( )); } -function normalizeSkillDirectory(skill: CompanySkill) { +function normalizeSkillDirectory(skill: SkillSourceInfoTarget) { if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null; const resolved = path.resolve(skill.sourceLocator); if (path.basename(resolved).toLowerCase() === "skill.md") { @@ -1329,7 +1395,7 @@ function isMarkdownPath(filePath: string) { return fileName === "skill.md" || fileName.endsWith(".md"); } -function deriveSkillSourceInfo(skill: CompanySkill): { +function deriveSkillSourceInfo(skill: SkillSourceInfoTarget): { editable: boolean; editableReason: string | null; sourceLabel: string | null; @@ -1428,7 +1494,7 @@ function enrichSkill(skill: CompanySkill, attachedAgentCount: number, usedByAgen }; } -function toCompanySkillListItem(skill: CompanySkill, attachedAgentCount: number): CompanySkillListItem { +function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: number): CompanySkillListItem { const source = deriveSkillSourceInfo(skill); return { id: skill.id, @@ -1526,7 +1592,29 @@ export function companySkillService(db: Db) { } async function list(companyId: string): Promise { - const rows = await listFull(companyId); + await ensureSkillInventoryCurrent(companyId); + const rows = await db + .select({ + id: companySkills.id, + companyId: companySkills.companyId, + key: companySkills.key, + slug: companySkills.slug, + name: companySkills.name, + description: companySkills.description, + sourceType: companySkills.sourceType, + sourceLocator: companySkills.sourceLocator, + sourceRef: companySkills.sourceRef, + trustLevel: companySkills.trustLevel, + compatibility: companySkills.compatibility, + fileInventory: companySkills.fileInventory, + metadata: companySkills.metadata, + createdAt: companySkills.createdAt, + updatedAt: companySkills.updatedAt, + }) + .from(companySkills) + .where(eq(companySkills.companyId, companyId)) + .orderBy(asc(companySkills.name), asc(companySkills.key)) + .then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow))); const agentRows = await agents.list(companyId); return rows.map((skill) => { const attachedAgentCount = agentRows.filter((agent) => { diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index 76a90f2dad..88825b498b 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -12,6 +12,10 @@ export interface CostDateRange { const METERED_BILLING_TYPE = "metered_api"; const SUBSCRIPTION_BILLING_TYPES = ["subscription_included", "subscription_overage"] as const; +function sumAsNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) { + return sql`coalesce(sum(${column}), 0)::double precision`; +} + function currentUtcMonthWindow(now = new Date()) { const year = now.getUTCFullYear(); const month = now.getUTCMonth(); @@ -36,7 +40,7 @@ async function getMonthlySpendTotal( } const [row] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sumAsNumber(costEvents.costCents), }) .from(costEvents) .where(and(...conditions)); @@ -111,7 +115,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { const [{ total }] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sumAsNumber(costEvents.costCents), }) .from(costEvents) .where(and(...conditions)); @@ -140,26 +144,26 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { agentId: costEvents.agentId, agentName: agents.name, agentStatus: agents.status, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, }) .from(costEvents) .leftJoin(agents, eq(costEvents.agentId, agents.id)) .where(and(...conditions)) .groupBy(costEvents.agentId, agents.name, agents.status) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, byProvider: async (companyId: string, range?: CostDateRange) => { @@ -173,25 +177,25 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { biller: costEvents.biller, billingType: costEvents.billingType, model: costEvents.model, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, }) .from(costEvents) .where(and(...conditions)) .groupBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, byBiller: async (companyId: string, range?: CostDateRange) => { @@ -202,27 +206,27 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { return db .select({ biller: costEvents.biller, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, providerCount: sql`count(distinct ${costEvents.provider})::int`, modelCount: sql`count(distinct ${costEvents.model})::int`, }) .from(costEvents) .where(and(...conditions)) .groupBy(costEvents.biller) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, /** @@ -244,10 +248,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { .select({ provider: costEvents.provider, biller: sql`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .where( @@ -257,7 +261,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { ), ) .groupBy(costEvents.provider) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); return rows.map((row) => ({ provider: row.provider, @@ -292,10 +296,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { biller: costEvents.biller, billingType: costEvents.billingType, model: costEvents.model, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .leftJoin(agents, eq(costEvents.agentId, agents.id)) @@ -342,16 +346,16 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to)); - const costCentsExpr = sql`coalesce(sum(${costEvents.costCents}), 0)::int`; + const costCentsExpr = sumAsNumber(costEvents.costCents); return db .select({ projectId: effectiveProjectId, projectName: projects.name, costCents: costCentsExpr, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .leftJoin(runProjectLinks, eq(costEvents.heartbeatRunId, runProjectLinks.runId)) diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts index e495208d9c..c1169aa98c 100644 --- a/server/src/services/dashboard.ts +++ b/server/src/services/dashboard.ts @@ -65,7 +65,7 @@ export function dashboardService(db: Db) { const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const [{ monthSpend }] = await db .select({ - monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where( diff --git a/server/src/services/finance.ts b/server/src/services/finance.ts index 29dcec494a..766bea7b1f 100644 --- a/server/src/services/finance.ts +++ b/server/src/services/finance.ts @@ -35,9 +35,9 @@ function rangeConditions(companyId: string, range?: FinanceDateRange) { } export function financeService(db: Db) { - const debitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::int`; - const creditExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::int`; - const estimatedDebitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::int`; + const debitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`; + const creditExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`; + const estimatedDebitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::double precision`; return { createEvent: async (companyId: string, data: Omit) => { @@ -95,12 +95,12 @@ export function financeService(db: Db) { estimatedDebitCents: estimatedDebitExpr, eventCount: sql`count(*)::int`, kindCount: sql`count(distinct ${financeEvents.eventKind})::int`, - netCents: sql`(${debitExpr} - ${creditExpr})::int`, + netCents: sql`(${debitExpr} - ${creditExpr})::double precision`, }) .from(financeEvents) .where(and(...conditions)) .groupBy(financeEvents.biller) - .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.biller); + .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.biller); }, byKind: async (companyId: string, range?: FinanceDateRange) => { @@ -113,12 +113,12 @@ export function financeService(db: Db) { estimatedDebitCents: estimatedDebitExpr, eventCount: sql`count(*)::int`, billerCount: sql`count(distinct ${financeEvents.biller})::int`, - netCents: sql`(${debitExpr} - ${creditExpr})::int`, + netCents: sql`(${debitExpr} - ${creditExpr})::double precision`, }) .from(financeEvents) .where(and(...conditions)) .groupBy(financeEvents.eventKind) - .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.eventKind); + .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.eventKind); }, list: async (companyId: string, range?: FinanceDateRange, limit: number = 100) => { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e18cd7c359..5d045e47ae 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -132,6 +132,7 @@ function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { } const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]); +const ISSUE_LIST_DESCRIPTION_MAX_CHARS = 1200; function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, "\\$&"); @@ -527,6 +528,51 @@ async function activeRunMapForIssues( return map; } +const issueListSelect = { + id: issues.id, + companyId: issues.companyId, + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + goalId: issues.goalId, + parentId: issues.parentId, + title: issues.title, + description: sql` + CASE + WHEN ${issues.description} IS NULL THEN NULL + ELSE substring(${issues.description} FROM 1 FOR ${ISSUE_LIST_DESCRIPTION_MAX_CHARS}) + END + `, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + checkoutRunId: issues.checkoutRunId, + executionRunId: issues.executionRunId, + executionAgentNameKey: issues.executionAgentNameKey, + executionLockedAt: issues.executionLockedAt, + createdByAgentId: issues.createdByAgentId, + createdByUserId: issues.createdByUserId, + issueNumber: issues.issueNumber, + identifier: issues.identifier, + originKind: issues.originKind, + originId: issues.originId, + originRunId: issues.originRunId, + requestDepth: issues.requestDepth, + billingCode: issues.billingCode, + assigneeAdapterOverrides: issues.assigneeAdapterOverrides, + executionPolicy: sql`null`, + executionState: sql`null`, + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspacePreference: issues.executionWorkspacePreference, + executionWorkspaceSettings: sql`null`, + startedAt: issues.startedAt, + completedAt: issues.completedAt, + cancelledAt: issues.cancelledAt, + hiddenAt: issues.hiddenAt, + createdAt: issues.createdAt, + updatedAt: issues.updatedAt, +}; + function withActiveRuns( issueRows: IssueWithLabels[], runMap: Map, @@ -1005,7 +1051,7 @@ export function issueService(db: Db) { `; const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId); const baseQuery = db - .select() + .select(issueListSelect) .from(issues) .where(and(...conditions)) .orderBy( @@ -2350,7 +2396,10 @@ export function issueService(db: Db) { return [...resolved]; }, - findMentionedProjectIds: async (issueId: string) => { + findMentionedProjectIds: async ( + issueId: string, + opts?: { includeCommentBodies?: boolean }, + ) => { const issue = await db .select({ companyId: issues.companyId, @@ -2362,21 +2411,26 @@ export function issueService(db: Db) { .then((rows) => rows[0] ?? null); if (!issue) return []; - const comments = await db - .select({ body: issueComments.body }) - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); - const mentionedIds = new Set(); - for (const source of [ - issue.title, - issue.description ?? "", - ...comments.map((comment) => comment.body), - ]) { + for (const source of [issue.title, issue.description ?? ""]) { for (const projectId of extractProjectMentionIds(source)) { mentionedIds.add(projectId); } } + + if (opts?.includeCommentBodies !== false) { + const comments = await db + .select({ body: issueComments.body }) + .from(issueComments) + .where(eq(issueComments.issueId, issueId)); + + for (const comment of comments) { + for (const projectId of extractProjectMentionIds(comment.body)) { + mentionedIds.add(projectId); + } + } + } + if (mentionedIds.size === 0) return []; const rows = await db diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 321d9b1d22..2702ea0c7e 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -1,7 +1,10 @@ +import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Filter, X, User, HardDrive } from "lucide-react"; +import { Bot, Filter, HardDrive, Search, User, X } from "lucide-react"; import { PriorityIcon } from "./PriorityIcon"; import { StatusIcon } from "./StatusIcon"; import { @@ -14,6 +17,7 @@ import { toggleIssueFilterValue, type IssueFilterState, } from "../lib/issue-filters"; +import { formatAssigneeUserLabel } from "../lib/assignees"; type AgentOption = { id: string; @@ -36,6 +40,13 @@ type WorkspaceOption = { name: string; }; +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; +}; + export function IssueFiltersPopover({ state, onChange, @@ -48,6 +59,7 @@ export function IssueFiltersPopover({ buttonVariant = "ghost", iconOnly = false, workspaces, + creators, }: { state: IssueFilterState; onChange: (patch: Partial) => void; @@ -60,7 +72,39 @@ export function IssueFiltersPopover({ buttonVariant?: "ghost" | "outline"; iconOnly?: boolean; workspaces?: WorkspaceOption[]; + creators?: CreatorOption[]; }) { + const [creatorSearch, setCreatorSearch] = useState(""); + const creatorOptions = creators ?? []; + const creatorOptionById = useMemo( + () => new Map(creatorOptions.map((option) => [option.id, option])), + [creatorOptions], + ); + const normalizedCreatorSearch = creatorSearch.trim().toLowerCase(); + const visibleCreatorOptions = useMemo(() => { + if (!normalizedCreatorSearch) return creatorOptions; + return creatorOptions.filter((option) => + `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(normalizedCreatorSearch), + ); + }, [creatorOptions, normalizedCreatorSearch]); + const selectedCreatorOptions = useMemo( + () => state.creators.map((creatorId) => { + const knownOption = creatorOptionById.get(creatorId); + if (knownOption) return knownOption; + if (creatorId.startsWith("agent:")) { + const agentId = creatorId.slice("agent:".length); + return { id: creatorId, label: agentId.slice(0, 8), kind: "agent" as const }; + } + const userId = creatorId.startsWith("user:") ? creatorId.slice("user:".length) : creatorId; + return { + id: creatorId, + label: formatAssigneeUserLabel(userId, currentUserId) ?? userId.slice(0, 5), + kind: "user" as const, + }; + }), + [creatorOptionById, currentUserId, state.creators], + ); + return ( @@ -191,6 +235,60 @@ export function IssueFiltersPopover({ + {creatorOptions.length > 0 ? ( +
+ Creator + {selectedCreatorOptions.length > 0 ? ( +
+ {selectedCreatorOptions.map((creator) => ( + + {creator.kind === "agent" ? : } + {creator.label} + + + ))} +
+ ) : null} +
+ + setCreatorSearch(event.target.value)} + placeholder="Search creators..." + className="h-8 pl-7 text-xs" + /> +
+
+ {visibleCreatorOptions.length > 0 ? visibleCreatorOptions.map((creator) => { + const selected = state.creators.includes(creator.id); + return ( + + ); + }) : ( +
No creators match.
+ )} +
+
+ ) : null} + {projects && projects.length > 0 ? (
Project diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 1f8ebcdcba..45d3f188a3 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -19,6 +19,7 @@ import { defaultIssueFilterState, issueFilterLabel, issuePriorityOrder, + normalizeIssueFilterState, resolveIssueFilterWorkspaceId, issueStatusOrder, type IssueFilterState, @@ -86,7 +87,10 @@ const defaultViewState: IssueViewState = { function getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); - if (raw) return { ...defaultViewState, ...JSON.parse(raw) }; + if (raw) { + const parsed = JSON.parse(raw); + return { ...defaultViewState, ...parsed, ...normalizeIssueFilterState(parsed) }; + } } catch { /* ignore */ } return { ...defaultViewState }; } @@ -161,6 +165,13 @@ interface Agent { name: string; } +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; +}; + type ProjectOption = Pick & Partial>; type IssueListRequestFilters = NonNullable[1]>; @@ -432,6 +443,66 @@ export function IssuesList({ .map(([id, name]) => ({ id, name })); }, [workspaceNameMap]); + const creatorOptions = useMemo(() => { + const options = new Map(); + const knownAgentIds = new Set(); + + if (currentUserId) { + options.set(`user:${currentUserId}`, { + id: `user:${currentUserId}`, + label: currentUserId === "local-board" ? "Board" : "Me", + kind: "user", + searchText: currentUserId === "local-board" ? "board me human local-board" : `me board human ${currentUserId}`, + }); + } + + for (const issue of issues) { + if (issue.createdByUserId) { + const id = `user:${issue.createdByUserId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: formatAssigneeUserLabel(issue.createdByUserId, currentUserId) ?? issue.createdByUserId.slice(0, 5), + kind: "user", + searchText: `${issue.createdByUserId} board user human`, + }); + } + } + } + + for (const agent of agents ?? []) { + knownAgentIds.add(agent.id); + const id = `agent:${agent.id}`; + if (!options.has(id)) { + options.set(id, { + id, + label: agent.name, + kind: "agent", + searchText: `${agent.name} ${agent.id} agent`, + }); + } + } + + for (const issue of issues) { + if (issue.createdByAgentId && !knownAgentIds.has(issue.createdByAgentId)) { + const id = `agent:${issue.createdByAgentId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: issue.createdByAgentId.slice(0, 8), + kind: "agent", + searchText: `${issue.createdByAgentId} agent`, + }); + } + } + } + + return [...options.values()].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "user" ? -1 : 1; + return a.label.localeCompare(b.label); + }); + }, [agents, currentUserId, issues]); + const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]); const availableIssueColumns = useMemo( () => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled), @@ -671,6 +742,7 @@ export function IssuesList({ onChange={updateView} activeFilterCount={activeFilterCount} agents={agents} + creators={creatorOptions} projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 3c1008b3b7..834c262c89 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -716,6 +716,7 @@ describe("inbox helpers", () => { statuses: ["in_progress"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -734,6 +735,7 @@ describe("inbox helpers", () => { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -752,6 +754,7 @@ describe("inbox helpers", () => { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -816,6 +819,7 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: ["high"], assignees: ["agent-1"], + creators: ["user:user-1"], labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], @@ -829,6 +833,7 @@ describe("inbox helpers", () => { statuses: ["done"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -843,6 +848,7 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: ["high"], assignees: ["agent-1"], + creators: ["user:user-1"], labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], @@ -856,6 +862,7 @@ describe("inbox helpers", () => { statuses: ["done"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -872,6 +879,7 @@ describe("inbox helpers", () => { statuses: ["todo", 123], priorities: "high", assignees: ["agent-1"], + creators: ["user:user-1", 42], labels: null, projects: ["project-1"], workspaces: ["workspace-1", false], @@ -886,6 +894,7 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: [], assignees: ["agent-1"], + creators: ["user:user-1"], labels: [], projects: ["project-1"], workspaces: ["workspace-1"], diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 43f116451d..6b66f66f2d 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -9,6 +9,7 @@ import type { import { applyIssueFilters, defaultIssueFilterState, + normalizeIssueFilterState, type IssueFilterState, } from "./issue-filters"; @@ -137,25 +138,6 @@ const defaultInboxFilterPreferences: InboxFilterPreferences = { issueFilters: defaultIssueFilterState, }; -function normalizeStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is string => typeof entry === "string"); -} - -function normalizeIssueFilterState(value: unknown): IssueFilterState { - if (!value || typeof value !== "object") return { ...defaultIssueFilterState }; - const candidate = value as Partial>; - return { - statuses: normalizeStringArray(candidate.statuses), - priorities: normalizeStringArray(candidate.priorities), - assignees: normalizeStringArray(candidate.assignees), - labels: normalizeStringArray(candidate.labels), - projects: normalizeStringArray(candidate.projects), - workspaces: normalizeStringArray(candidate.workspaces), - hideRoutineExecutions: candidate.hideRoutineExecutions === true, - }; -} - function normalizeInboxCategoryFilter(value: unknown): InboxCategoryFilter { return value === "issues_i_touched" || value === "join_requests" @@ -244,7 +226,7 @@ export function loadCollapsedInboxGroupKeys( const raw = localStorage.getItem(storageKey); if (!raw) return new Set(); const parsed = JSON.parse(raw); - return new Set(normalizeStringArray(parsed)); + return new Set(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []); } catch { return new Set(); } diff --git a/ui/src/lib/issue-filters.test.ts b/ui/src/lib/issue-filters.test.ts new file mode 100644 index 0000000000..523a2222d6 --- /dev/null +++ b/ui/src/lib/issue-filters.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import type { Issue } from "@paperclipai/shared"; +import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState } from "./issue-filters"; + +function makeIssue(overrides: Partial = {}): Issue { + return { + id: overrides.id ?? "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Issue", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + identifier: "PAP-1", + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + labels: [], + labelIds: [], + createdAt: new Date("2026-04-15T00:00:00.000Z"), + updatedAt: new Date("2026-04-15T00:00:00.000Z"), + ...overrides, + }; +} + +describe("issue filters", () => { + it("filters issues by creator across agents and users", () => { + const issues = [ + makeIssue({ id: "agent-match", createdByAgentId: "agent-1" }), + makeIssue({ id: "user-match", createdByUserId: "user-1" }), + makeIssue({ id: "excluded", createdByAgentId: "agent-2", createdByUserId: "user-2" }), + ]; + + const filtered = applyIssueFilters(issues, { + ...defaultIssueFilterState, + creators: ["agent:agent-1", "user:user-1"], + }); + + expect(filtered.map((issue) => issue.id)).toEqual(["agent-match", "user-match"]); + }); + + it("counts creator filters as an active filter group", () => { + expect(countActiveIssueFilters({ + ...defaultIssueFilterState, + creators: ["user:user-1"], + })).toBe(1); + }); +}); diff --git a/ui/src/lib/issue-filters.ts b/ui/src/lib/issue-filters.ts index c9ec4a5bad..4a64b839f3 100644 --- a/ui/src/lib/issue-filters.ts +++ b/ui/src/lib/issue-filters.ts @@ -4,6 +4,7 @@ export type IssueFilterState = { statuses: string[]; priorities: string[]; assignees: string[]; + creators: string[]; labels: string[]; projects: string[]; workspaces: string[]; @@ -14,6 +15,7 @@ export const defaultIssueFilterState: IssueFilterState = { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -41,6 +43,26 @@ export function issueFilterArraysEqual(a: string[], b: string[]): boolean { return sortedA.every((value, index) => value === sortedB[index]); } +function normalizeIssueFilterValueArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); +} + +export function normalizeIssueFilterState(value: unknown): IssueFilterState { + if (!value || typeof value !== "object") return { ...defaultIssueFilterState }; + const candidate = value as Partial>; + return { + statuses: normalizeIssueFilterValueArray(candidate.statuses), + priorities: normalizeIssueFilterValueArray(candidate.priorities), + assignees: normalizeIssueFilterValueArray(candidate.assignees), + creators: normalizeIssueFilterValueArray(candidate.creators), + labels: normalizeIssueFilterValueArray(candidate.labels), + projects: normalizeIssueFilterValueArray(candidate.projects), + workspaces: normalizeIssueFilterValueArray(candidate.workspaces), + hideRoutineExecutions: candidate.hideRoutineExecutions === true, + }; +} + export function toggleIssueFilterValue(values: string[], value: string): string[] { return values.includes(value) ? values.filter((existing) => existing !== value) : [...values, value]; } @@ -73,6 +95,15 @@ export function applyIssueFilters( return false; }); } + if (state.creators.length > 0) { + result = result.filter((issue) => { + for (const creator of state.creators) { + if (creator.startsWith("agent:") && issue.createdByAgentId === creator.slice("agent:".length)) return true; + if (creator.startsWith("user:") && issue.createdByUserId === creator.slice("user:".length)) return true; + } + return false; + }); + } if (state.labels.length > 0) { result = result.filter((issue) => (issue.labelIds ?? []).some((id) => state.labels.includes(id))); } @@ -96,6 +127,7 @@ export function countActiveIssueFilters( if (state.statuses.length > 0) count += 1; if (state.priorities.length > 0) count += 1; if (state.assignees.length > 0) count += 1; + if (state.creators.length > 0) count += 1; if (state.labels.length > 0) count += 1; if (state.projects.length > 0) count += 1; if (state.workspaces.length > 0) count += 1; diff --git a/ui/src/lib/issue-reference.test.ts b/ui/src/lib/issue-reference.test.ts index a7c7d4c768..d1d613e032 100644 --- a/ui/src/lib/issue-reference.test.ts +++ b/ui/src/lib/issue-reference.test.ts @@ -5,12 +5,19 @@ describe("issue-reference", () => { it("extracts issue ids from company-scoped issue paths", () => { expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271"); expect(parseIssuePathIdFromPath("/issues/PAP-1179")).toBe("PAP-1179"); + expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull(); }); it("extracts issue ids from full issue URLs", () => { expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBe("PAP-1179"); }); + it("ignores placeholder issue paths", () => { + expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull(); + expect(parseIssuePathIdFromPath("http://localhost:3100/issues/:id")).toBeNull(); + expect(parseIssueReferenceFromHref("/issues/:id")).toBeNull(); + }); + it("normalizes bare identifiers, issue URLs, and issue scheme links into internal links", () => { expect(parseIssueReferenceFromHref("pap-1271")).toEqual({ issuePathId: "PAP-1271", @@ -36,4 +43,9 @@ describe("issue-reference", () => { href: "/issues/PAP-1271", }); }); + + it("ignores literal route placeholder paths", () => { + expect(parseIssueReferenceFromHref("/issues/:id")).toBeNull(); + expect(parseIssueReferenceFromHref("http://localhost:3100/api/issues/:id")).toBeNull(); + }); }); diff --git a/ui/src/lib/issue-reference.ts b/ui/src/lib/issue-reference.ts index 18de5a3244..25b4a4a27f 100644 --- a/ui/src/lib/issue-reference.ts +++ b/ui/src/lib/issue-reference.ts @@ -25,7 +25,9 @@ export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): const segments = pathname.split("/").filter(Boolean); const issueIndex = segments.findIndex((segment) => segment === "issues"); if (issueIndex === -1 || issueIndex === segments.length - 1) return null; - return decodeURIComponent(segments[issueIndex + 1] ?? ""); + const issuePathId = decodeURIComponent(segments[issueIndex + 1] ?? ""); + if (!issuePathId || issuePathId.startsWith(":")) return null; + return issuePathId; } export function parseIssueReferenceFromHref(href: string | null | undefined) { diff --git a/ui/src/lib/router.tsx b/ui/src/lib/router.tsx index c8d3a352d3..0ec23ede33 100644 --- a/ui/src/lib/router.tsx +++ b/ui/src/lib/router.tsx @@ -9,12 +9,7 @@ import { extractCompanyPrefixFromPath, normalizeCompanyPrefix, } from "@/lib/company-routes"; - -function parseIssuePathIdFromPath(pathname: string | null | undefined): string | null { - if (!pathname) return null; - const match = pathname.match(/(?:^|\/)issues\/([^/?#]+)/); - return match?.[1] ?? null; -} +import { parseIssuePathIdFromPath } from "@/lib/issue-reference"; function resolveTo(to: To, companyPrefix: string | null): To { if (typeof to === "string") { diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 6cf23a8b7d..5134bd9b1a 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -23,6 +23,7 @@ import { countActiveIssueFilters, type IssueFilterState, } from "../lib/issue-filters"; +import { formatAssigneeUserLabel } from "../lib/assignees"; import { armIssueDetailInboxQuickArchive, createIssueDetailLocationState, @@ -149,6 +150,12 @@ type SectionKey = /** A flat navigation entry for keyboard j/k traversal that includes expanded children. */ type NavEntry = InboxKeyboardNavEntry; +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; +}; function firstNonEmptyLine(value: string | null | undefined): string | null { if (!value) return null; @@ -796,6 +803,66 @@ export function Inbox() { () => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe), [visibleTouchedIssues], ); + const creatorOptions = useMemo(() => { + const options = new Map(); + const sourceIssues = [...mineIssues, ...touchedIssues]; + + if (currentUserId) { + options.set(`user:${currentUserId}`, { + id: `user:${currentUserId}`, + label: currentUserId === "local-board" ? "Board" : "Me", + kind: "user", + searchText: currentUserId === "local-board" ? "board me human local-board" : `me board human ${currentUserId}`, + }); + } + + for (const issue of sourceIssues) { + if (issue.createdByUserId) { + const id = `user:${issue.createdByUserId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: formatAssigneeUserLabel(issue.createdByUserId, currentUserId) ?? issue.createdByUserId.slice(0, 5), + kind: "user", + searchText: `${issue.createdByUserId} board user human`, + }); + } + } + } + + const knownAgentIds = new Set(); + for (const agent of agents ?? []) { + knownAgentIds.add(agent.id); + const id = `agent:${agent.id}`; + if (!options.has(id)) { + options.set(id, { + id, + label: agent.name, + kind: "agent", + searchText: `${agent.name} ${agent.id} agent`, + }); + } + } + + for (const issue of sourceIssues) { + if (issue.createdByAgentId && !knownAgentIds.has(issue.createdByAgentId)) { + const id = `agent:${issue.createdByAgentId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: issue.createdByAgentId.slice(0, 8), + kind: "agent", + searchText: `${issue.createdByAgentId} agent`, + }); + } + } + } + + return [...options.values()].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "user" ? -1 : 1; + return a.label.localeCompare(b.label); + }); + }, [agents, currentUserId, mineIssues, touchedIssues]); const issuesToRender = useMemo( () => { if (tab === "mine") return visibleMineIssues; @@ -1814,6 +1881,7 @@ export function Inbox() { onChange={updateIssueFilters} activeFilterCount={activeIssueFilterCount} agents={agents} + creators={creatorOptions} projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 4a6f226906..7edea69730 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -573,7 +573,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ refetchInterval: liveRunCount > 0 ? false : 3000, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); - const hasLiveRuns = liveRunCount > 0 || !!activeRun; + const resolvedActiveRun = useMemo( + () => resolveIssueActiveRun({ status: issueStatus, executionRunId }, activeRun), + [activeRun, executionRunId, issueStatus], + ); + const hasLiveRuns = liveRunCount > 0 || !!resolvedActiveRun; const { data: linkedRuns } = useQuery({ queryKey: queryKeys.issues.runs(issueId), queryFn: () => activityApi.runsForIssue(issueId), @@ -584,8 +588,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ const resolvedLinkedRuns = linkedRuns ?? []; const runningIssueRun = useMemo( - () => resolveRunningIssueRun(activeRun, resolvedLiveRuns), - [activeRun, resolvedLiveRuns], + () => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns), + [resolvedActiveRun, resolvedLiveRuns], ); const timelineRuns = useMemo(() => { const liveIds = new Set(); @@ -672,7 +676,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ linkedRuns={timelineRuns} timelineEvents={timelineEvents} liveRuns={resolvedLiveRuns} - activeRun={activeRun} + activeRun={resolvedActiveRun} companyId={companyId} projectId={projectId} issueStatus={issueStatus} @@ -950,7 +954,8 @@ export function IssueDetail() { select: (run) => !!run, placeholderData: keepPreviousDataForSameQueryTail(issueId ?? "pending"), }); - const hasLiveRuns = liveRunCount > 0 || hasActiveRun; + const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun; + const hasLiveRuns = liveRunCount > 0 || resolvedHasActiveRun; const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" }, [issueId, location.state, location.search],