mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Bundle control-plane QoL updates
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d0bdbe11a9
commit
c9ef93e257
106 files changed
+37599
-491
No files matched your search
+2
-1
@@ -41,7 +41,8 @@
|
||||
"evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval",
|
||||
"test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts",
|
||||
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed",
|
||||
"metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts"
|
||||
"metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts",
|
||||
"perf:issue-chat-long-thread": "node scripts/measure-issue-chat-long-thread.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
|
||||
@@ -66,8 +66,6 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
|
||||
if (v.chrome) ac.chrome = true;
|
||||
|
||||
@@ -70,8 +70,6 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
ac.model = v.model || DEFAULT_CODEX_LOCAL_MODEL;
|
||||
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
|
||||
ac.timeoutSec = 0;
|
||||
|
||||
@@ -61,8 +61,6 @@ export function buildCursorLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
ac.model = v.model || DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
const mode = normalizeMode(v.thinkingEffort);
|
||||
if (mode) ac.mode = mode;
|
||||
|
||||
@@ -55,8 +55,6 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
|
||||
@@ -54,8 +54,6 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
|
||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||
|
||||
@@ -47,8 +47,6 @@ export function buildPiLocalConfig(v: CreateConfigValues): Record<string, unknow
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.thinking = v.thinkingEffort;
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "companies" ADD COLUMN "attachment_max_bytes" integer DEFAULT 1073741824 NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE UNIQUE INDEX "issues_active_productivity_review_uq" ON "issues" USING btree ("company_id","origin_kind","origin_id") WHERE "issues"."origin_kind" = 'issue_productivity_review'
|
||||
and "issues"."origin_id" is not null
|
||||
and "issues"."hidden_at" is null
|
||||
and "issues"."status" not in ('done', 'cancelled');
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -512,6 +512,20 @@
|
||||
"when": 1777305216238,
|
||||
"tag": "0072_large_sandman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 73,
|
||||
"version": "7",
|
||||
"when": 1777382021347,
|
||||
"tag": "0073_shiny_salo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 74,
|
||||
"version": "7",
|
||||
"when": 1777384535070,
|
||||
"tag": "0074_striped_genesis",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ export const companies = pgTable(
|
||||
issueCounter: integer("issue_counter").notNull().default(0),
|
||||
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
|
||||
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
|
||||
attachmentMaxBytes: integer("attachment_max_bytes")
|
||||
.notNull()
|
||||
.default(1024 * 1024 * 1024),
|
||||
requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
@@ -115,6 +115,14 @@ export const issues = pgTable(
|
||||
and ${table.hiddenAt} is null
|
||||
and ${table.status} not in ('done', 'cancelled')`,
|
||||
),
|
||||
activeProductivityReviewIdx: uniqueIndex("issues_active_productivity_review_uq")
|
||||
.on(table.companyId, table.originKind, table.originId)
|
||||
.where(
|
||||
sql`${table.originKind} = 'issue_productivity_review'
|
||||
and ${table.originId} is not null
|
||||
and ${table.hiddenAt} is null
|
||||
and ${table.status} not in ('done', 'cancelled')`,
|
||||
),
|
||||
activeStrandedIssueRecoveryIdx: uniqueIndex("issues_active_stranded_issue_recovery_uq")
|
||||
.on(table.companyId, table.originKind, table.originId)
|
||||
.where(
|
||||
|
||||
@@ -26,6 +26,20 @@ describe("dynamic adapter type validation schemas", () => {
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts an explicit managed instructions bundle for new agents", () => {
|
||||
expect(
|
||||
createAgentSchema.parse({
|
||||
name: "Bundle Agent",
|
||||
adapterType: "codex_local",
|
||||
instructionsBundle: {
|
||||
files: {
|
||||
"AGENTS.md": "Use AGENTS.md.",
|
||||
},
|
||||
},
|
||||
}).instructionsBundle?.files["AGENTS.md"],
|
||||
).toBe("Use AGENTS.md.");
|
||||
});
|
||||
|
||||
it("accepts external adapter types in invite acceptance schema", () => {
|
||||
expect(
|
||||
acceptInviteSchema.parse({
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const COMPANY_STATUSES = ["active", "paused", "archived"] as const;
|
||||
export type CompanyStatus = (typeof COMPANY_STATUSES)[number];
|
||||
|
||||
export const DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
export const MAX_COMPANY_ATTACHMENT_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
export const DEPLOYMENT_MODES = ["local_trusted", "authenticated"] as const;
|
||||
export type DeploymentMode = (typeof DEPLOYMENT_MODES)[number];
|
||||
|
||||
@@ -138,6 +141,12 @@ export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join(","
|
||||
|
||||
export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
|
||||
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
|
||||
export const MAX_ISSUE_REQUEST_DEPTH = 1024;
|
||||
|
||||
export function clampIssueRequestDepth(value: number | null | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
||||
return Math.min(MAX_ISSUE_REQUEST_DEPTH, Math.max(0, Math.floor(value)));
|
||||
}
|
||||
|
||||
export const ISSUE_THREAD_INTERACTION_KINDS = [
|
||||
"suggest_tasks",
|
||||
@@ -164,7 +173,14 @@ export const ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES = [
|
||||
export type IssueThreadInteractionContinuationPolicy =
|
||||
(typeof ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES)[number];
|
||||
|
||||
export const ISSUE_ORIGIN_KINDS = ["manual", "routine_execution", "stale_active_run_evaluation"] as const;
|
||||
export const ISSUE_ORIGIN_KINDS = [
|
||||
"manual",
|
||||
"routine_execution",
|
||||
"stale_active_run_evaluation",
|
||||
"harness_liveness_escalation",
|
||||
"issue_productivity_review",
|
||||
"stranded_issue_recovery",
|
||||
] as const;
|
||||
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
|
||||
export type PluginIssueOriginKind = `plugin:${string}`;
|
||||
export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
|
||||
export {
|
||||
COMPANY_STATUSES,
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
DEPLOYMENT_MODES,
|
||||
DEPLOYMENT_EXPOSURES,
|
||||
BIND_MODES,
|
||||
@@ -16,6 +18,8 @@ export {
|
||||
INBOX_MINE_ISSUE_STATUSES,
|
||||
INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
ISSUE_PRIORITIES,
|
||||
MAX_ISSUE_REQUEST_DEPTH,
|
||||
clampIssueRequestDepth,
|
||||
ISSUE_THREAD_INTERACTION_KINDS,
|
||||
ISSUE_THREAD_INTERACTION_STATUSES,
|
||||
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
|
||||
@@ -329,6 +333,8 @@ export type {
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
IssueReferenceSource,
|
||||
IssueRelatedWorkItem,
|
||||
IssueRelatedWorkSummary,
|
||||
|
||||
@@ -46,8 +46,10 @@ describe("routine variable helpers", () => {
|
||||
|
||||
it("identifies built-in variable names", () => {
|
||||
expect(isBuiltinRoutineVariable("date")).toBe(true);
|
||||
expect(isBuiltinRoutineVariable("timestamp")).toBe(true);
|
||||
expect(isBuiltinRoutineVariable("repo")).toBe(false);
|
||||
expect(BUILTIN_ROUTINE_VARIABLE_NAMES.has("date")).toBe(true);
|
||||
expect(BUILTIN_ROUTINE_VARIABLE_NAMES.has("timestamp")).toBe(true);
|
||||
});
|
||||
|
||||
it("getBuiltinRoutineVariableValues returns date in YYYY-MM-DD format", () => {
|
||||
@@ -56,9 +58,17 @@ describe("routine variable helpers", () => {
|
||||
expect(values.date).toBe(new Date().toISOString().slice(0, 10));
|
||||
});
|
||||
|
||||
it("getBuiltinRoutineVariableValues returns a human-readable timestamp with year, time, and UTC", () => {
|
||||
const values = getBuiltinRoutineVariableValues();
|
||||
const year = String(new Date().getUTCFullYear());
|
||||
expect(values.timestamp).toContain(year);
|
||||
expect(values.timestamp).toMatch(/\d{1,2}:\d{2}\s?(AM|PM)/);
|
||||
expect(values.timestamp).toContain("UTC");
|
||||
});
|
||||
|
||||
it("excludes built-in variables from syncRoutineVariablesWithTemplate", () => {
|
||||
const result = syncRoutineVariablesWithTemplate(
|
||||
"Daily report for {{date}} — {{repo}}",
|
||||
"Daily report for {{date}} at {{timestamp}} — {{repo}}",
|
||||
[],
|
||||
);
|
||||
expect(result).toEqual([
|
||||
@@ -66,11 +76,11 @@ describe("routine variable helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("interpolates built-in date variable alongside user variables", () => {
|
||||
it("interpolates built-in variables alongside user variables", () => {
|
||||
const builtins = getBuiltinRoutineVariableValues();
|
||||
const allVars = { ...builtins, repo: "paperclip" };
|
||||
expect(
|
||||
interpolateRoutineTemplate("Report for {{date}} on {{repo}}", allVars),
|
||||
).toBe(`Report for ${builtins.date} on paperclip`);
|
||||
interpolateRoutineTemplate("Report for {{date}} ({{timestamp}}) on {{repo}}", allVars),
|
||||
).toBe(`Report for ${builtins.date} (${builtins.timestamp}) on paperclip`);
|
||||
});
|
||||
});
|
||||
@@ -7,19 +7,33 @@ type RoutineTemplateInput = string | null | undefined | Array<string | null | un
|
||||
* Built-in variable names that are automatically available in routine templates
|
||||
* without needing to be defined in the routine's variables list.
|
||||
*/
|
||||
export const BUILTIN_ROUTINE_VARIABLE_NAMES = new Set(["date"]);
|
||||
export const BUILTIN_ROUTINE_VARIABLE_NAMES = new Set(["date", "timestamp"]);
|
||||
|
||||
export function isBuiltinRoutineVariable(name: string): boolean {
|
||||
return BUILTIN_ROUTINE_VARIABLE_NAMES.has(name);
|
||||
}
|
||||
|
||||
const HUMAN_TIMESTAMP_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
timeZone: "UTC",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns current values for all built-in routine variables.
|
||||
* `date` expands to the current date in YYYY-MM-DD format (UTC).
|
||||
* `timestamp` expands to a human-readable date and time (e.g. "April 28, 2026 at 12:17 PM UTC").
|
||||
*/
|
||||
export function getBuiltinRoutineVariableValues(): Record<string, string> {
|
||||
const now = new Date();
|
||||
return {
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
date: now.toISOString().slice(0, 10),
|
||||
timestamp: HUMAN_TIMESTAMP_FORMATTER.format(now),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface CompanyPortabilityCompanyManifestEntry {
|
||||
description: string | null;
|
||||
brandColor: string | null;
|
||||
logoPath: string | null;
|
||||
attachmentMaxBytes: number | null;
|
||||
requireBoardApprovalForNewAgents: boolean;
|
||||
feedbackDataSharingEnabled: boolean;
|
||||
feedbackDataSharingConsentAt: string | null;
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Company {
|
||||
issueCounter: number;
|
||||
budgetMonthlyCents: number;
|
||||
spentMonthlyCents: number;
|
||||
attachmentMaxBytes?: number;
|
||||
requireBoardApprovalForNewAgents: boolean;
|
||||
feedbackDataSharingEnabled: boolean;
|
||||
feedbackDataSharingConsentAt: Date | null;
|
||||
|
||||
@@ -136,6 +136,8 @@ export type {
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
IssueReferenceSource,
|
||||
IssueRelatedWorkItem,
|
||||
IssueRelatedWorkSummary,
|
||||
|
||||
@@ -139,6 +139,22 @@ export interface IssueBlockerAttention {
|
||||
sampleStalledBlockerIdentifier: string | null;
|
||||
}
|
||||
|
||||
export type IssueProductivityReviewTrigger =
|
||||
| "no_comment_streak"
|
||||
| "long_active_duration"
|
||||
| "high_churn";
|
||||
|
||||
export interface IssueProductivityReview {
|
||||
reviewIssueId: string;
|
||||
reviewIdentifier: string | null;
|
||||
status: IssueStatus;
|
||||
priority: IssuePriority;
|
||||
trigger: IssueProductivityReviewTrigger | null;
|
||||
noCommentStreak: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface IssueRelation {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -264,6 +280,7 @@ export interface Issue {
|
||||
blockedBy?: IssueRelationIssueSummary[];
|
||||
blocks?: IssueRelationIssueSummary[];
|
||||
blockerAttention?: IssueBlockerAttention;
|
||||
productivityReview?: IssueProductivityReview | null;
|
||||
relatedWork?: IssueRelatedWorkSummary;
|
||||
referencedIssueIdentifiers?: string[];
|
||||
planDocument?: IssueDocument | null;
|
||||
|
||||
@@ -44,6 +44,13 @@ const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
export const createAgentInstructionsBundleSchema = z.object({
|
||||
entryFile: z.string().trim().min(1).optional(),
|
||||
files: z.record(z.string()).refine((files) => Object.keys(files).length > 0, {
|
||||
message: "instructionsBundle.files must contain at least one file",
|
||||
}),
|
||||
});
|
||||
|
||||
export const createAgentSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
role: z.enum(AGENT_ROLES).optional().default("general"),
|
||||
@@ -54,6 +61,7 @@ export const createAgentSchema = z.object({
|
||||
desiredSkills: z.array(z.string().min(1)).optional(),
|
||||
adapterType: agentAdapterTypeSchema,
|
||||
adapterConfig: adapterConfigSchema.optional().default({}),
|
||||
instructionsBundle: createAgentInstructionsBundleSchema.optional(),
|
||||
runtimeConfig: z.record(z.unknown()).optional().default({}),
|
||||
defaultEnvironmentId: z.string().uuid().optional().nullable(),
|
||||
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { MAX_COMPANY_ATTACHMENT_MAX_BYTES } from "../constants.js";
|
||||
import { routineVariableSchema } from "./routine.js";
|
||||
|
||||
export const portabilityIncludeSchema = z
|
||||
@@ -37,6 +38,7 @@ export const portabilityCompanyManifestEntrySchema = z.object({
|
||||
description: z.string().nullable(),
|
||||
brandColor: z.string().nullable(),
|
||||
logoPath: z.string().nullable(),
|
||||
attachmentMaxBytes: z.number().int().min(1).max(MAX_COMPANY_ATTACHMENT_MAX_BYTES).nullable().default(null),
|
||||
requireBoardApprovalForNewAgents: z.boolean(),
|
||||
feedbackDataSharingEnabled: z.boolean().default(false),
|
||||
feedbackDataSharingConsentAt: z.string().datetime().nullable().default(null),
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { z } from "zod";
|
||||
import { COMPANY_STATUSES } from "../constants.js";
|
||||
import {
|
||||
COMPANY_STATUSES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
} from "../constants.js";
|
||||
|
||||
const logoAssetIdSchema = z.string().uuid().nullable().optional();
|
||||
const brandColorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional();
|
||||
const feedbackDataSharingTermsVersionSchema = z.string().min(1).nullable().optional();
|
||||
const attachmentMaxBytesSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(MAX_COMPANY_ATTACHMENT_MAX_BYTES);
|
||||
|
||||
export const createCompanySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional().nullable(),
|
||||
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
|
||||
attachmentMaxBytes: attachmentMaxBytesSchema.optional(),
|
||||
});
|
||||
|
||||
export type CreateCompany = z.infer<typeof createCompanySchema>;
|
||||
@@ -25,6 +34,7 @@ export const updateCompanySchema = createCompanySchema
|
||||
feedbackDataSharingTermsVersion: feedbackDataSharingTermsVersionSchema,
|
||||
brandColor: brandColorSchema,
|
||||
logoAssetId: logoAssetIdSchema,
|
||||
attachmentMaxBytes: attachmentMaxBytesSchema.optional(),
|
||||
});
|
||||
|
||||
export type UpdateCompany = z.infer<typeof updateCompanySchema>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MAX_ISSUE_REQUEST_DEPTH } from "../index.js";
|
||||
import {
|
||||
addIssueCommentSchema,
|
||||
createIssueSchema,
|
||||
@@ -75,4 +76,21 @@ describe("issue validators", () => {
|
||||
expect(response.summaryMarkdown).toBe("Summary\n\nNext action");
|
||||
expect(document.body).toBe("# Plan\n\nShip it");
|
||||
});
|
||||
|
||||
it("clamps oversized requestDepth values on create", () => {
|
||||
const parsed = createIssueSchema.parse({
|
||||
title: "Clamp request depth",
|
||||
requestDepth: MAX_ISSUE_REQUEST_DEPTH + 500,
|
||||
});
|
||||
|
||||
expect(parsed.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
|
||||
});
|
||||
|
||||
it("clamps oversized requestDepth values on update", () => {
|
||||
const parsed = updateIssueSchema.parse({
|
||||
requestDepth: MAX_ISSUE_REQUEST_DEPTH + 1,
|
||||
});
|
||||
|
||||
expect(parsed.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ISSUE_EXECUTION_STAGE_TYPES,
|
||||
ISSUE_EXECUTION_STATE_STATUSES,
|
||||
ISSUE_PRIORITIES,
|
||||
clampIssueRequestDepth,
|
||||
ISSUE_STATUSES,
|
||||
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
|
||||
ISSUE_THREAD_INTERACTION_KINDS,
|
||||
@@ -123,6 +124,12 @@ export const issueExecutionStateSchema = z.object({
|
||||
lastDecisionOutcome: z.enum(ISSUE_EXECUTION_DECISION_OUTCOMES).nullable(),
|
||||
});
|
||||
|
||||
const issueRequestDepthInputSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.transform((value) => clampIssueRequestDepth(value));
|
||||
|
||||
export const createIssueSchema = z.object({
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
projectWorkspaceId: z.string().uuid().optional().nullable(),
|
||||
@@ -136,7 +143,7 @@ export const createIssueSchema = z.object({
|
||||
priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"),
|
||||
assigneeAgentId: z.string().uuid().optional().nullable(),
|
||||
assigneeUserId: z.string().optional().nullable(),
|
||||
requestDepth: z.number().int().nonnegative().optional().default(0),
|
||||
requestDepth: issueRequestDepthInputSchema.optional().default(0),
|
||||
billingCode: z.string().optional().nullable(),
|
||||
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
|
||||
executionPolicy: issueExecutionPolicySchema.optional().nullable(),
|
||||
@@ -168,6 +175,7 @@ export const createIssueLabelSchema = z.object({
|
||||
export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
|
||||
|
||||
export const updateIssueSchema = createIssueSchema.partial().extend({
|
||||
requestDepth: issueRequestDepthInputSchema.optional(),
|
||||
assigneeAgentId: z.string().trim().min(1).optional().nullable(),
|
||||
comment: multilineTextSchema.pipe(z.string().min(1)).optional(),
|
||||
reviewRequest: issueReviewRequestSchema.optional().nullable(),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const baseUrl = (process.env.PAPERCLIP_PERF_BASE_URL || "http://localhost:3100").replace(/\/$/, "");
|
||||
const companyPrefix = process.env.PAPERCLIP_PERF_COMPANY_PREFIX;
|
||||
const url = companyPrefix
|
||||
? `${baseUrl}/${companyPrefix}/tests/perf/long-thread`
|
||||
: `${baseUrl}/tests/perf/long-thread`;
|
||||
const origin = new URL(url).origin;
|
||||
|
||||
function loadBoardToken() {
|
||||
const authPath = path.resolve(os.homedir(), ".paperclip/auth.json");
|
||||
try {
|
||||
const auth = JSON.parse(fs.readFileSync(authPath, "utf-8"));
|
||||
const credentials = auth.credentials || {};
|
||||
const matching = Object.values(credentials).find((entry) => {
|
||||
if (!entry || !entry.token || !entry.apiBase) return false;
|
||||
return new URL(entry.apiBase).origin === origin;
|
||||
});
|
||||
if (matching?.token) return matching.token;
|
||||
const fallback = Object.values(credentials).find((entry) => entry?.token);
|
||||
return fallback?.token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
const boardToken = process.env.PAPERCLIP_PERF_BEARER_TOKEN || loadBoardToken();
|
||||
|
||||
if (boardToken) {
|
||||
await page.route(`${origin}/**`, async (route) => {
|
||||
await route.continue({
|
||||
headers: { ...route.request().headers(), Authorization: `Bearer ${boardToken}` },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector('[data-testid="issue-chat-long-thread-perf"]', { timeout: 30_000 });
|
||||
await page.waitForFunction(() => {
|
||||
const target = Number(document.querySelector('[data-testid="perf-fixture-row-target"]')?.textContent ?? "450");
|
||||
const renderedRows = document.querySelectorAll('[data-testid="issue-chat-message-row"]').length;
|
||||
const virtualizer = document.querySelector('[data-testid="issue-chat-thread-virtualizer"]');
|
||||
if (!virtualizer) return renderedRows >= target;
|
||||
const virtualCount = Number(virtualizer.getAttribute("data-virtual-count") ?? "0");
|
||||
return virtualCount >= target && renderedRows > 0 && renderedRows < target;
|
||||
}, null, { timeout: 60_000 });
|
||||
const rowReadyMs = Date.now() - startedAt;
|
||||
|
||||
const metrics = await page.evaluate(async () => {
|
||||
const text = (testId) => document.querySelector(`[data-testid="${testId}"]`)?.textContent?.trim() ?? "";
|
||||
const numericMs = (testId) => {
|
||||
const value = text(testId).replace(/\s*ms$/, "");
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const rowCount = document.querySelectorAll('[data-testid="issue-chat-message-row"]').length;
|
||||
const virtualizer = document.querySelector('[data-testid="issue-chat-thread-virtualizer"]');
|
||||
const virtualCount = Number(virtualizer?.getAttribute("data-virtual-count") ?? "0");
|
||||
const assistantRowCount = document.querySelectorAll('[data-testid="issue-chat-message-row"][data-message-role="assistant"]').length;
|
||||
const systemRowCount = document.querySelectorAll('[data-testid="issue-chat-message-row"][data-message-role="system"]').length;
|
||||
const userRowCount = document.querySelectorAll('[data-testid="issue-chat-message-row"][data-message-role="user"]').length;
|
||||
const markdownRows = Number(text("perf-fixture-markdown-rows"));
|
||||
const commitCount = Number(text("perf-commit-count"));
|
||||
const scrollStartY = window.scrollY;
|
||||
const scrollTarget = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
||||
const scrollStartedAt = performance.now();
|
||||
window.scrollTo({ top: scrollTarget, behavior: "instant" });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const scrollResponsiveMs = performance.now() - scrollStartedAt;
|
||||
|
||||
return {
|
||||
url: window.location.href,
|
||||
fixtureRowTarget: Number(text("perf-fixture-row-target")),
|
||||
virtualized: Boolean(virtualizer),
|
||||
virtualCount,
|
||||
rowCount,
|
||||
assistantRowCount,
|
||||
userRowCount,
|
||||
systemRowCount,
|
||||
markdownRows,
|
||||
commitCount,
|
||||
mountActualDurationMs: numericMs("perf-mount-duration"),
|
||||
latestActualDurationMs: numericMs("perf-latest-duration"),
|
||||
maxActualDurationMs: numericMs("perf-max-duration"),
|
||||
totalActualDurationMs: numericMs("perf-total-duration"),
|
||||
reactProfilerAvailable: commitCount > 0,
|
||||
scrollResponsiveMs: Number(scrollResponsiveMs.toFixed(1)),
|
||||
scrollDeltaPx: Math.round(Math.abs(window.scrollY - scrollStartY)),
|
||||
documentHeightPx: Math.round(document.documentElement.scrollHeight),
|
||||
};
|
||||
});
|
||||
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
console.log(JSON.stringify({ ...metrics, renderReadyMs: rowReadyMs, elapsedMs }, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -481,8 +481,11 @@ describe.sequential("agent skill routes", () => {
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
promptTemplate: "You are QA.",
|
||||
adapterConfig: {},
|
||||
instructionsBundle: {
|
||||
files: {
|
||||
"AGENTS.md": "You are QA.",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -504,6 +507,26 @@ describe.sequential("agent skill routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects legacy prompt templates for directly created local agents", async () => {
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
instructionsFilePath: "/tmp/existing/AGENTS.md",
|
||||
promptTemplate: "You are QA.",
|
||||
bootstrapPromptTemplate: "Bootstrap QA.",
|
||||
},
|
||||
}));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body.error).toContain("New agents must use instructionsBundle/AGENTS.md");
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("materializes the bundled CEO instruction set for default CEO agents", async () => {
|
||||
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
@@ -652,8 +675,11 @@ describe.sequential("agent skill routes", () => {
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
promptTemplate: "You are QA.",
|
||||
adapterConfig: {},
|
||||
instructionsBundle: {
|
||||
files: {
|
||||
"AGENTS.md": "You are QA.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -675,4 +701,24 @@ describe.sequential("agent skill routes", () => {
|
||||
| undefined;
|
||||
expect(approvalInput?.payload?.adapterConfig?.promptTemplate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects legacy prompt templates for hire approval payloads", async () => {
|
||||
const res = await request(await createApp(createDb(true)))
|
||||
.post("/api/companies/company-1/agent-hires")
|
||||
.send({
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
instructionsFilePath: "/tmp/existing/AGENTS.md",
|
||||
promptTemplate: "You are QA.",
|
||||
bootstrapPromptTemplate: "Bootstrap QA.",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body.error).toContain("New agents must use instructionsBundle/AGENTS.md");
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -672,7 +672,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const childIssueId = randomUUID();
|
||||
const issueChain = Array.from({ length: 17 }, () => randomUUID());
|
||||
const deepDescendantIssueId = issueChain.at(-1)!;
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
@@ -705,15 +706,15 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: childIssueId,
|
||||
...issueChain.map((issueId, index) => ({
|
||||
id: issueId,
|
||||
companyId,
|
||||
parentId: rootIssueId,
|
||||
title: "Paused child",
|
||||
parentId: index === 0 ? rootIssueId : issueChain[index - 1],
|
||||
title: `Paused desc ${index + 1}`,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
})),
|
||||
]);
|
||||
const [hold] = await db
|
||||
.insert(issueTreeHolds)
|
||||
@@ -731,8 +732,8 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_blockers_resolved",
|
||||
payload: { issueId: childIssueId },
|
||||
contextSnapshot: { issueId: childIssueId, wakeReason: "issue_blockers_resolved" },
|
||||
payload: { issueId: deepDescendantIssueId },
|
||||
contextSnapshot: { issueId: deepDescendantIssueId, wakeReason: "issue_blockers_resolved" },
|
||||
});
|
||||
|
||||
expect(blockedWake).toBeNull();
|
||||
@@ -742,7 +743,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
reason: agentWakeupRequests.reason,
|
||||
})
|
||||
.from(agentWakeupRequests)
|
||||
.where(sql`${agentWakeupRequests.payload} ->> 'issueId' = ${childIssueId}`)
|
||||
.where(sql`${agentWakeupRequests.payload} ->> 'issueId' = ${deepDescendantIssueId}`)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(skippedWake).toMatchObject({ status: "skipped", reason: "issue_tree_hold_active" });
|
||||
|
||||
@@ -750,7 +751,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
await db.insert(issueComments).values({
|
||||
id: childCommentId,
|
||||
companyId,
|
||||
issueId: childIssueId,
|
||||
issueId: deepDescendantIssueId,
|
||||
authorUserId: "board-user",
|
||||
body: "Please respond while this hold is active.",
|
||||
});
|
||||
@@ -759,7 +760,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId: childIssueId, commentId: childCommentId },
|
||||
payload: { issueId: deepDescendantIssueId, commentId: childCommentId },
|
||||
requestedByActorType: "agent",
|
||||
requestedByActorId: agentId,
|
||||
});
|
||||
@@ -769,11 +770,11 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId: childIssueId, commentId: childCommentId },
|
||||
payload: { issueId: deepDescendantIssueId, commentId: childCommentId },
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "board-user",
|
||||
contextSnapshot: {
|
||||
issueId: childIssueId,
|
||||
issueId: deepDescendantIssueId,
|
||||
commentId: childCommentId,
|
||||
wakeCommentId: childCommentId,
|
||||
wakeReason: "issue_commented",
|
||||
|
||||
@@ -472,6 +472,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
retryReason?: "assignment_recovery" | "issue_continuation_needed" | null;
|
||||
assignToUser?: boolean;
|
||||
activePauseHold?: boolean;
|
||||
livenessState?: "completed" | "advanced" | "plan_only" | "empty_response" | "blocked" | "failed" | "needs_followup" | null;
|
||||
runErrorCode?: string | null;
|
||||
runError?: string | null;
|
||||
}) {
|
||||
@@ -545,6 +546,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
error: input.runStatus === "succeeded"
|
||||
? null
|
||||
: ("runError" in input ? input.runError : "run failed before issue advanced"),
|
||||
livenessState: input.livenessState ?? null,
|
||||
});
|
||||
|
||||
await db.insert(issues).values([
|
||||
@@ -1417,6 +1419,59 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["failed", "adapter_failed"],
|
||||
["failed", "process_lost"],
|
||||
["timed_out", "adapter_timed_out"],
|
||||
] as const)(
|
||||
"re-enqueues stranded in-progress work after a %s/%s run before escalating",
|
||||
async (runStatus, runErrorCode) => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus,
|
||||
runErrorCode,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.dispatchRequeued).toBe(0);
|
||||
expect(result.continuationRequeued).toBe(1);
|
||||
expect(result.escalated).toBe(0);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(2);
|
||||
|
||||
const retryRun = runs.find((row) => row.id !== runId);
|
||||
expect(retryRun?.contextSnapshot as Record<string, unknown> | undefined).toMatchObject({
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
retryReason: "issue_continuation_needed",
|
||||
retryOfRunId: runId,
|
||||
source: "issue.continuation_recovery",
|
||||
});
|
||||
|
||||
const recoveries = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, "stranded_issue_recovery"),
|
||||
eq(issues.originId, issueId),
|
||||
),
|
||||
);
|
||||
expect(recoveries).toHaveLength(0);
|
||||
|
||||
if (retryRun?.id) {
|
||||
await waitForRunToSettle(heartbeat, retryRun.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("still re-enqueues stranded assigned todo recovery when an old queued wake exists", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "todo",
|
||||
@@ -2055,18 +2110,21 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(wakeups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("re-enqueues continuation when the latest automatic continuation succeeded without closing the issue", async () => {
|
||||
it("records productive continuation instead of recovery when the latest automatic continuation succeeded", async () => {
|
||||
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
retryReason: "issue_continuation_needed",
|
||||
livenessState: "advanced",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.continuationRequeued).toBe(1);
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
expect(result.productiveContinuationObserved).toBe(1);
|
||||
expect(result.successfulContinuationObserved).toBe(0);
|
||||
expect(result.escalated).toBe(0);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
expect(result.issueIds).toEqual([]);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
|
||||
expect(issue?.status).toBe("in_progress");
|
||||
@@ -2078,14 +2136,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(2);
|
||||
expect(runs.map((row) => row.id)).toEqual([runId]);
|
||||
|
||||
const retryRun = runs.find((row) => row.id !== runId);
|
||||
expect(retryRun?.id).toBeTruthy();
|
||||
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("issue_continuation_needed");
|
||||
if (retryRun) {
|
||||
await waitForRunToSettle(heartbeat, retryRun.id);
|
||||
}
|
||||
const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));
|
||||
expect(wakeups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => {
|
||||
|
||||
@@ -36,6 +36,10 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCompanyService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockDocumentService = vi.hoisted(() => ({
|
||||
upsertIssueDocument: vi.fn(),
|
||||
}));
|
||||
@@ -94,6 +98,7 @@ function registerRouteMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
companyService: () => mockCompanyService,
|
||||
documentService: () => mockDocumentService,
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
@@ -244,6 +249,7 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
mockAgentService.getById.mockReset();
|
||||
mockAgentService.list.mockReset();
|
||||
mockAgentService.resolveByReference.mockReset();
|
||||
mockCompanyService.getById.mockReset();
|
||||
mockIssueService.addComment.mockReset();
|
||||
mockIssueService.assertCheckoutOwner.mockReset();
|
||||
mockIssueService.getAttachmentById.mockReset();
|
||||
@@ -276,6 +282,7 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
makeAgent(peerAgentId),
|
||||
]);
|
||||
mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: null });
|
||||
mockCompanyService.getById.mockResolvedValue({ id: companyId, issuePrefix: "PAP" });
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue());
|
||||
mockIssueService.getByIdentifier.mockResolvedValue(null);
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
@@ -430,18 +437,20 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
expect(mockIssueService.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows same-company agent mutations when the issue is not in progress", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: ownerAgentId }));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeIssue({ status: "todo", assigneeAgentId: ownerAgentId }),
|
||||
...patch,
|
||||
}));
|
||||
it.each([
|
||||
["todo", "patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Todo update" })],
|
||||
["todo", "comment", (app: express.Express) => request(app).post(`/api/issues/${issueId}/comments`).send({ body: "Todo noise" })],
|
||||
["blocked", "patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Blocked update" })],
|
||||
])("rejects peer agent %s issue %s mutations outside active checkout ownership", async (status, _kind, sendRequest) => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue({ status: status as "todo" | "blocked", assigneeAgentId: ownerAgentId }));
|
||||
|
||||
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Todo update" });
|
||||
const res = await sendRequest(await createApp(peerActor()));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Agent cannot mutate another agent's issue");
|
||||
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.update).toHaveBeenCalled();
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows same-company agent mutations on unassigned in-progress issues", async () => {
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
createAttachment: vi.fn(),
|
||||
getAttachmentById: vi.fn(),
|
||||
}));
|
||||
const mockCompanyService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
@@ -39,6 +42,7 @@ function registerRouteMocks() {
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
companyService: () => mockCompanyService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
@@ -180,6 +184,10 @@ describe("issue attachment routes", () => {
|
||||
registerRouteMocks();
|
||||
vi.clearAllMocks();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockCompanyService.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
attachmentMaxBytes: 1024 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts zip uploads for issue attachments", async () => {
|
||||
@@ -215,6 +223,49 @@ describe("issue attachment routes", () => {
|
||||
expect(res.body.contentType).toBe("application/zip");
|
||||
});
|
||||
|
||||
it("allows issue attachments larger than the legacy 10 MiB process default when company limit allows it", async () => {
|
||||
const storage = createStorageService();
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
});
|
||||
mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/octet-stream", "large.bin"));
|
||||
|
||||
const app = await createApp(storage);
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
|
||||
.attach("file", Buffer.alloc(10 * 1024 * 1024 + 1), {
|
||||
filename: "large.bin",
|
||||
contentType: "application/octet-stream",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(storage.__calls.putFile?.body.length).toBe(10 * 1024 * 1024 + 1);
|
||||
});
|
||||
|
||||
it("enforces the configured per-company issue attachment limit", async () => {
|
||||
const storage = createStorageService();
|
||||
mockCompanyService.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
attachmentMaxBytes: 4,
|
||||
});
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
});
|
||||
|
||||
const app = await createApp(storage);
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
|
||||
.attach("file", Buffer.from("large"), { filename: "large.txt", contentType: "text/plain" });
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Attachment exceeds 4 bytes");
|
||||
expect(mockIssueService.createAttachment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves html attachments as downloads with nosniff", async () => {
|
||||
const storage = createStorageService();
|
||||
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("text/html", "report.html"));
|
||||
|
||||
@@ -355,4 +355,44 @@ describe("issue tree control routes", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns resume operations as released holds and avoids cancellation side effects", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-2"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
mockTreeControlService.createHold.mockResolvedValue({
|
||||
hold: {
|
||||
id: "77777777-7777-4777-8777-777777777777",
|
||||
mode: "resume",
|
||||
status: "released",
|
||||
reason: "resume subtree",
|
||||
},
|
||||
preview: {
|
||||
mode: "resume",
|
||||
totals: {
|
||||
affectedIssues: 1,
|
||||
},
|
||||
warnings: [],
|
||||
activeRuns: [],
|
||||
},
|
||||
resumedPauseHoldIds: ["33333333-3333-4333-8333-333333333333"],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds")
|
||||
.send({ mode: "resume", reason: "resume subtree" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hold.mode).toBe("resume");
|
||||
expect(res.body.hold.status).toBe("released");
|
||||
expect(res.body.resumedPauseHoldIds).toEqual(["33333333-3333-4333-8333-333333333333"]);
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
expect(mockTreeControlService.cancelUnclaimedWakeupsForTree).not.toHaveBeenCalled();
|
||||
expect(mockTreeControlService.cancelIssueStatusesForHold).not.toHaveBeenCalled();
|
||||
expect(mockTreeControlService.restoreIssueStatusesForHold).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -337,19 +337,20 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks normal checkout but allows comment interaction checkout under a pause hold", async () => {
|
||||
it("walks pause-hold ancestry beyond 15 levels for checkout and interaction waives", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const childIssueId = randomUUID();
|
||||
const issuePath = Array.from({ length: 17 }, () => randomUUID());
|
||||
const rootIssueId = issuePath[0];
|
||||
const deepDescendantIssueId = issuePath.at(-1)!;
|
||||
const rootRunId = randomUUID();
|
||||
const childRunId = randomUUID();
|
||||
const deepDescendantRunId = randomUUID();
|
||||
const forgedRunId = randomUUID();
|
||||
const rootWakeupRequestId = randomUUID();
|
||||
const childWakeupRequestId = randomUUID();
|
||||
const deepDescendantWakeupRequestId = randomUUID();
|
||||
const forgedWakeupRequestId = randomUUID();
|
||||
const rootCommentId = randomUUID();
|
||||
const childCommentId = randomUUID();
|
||||
const deepDescendantCommentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
@@ -368,25 +369,17 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: rootIssueId,
|
||||
await db.insert(issues).values(
|
||||
issuePath.map((issueId, index) => ({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Paused root",
|
||||
parentId: index > 0 ? issuePath[index - 1] : null,
|
||||
title: `Issue ${index}`,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: childIssueId,
|
||||
companyId,
|
||||
parentId: rootIssueId,
|
||||
title: "Paused child",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
]);
|
||||
})),
|
||||
);
|
||||
await db.insert(issueComments).values([
|
||||
{
|
||||
id: rootCommentId,
|
||||
@@ -396,11 +389,11 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
||||
body: "Please answer this root issue question.",
|
||||
},
|
||||
{
|
||||
id: childCommentId,
|
||||
id: deepDescendantCommentId,
|
||||
companyId,
|
||||
issueId: childIssueId,
|
||||
issueId: deepDescendantIssueId,
|
||||
authorUserId: "board-user",
|
||||
body: "Please answer this child issue question.",
|
||||
body: "Please answer this deep descendant issue question.",
|
||||
},
|
||||
]);
|
||||
await db.insert(agentWakeupRequests).values([
|
||||
@@ -424,24 +417,24 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId: childIssueId, commentId: childCommentId },
|
||||
payload: { issueId: deepDescendantIssueId, commentId: deepDescendantCommentId },
|
||||
status: "queued",
|
||||
requestedByActorType: "agent",
|
||||
requestedByActorId: agentId,
|
||||
runId: forgedRunId,
|
||||
},
|
||||
{
|
||||
id: childWakeupRequestId,
|
||||
id: deepDescendantWakeupRequestId,
|
||||
companyId,
|
||||
agentId,
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId: childIssueId, commentId: childCommentId },
|
||||
payload: { issueId: deepDescendantIssueId, commentId: deepDescendantCommentId },
|
||||
status: "queued",
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: "board-user",
|
||||
runId: childRunId,
|
||||
runId: deepDescendantRunId,
|
||||
},
|
||||
]);
|
||||
await db.insert(heartbeatRuns).values([
|
||||
@@ -470,25 +463,25 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
||||
status: "queued",
|
||||
wakeupRequestId: forgedWakeupRequestId,
|
||||
contextSnapshot: {
|
||||
issueId: childIssueId,
|
||||
issueId: deepDescendantIssueId,
|
||||
wakeReason: "issue_commented",
|
||||
commentId: childCommentId,
|
||||
wakeCommentId: childCommentId,
|
||||
commentId: deepDescendantCommentId,
|
||||
wakeCommentId: deepDescendantCommentId,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: childRunId,
|
||||
id: deepDescendantRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
wakeupRequestId: childWakeupRequestId,
|
||||
wakeupRequestId: deepDescendantWakeupRequestId,
|
||||
contextSnapshot: {
|
||||
issueId: childIssueId,
|
||||
issueId: deepDescendantIssueId,
|
||||
wakeReason: "issue_commented",
|
||||
commentId: childCommentId,
|
||||
wakeCommentId: childCommentId,
|
||||
commentId: deepDescendantCommentId,
|
||||
wakeCommentId: deepDescendantCommentId,
|
||||
source: "issue.comment",
|
||||
},
|
||||
},
|
||||
@@ -500,16 +493,28 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
||||
reason: "operator requested pause",
|
||||
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
|
||||
});
|
||||
const deepDescendantGate = await treeSvc.getActivePauseHoldGate(companyId, deepDescendantIssueId);
|
||||
expect(deepDescendantGate).toMatchObject({
|
||||
holdId: expect.any(String),
|
||||
rootIssueId,
|
||||
issueId: deepDescendantIssueId,
|
||||
isRoot: false,
|
||||
mode: "pause",
|
||||
});
|
||||
|
||||
const issueSvc = issueService(db);
|
||||
await expect(issueSvc.checkout(childIssueId, agentId, ["todo"], randomUUID())).rejects.toMatchObject({
|
||||
await expect(
|
||||
issueSvc.checkout(deepDescendantIssueId, agentId, ["todo"], randomUUID()),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: expect.objectContaining({
|
||||
rootIssueId,
|
||||
mode: "pause",
|
||||
}),
|
||||
});
|
||||
await expect(issueSvc.checkout(childIssueId, agentId, ["todo"], forgedRunId)).rejects.toMatchObject({
|
||||
await expect(
|
||||
issueSvc.checkout(deepDescendantIssueId, agentId, ["todo"], forgedRunId),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: expect.objectContaining({
|
||||
rootIssueId,
|
||||
@@ -517,9 +522,9 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const checkedOutChild = await issueSvc.checkout(childIssueId, agentId, ["todo"], childRunId);
|
||||
const checkedOutChild = await issueSvc.checkout(deepDescendantIssueId, agentId, ["todo"], deepDescendantRunId);
|
||||
expect(checkedOutChild.status).toBe("in_progress");
|
||||
expect(checkedOutChild.checkoutRunId).toBe(childRunId);
|
||||
expect(checkedOutChild.checkoutRunId).toBe(deepDescendantRunId);
|
||||
|
||||
const checkedOutRoot = await issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId);
|
||||
expect(checkedOutRoot.status).toBe("in_progress");
|
||||
@@ -552,4 +557,86 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
||||
expect(checkedOutLegacyFullPauseRoot.status).toBe("in_progress");
|
||||
expect(checkedOutLegacyFullPauseRoot.checkoutRunId).toBe(rootRunId);
|
||||
});
|
||||
|
||||
it("resumes subtree pauses by releasing matching pause holds", async () => {
|
||||
const companyId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const childIssueId = randomUUID();
|
||||
const nonSubtreeIssueId = randomUUID();
|
||||
|
||||
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: rootIssueId,
|
||||
companyId,
|
||||
title: "Root",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
id: childIssueId,
|
||||
companyId,
|
||||
parentId: rootIssueId,
|
||||
title: "Child",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
id: nonSubtreeIssueId,
|
||||
companyId,
|
||||
title: "Unrelated",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
]);
|
||||
|
||||
const treeSvc = issueTreeControlService(db);
|
||||
const subtreePause = await treeSvc.createHold(companyId, childIssueId, {
|
||||
mode: "pause",
|
||||
reason: "pause child only",
|
||||
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
|
||||
});
|
||||
const nonSubtreePause = await treeSvc.createHold(companyId, nonSubtreeIssueId, {
|
||||
mode: "pause",
|
||||
reason: "pause unrelated issue",
|
||||
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
|
||||
});
|
||||
|
||||
const resumed = await treeSvc.createHold(companyId, rootIssueId, {
|
||||
mode: "resume",
|
||||
reason: "resume subtree",
|
||||
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
|
||||
});
|
||||
|
||||
expect(resumed.hold.mode).toBe("resume");
|
||||
expect(resumed.hold.status).toBe("released");
|
||||
expect(resumed.resumedPauseHoldIds).toEqual([subtreePause.hold.id]);
|
||||
|
||||
const rows = await db
|
||||
.select({ id: issueTreeHolds.id, status: issueTreeHolds.status, releaseMetadata: issueTreeHolds.releaseMetadata })
|
||||
.from(issueTreeHolds)
|
||||
.where(eq(issueTreeHolds.companyId, companyId));
|
||||
const byId = new Map(rows.map((row) => [row.id, row] as const));
|
||||
expect(byId.get(subtreePause.hold.id)?.status).toBe("released");
|
||||
expect(byId.get(nonSubtreePause.hold.id)?.status).toBe("active");
|
||||
expect(byId.get(resumed.hold.id)?.status).toBe("released");
|
||||
|
||||
const releaseMetadata = byId.get(subtreePause.hold.id)?.releaseMetadata as
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
expect(releaseMetadata).toMatchObject({
|
||||
resumedByResumeHoldId: resumed.hold.id,
|
||||
resumeHoldMode: "tree_resume",
|
||||
resumedPauseHoldId: subtreePause.hold.id,
|
||||
});
|
||||
expect((byId.get(resumed.hold.id)?.releaseMetadata as Record<string, unknown> | null)).toMatchObject({
|
||||
resumedPauseHoldIds: [subtreePause.hold.id],
|
||||
resumeMode: "subtree",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import { clampIssueListLimit, ISSUE_LIST_MAX_LIMIT, issueService } from "../services/issues.ts";
|
||||
import { buildProjectMentionHref } from "@paperclipai/shared";
|
||||
import { buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -1451,6 +1451,56 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("clamps helper-created child requestDepth to the safe maximum", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const parentIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Ship child helpers",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
goalId,
|
||||
name: "Workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: parentIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
goalId,
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
requestDepth: MAX_ISSUE_REQUEST_DEPTH,
|
||||
});
|
||||
|
||||
const { issue: child } = await svc.createChild(parentIssueId, {
|
||||
title: "Child helper",
|
||||
status: "todo",
|
||||
requestDepth: MAX_ISSUE_REQUEST_DEPTH + 100,
|
||||
});
|
||||
|
||||
expect(child.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => {
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared";
|
||||
import {
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
productivityReviewService,
|
||||
} from "../services/productivity-review.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres productivity review tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("productivity review service", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-productivity-review-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 30_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.execute(sql.raw(`TRUNCATE TABLE "companies" CASCADE`));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedAssignedIssue(opts?: {
|
||||
status?: "todo" | "in_progress";
|
||||
startedAt?: Date;
|
||||
parentId?: string | null;
|
||||
originKind?: string;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const managerId = randomUUID();
|
||||
const coderId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const issuePrefix = `PR${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const createdAt = new Date("2026-04-28T10:00:00.000Z");
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Productivity Review Co",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: managerId,
|
||||
companyId,
|
||||
name: "CTO",
|
||||
role: "cto",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: coderId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
reportsTo: managerId,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Implement data import",
|
||||
status: opts?.status ?? "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: coderId,
|
||||
parentId: opts?.parentId ?? null,
|
||||
originKind: opts?.originKind ?? "manual",
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
startedAt: opts?.startedAt ?? createdAt,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
|
||||
return { companyId, managerId, coderId, issueId, issuePrefix, createdAt };
|
||||
}
|
||||
|
||||
async function insertRuns(input: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
issueId: string;
|
||||
count: number;
|
||||
now: Date;
|
||||
withRunComments?: boolean;
|
||||
}) {
|
||||
const runs: Array<typeof heartbeatRuns.$inferInsert> = [];
|
||||
for (let index = 0; index < input.count; index += 1) {
|
||||
const runId = randomUUID();
|
||||
const createdAt = new Date(input.now.getTime() - index * 60_000);
|
||||
runs.push({
|
||||
id: runId,
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId,
|
||||
status: "succeeded",
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
startedAt: createdAt,
|
||||
finishedAt: new Date(createdAt.getTime() + 30_000),
|
||||
contextSnapshot: { issueId: input.issueId, taskId: input.issueId },
|
||||
livenessState: "advanced",
|
||||
nextAction: "Continue processing the next batch.",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
}
|
||||
await db.insert(heartbeatRuns).values(runs);
|
||||
|
||||
if (input.withRunComments) {
|
||||
await db.insert(issueComments).values(
|
||||
runs.map((run, index) => ({
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
authorAgentId: input.agentId,
|
||||
createdByRunId: run.id,
|
||||
body: `Progress update ${index}`,
|
||||
createdAt: run.createdAt as Date,
|
||||
updatedAt: run.createdAt as Date,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
async function listProductivityReviews(companyId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND)))
|
||||
.orderBy(issues.createdAt);
|
||||
}
|
||||
|
||||
it("creates exactly one manager-assigned review for a no-comment run streak and refreshes it idempotently", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
now,
|
||||
});
|
||||
|
||||
const service = productivityReviewService(db);
|
||||
const first = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const second = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
|
||||
expect(first.created).toBe(1);
|
||||
expect(second.updated).toBe(1);
|
||||
const reviews = await listProductivityReviews(seeded.companyId);
|
||||
expect(reviews).toHaveLength(1);
|
||||
expect(reviews[0]?.parentId).toBe(seeded.issueId);
|
||||
expect(reviews[0]?.assigneeAgentId).toBe(seeded.managerId);
|
||||
expect(reviews[0]?.originId).toBe(seeded.issueId);
|
||||
expect(reviews[0]?.originFingerprint).toBe(`productivity-review:${seeded.issueId}`);
|
||||
expect(reviews[0]?.description).toContain("Primary trigger: `no_comment_streak`");
|
||||
expect(reviews[0]?.description).toContain("No-comment completed-run streak: 10");
|
||||
|
||||
const comments = await db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, reviews[0]!.id));
|
||||
expect(comments.some((comment) => comment.body.includes("Productivity review evidence refreshed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("creates a long-active review without enabling a continuation hold", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue({
|
||||
status: "in_progress",
|
||||
startedAt: new Date(now.getTime() - 7 * 60 * 60 * 1000),
|
||||
});
|
||||
const service = productivityReviewService(db);
|
||||
|
||||
const result = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const hold = await service.isProductivityReviewContinuationHoldActive({
|
||||
companyId: seeded.companyId,
|
||||
issueId: seeded.issueId,
|
||||
agentId: seeded.coderId,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
expect(review?.description).toContain("Primary trigger: `long_active_duration`");
|
||||
expect(review?.priority).toBe("medium");
|
||||
expect(hold.held).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a high-churn review even when every sampled run has a progress comment", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: 10,
|
||||
now,
|
||||
withRunComments: true,
|
||||
});
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
expect(review?.description).toContain("Primary trigger: `high_churn`");
|
||||
expect(review?.description).toContain("Runs in rolling windows: 10/1h");
|
||||
});
|
||||
|
||||
it("ignores non-assignee comments when evaluating high-churn productivity reviews", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: 9,
|
||||
now,
|
||||
});
|
||||
const managerRuns = await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.managerId,
|
||||
issueId: seeded.issueId,
|
||||
count: 10,
|
||||
now,
|
||||
});
|
||||
await db.insert(issueComments).values(
|
||||
managerRuns.map((run, index) => ({
|
||||
companyId: seeded.companyId,
|
||||
issueId: seeded.issueId,
|
||||
authorAgentId: seeded.managerId,
|
||||
createdByRunId: run.id,
|
||||
body: `Manager note ${index}`,
|
||||
createdAt: run.createdAt as Date,
|
||||
updatedAt: run.createdAt as Date,
|
||||
})),
|
||||
);
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(0);
|
||||
expect(await listProductivityReviews(seeded.companyId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips productivity-review descendants so reviews cannot recursively spawn reviews", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
const reviewId = randomUUID();
|
||||
const childId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: reviewId,
|
||||
companyId: seeded.companyId,
|
||||
title: "Existing productivity review",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
originId: seeded.issueId,
|
||||
originFingerprint: `productivity-review:${seeded.issueId}`,
|
||||
parentId: seeded.issueId,
|
||||
issueNumber: 2,
|
||||
identifier: `${seeded.issuePrefix}-2`,
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: childId,
|
||||
companyId: seeded.companyId,
|
||||
title: "Review follow-up child",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: seeded.coderId,
|
||||
parentId: reviewId,
|
||||
issueNumber: 3,
|
||||
identifier: `${seeded.issuePrefix}-3`,
|
||||
startedAt: new Date(now.getTime() - 7 * 60 * 60 * 1000),
|
||||
});
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: childId,
|
||||
count: 10,
|
||||
now,
|
||||
});
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
const reviews = await listProductivityReviews(seeded.companyId);
|
||||
|
||||
expect(result.created).toBe(0);
|
||||
expect(reviews).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("treats a recently completed review as a snooze window", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: 10,
|
||||
now,
|
||||
});
|
||||
const service = productivityReviewService(db);
|
||||
await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ status: "done", updatedAt: now })
|
||||
.where(eq(issues.id, review!.id));
|
||||
|
||||
const result = await service.reconcileProductivityReviews({
|
||||
now: new Date(now.getTime() + 30 * 60 * 1000),
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
const reviews = await listProductivityReviews(seeded.companyId);
|
||||
|
||||
expect(result.snoozed).toBe(1);
|
||||
expect(reviews).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports and logs soft-stop holds for open no-comment reviews", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
const [latestRun] = await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: 10,
|
||||
now,
|
||||
});
|
||||
const service = productivityReviewService(db);
|
||||
await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
|
||||
const hold = await service.isProductivityReviewContinuationHoldActive({
|
||||
companyId: seeded.companyId,
|
||||
issueId: seeded.issueId,
|
||||
agentId: seeded.coderId,
|
||||
now,
|
||||
});
|
||||
expect(hold.held).toBe(true);
|
||||
if (!hold.held) return;
|
||||
|
||||
await service.recordContinuationHold({
|
||||
companyId: seeded.companyId,
|
||||
issueId: seeded.issueId,
|
||||
runId: latestRun!.id as string,
|
||||
agentId: seeded.coderId,
|
||||
reviewIssueId: review!.id,
|
||||
trigger: hold.trigger,
|
||||
reason: hold.reason,
|
||||
});
|
||||
const activities = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.action, "issue.productivity_review_continuation_held"));
|
||||
expect(activities).toHaveLength(1);
|
||||
expect(activities[0]?.entityId).toBe(seeded.issueId);
|
||||
});
|
||||
|
||||
it("clamps poisoned requestDepth metadata instead of aborting productivity reconciliation", async () => {
|
||||
const now = new Date("2026-04-28T12:00:00.000Z");
|
||||
const seeded = await seedAssignedIssue();
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ requestDepth: 2_147_483_647 })
|
||||
.where(eq(issues.id, seeded.issueId));
|
||||
|
||||
await insertRuns({
|
||||
companyId: seeded.companyId,
|
||||
agentId: seeded.coderId,
|
||||
issueId: seeded.issueId,
|
||||
count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
now,
|
||||
});
|
||||
|
||||
const result = await productivityReviewService(db).reconcileProductivityReviews({
|
||||
now,
|
||||
companyId: seeded.companyId,
|
||||
});
|
||||
|
||||
expect(result.failed).toBe(0);
|
||||
const [review] = await listProductivityReviews(seeded.companyId);
|
||||
expect(review?.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH);
|
||||
});
|
||||
});
|
||||
@@ -320,6 +320,55 @@ describe.sequential("workspace runtime service route authorization", () => {
|
||||
expect(mockAssertCanManageProjectWorkspaceRuntimeServices).toHaveBeenCalled();
|
||||
}, 15000);
|
||||
|
||||
it("blocks shared-project stop/restart requests from agents", async () => {
|
||||
mockProjectService.getById.mockResolvedValue(buildProject({
|
||||
id: projectId,
|
||||
workspaces: [{
|
||||
id: workspaceId,
|
||||
companyId: "company-1",
|
||||
projectId,
|
||||
name: "Workspace",
|
||||
sourceType: "local_path",
|
||||
cwd: "/tmp/project",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
visibility: "default",
|
||||
setupCommand: null,
|
||||
cleanupCommand: null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: "shared-key",
|
||||
metadata: null,
|
||||
runtimeConfig: null,
|
||||
isPrimary: false,
|
||||
runtimeServices: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
}));
|
||||
const app = await createProjectApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
runId: "run-1",
|
||||
});
|
||||
|
||||
const responses = await Promise.all([
|
||||
request(app).post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/stop`).send({}),
|
||||
request(app).post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/restart`).send({}),
|
||||
]);
|
||||
|
||||
for (const res of responses) {
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Missing permission");
|
||||
expect(mockProjectService.getById).toHaveBeenCalledWith(projectId);
|
||||
expect(mockAssertCanManageProjectWorkspaceRuntimeServices).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
}, 15000);
|
||||
|
||||
it("rejects agent callers that create project execution workspace commands", async () => {
|
||||
const app = await createProjectApp({
|
||||
type: "agent",
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
* - Exact types: "application/pdf"
|
||||
* - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*"
|
||||
*/
|
||||
import {
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
|
||||
"image/png",
|
||||
@@ -92,3 +96,10 @@ export function isAllowedContentType(contentType: string): boolean {
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES =
|
||||
Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
|
||||
export function normalizeIssueAttachmentMaxBytes(value: number | null | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES;
|
||||
}
|
||||
return Math.min(Math.floor(value), MAX_COMPANY_ATTACHMENT_MAX_BYTES);
|
||||
}
|
||||
@@ -705,6 +705,12 @@ export async function startServer(): Promise<StartedServer> {
|
||||
logger.warn({ ...scanned }, "startup active-run output watchdog created review work");
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
const reviewed = await heartbeat.reconcileProductivityReviews();
|
||||
if (reviewed.created > 0 || reviewed.updated > 0 || reviewed.failed > 0) {
|
||||
logger.warn({ ...reviewed }, "startup productivity reconciliation created or updated review work");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "startup heartbeat recovery failed");
|
||||
});
|
||||
@@ -764,6 +770,12 @@ export async function startServer(): Promise<StartedServer> {
|
||||
logger.warn({ ...scanned }, "periodic active-run output watchdog created review work");
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
const reviewed = await heartbeat.reconcileProductivityReviews();
|
||||
if (reviewed.created > 0 || reviewed.updated > 0 || reviewed.failed > 0) {
|
||||
logger.warn({ ...reviewed }, "periodic productivity reconciliation created or updated review work");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "periodic heartbeat recovery failed");
|
||||
});
|
||||
|
||||
@@ -141,6 +141,7 @@ function readSkillMarkdown(skillName: string): string | null {
|
||||
normalized !== "paperclip" &&
|
||||
normalized !== "paperclip-create-agent" &&
|
||||
normalized !== "paperclip-create-plugin" &&
|
||||
normalized !== "paperclip-converting-plans-to-tasks" &&
|
||||
normalized !== "para-memory-files"
|
||||
)
|
||||
return null;
|
||||
@@ -2862,6 +2863,10 @@ export function accessRoutes(
|
||||
{
|
||||
name: "paperclip-create-agent",
|
||||
path: "/api/skills/paperclip-create-agent"
|
||||
},
|
||||
{
|
||||
name: "paperclip-converting-plans-to-tasks",
|
||||
path: "/api/skills/paperclip-converting-plans-to-tasks"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
+42
-11
@@ -690,7 +690,10 @@ export function agentRoutes(
|
||||
role: string;
|
||||
adapterType: string;
|
||||
adapterConfig: unknown;
|
||||
}>(agent: T): Promise<T> {
|
||||
}>(
|
||||
agent: T,
|
||||
input?: { files: Record<string, string>; entryFile?: string },
|
||||
): Promise<T> {
|
||||
if (!adapterSupportsInstructionsBundle(agent.adapterType)) {
|
||||
return agent;
|
||||
}
|
||||
@@ -703,27 +706,45 @@ export function agentRoutes(
|
||||
|| Boolean(asNonEmptyString(adapterConfig.instructionsFilePath))
|
||||
|| Boolean(asNonEmptyString(adapterConfig.agentsMdPath));
|
||||
if (hasExplicitInstructionsBundle) {
|
||||
return agent;
|
||||
const nextAdapterConfig = { ...adapterConfig };
|
||||
const hadLegacyPrompt =
|
||||
Object.prototype.hasOwnProperty.call(nextAdapterConfig, "promptTemplate")
|
||||
|| Object.prototype.hasOwnProperty.call(nextAdapterConfig, "bootstrapPromptTemplate");
|
||||
delete nextAdapterConfig.promptTemplate;
|
||||
delete nextAdapterConfig.bootstrapPromptTemplate;
|
||||
if (!hadLegacyPrompt) return agent;
|
||||
|
||||
const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig });
|
||||
return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig };
|
||||
}
|
||||
|
||||
const promptTemplate = typeof adapterConfig.promptTemplate === "string"
|
||||
? adapterConfig.promptTemplate
|
||||
: "";
|
||||
const files = promptTemplate.trim().length === 0
|
||||
? await loadDefaultAgentInstructionsBundle(resolveDefaultAgentInstructionsBundleRole(agent.role))
|
||||
: { "AGENTS.md": promptTemplate };
|
||||
const files = input?.files
|
||||
?? await loadDefaultAgentInstructionsBundle(resolveDefaultAgentInstructionsBundleRole(agent.role));
|
||||
const materialized = await instructions.materializeManagedBundle(
|
||||
agent,
|
||||
files,
|
||||
{ entryFile: "AGENTS.md", replaceExisting: false },
|
||||
{ entryFile: input?.entryFile ?? "AGENTS.md", replaceExisting: false },
|
||||
);
|
||||
const nextAdapterConfig = { ...materialized.adapterConfig };
|
||||
delete nextAdapterConfig.promptTemplate;
|
||||
delete nextAdapterConfig.bootstrapPromptTemplate;
|
||||
|
||||
const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig });
|
||||
return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig };
|
||||
}
|
||||
|
||||
function assertNoNewAgentLegacyPromptTemplate(adapterType: string, adapterConfig: Record<string, unknown>) {
|
||||
if (!adapterSupportsInstructionsBundle(adapterType)) return;
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(adapterConfig, "promptTemplate")
|
||||
|| Object.prototype.hasOwnProperty.call(adapterConfig, "bootstrapPromptTemplate")
|
||||
) {
|
||||
throw unprocessable(
|
||||
"New agents must use instructionsBundle/AGENTS.md instead of adapterConfig.promptTemplate or bootstrapPromptTemplate",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
assertCompanyAccess(req, targetAgent.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
@@ -1465,11 +1486,16 @@ export function agentRoutes(
|
||||
const sourceIssueIds = parseSourceIssueIds(req.body);
|
||||
const {
|
||||
desiredSkills: requestedDesiredSkills,
|
||||
instructionsBundle,
|
||||
sourceIssueId: _sourceIssueId,
|
||||
sourceIssueIds: _sourceIssueIds,
|
||||
...hireInput
|
||||
} = req.body;
|
||||
hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType);
|
||||
assertNoNewAgentLegacyPromptTemplate(
|
||||
hireInput.adapterType,
|
||||
(hireInput.adapterConfig ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig),
|
||||
@@ -1522,7 +1548,7 @@ export function agentRoutes(
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent);
|
||||
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
|
||||
|
||||
let approval: Awaited<ReturnType<typeof approvalsSvc.getById>> | null = null;
|
||||
const actor = getActorInfo(req);
|
||||
@@ -1652,9 +1678,14 @@ export function agentRoutes(
|
||||
|
||||
const {
|
||||
desiredSkills: requestedDesiredSkills,
|
||||
instructionsBundle,
|
||||
...createInput
|
||||
} = req.body;
|
||||
createInput.adapterType = assertKnownAdapterType(createInput.adapterType);
|
||||
assertNoNewAgentLegacyPromptTemplate(
|
||||
createInput.adapterType,
|
||||
(createInput.adapterConfig ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectAgentAdapterWorkspaceCommandPaths(createInput.adapterConfig),
|
||||
@@ -1697,7 +1728,7 @@ export function agentRoutes(
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent);
|
||||
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
|
||||
@@ -291,7 +291,9 @@ export function issueTreeControlRoutes(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
res.status(result.hold.mode === "restore" ? 200 : 201).json(result);
|
||||
res
|
||||
.status(result.hold.mode === "restore" || result.hold.mode === "resume" ? 200 : 201)
|
||||
.json(result);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/tree-control/state", async (req, res) => {
|
||||
|
||||
+65
-19
@@ -39,6 +39,7 @@ import * as serviceIndex from "../services/index.js";
|
||||
import {
|
||||
accessService,
|
||||
agentService,
|
||||
companyService,
|
||||
executionWorkspaceService,
|
||||
goalService,
|
||||
heartbeatService,
|
||||
@@ -65,7 +66,7 @@ import {
|
||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||
import {
|
||||
isInlineAttachmentContentType,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
normalizeIssueAttachmentMaxBytes,
|
||||
normalizeContentType,
|
||||
SVG_CONTENT_TYPE,
|
||||
} from "../attachment-types.js";
|
||||
@@ -404,6 +405,7 @@ export function issueRoutes(
|
||||
pluginWorkerManager: opts.pluginWorkerManager,
|
||||
});
|
||||
const feedback = feedbackService(db);
|
||||
const companiesSvc = companyService(db);
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
const agentsSvc = agentService(db);
|
||||
const projectsSvc = projectService(db);
|
||||
@@ -427,11 +429,6 @@ export function issueRoutes(
|
||||
};
|
||||
const feedbackExportService = opts?.feedbackExportService;
|
||||
const environmentsSvc = environmentService(db);
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
});
|
||||
|
||||
function withContentPath<T extends { id: string }>(attachment: T) {
|
||||
return {
|
||||
...attachment,
|
||||
@@ -493,7 +490,11 @@ export function issueRoutes(
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function runSingleFileUpload(req: Request, res: Response) {
|
||||
async function runSingleFileUpload(req: Request, res: Response, fileSizeLimit: number) {
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: fileSizeLimit, files: 1 },
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upload.single("file")(req, res, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
@@ -602,23 +603,39 @@ export function issueRoutes(
|
||||
res.status(403).json({ error: "Agent authentication required" });
|
||||
return false;
|
||||
}
|
||||
if (issue.status !== "in_progress" || issue.assigneeAgentId === null) {
|
||||
if (issue.assigneeAgentId === null) {
|
||||
return true;
|
||||
}
|
||||
if (issue.assigneeAgentId !== actorAgentId) {
|
||||
if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) {
|
||||
return true;
|
||||
}
|
||||
res.status(409).json({
|
||||
error: "Issue is checked out by another agent",
|
||||
details: {
|
||||
issueId: issue.id,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
actorAgentId,
|
||||
},
|
||||
});
|
||||
if (issue.status === "in_progress") {
|
||||
res.status(409).json({
|
||||
error: "Issue is checked out by another agent",
|
||||
details: {
|
||||
issueId: issue.id,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
actorAgentId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(403).json({
|
||||
error: "Agent cannot mutate another agent's issue",
|
||||
details: {
|
||||
issueId: issue.id,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
actorAgentId,
|
||||
status: issue.status,
|
||||
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
|
||||
},
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (issue.status !== "in_progress") {
|
||||
return true;
|
||||
}
|
||||
const runId = requireAgentRunId(req, res);
|
||||
if (!runId) return false;
|
||||
const ownership = await svc.assertCheckoutOwner(issue.id, actorAgentId, runId);
|
||||
@@ -907,6 +924,11 @@ export function issueRoutes(
|
||||
? Number.parseInt(rawLimit, 10)
|
||||
: null;
|
||||
const limit = parsedLimit === null ? ISSUE_LIST_DEFAULT_LIMIT : clampIssueListLimit(parsedLimit);
|
||||
const rawOffset = req.query.offset as string | undefined;
|
||||
const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
|
||||
? Number.parseInt(rawOffset, 10)
|
||||
: null;
|
||||
const offset = parsedOffset ?? 0;
|
||||
|
||||
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
||||
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
||||
@@ -928,6 +950,10 @@ export function issueRoutes(
|
||||
res.status(400).json({ error: `limit must be a positive integer up to ${ISSUE_LIST_MAX_LIMIT}` });
|
||||
return;
|
||||
}
|
||||
if (rawOffset !== undefined && (parsedOffset === null || !Number.isInteger(parsedOffset) || parsedOffset < 0)) {
|
||||
res.status(400).json({ error: "offset must be a non-negative integer" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
status: req.query.status as string | undefined,
|
||||
@@ -952,6 +978,7 @@ export function issueRoutes(
|
||||
includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1",
|
||||
q: req.query.q as string | undefined,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
@@ -1034,6 +1061,7 @@ export function issueRoutes(
|
||||
wakeComment,
|
||||
relations,
|
||||
blockerAttention,
|
||||
productivityReview,
|
||||
attachments,
|
||||
continuationSummary,
|
||||
currentExecutionWorkspace,
|
||||
@@ -1045,6 +1073,7 @@ export function issueRoutes(
|
||||
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
||||
svc.getRelationSummaries(issue.id),
|
||||
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
|
||||
svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null),
|
||||
svc.listAttachments(issue.id),
|
||||
documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY),
|
||||
currentExecutionWorkspacePromise,
|
||||
@@ -1058,6 +1087,7 @@ export function issueRoutes(
|
||||
description: issue.description,
|
||||
status: issue.status,
|
||||
...(blockerAttention ? { blockerAttention } : {}),
|
||||
productivityReview,
|
||||
priority: issue.priority,
|
||||
projectId: issue.projectId,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
@@ -1066,6 +1096,8 @@ export function issueRoutes(
|
||||
blocks: relations.blocks,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
originKind: issue.originKind,
|
||||
originId: issue.originId,
|
||||
updatedAt: issue.updatedAt,
|
||||
},
|
||||
ancestors: ancestors.map((ancestor) => ({
|
||||
@@ -1127,13 +1159,23 @@ export function issueRoutes(
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations, blockerAttention, referenceSummary] = await Promise.all([
|
||||
const [
|
||||
{ project, goal },
|
||||
ancestors,
|
||||
mentionedProjectIds,
|
||||
documentPayload,
|
||||
relations,
|
||||
blockerAttention,
|
||||
productivityReview,
|
||||
referenceSummary,
|
||||
] = await Promise.all([
|
||||
resolveIssueProjectAndGoal(issue),
|
||||
svc.getAncestors(issue.id),
|
||||
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
|
||||
documentsSvc.getIssueDocumentPayload(issue),
|
||||
svc.getRelationSummaries(issue.id),
|
||||
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
|
||||
svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null),
|
||||
issueReferencesSvc.listIssueReferenceSummary(issue.id),
|
||||
]);
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
@@ -1148,6 +1190,7 @@ export function issueRoutes(
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
ancestors,
|
||||
...(blockerAttention ? { blockerAttention } : {}),
|
||||
productivityReview,
|
||||
blockedBy: relations.blockedBy,
|
||||
blocks: relations.blocks,
|
||||
relatedWork: referenceSummary,
|
||||
@@ -3692,12 +3735,15 @@ export function issueRoutes(
|
||||
}
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
||||
|
||||
const company = await companiesSvc.getById(companyId);
|
||||
const attachmentMaxBytes = normalizeIssueAttachmentMaxBytes(company?.attachmentMaxBytes);
|
||||
|
||||
try {
|
||||
await runSingleFileUpload(req, res);
|
||||
await runSingleFileUpload(req, res, attachmentMaxBytes);
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(422).json({ error: `Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
||||
res.status(422).json({ error: `Attachment exceeds ${attachmentMaxBytes} bytes` });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: err.message });
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } fr
|
||||
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { projectService, logActivity, workspaceOperationService } from "../services/index.js";
|
||||
import { conflict } from "../errors.js";
|
||||
import { conflict, forbidden } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import {
|
||||
buildWorkspaceRuntimeDesiredStatePatch,
|
||||
@@ -36,6 +36,7 @@ import { environmentService } from "../services/environments.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
|
||||
const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024;
|
||||
const SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS = new Set(["stop", "restart"]);
|
||||
|
||||
export function projectRoutes(db: Db) {
|
||||
const router = Router();
|
||||
@@ -346,6 +347,15 @@ export function projectRoutes(db: Db) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSharedWorkspace = Boolean(workspace.sharedWorkspaceKey);
|
||||
if (
|
||||
req.actor.type === "agent"
|
||||
&& isSharedWorkspace
|
||||
&& SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS.has(action)
|
||||
) {
|
||||
throw forbidden("Missing permission to manage workspace runtime services");
|
||||
}
|
||||
|
||||
await assertCanManageProjectWorkspaceRuntimeServices(db, req, {
|
||||
companyId: project.companyId,
|
||||
projectWorkspaceId: workspace.id,
|
||||
@@ -549,9 +559,9 @@ export function projectRoutes(db: Db) {
|
||||
stderr,
|
||||
system:
|
||||
action === "stop"
|
||||
? "Stopped project workspace runtime services.\n"
|
||||
? "Stopped project workspace runtime services.\nThis does not pause issue work or held wake scheduling."
|
||||
: action === "restart"
|
||||
? "Restarted project workspace runtime services.\n"
|
||||
? "Restarted project workspace runtime services.\nThis does not pause issue work or held wake scheduling."
|
||||
: "Started project workspace runtime services.\n",
|
||||
metadata: {
|
||||
runtimeServiceCount,
|
||||
|
||||
@@ -45,6 +45,7 @@ export function companyService(db: Db) {
|
||||
issueCounter: companies.issueCounter,
|
||||
budgetMonthlyCents: companies.budgetMonthlyCents,
|
||||
spentMonthlyCents: companies.spentMonthlyCents,
|
||||
attachmentMaxBytes: companies.attachmentMaxBytes,
|
||||
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
|
||||
feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled,
|
||||
feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt,
|
||||
|
||||
@@ -2428,6 +2428,10 @@ function buildManifestFromPackageFiles(
|
||||
description: asString(companyFrontmatter.description),
|
||||
brandColor: asString(paperclipCompany.brandColor),
|
||||
logoPath: asString(paperclipCompany.logoPath) ?? asString(paperclipCompany.logo),
|
||||
attachmentMaxBytes:
|
||||
typeof paperclipCompany.attachmentMaxBytes === "number" && Number.isFinite(paperclipCompany.attachmentMaxBytes)
|
||||
? Math.max(1, Math.floor(paperclipCompany.attachmentMaxBytes))
|
||||
: null,
|
||||
requireBoardApprovalForNewAgents:
|
||||
typeof paperclipCompany.requireBoardApprovalForNewAgents === "boolean"
|
||||
? paperclipCompany.requireBoardApprovalForNewAgents
|
||||
@@ -3465,6 +3469,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
company: stripEmptyValues({
|
||||
brandColor: company.brandColor ?? null,
|
||||
logoPath: companyLogoPath,
|
||||
attachmentMaxBytes: company.attachmentMaxBytes,
|
||||
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? true : undefined,
|
||||
feedbackDataSharingEnabled: company.feedbackDataSharingEnabled ? true : undefined,
|
||||
feedbackDataSharingConsentAt: company.feedbackDataSharingConsentAt?.toISOString() ?? null,
|
||||
@@ -3963,6 +3968,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
id: string;
|
||||
name: string;
|
||||
requireBoardApprovalForNewAgents?: boolean | null;
|
||||
attachmentMaxBytes?: number | null;
|
||||
} | null = null;
|
||||
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
|
||||
|
||||
@@ -3985,6 +3991,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
name: companyName,
|
||||
description: include.company ? (sourceManifest.company?.description ?? null) : null,
|
||||
brandColor: include.company ? (sourceManifest.company?.brandColor ?? null) : null,
|
||||
attachmentMaxBytes: include.company
|
||||
? (sourceManifest.company?.attachmentMaxBytes ?? undefined)
|
||||
: undefined,
|
||||
requireBoardApprovalForNewAgents: include.company
|
||||
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? false)
|
||||
: false,
|
||||
@@ -4016,6 +4025,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
name: sourceManifest.company.name,
|
||||
description: sourceManifest.company.description,
|
||||
brandColor: sourceManifest.company.brandColor,
|
||||
attachmentMaxBytes: sourceManifest.company.attachmentMaxBytes ?? undefined,
|
||||
requireBoardApprovalForNewAgents: sourceManifest.company.requireBoardApprovalForNewAgents,
|
||||
feedbackDataSharingEnabled: sourceManifest.company.feedbackDataSharingEnabled,
|
||||
feedbackDataSharingConsentAt: sourceManifest.company.feedbackDataSharingConsentAt
|
||||
|
||||
@@ -110,6 +110,7 @@ import {
|
||||
} from "./recovery/index.js";
|
||||
import { isAutomaticRecoverySuppressedByPauseHold } from "./recovery/pause-hold-guard.js";
|
||||
import { recoveryService } from "./recovery/service.js";
|
||||
import { productivityReviewService } from "./productivity-review.js";
|
||||
import { withAgentStartLock } from "./agent-start-lock.js";
|
||||
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
|
||||
import {
|
||||
@@ -2004,6 +2005,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
};
|
||||
const budgets = budgetService(db, budgetHooks);
|
||||
const recovery = recoveryService(db, { enqueueWakeup });
|
||||
const productivityReviews = productivityReviewService(db, { enqueueWakeup });
|
||||
let unsafeTextProjectionPromise: Promise<boolean> | null = null;
|
||||
|
||||
async function hasUnsafeTextProjectionDatabase() {
|
||||
@@ -2807,6 +2809,29 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
projectId: issue.projectId,
|
||||
})
|
||||
: null;
|
||||
if (issue) {
|
||||
const productivityHold = await productivityReviews.isProductivityReviewContinuationHoldActive({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
agentId: run.agentId,
|
||||
});
|
||||
if (productivityHold.held) {
|
||||
await setRunStatus(run.id, run.status, {
|
||||
livenessReason:
|
||||
`${run.livenessReason ?? "Run ended without concrete progress"}; continuation held by productivity review ${productivityHold.reviewIdentifier ?? productivityHold.reviewIssueId}`,
|
||||
});
|
||||
await productivityReviews.recordContinuationHold({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
runId: run.id,
|
||||
agentId: run.agentId,
|
||||
reviewIssueId: productivityHold.reviewIssueId,
|
||||
trigger: productivityHold.trigger,
|
||||
reason: productivityHold.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextAttempt = readContinuationAttempt(run.continuationAttempt) + 1;
|
||||
const idempotencyKey = issue
|
||||
@@ -4494,6 +4519,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
return recovery.scanSilentActiveRuns(opts);
|
||||
}
|
||||
|
||||
async function reconcileProductivityReviews(opts?: { now?: Date; companyId?: string }) {
|
||||
return productivityReviews.reconcileProductivityReviews(opts);
|
||||
}
|
||||
|
||||
async function buildRunOutputSilence(
|
||||
run: Pick<
|
||||
typeof heartbeatRuns.$inferSelect,
|
||||
@@ -7494,6 +7523,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
|
||||
scanSilentActiveRuns,
|
||||
|
||||
reconcileProductivityReviews,
|
||||
|
||||
buildRunOutputSilence,
|
||||
|
||||
tickTimers: async (now = new Date()) => {
|
||||
|
||||
@@ -32,6 +32,10 @@ export { routineService } from "./routines.js";
|
||||
export { costService } from "./costs.js";
|
||||
export { financeService } from "./finance.js";
|
||||
export { heartbeatService } from "./heartbeat.js";
|
||||
export {
|
||||
productivityReviewService,
|
||||
PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
} from "./productivity-review.js";
|
||||
export { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./recovery/index.js";
|
||||
export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
|
||||
@@ -71,7 +71,6 @@ type RestoreTreeStatusResult = TreeStatusUpdateResult & {
|
||||
const TERMINAL_ISSUE_STATUSES = new Set<IssueStatus>(["done", "cancelled"]);
|
||||
const ACTIVE_RUN_STATUSES = ["queued", "running"] as const;
|
||||
const DEFAULT_RELEASE_POLICY: IssueTreeHoldReleasePolicy = { strategy: "manual" };
|
||||
const MAX_PAUSE_HOLD_GATE_DEPTH = 15;
|
||||
export const ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS: ReadonlySet<string> = new Set([
|
||||
"issue_commented",
|
||||
"issue_reopened_via_comment",
|
||||
@@ -548,6 +547,22 @@ export function issueTreeControlService(db: Db) {
|
||||
return byIssueId;
|
||||
}
|
||||
|
||||
async function activePauseHoldsForIssueIds(companyId: string, issueIds: string[]) {
|
||||
if (issueIds.length === 0) return [];
|
||||
return db
|
||||
.select()
|
||||
.from(issueTreeHolds)
|
||||
.where(
|
||||
and(
|
||||
eq(issueTreeHolds.companyId, companyId),
|
||||
eq(issueTreeHolds.status, "active"),
|
||||
eq(issueTreeHolds.mode, "pause"),
|
||||
inArray(issueTreeHolds.rootIssueId, issueIds),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(issueTreeHolds.createdAt), asc(issueTreeHolds.id));
|
||||
}
|
||||
|
||||
async function getActivePauseHoldGate(
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
@@ -573,9 +588,8 @@ export function issueTreeControlService(db: Db) {
|
||||
const holdByRootIssueId = new Map(activePauseHolds.map((hold) => [hold.rootIssueId, hold]));
|
||||
let currentIssueId: string | null = issueId;
|
||||
const visited = new Set<string>();
|
||||
let depth = 0;
|
||||
|
||||
while (currentIssueId && !visited.has(currentIssueId) && depth < MAX_PAUSE_HOLD_GATE_DEPTH) {
|
||||
while (currentIssueId && !visited.has(currentIssueId)) {
|
||||
visited.add(currentIssueId);
|
||||
const hold = holdByRootIssueId.get(currentIssueId);
|
||||
if (hold) {
|
||||
@@ -596,7 +610,6 @@ export function issueTreeControlService(db: Db) {
|
||||
.where(and(eq(issues.id, currentIssueId), eq(issues.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
currentIssueId = parent?.parentId ?? null;
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -690,13 +703,100 @@ export function issueTreeControlService(db: Db) {
|
||||
releasePolicy?: IssueTreeHoldReleasePolicy | null;
|
||||
actor: ActorInput;
|
||||
},
|
||||
) {
|
||||
): Promise<{
|
||||
hold: IssueTreeHold;
|
||||
preview: IssueTreeControlPreview;
|
||||
resumedPauseHoldIds?: string[];
|
||||
}> {
|
||||
const holdReleasePolicy = normalizeReleasePolicy(input.releasePolicy);
|
||||
const holdPreview = await preview(companyId, rootIssueId, {
|
||||
mode: input.mode,
|
||||
releasePolicy: holdReleasePolicy,
|
||||
});
|
||||
|
||||
if (input.mode === "resume") {
|
||||
const issueIds = [...new Set(holdPreview.issues.map((issue) => issue.id))];
|
||||
const activePauseHolds = await activePauseHoldsForIssueIds(companyId, issueIds);
|
||||
const releaseReason = input.reason ?? "Subtree resume applied.";
|
||||
|
||||
const { hold: resumeHold } = await db.transaction(async (tx) => {
|
||||
const [createdHold] = await tx
|
||||
.insert(issueTreeHolds)
|
||||
.values({
|
||||
companyId,
|
||||
rootIssueId,
|
||||
mode: input.mode,
|
||||
status: "active",
|
||||
reason: input.reason ?? null,
|
||||
releasePolicy: holdReleasePolicy as unknown as Record<string, unknown>,
|
||||
createdByActorType: input.actor.actorType,
|
||||
createdByAgentId: input.actor.agentId ?? null,
|
||||
createdByUserId: input.actor.userId ?? (input.actor.actorType === "user" ? input.actor.actorId : null),
|
||||
createdByRunId: input.actor.runId ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const memberRows = holdPreview.issues.map((issue) => ({
|
||||
companyId,
|
||||
holdId: createdHold.id,
|
||||
issueId: issue.id,
|
||||
parentIssueId: issue.parentId,
|
||||
depth: issue.depth,
|
||||
issueIdentifier: issue.identifier,
|
||||
issueTitle: issue.title,
|
||||
issueStatus: issue.status,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
activeRunId: issue.activeRun?.id ?? null,
|
||||
activeRunStatus: issue.activeRun?.status ?? null,
|
||||
skipped: issue.skipped,
|
||||
skipReason: issue.skipReason,
|
||||
}));
|
||||
|
||||
const createdMembers = memberRows.length > 0
|
||||
? await tx
|
||||
.insert(issueTreeHoldMembers)
|
||||
.values(memberRows)
|
||||
.returning()
|
||||
: [];
|
||||
|
||||
return { hold: toHold(createdHold, createdMembers) };
|
||||
});
|
||||
|
||||
const resumedPauseHoldIds = activePauseHolds.map((hold) => hold.id);
|
||||
if (resumedPauseHoldIds.length > 0) {
|
||||
await Promise.all(
|
||||
activePauseHolds.map((pauseHold) =>
|
||||
releaseHold(companyId, pauseHold.rootIssueId, pauseHold.id, {
|
||||
reason: releaseReason,
|
||||
metadata: {
|
||||
resumedByResumeHoldId: resumeHold.id,
|
||||
resumeHoldMode: "tree_resume",
|
||||
resumedPauseHoldId: pauseHold.id,
|
||||
},
|
||||
actor: input.actor,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const releasedResumeHold = await releaseHold(companyId, rootIssueId, resumeHold.id, {
|
||||
reason: releaseReason,
|
||||
metadata: {
|
||||
resumedPauseHoldIds,
|
||||
resumeMode: "subtree",
|
||||
...(input.releasePolicy ? { releasePolicy: holdReleasePolicy } : {}),
|
||||
},
|
||||
actor: input.actor,
|
||||
});
|
||||
|
||||
return {
|
||||
hold: releasedResumeHold,
|
||||
preview: holdPreview,
|
||||
resumedPauseHoldIds,
|
||||
};
|
||||
}
|
||||
|
||||
const { hold, members } = await db.transaction(async (tx) => {
|
||||
const [createdHold] = await tx
|
||||
.insert(issueTreeHolds)
|
||||
|
||||
@@ -27,8 +27,13 @@ import {
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||
import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
|
||||
import type {
|
||||
IssueBlockerAttention,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
IssueRelationIssueSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { clampIssueRequestDepth, extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import {
|
||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
@@ -107,6 +112,7 @@ export interface IssueFilters {
|
||||
includeBlockedBy?: boolean;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
type IssueRow = typeof issues.$inferSelect;
|
||||
@@ -666,6 +672,17 @@ const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execut
|
||||
const BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES = ["pending"];
|
||||
const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"];
|
||||
const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND = "harness_liveness_escalation";
|
||||
const PRODUCTIVITY_REVIEW_ORIGIN_KIND = "issue_productivity_review";
|
||||
const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"];
|
||||
const PRODUCTIVITY_REVIEW_ACTIVITY_ACTIONS = [
|
||||
"issue.productivity_review_created",
|
||||
"issue.productivity_review_updated",
|
||||
];
|
||||
const PRODUCTIVITY_REVIEW_TRIGGERS: readonly IssueProductivityReviewTrigger[] = [
|
||||
"no_comment_streak",
|
||||
"long_active_duration",
|
||||
"high_churn",
|
||||
];
|
||||
const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"];
|
||||
const BLOCKER_ATTENTION_MAX_DEPTH = 8;
|
||||
const BLOCKER_ATTENTION_MAX_NODES = 2000;
|
||||
@@ -876,6 +893,114 @@ async function terminalExplicitBlockersByRoot(
|
||||
return terminalByRoot;
|
||||
}
|
||||
|
||||
function readProductivityReviewTrigger(value: unknown): IssueProductivityReviewTrigger | null {
|
||||
if (typeof value !== "string") return null;
|
||||
return PRODUCTIVITY_REVIEW_TRIGGERS.includes(value as IssueProductivityReviewTrigger)
|
||||
? (value as IssueProductivityReviewTrigger)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readProductivityReviewStreak(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
async function listIssueProductivityReviewMap(
|
||||
dbOrTx: any,
|
||||
companyId: string,
|
||||
sourceIssueIds: string[],
|
||||
): Promise<Map<string, IssueProductivityReview>> {
|
||||
const map = new Map<string, IssueProductivityReview>();
|
||||
if (sourceIssueIds.length === 0) return map;
|
||||
|
||||
const reviewRows: Array<{
|
||||
sourceIssueId: string | null;
|
||||
reviewIssueId: string;
|
||||
reviewIdentifier: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}> = [];
|
||||
for (const chunk of chunkList([...new Set(sourceIssueIds)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const rows = await dbOrTx
|
||||
.select({
|
||||
sourceIssueId: issues.originId,
|
||||
reviewIssueId: issues.id,
|
||||
reviewIdentifier: issues.identifier,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
createdAt: issues.createdAt,
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
|
||||
inArray(issues.originId, chunk),
|
||||
isNull(issues.hiddenAt),
|
||||
notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES),
|
||||
),
|
||||
);
|
||||
reviewRows.push(...rows);
|
||||
}
|
||||
|
||||
if (reviewRows.length === 0) return map;
|
||||
|
||||
const reviewIssueIds = reviewRows.map((row) => row.reviewIssueId);
|
||||
const triggerByReviewIssueId = new Map<
|
||||
string,
|
||||
{ trigger: IssueProductivityReviewTrigger | null; noCommentStreak: number | null }
|
||||
>();
|
||||
for (const chunk of chunkList(reviewIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const detailRows = await dbOrTx
|
||||
.select({
|
||||
entityId: activityLog.entityId,
|
||||
details: activityLog.details,
|
||||
createdAt: activityLog.createdAt,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
inArray(activityLog.entityId, chunk),
|
||||
inArray(activityLog.action, PRODUCTIVITY_REVIEW_ACTIVITY_ACTIONS),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(activityLog.createdAt));
|
||||
for (const row of detailRows as Array<{
|
||||
entityId: string;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
}>) {
|
||||
if (triggerByReviewIssueId.has(row.entityId)) continue;
|
||||
triggerByReviewIssueId.set(row.entityId, {
|
||||
trigger: readProductivityReviewTrigger(row.details?.trigger),
|
||||
noCommentStreak: readProductivityReviewStreak(row.details?.noCommentStreak),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of reviewRows) {
|
||||
if (!row.sourceIssueId) continue;
|
||||
const detail = triggerByReviewIssueId.get(row.reviewIssueId);
|
||||
map.set(row.sourceIssueId, {
|
||||
reviewIssueId: row.reviewIssueId,
|
||||
reviewIdentifier: row.reviewIdentifier,
|
||||
status: row.status as IssueProductivityReview["status"],
|
||||
priority: row.priority as IssueProductivityReview["priority"],
|
||||
trigger: detail?.trigger ?? null,
|
||||
noCommentStreak: detail?.noCommentStreak ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
async function listIssueBlockerAttentionMap(
|
||||
dbOrTx: any,
|
||||
companyId: string,
|
||||
@@ -1959,6 +2084,9 @@ export function issueService(db: Db) {
|
||||
const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit)
|
||||
? Math.max(1, Math.floor(filters.limit))
|
||||
: undefined;
|
||||
const offset = typeof filters?.offset === "number" && Number.isFinite(filters.offset)
|
||||
? Math.max(0, Math.floor(filters.offset))
|
||||
: 0;
|
||||
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
|
||||
const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
|
||||
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
|
||||
@@ -2081,8 +2209,12 @@ export function issueService(db: Db) {
|
||||
asc(priorityOrder),
|
||||
desc(canonicalLastActivityAt),
|
||||
desc(issues.updatedAt),
|
||||
desc(issues.id),
|
||||
);
|
||||
const rows = (limit === undefined ? await baseQuery : await baseQuery.limit(limit)).map((row) => ({
|
||||
const pageQuery = offset > 0
|
||||
? (limit === undefined ? baseQuery.offset(offset) : baseQuery.limit(limit).offset(offset))
|
||||
: (limit === undefined ? baseQuery : baseQuery.limit(limit));
|
||||
const rows = (await pageQuery).map((row) => ({
|
||||
...row,
|
||||
description: decodeDatabaseTextPreview(row.description, ISSUE_LIST_DESCRIPTION_MAX_CHARS),
|
||||
}));
|
||||
@@ -2108,7 +2240,10 @@ export function issueService(db: Db) {
|
||||
]);
|
||||
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
|
||||
const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row]));
|
||||
const blockerAttentionByIssueId = await listIssueBlockerAttentionMap(db, companyId, withRuns);
|
||||
const [blockerAttentionByIssueId, productivityReviewByIssueId] = await Promise.all([
|
||||
listIssueBlockerAttentionMap(db, companyId, withRuns),
|
||||
listIssueProductivityReviewMap(db, companyId, issueIds),
|
||||
]);
|
||||
|
||||
if (!contextUserId) {
|
||||
return withRuns.map((row) => {
|
||||
@@ -2123,6 +2258,9 @@ export function issueService(db: Db) {
|
||||
...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}),
|
||||
lastActivityAt,
|
||||
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
|
||||
...(productivityReviewByIssueId.has(row.id)
|
||||
? { productivityReview: productivityReviewByIssueId.get(row.id) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -2141,6 +2279,9 @@ export function issueService(db: Db) {
|
||||
...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}),
|
||||
lastActivityAt,
|
||||
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
|
||||
...(productivityReviewByIssueId.has(row.id)
|
||||
? { productivityReview: productivityReviewByIssueId.get(row.id) }
|
||||
: {}),
|
||||
...deriveIssueUserContext(row, contextUserId, {
|
||||
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
||||
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
||||
@@ -2292,6 +2433,14 @@ export function issueService(db: Db) {
|
||||
return listIssueBlockerAttentionMap(dbOrTx, companyId, issueRows);
|
||||
},
|
||||
|
||||
listProductivityReviews: async (
|
||||
companyId: string,
|
||||
sourceIssueIds: string[],
|
||||
dbOrTx: any = db,
|
||||
) => {
|
||||
return listIssueProductivityReviewMap(dbOrTx, companyId, sourceIssueIds);
|
||||
},
|
||||
|
||||
listWakeableBlockedDependents: async (blockerIssueId: string) => {
|
||||
const blockerIssue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
@@ -2458,7 +2607,9 @@ export function issueService(db: Db) {
|
||||
parentId: parent.id,
|
||||
projectId: issueData.projectId ?? parent.projectId,
|
||||
goalId: issueData.goalId ?? parent.goalId,
|
||||
requestDepth: Math.max(parent.requestDepth + 1, issueData.requestDepth ?? 0),
|
||||
requestDepth: clampIssueRequestDepth(
|
||||
Math.max(clampIssueRequestDepth(parent.requestDepth) + 1, issueData.requestDepth ?? 0),
|
||||
),
|
||||
description: appendAcceptanceCriteriaToDescription(issueData.description, acceptanceCriteria),
|
||||
inheritExecutionWorkspaceFromIssueId: parent.id,
|
||||
});
|
||||
@@ -2615,6 +2766,7 @@ export function issueService(db: Db) {
|
||||
|
||||
const values = {
|
||||
...issueData,
|
||||
requestDepth: clampIssueRequestDepth(issueData.requestDepth),
|
||||
originKind: issueData.originKind ?? "manual",
|
||||
goalId: resolveIssueGoalId({
|
||||
projectId: issueData.projectId,
|
||||
@@ -2700,6 +2852,9 @@ export function issueService(db: Db) {
|
||||
...issueData,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (issueData.requestDepth !== undefined) {
|
||||
patch.requestDepth = clampIssueRequestDepth(issueData.requestDepth);
|
||||
}
|
||||
|
||||
const nextAssigneeAgentId =
|
||||
issueData.assigneeAgentId !== undefined ? issueData.assigneeAgentId : existing.assigneeAgentId;
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, notInArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { clampIssueRequestDepth } from "@paperclipai/shared";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
costEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issues,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { RECOVERY_ORIGIN_KINDS } from "./recovery/origins.js";
|
||||
|
||||
export const PRODUCTIVITY_REVIEW_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.issueProductivityReview;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS = 10;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_LONG_ACTIVE_HOURS = 6;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_HOURLY = 10;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_SIX_HOURS = 30;
|
||||
export const DEFAULT_PRODUCTIVITY_REVIEW_RESOLVED_SNOOZE_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
const TERMINAL_RUN_STATUSES = ["succeeded", "failed", "cancelled", "timed_out"] as const;
|
||||
const ACTIVE_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
|
||||
const MAX_CANDIDATE_ISSUES = 250;
|
||||
const MAX_RUNS_FOR_STREAK = 100;
|
||||
const MAX_PARENT_WALK_DEPTH = 25;
|
||||
|
||||
type IssueRow = typeof issues.$inferSelect;
|
||||
type AgentRow = typeof agents.$inferSelect;
|
||||
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
|
||||
type ProductivityReviewTrigger = "no_comment_streak" | "long_active_duration" | "high_churn";
|
||||
|
||||
type ProductivityReviewThresholds = {
|
||||
noCommentStreakRuns: number;
|
||||
longActiveMs: number;
|
||||
highChurnHourly: number;
|
||||
highChurnSixHours: number;
|
||||
resolvedSnoozeMs: number;
|
||||
};
|
||||
|
||||
type ProductivityReviewEvidence = {
|
||||
trigger: ProductivityReviewTrigger;
|
||||
triggerReasons: string[];
|
||||
sourceIssue: IssueRow;
|
||||
sourceAgent: AgentRow;
|
||||
noCommentStreak: number;
|
||||
totalRunCount: number;
|
||||
terminalRunCount: number;
|
||||
activeRunCount: number;
|
||||
runCountLastHour: number;
|
||||
runCountLastSixHours: number;
|
||||
commentCount: number;
|
||||
commentCountLastHour: number;
|
||||
commentCountLastSixHours: number;
|
||||
elapsedMs: number | null;
|
||||
latestRuns: HeartbeatRunRow[];
|
||||
latestComments: Array<typeof issueComments.$inferSelect>;
|
||||
costCents: number;
|
||||
usageSamples: Array<{ runId: string; usageJson: Record<string, unknown> | null }>;
|
||||
nextAction: string | null;
|
||||
thresholds: ProductivityReviewThresholds;
|
||||
generatedAt: Date;
|
||||
};
|
||||
|
||||
type EnqueueWakeup = (
|
||||
agentId: string,
|
||||
opts?: {
|
||||
source?: "timer" | "assignment" | "on_demand" | "automation";
|
||||
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
||||
reason?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
requestedByActorType?: "user" | "agent" | "system";
|
||||
requestedByActorId?: string | null;
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
},
|
||||
) => Promise<unknown | null>;
|
||||
|
||||
function productivityReviewFingerprint(sourceIssueId: string) {
|
||||
return `productivity-review:${sourceIssueId}`;
|
||||
}
|
||||
|
||||
function issueRunScopeSql(issueId: string) {
|
||||
return sql`(
|
||||
${heartbeatRuns.contextSnapshot}->>'issueId' = ${issueId}
|
||||
or ${heartbeatRuns.contextSnapshot}->>'taskId' = ${issueId}
|
||||
or ${heartbeatRuns.contextSnapshot}->>'taskKey' = ${issueId}
|
||||
)`;
|
||||
}
|
||||
|
||||
function msToHuman(ms: number | null) {
|
||||
if (ms === null) return "unknown";
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days > 0) return `${days}d ${hours % 24}h`;
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
function issueUiLink(issue: { identifier: string | null; id: string }, prefix: string) {
|
||||
const label = issue.identifier ?? issue.id;
|
||||
return `[${label}](/${prefix}/issues/${label})`;
|
||||
}
|
||||
|
||||
function runUiLink(run: { id: string; agentId: string }, prefix: string) {
|
||||
return `[${run.id}](/${prefix}/agents/${run.agentId}/runs/${run.id})`;
|
||||
}
|
||||
|
||||
function truncateInline(value: string | null | undefined, max = 260) {
|
||||
if (!value) return "";
|
||||
const compact = value.replace(/\s+/g, " ").trim();
|
||||
return compact.length <= max ? compact : `${compact.slice(0, max - 3)}...`;
|
||||
}
|
||||
|
||||
function readPositiveInteger(value: number, fallback: number) {
|
||||
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
||||
}
|
||||
|
||||
function buildThresholds(overrides?: Partial<ProductivityReviewThresholds>): ProductivityReviewThresholds {
|
||||
return {
|
||||
noCommentStreakRuns: readPositiveInteger(
|
||||
overrides?.noCommentStreakRuns ?? DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS,
|
||||
),
|
||||
longActiveMs: readPositiveInteger(
|
||||
overrides?.longActiveMs ?? DEFAULT_PRODUCTIVITY_REVIEW_LONG_ACTIVE_HOURS * 60 * 60 * 1000,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_LONG_ACTIVE_HOURS * 60 * 60 * 1000,
|
||||
),
|
||||
highChurnHourly: readPositiveInteger(
|
||||
overrides?.highChurnHourly ?? DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_HOURLY,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_HOURLY,
|
||||
),
|
||||
highChurnSixHours: readPositiveInteger(
|
||||
overrides?.highChurnSixHours ?? DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_SIX_HOURS,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_SIX_HOURS,
|
||||
),
|
||||
resolvedSnoozeMs: readPositiveInteger(
|
||||
overrides?.resolvedSnoozeMs ?? DEFAULT_PRODUCTIVITY_REVIEW_RESOLVED_SNOOZE_MS,
|
||||
DEFAULT_PRODUCTIVITY_REVIEW_RESOLVED_SNOOZE_MS,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function choosePrimaryTrigger(input: {
|
||||
noComment: boolean;
|
||||
longActive: boolean;
|
||||
highChurn: boolean;
|
||||
}): ProductivityReviewTrigger | null {
|
||||
if (input.noComment) return "no_comment_streak";
|
||||
if (input.highChurn) return "high_churn";
|
||||
if (input.longActive) return "long_active_duration";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSoftStopTrigger(trigger: ProductivityReviewTrigger) {
|
||||
return trigger === "no_comment_streak" || trigger === "high_churn";
|
||||
}
|
||||
|
||||
function formatTrigger(trigger: ProductivityReviewTrigger) {
|
||||
if (trigger === "no_comment_streak") return "No-comment streak";
|
||||
if (trigger === "high_churn") return "High churn";
|
||||
return "Long active duration";
|
||||
}
|
||||
|
||||
export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: EnqueueWakeup }) {
|
||||
const issuesSvc = issueService(db);
|
||||
const budgets = budgetService(db);
|
||||
|
||||
async function getCompanyIssuePrefix(companyId: string) {
|
||||
return db
|
||||
.select({ issuePrefix: companies.issuePrefix })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0]?.issuePrefix ?? "PAP");
|
||||
}
|
||||
|
||||
async function getAgent(agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
function isAgentInvokable(agent: AgentRow | null | undefined) {
|
||||
return Boolean(agent && !["paused", "terminated", "pending_approval"].includes(agent.status));
|
||||
}
|
||||
|
||||
async function isProductivityReviewDescendant(issue: Pick<IssueRow, "companyId" | "parentId">) {
|
||||
let parentId = issue.parentId;
|
||||
let depth = 0;
|
||||
while (parentId && depth < MAX_PARENT_WALK_DEPTH) {
|
||||
const parent = await db
|
||||
.select({ id: issues.id, parentId: issues.parentId, originKind: issues.originKind })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, issue.companyId), eq(issues.id, parentId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!parent) return false;
|
||||
if (parent.originKind === PRODUCTIVITY_REVIEW_ORIGIN_KIND) return true;
|
||||
parentId = parent.parentId;
|
||||
depth += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function findOpenProductivityReview(companyId: string, sourceIssueId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
|
||||
eq(issues.originId, sourceIssueId),
|
||||
isNull(issues.hiddenAt),
|
||||
notInArray(issues.status, ["done", "cancelled"]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issues.updatedAt))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function findRecentResolvedProductivityReview(
|
||||
companyId: string,
|
||||
sourceIssueId: string,
|
||||
thresholds: ProductivityReviewThresholds,
|
||||
now: Date,
|
||||
) {
|
||||
const cutoff = new Date(now.getTime() - thresholds.resolvedSnoozeMs);
|
||||
return db
|
||||
.select({ id: issues.id, identifier: issues.identifier, status: issues.status, updatedAt: issues.updatedAt })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
|
||||
eq(issues.originId, sourceIssueId),
|
||||
eq(issues.status, "done"),
|
||||
gt(issues.updatedAt, cutoff),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issues.updatedAt))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function countIssueRunsSince(companyId: string, agentId: string, issueId: string, since: Date) {
|
||||
return db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
eq(heartbeatRuns.agentId, agentId),
|
||||
issueRunScopeSql(issueId),
|
||||
sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) >= ${since.toISOString()}::timestamptz`,
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
async function countIssueCommentsSince(companyId: string, issueId: string, agentId: string, since?: Date) {
|
||||
return db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(issueComments)
|
||||
.innerJoin(heartbeatRuns, eq(heartbeatRuns.id, issueComments.createdByRunId))
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
eq(issueComments.issueId, issueId),
|
||||
eq(issueComments.authorAgentId, agentId),
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
eq(heartbeatRuns.agentId, agentId),
|
||||
issueRunScopeSql(issueId),
|
||||
since ? sql`${issueComments.createdAt} >= ${since.toISOString()}::timestamptz` : undefined,
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
async function collectEvidence(
|
||||
sourceIssue: IssueRow,
|
||||
sourceAgent: AgentRow,
|
||||
thresholds: ProductivityReviewThresholds,
|
||||
now: Date,
|
||||
): Promise<ProductivityReviewEvidence | null> {
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
const sixHoursAgo = new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
||||
|
||||
const latestRuns = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, sourceIssue.companyId),
|
||||
eq(heartbeatRuns.agentId, sourceAgent.id),
|
||||
issueRunScopeSql(sourceIssue.id),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id))
|
||||
.limit(MAX_RUNS_FOR_STREAK);
|
||||
|
||||
const runIds = latestRuns.map((run) => run.id);
|
||||
const commentRunIds = new Set<string>();
|
||||
if (runIds.length > 0) {
|
||||
const commentRows = await db
|
||||
.select({ createdByRunId: issueComments.createdByRunId })
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, sourceIssue.companyId),
|
||||
eq(issueComments.issueId, sourceIssue.id),
|
||||
inArray(issueComments.createdByRunId, runIds),
|
||||
),
|
||||
);
|
||||
for (const row of commentRows) {
|
||||
if (row.createdByRunId) commentRunIds.add(row.createdByRunId);
|
||||
}
|
||||
}
|
||||
|
||||
const terminalRuns = latestRuns.filter((run) =>
|
||||
TERMINAL_RUN_STATUSES.includes(run.status as (typeof TERMINAL_RUN_STATUSES)[number]),
|
||||
);
|
||||
let noCommentStreak = 0;
|
||||
for (const run of terminalRuns) {
|
||||
if (commentRunIds.has(run.id)) break;
|
||||
noCommentStreak += 1;
|
||||
}
|
||||
|
||||
const [
|
||||
runCountLastHour,
|
||||
runCountLastSixHours,
|
||||
assigneeRunCommentCount,
|
||||
assigneeRunCommentCountLastHour,
|
||||
assigneeRunCommentCountLastSixHours,
|
||||
latestComments,
|
||||
costRow,
|
||||
] = await Promise.all([
|
||||
countIssueRunsSince(sourceIssue.companyId, sourceAgent.id, sourceIssue.id, oneHourAgo),
|
||||
countIssueRunsSince(sourceIssue.companyId, sourceAgent.id, sourceIssue.id, sixHoursAgo),
|
||||
countIssueCommentsSince(sourceIssue.companyId, sourceIssue.id, sourceAgent.id),
|
||||
countIssueCommentsSince(sourceIssue.companyId, sourceIssue.id, sourceAgent.id, oneHourAgo),
|
||||
countIssueCommentsSince(sourceIssue.companyId, sourceIssue.id, sourceAgent.id, sixHoursAgo),
|
||||
db
|
||||
.select({ comment: issueComments })
|
||||
.from(issueComments)
|
||||
.innerJoin(heartbeatRuns, eq(heartbeatRuns.id, issueComments.createdByRunId))
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, sourceIssue.companyId),
|
||||
eq(issueComments.issueId, sourceIssue.id),
|
||||
eq(issueComments.authorAgentId, sourceAgent.id),
|
||||
eq(heartbeatRuns.companyId, sourceIssue.companyId),
|
||||
eq(heartbeatRuns.agentId, sourceAgent.id),
|
||||
issueRunScopeSql(sourceIssue.id),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
|
||||
.limit(5)
|
||||
.then((rows) => rows.map((row) => row.comment)),
|
||||
db
|
||||
.select({ costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int` })
|
||||
.from(costEvents)
|
||||
.where(and(eq(costEvents.companyId, sourceIssue.companyId), eq(costEvents.issueId, sourceIssue.id)))
|
||||
.then((rows) => rows[0] ?? { costCents: 0 }),
|
||||
]);
|
||||
|
||||
const activeRunCount = latestRuns.filter((run) =>
|
||||
ACTIVE_RUN_STATUSES.includes(run.status as (typeof ACTIVE_RUN_STATUSES)[number]),
|
||||
).length;
|
||||
const activeStartedAt = sourceIssue.startedAt ?? sourceIssue.executionLockedAt ?? null;
|
||||
const elapsedMs = sourceIssue.status === "in_progress" && activeStartedAt
|
||||
? Math.max(0, now.getTime() - activeStartedAt.getTime())
|
||||
: null;
|
||||
|
||||
const noComment = noCommentStreak >= thresholds.noCommentStreakRuns;
|
||||
const longActive = elapsedMs !== null && elapsedMs >= thresholds.longActiveMs;
|
||||
const highChurn =
|
||||
runCountLastHour >= thresholds.highChurnHourly ||
|
||||
assigneeRunCommentCountLastHour >= thresholds.highChurnHourly ||
|
||||
runCountLastSixHours >= thresholds.highChurnSixHours ||
|
||||
assigneeRunCommentCountLastSixHours >= thresholds.highChurnSixHours;
|
||||
const trigger = choosePrimaryTrigger({ noComment, longActive, highChurn });
|
||||
if (!trigger) return null;
|
||||
|
||||
const triggerReasons: string[] = [];
|
||||
if (noComment) triggerReasons.push(`${noCommentStreak} consecutive completed issue-linked runs had no run-created issue comment`);
|
||||
if (longActive) triggerReasons.push(`current active episode has lasted ${msToHuman(elapsedMs)}`);
|
||||
if (highChurn) {
|
||||
triggerReasons.push(
|
||||
`${runCountLastHour} runs/${assigneeRunCommentCountLastHour} assignee-run comments in 1h; ${runCountLastSixHours} runs/${assigneeRunCommentCountLastSixHours} assignee-run comments in 6h`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
trigger,
|
||||
triggerReasons,
|
||||
sourceIssue,
|
||||
sourceAgent,
|
||||
noCommentStreak,
|
||||
totalRunCount: latestRuns.length,
|
||||
terminalRunCount: terminalRuns.length,
|
||||
activeRunCount,
|
||||
runCountLastHour,
|
||||
runCountLastSixHours,
|
||||
commentCount: assigneeRunCommentCount,
|
||||
commentCountLastHour: assigneeRunCommentCountLastHour,
|
||||
commentCountLastSixHours: assigneeRunCommentCountLastSixHours,
|
||||
elapsedMs,
|
||||
latestRuns: latestRuns.slice(0, 5),
|
||||
latestComments,
|
||||
costCents: costRow.costCents,
|
||||
usageSamples: latestRuns
|
||||
.filter((run) => run.usageJson)
|
||||
.slice(0, 3)
|
||||
.map((run) => ({ runId: run.id, usageJson: run.usageJson ?? null })),
|
||||
nextAction: latestRuns.find((run) => run.nextAction)?.nextAction ?? null,
|
||||
thresholds,
|
||||
generatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveReviewOwnerAgentId(sourceIssue: IssueRow, sourceAgent: AgentRow) {
|
||||
const candidateIds: string[] = [];
|
||||
if (sourceAgent.reportsTo) candidateIds.push(sourceAgent.reportsTo);
|
||||
if (sourceIssue.createdByAgentId) candidateIds.push(sourceIssue.createdByAgentId);
|
||||
if (sourceIssue.projectId) {
|
||||
const project = await db
|
||||
.select({ leadAgentId: projects.leadAgentId })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, sourceIssue.companyId), eq(projects.id, sourceIssue.projectId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (project?.leadAgentId) candidateIds.push(project.leadAgentId);
|
||||
}
|
||||
const roleCandidates = await db
|
||||
.select({ id: agents.id })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, sourceIssue.companyId), inArray(agents.role, ["cto", "ceo"])))
|
||||
.orderBy(sql`case when ${agents.role} = 'cto' then 0 else 1 end`, asc(agents.createdAt), asc(agents.id));
|
||||
candidateIds.push(...roleCandidates.map((agent) => agent.id));
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const agentId of candidateIds) {
|
||||
if (seen.has(agentId)) continue;
|
||||
seen.add(agentId);
|
||||
const candidate = await getAgent(agentId);
|
||||
if (!candidate || candidate.companyId !== sourceIssue.companyId || !isAgentInvokable(candidate)) continue;
|
||||
const budgetBlock = await budgets.getInvocationBlock(sourceIssue.companyId, candidate.id, {
|
||||
issueId: sourceIssue.id,
|
||||
projectId: sourceIssue.projectId ?? null,
|
||||
});
|
||||
if (!budgetBlock) return candidate.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildReviewMarkdown(evidence: ProductivityReviewEvidence, prefix: string) {
|
||||
const latestRuns = evidence.latestRuns.length > 0
|
||||
? evidence.latestRuns.map((run) =>
|
||||
`- ${runUiLink(run, prefix)} \`${run.status}\` liveness \`${run.livenessState ?? "unknown"}\`, created ${run.createdAt.toISOString()}${run.nextAction ? `, next action: ${truncateInline(run.nextAction, 160)}` : ""}`,
|
||||
).join("\n")
|
||||
: "- none";
|
||||
const latestComments = evidence.latestComments.length > 0
|
||||
? evidence.latestComments.map((comment) =>
|
||||
`- ${comment.createdAt.toISOString()}${comment.createdByRunId ? ` run \`${comment.createdByRunId}\`` : ""}: ${truncateInline(comment.body)}`,
|
||||
).join("\n")
|
||||
: "- none";
|
||||
const usage = evidence.usageSamples.length > 0
|
||||
? evidence.usageSamples.map((sample) => `- \`${sample.runId}\`: \`${JSON.stringify(sample.usageJson).slice(0, 500)}\``).join("\n")
|
||||
: "- no usage payloads on sampled runs";
|
||||
return [
|
||||
"Paperclip detected an unusual productivity/progression pattern on an assigned issue.",
|
||||
"",
|
||||
"## Source",
|
||||
"",
|
||||
`- Source issue: ${issueUiLink(evidence.sourceIssue, prefix)}`,
|
||||
`- Assigned agent: ${evidence.sourceAgent.name} (${evidence.sourceAgent.role})`,
|
||||
`- Primary trigger: \`${evidence.trigger}\` (${formatTrigger(evidence.trigger)})`,
|
||||
`- Trigger reasons: ${evidence.triggerReasons.join("; ")}`,
|
||||
`- Generated at: ${evidence.generatedAt.toISOString()}`,
|
||||
"",
|
||||
"## Evidence",
|
||||
"",
|
||||
`- Total sampled issue-linked runs: ${evidence.totalRunCount}`,
|
||||
`- Terminal sampled runs: ${evidence.terminalRunCount}`,
|
||||
`- Active queued/running/scheduled runs: ${evidence.activeRunCount}`,
|
||||
`- No-comment completed-run streak: ${evidence.noCommentStreak}`,
|
||||
`- Current active elapsed time: ${msToHuman(evidence.elapsedMs)}`,
|
||||
`- Runs in rolling windows: ${evidence.runCountLastHour}/1h, ${evidence.runCountLastSixHours}/6h`,
|
||||
`- Assignee run-linked comments total/window: ${evidence.commentCount} total, ${evidence.commentCountLastHour}/1h, ${evidence.commentCountLastSixHours}/6h`,
|
||||
`- Cost events total: ${evidence.costCents} cents`,
|
||||
`- Current next action: ${evidence.nextAction ? truncateInline(evidence.nextAction, 500) : "none recorded"}`,
|
||||
"",
|
||||
"## Thresholds",
|
||||
"",
|
||||
`- No-comment streak: ${evidence.thresholds.noCommentStreakRuns} completed runs`,
|
||||
`- Long active duration: ${msToHuman(evidence.thresholds.longActiveMs)}`,
|
||||
`- High churn: ${evidence.thresholds.highChurnHourly}/1h or ${evidence.thresholds.highChurnSixHours}/6h runs/assignee-run comments`,
|
||||
`- Resolved-review snooze: ${msToHuman(evidence.thresholds.resolvedSnoozeMs)}`,
|
||||
"",
|
||||
"## Latest Runs",
|
||||
"",
|
||||
latestRuns,
|
||||
"",
|
||||
"## Latest Assignee Run Comments",
|
||||
"",
|
||||
latestComments,
|
||||
"",
|
||||
"## Usage Samples",
|
||||
"",
|
||||
usage,
|
||||
"",
|
||||
"## Manager Decision",
|
||||
"",
|
||||
"- Close as productive if this pattern is expected.",
|
||||
"- Continue with a snooze window if the current work should keep running without repeat review spam.",
|
||||
"- Request decomposition, reroute, block with an unblock owner, or stop/cancel the source work if the work is inefficient.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildRefreshComment(evidence: ProductivityReviewEvidence, prefix: string) {
|
||||
return [
|
||||
"Productivity review evidence refreshed.",
|
||||
"",
|
||||
`- Source issue: ${issueUiLink(evidence.sourceIssue, prefix)}`,
|
||||
`- Trigger: \`${evidence.trigger}\` (${formatTrigger(evidence.trigger)})`,
|
||||
`- Reasons: ${evidence.triggerReasons.join("; ")}`,
|
||||
`- No-comment streak: ${evidence.noCommentStreak}`,
|
||||
`- Runs/assignee comments: ${evidence.runCountLastHour}/${evidence.commentCountLastHour} in 1h, ${evidence.runCountLastSixHours}/${evidence.commentCountLastSixHours} in 6h`,
|
||||
`- Next action: ${evidence.nextAction ? truncateInline(evidence.nextAction, 300) : "none recorded"}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function createOrUpdateReview(
|
||||
evidence: ProductivityReviewEvidence,
|
||||
opts: { prefix: string },
|
||||
) {
|
||||
const existing = await findOpenProductivityReview(evidence.sourceIssue.companyId, evidence.sourceIssue.id);
|
||||
if (existing) {
|
||||
await issuesSvc.addComment(existing.id, buildRefreshComment(evidence, opts.prefix), {});
|
||||
await logActivity(db, {
|
||||
companyId: evidence.sourceIssue.companyId,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
action: "issue.productivity_review_updated",
|
||||
entityType: "issue",
|
||||
entityId: existing.id,
|
||||
agentId: existing.assigneeAgentId,
|
||||
details: {
|
||||
source: "productivity_review.reconcile",
|
||||
sourceIssueId: evidence.sourceIssue.id,
|
||||
trigger: evidence.trigger,
|
||||
noCommentStreak: evidence.noCommentStreak,
|
||||
runCountLastHour: evidence.runCountLastHour,
|
||||
commentCountLastHour: evidence.commentCountLastHour,
|
||||
},
|
||||
});
|
||||
return { kind: "updated" as const, reviewIssueId: existing.id };
|
||||
}
|
||||
|
||||
const ownerAgentId = await resolveReviewOwnerAgentId(evidence.sourceIssue, evidence.sourceAgent);
|
||||
let review: Awaited<ReturnType<typeof issuesSvc.create>>;
|
||||
try {
|
||||
review = await issuesSvc.create(evidence.sourceIssue.companyId, {
|
||||
title: `Review productivity for ${evidence.sourceIssue.identifier ?? evidence.sourceIssue.title}`,
|
||||
description: buildReviewMarkdown(evidence, opts.prefix),
|
||||
status: "todo",
|
||||
priority: evidence.trigger === "long_active_duration" ? "medium" : "high",
|
||||
parentId: evidence.sourceIssue.id,
|
||||
projectId: evidence.sourceIssue.projectId,
|
||||
goalId: evidence.sourceIssue.goalId,
|
||||
billingCode: evidence.sourceIssue.billingCode,
|
||||
assigneeAgentId: ownerAgentId,
|
||||
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
originId: evidence.sourceIssue.id,
|
||||
originFingerprint: productivityReviewFingerprint(evidence.sourceIssue.id),
|
||||
requestDepth: clampIssueRequestDepth(clampIssueRequestDepth(evidence.sourceIssue.requestDepth) + 1),
|
||||
});
|
||||
} catch (error) {
|
||||
const maybe = error as { code?: string; constraint?: string; message?: string };
|
||||
const uniqueConflict = maybe.code === "23505" &&
|
||||
(
|
||||
maybe.constraint === "issues_active_productivity_review_uq" ||
|
||||
typeof maybe.message === "string" && maybe.message.includes("issues_active_productivity_review_uq")
|
||||
);
|
||||
if (!uniqueConflict) throw error;
|
||||
const raced = await findOpenProductivityReview(evidence.sourceIssue.companyId, evidence.sourceIssue.id);
|
||||
if (!raced) throw error;
|
||||
return { kind: "existing" as const, reviewIssueId: raced.id };
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: evidence.sourceIssue.companyId,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
action: "issue.productivity_review_created",
|
||||
entityType: "issue",
|
||||
entityId: review.id,
|
||||
agentId: ownerAgentId,
|
||||
details: {
|
||||
source: "productivity_review.reconcile",
|
||||
sourceIssueId: evidence.sourceIssue.id,
|
||||
trigger: evidence.trigger,
|
||||
noCommentStreak: evidence.noCommentStreak,
|
||||
runCountLastHour: evidence.runCountLastHour,
|
||||
commentCountLastHour: evidence.commentCountLastHour,
|
||||
},
|
||||
});
|
||||
|
||||
if (ownerAgentId && deps?.enqueueWakeup) {
|
||||
await deps.enqueueWakeup(ownerAgentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
issueId: review.id,
|
||||
sourceIssueId: evidence.sourceIssue.id,
|
||||
trigger: evidence.trigger,
|
||||
},
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: "productivity_review",
|
||||
contextSnapshot: {
|
||||
issueId: review.id,
|
||||
taskId: review.id,
|
||||
wakeReason: "issue_assigned",
|
||||
source: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
sourceIssueId: evidence.sourceIssue.id,
|
||||
productivityReviewTrigger: evidence.trigger,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { kind: "created" as const, reviewIssueId: review.id };
|
||||
}
|
||||
|
||||
async function reconcileProductivityReviews(opts?: {
|
||||
now?: Date;
|
||||
companyId?: string;
|
||||
thresholds?: Partial<ProductivityReviewThresholds>;
|
||||
}) {
|
||||
const now = opts?.now ?? new Date();
|
||||
const thresholds = buildThresholds(opts?.thresholds);
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
opts?.companyId ? eq(issues.companyId, opts.companyId) : undefined,
|
||||
isNull(issues.hiddenAt),
|
||||
isNull(issues.assigneeUserId),
|
||||
inArray(issues.status, ["todo", "in_progress"]),
|
||||
sql`${issues.assigneeAgentId} is not null`,
|
||||
sql`${issues.originKind} <> ${PRODUCTIVITY_REVIEW_ORIGIN_KIND}`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(issues.updatedAt), asc(issues.id))
|
||||
.limit(MAX_CANDIDATE_ISSUES);
|
||||
|
||||
const result = {
|
||||
scanned: candidates.length,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
existing: 0,
|
||||
snoozed: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
reviewIssueIds: [] as string[],
|
||||
failedIssueIds: [] as string[],
|
||||
};
|
||||
|
||||
const prefixCache = new Map<string, string>();
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.assigneeAgentId) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (await isProductivityReviewDescendant(candidate)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (await findRecentResolvedProductivityReview(candidate.companyId, candidate.id, thresholds, now)) {
|
||||
result.snoozed += 1;
|
||||
continue;
|
||||
}
|
||||
const sourceAgent = await getAgent(candidate.assigneeAgentId);
|
||||
if (!sourceAgent || sourceAgent.companyId !== candidate.companyId) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const evidence = await collectEvidence(candidate, sourceAgent, thresholds, now);
|
||||
if (!evidence) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let prefix = prefixCache.get(candidate.companyId);
|
||||
if (!prefix) {
|
||||
prefix = await getCompanyIssuePrefix(candidate.companyId);
|
||||
prefixCache.set(candidate.companyId, prefix);
|
||||
}
|
||||
try {
|
||||
const outcome = await createOrUpdateReview(evidence, { prefix });
|
||||
if (outcome.kind === "created") result.created += 1;
|
||||
else if (outcome.kind === "updated") result.updated += 1;
|
||||
else result.existing += 1;
|
||||
result.reviewIssueIds.push(outcome.reviewIssueId);
|
||||
} catch (err) {
|
||||
result.failed += 1;
|
||||
result.failedIssueIds.push(candidate.id);
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
companyId: candidate.companyId,
|
||||
issueId: candidate.id,
|
||||
requestDepth: candidate.requestDepth,
|
||||
},
|
||||
"productivity review reconciliation skipped malformed candidate",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function isProductivityReviewContinuationHoldActive(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
agentId: string;
|
||||
now?: Date;
|
||||
thresholds?: Partial<ProductivityReviewThresholds>;
|
||||
}) {
|
||||
const now = input.now ?? new Date();
|
||||
const thresholds = buildThresholds(input.thresholds);
|
||||
const [sourceIssue, sourceAgent, openReview] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId)))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
getAgent(input.agentId),
|
||||
findOpenProductivityReview(input.companyId, input.issueId),
|
||||
]);
|
||||
if (!sourceIssue || !sourceAgent || !openReview) return { held: false as const };
|
||||
if (sourceAgent.companyId !== input.companyId) return { held: false as const };
|
||||
const evidence = await collectEvidence(sourceIssue, sourceAgent, thresholds, now);
|
||||
if (!evidence || !isSoftStopTrigger(evidence.trigger)) return { held: false as const };
|
||||
return {
|
||||
held: true as const,
|
||||
reviewIssueId: openReview.id,
|
||||
reviewIdentifier: openReview.identifier,
|
||||
trigger: evidence.trigger,
|
||||
reason: evidence.triggerReasons.join("; "),
|
||||
};
|
||||
}
|
||||
|
||||
async function recordContinuationHold(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
runId: string;
|
||||
agentId: string;
|
||||
reviewIssueId: string;
|
||||
trigger: ProductivityReviewTrigger;
|
||||
reason: string;
|
||||
}) {
|
||||
await logActivity(db, {
|
||||
companyId: input.companyId,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
agentId: input.agentId,
|
||||
runId: input.runId,
|
||||
action: "issue.productivity_review_continuation_held",
|
||||
entityType: "issue",
|
||||
entityId: input.issueId,
|
||||
details: {
|
||||
source: "productivity_review.continuation_hold",
|
||||
reviewIssueId: input.reviewIssueId,
|
||||
trigger: input.trigger,
|
||||
reason: input.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
reconcileProductivityReviews,
|
||||
isProductivityReviewContinuationHoldActive,
|
||||
recordContinuationHold,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export const RECOVERY_ORIGIN_KINDS = {
|
||||
issueGraphLivenessEscalation: "harness_liveness_escalation",
|
||||
issueProductivityReview: "issue_productivity_review",
|
||||
strandedIssueRecovery: "stranded_issue_recovery",
|
||||
staleActiveRunEvaluation: "stale_active_run_evaluation",
|
||||
} as const;
|
||||
|
||||
@@ -72,7 +72,7 @@ type RecoveryWakeup = (
|
||||
|
||||
type LatestIssueRun = Pick<
|
||||
typeof heartbeatRuns.$inferSelect,
|
||||
"id" | "agentId" | "status" | "error" | "errorCode" | "contextSnapshot"
|
||||
"id" | "agentId" | "status" | "error" | "errorCode" | "contextSnapshot" | "livenessState"
|
||||
> | null;
|
||||
|
||||
type WatchdogDecisionActor =
|
||||
@@ -188,6 +188,18 @@ function isUnsuccessfulTerminalIssueRun(latestRun: LatestIssueRun) {
|
||||
);
|
||||
}
|
||||
|
||||
function isSuccessfulInProgressContinuationRun(latestRun: LatestIssueRun) {
|
||||
return latestRun?.status === "succeeded";
|
||||
}
|
||||
|
||||
function isProductiveContinuationRun(latestRun: LatestIssueRun) {
|
||||
return latestRun?.status === "succeeded" &&
|
||||
(latestRun.livenessState === "advanced" ||
|
||||
latestRun.livenessState === "completed" ||
|
||||
latestRun.livenessState === "blocked" ||
|
||||
latestRun.livenessState === "needs_followup");
|
||||
}
|
||||
|
||||
function parseLivenessIncidentKey(incidentKey: string | null | undefined) {
|
||||
if (!incidentKey) return null;
|
||||
return parseIssueGraphLivenessIncidentKey(incidentKey);
|
||||
@@ -299,6 +311,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
error: heartbeatRuns.error,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
@@ -332,7 +345,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`,
|
||||
),
|
||||
)
|
||||
@@ -1572,6 +1585,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
assignmentDispatched: 0,
|
||||
dispatchRequeued: 0,
|
||||
continuationRequeued: 0,
|
||||
productiveContinuationObserved: 0,
|
||||
successfulContinuationObserved: 0,
|
||||
orphanBlockersAssigned: 0,
|
||||
escalated: 0,
|
||||
skipped: 0,
|
||||
@@ -1690,6 +1705,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (isSuccessfulInProgressContinuationRun(latestRun)) {
|
||||
if (isProductiveContinuationRun(latestRun)) {
|
||||
result.productiveContinuationObserved += 1;
|
||||
} else {
|
||||
result.successfulContinuationObserved += 1;
|
||||
}
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (didAutomaticRecoveryFail(latestRun, "issue_continuation_needed")) {
|
||||
const failureSummary = summarizeRunFailureForIssueComment(latestRun);
|
||||
const updated = await escalateStrandedAssignedIssue({
|
||||
|
||||
+3
-2
@@ -46,6 +46,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-virtual": "^3.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -62,17 +63,17 @@
|
||||
"tailwind-merge": "^3.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.7",
|
||||
"@storybook/addon-a11y": "10.3.5",
|
||||
"@storybook/addon-docs": "10.3.5",
|
||||
"@storybook/react-vite": "10.3.5",
|
||||
"@tailwindcss/vite": "^4.0.7",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"storybook": "10.3.5",
|
||||
"tailwindcss": "^4.0.7",
|
||||
"typescript": "^5.7.3",
|
||||
"storybook": "10.3.5",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
|
||||
+6
-3
@@ -14,6 +14,7 @@ import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail";
|
||||
import { Workspaces } from "./pages/Workspaces";
|
||||
import { Issues } from "./pages/Issues";
|
||||
import { IssueDetail } from "./pages/IssueDetail";
|
||||
import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf";
|
||||
import { Routines } from "./pages/Routines";
|
||||
import { RoutineDetail } from "./pages/RoutineDetail";
|
||||
import { UserProfile } from "./pages/UserProfile";
|
||||
@@ -50,7 +51,7 @@ import { InviteLandingPage } from "./pages/InviteLanding";
|
||||
import { JoinRequestQueue } from "./pages/JoinRequestQueue";
|
||||
import { NotFoundPage } from "./pages/NotFound";
|
||||
import { useCompany } from "./context/CompanyContext";
|
||||
import { useDialog } from "./context/DialogContext";
|
||||
import { useDialogActions } from "./context/DialogContext";
|
||||
import { loadLastInboxTab } from "./lib/inbox";
|
||||
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
|
||||
|
||||
@@ -98,6 +99,7 @@ function boardRoutes() {
|
||||
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||
<Route path="routines" element={<Routines />} />
|
||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||
@@ -139,7 +141,7 @@ function LegacySettingsRedirect() {
|
||||
|
||||
function OnboardingRoutePage() {
|
||||
const { companies } = useCompany();
|
||||
const { openOnboarding } = useDialog();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||
const matchedCompany = companyPrefix
|
||||
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
|
||||
@@ -231,7 +233,7 @@ function UnprefixedBoardRedirect() {
|
||||
}
|
||||
|
||||
function NoCompaniesStartPage() {
|
||||
const { openOnboarding } = useDialog();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
@@ -256,6 +258,7 @@ export function App() {
|
||||
<Route path="board-claim/:token" element={<BoardClaimPage />} />
|
||||
<Route path="cli-auth/:id" element={<CliAuthPage />} />
|
||||
<Route path="invite/:token" element={<InviteLandingPage />} />
|
||||
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||
|
||||
<Route element={<CloudAccessGate />}>
|
||||
<Route index element={<CompanyRootRedirect />} />
|
||||
|
||||
@@ -32,6 +32,7 @@ export const companiesApi = {
|
||||
| "description"
|
||||
| "status"
|
||||
| "budgetMonthlyCents"
|
||||
| "attachmentMaxBytes"
|
||||
| "requireBoardApprovalForNewAgents"
|
||||
| "feedbackDataSharingEnabled"
|
||||
| "brandColor"
|
||||
|
||||
@@ -39,4 +39,12 @@ describe("issuesApi.list", () => {
|
||||
"/companies/company-1/issues?workspaceId=workspace-1&limit=1000",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes pagination offsets through to the company issues endpoint", async () => {
|
||||
await issuesApi.list("company-1", { limit: 500, offset: 1500 });
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/companies/company-1/issues?limit=500&offset=1500",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ export const issuesApi = {
|
||||
includeBlockedBy?: boolean;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -70,6 +71,7 @@ export const issuesApi = {
|
||||
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
|
||||
if (filters?.q) params.set("q", filters.q);
|
||||
if (filters?.limit) params.set("limit", String(filters.limit));
|
||||
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
|
||||
const qs = params.toString();
|
||||
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
||||
@@ -724,28 +724,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Prompt template (create mode only — edit mode shows this in Identity) */}
|
||||
{isLocal && isCreate && (
|
||||
<>
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
value={val!.promptTemplate}
|
||||
onChange={(v) => set!({ promptTemplate: v })}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = "agents/drafts/prompt-template";
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
Prompt template is replayed on every heartbeat. Prefer small task framing and variables like <code>{"{{ context.* }}"}</code> or <code>{"{{ run.* }}"}</code>; avoid repeating stable instructions here.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Adapter-specific fields are rendered inside Permissions & Configuration */}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ vi.mock("../context/CompanyContext", () => ({
|
||||
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => dialogState,
|
||||
useDialogActions: () => dialogState,
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -37,7 +37,7 @@ export function CommandPalette() {
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue, openNewAgent } = useDialog();
|
||||
const { openNewIssue, openNewAgent } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const searchQuery = query.trim();
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { cn } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||
@@ -125,7 +125,7 @@ function SortableCompanyItem({
|
||||
|
||||
export function CompanyRail() {
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { openOnboarding } = useDialog();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isInstanceRoute = location.pathname.startsWith("/instance/");
|
||||
|
||||
@@ -8,17 +8,40 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import {
|
||||
IssueChatThread,
|
||||
VIRTUALIZED_THREAD_ROW_THRESHOLD,
|
||||
canStopIssueChatRun,
|
||||
findLatestCommentMessageIndex,
|
||||
resolveAssistantMessageFoldedState,
|
||||
resolveIssueChatHumanAuthor,
|
||||
} from "./IssueChatThread";
|
||||
import { ToastProvider } from "../context/ToastContext";
|
||||
import { ToastViewport } from "./ToastViewport";
|
||||
import type {
|
||||
AskUserQuestionsInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
import {
|
||||
issueChatLongThreadAgentMap,
|
||||
issueChatLongThreadComments,
|
||||
issueChatLongThreadEvents,
|
||||
issueChatLongThreadLinkedRuns,
|
||||
issueChatLongThreadTranscriptsByRunId,
|
||||
} from "../fixtures/issueChatLongThreadFixture";
|
||||
import type {
|
||||
IssueChatLinkedRun,
|
||||
IssueChatTranscriptEntry,
|
||||
} from "../lib/issue-chat-messages";
|
||||
|
||||
const { markdownEditorFocusMock } = vi.hoisted(() => ({
|
||||
function hasSmoothScrollBehavior(arg: unknown) {
|
||||
return typeof arg === "object"
|
||||
&& arg !== null
|
||||
&& "behavior" in arg
|
||||
&& (arg as ScrollToOptions).behavior === "smooth";
|
||||
}
|
||||
|
||||
const { markdownBodyRenderMock, markdownEditorFocusMock } = vi.hoisted(() => ({
|
||||
markdownBodyRenderMock: vi.fn(),
|
||||
markdownEditorFocusMock: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -59,7 +82,10 @@ vi.mock("../lib/issue-chat-scroll", async (importOriginal) => {
|
||||
});
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
MarkdownBody: ({ children }: { children: ReactNode }) => {
|
||||
markdownBodyRenderMock(children);
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownEditor", () => ({
|
||||
@@ -273,6 +299,7 @@ describe("IssueChatThread", () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
window.scrollTo = vi.fn();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
@@ -284,6 +311,7 @@ describe("IssueChatThread", () => {
|
||||
captureComposerViewportSnapshotMock.mockClear();
|
||||
restoreComposerViewportSnapshotMock.mockClear();
|
||||
shouldPreserveComposerViewportMock.mockClear();
|
||||
markdownBodyRenderMock.mockClear();
|
||||
});
|
||||
|
||||
it("drops the count heading and does not use an internal scrollbox", () => {
|
||||
@@ -318,6 +346,536 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("virtualizes long merged threads so only a windowed slice mounts", () => {
|
||||
const root = createRoot(container);
|
||||
const totalMergedRows =
|
||||
issueChatLongThreadComments.length
|
||||
+ issueChatLongThreadEvents.length
|
||||
+ issueChatLongThreadLinkedRuns.length;
|
||||
expect(totalMergedRows).toBeGreaterThanOrEqual(VIRTUALIZED_THREAD_ROW_THRESHOLD);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={issueChatLongThreadComments}
|
||||
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||
timelineEvents={issueChatLongThreadEvents}
|
||||
liveRuns={[]}
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={async () => {}}
|
||||
showComposer={false}
|
||||
showJumpToLatest={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const virtualizer = container.querySelector(
|
||||
'[data-testid="issue-chat-thread-virtualizer"]',
|
||||
) as HTMLDivElement | null;
|
||||
expect(virtualizer).not.toBeNull();
|
||||
expect(virtualizer?.dataset.virtualCount).toBe(String(totalMergedRows));
|
||||
|
||||
const rows = container.querySelectorAll('[data-testid="issue-chat-message-row"]');
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.length).toBeLessThan(totalMergedRows);
|
||||
|
||||
const virtualRows = container.querySelectorAll(
|
||||
'[data-testid="issue-chat-thread-virtual-row"]',
|
||||
);
|
||||
expect(virtualRows.length).toBe(rows.length);
|
||||
for (const row of Array.from(virtualRows)) {
|
||||
const transform = (row as HTMLDivElement).style.transform;
|
||||
expect(transform).toMatch(/translateY\(/);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("scrolls loaded hash targets through the virtualized message index", () => {
|
||||
const root = createRoot(container);
|
||||
const targetComment = issueChatLongThreadComments.at(-1);
|
||||
expect(targetComment).toBeDefined();
|
||||
const scrollToMock = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={[`/issues/PAP-1#comment-${targetComment!.id}`]}>
|
||||
<IssueChatThread
|
||||
comments={issueChatLongThreadComments}
|
||||
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||
timelineEvents={issueChatLongThreadEvents}
|
||||
liveRuns={[]}
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={async () => {}}
|
||||
showComposer={false}
|
||||
showJumpToLatest={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))).toBe(true);
|
||||
|
||||
scrollToMock.mockRestore();
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the virtualizer when jumping to the latest long-thread row", () => {
|
||||
const root = createRoot(container);
|
||||
const scrollToMock = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={issueChatLongThreadComments}
|
||||
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||
timelineEvents={issueChatLongThreadEvents}
|
||||
liveRuns={[]}
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const jump = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Jump to latest",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(jump).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
jump?.click();
|
||||
});
|
||||
|
||||
expect(scrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))).toBe(true);
|
||||
|
||||
scrollToMock.mockRestore();
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression for PAP-2660: on the real issue page the chat thread is wrapped
|
||||
// in `<main id="main-content" overflow-auto>`, so the virtualizer must bind
|
||||
// to that ancestor's scroll instead of `window` (which never moves on
|
||||
// desktop). When mounted inside an overflow-auto ancestor the jump-to-latest
|
||||
// action must drive that element's scrollTo, not window.scrollTo.
|
||||
it("targets an overflow-auto ancestor instead of window scroll on jump-to-latest", () => {
|
||||
container.remove();
|
||||
const scrollHost = document.createElement("main");
|
||||
scrollHost.id = "main-content";
|
||||
scrollHost.style.overflowY = "auto";
|
||||
scrollHost.style.overflow = "auto";
|
||||
scrollHost.style.height = "640px";
|
||||
document.body.appendChild(scrollHost);
|
||||
container = document.createElement("div");
|
||||
scrollHost.appendChild(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
const windowScrollToMock = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
const elementScrollToMock = vi.fn();
|
||||
scrollHost.scrollTo = elementScrollToMock as unknown as typeof scrollHost.scrollTo;
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={issueChatLongThreadComments}
|
||||
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||
timelineEvents={issueChatLongThreadEvents}
|
||||
liveRuns={[]}
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const jump = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Jump to latest",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(jump).toBeDefined();
|
||||
|
||||
windowScrollToMock.mockClear();
|
||||
elementScrollToMock.mockClear();
|
||||
|
||||
act(() => {
|
||||
jump?.click();
|
||||
});
|
||||
|
||||
expect(elementScrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))).toBe(true);
|
||||
expect(windowScrollToMock.mock.calls.some(([arg]) => hasSmoothScrollBehavior(arg))).toBe(false);
|
||||
|
||||
windowScrollToMock.mockRestore();
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
scrollHost.remove();
|
||||
});
|
||||
|
||||
// Regression for PAP-2672: when the merged feed ends with a non-comment row
|
||||
// (run/timeline/embedded output) we still want Jump to latest to land on the
|
||||
// last comment, not whichever activity row sorts last.
|
||||
it("targets the latest comment row when trailing rows are non-comments (PAP-2672)", () => {
|
||||
const lastComment = issueChatLongThreadComments.at(-1);
|
||||
expect(lastComment).toBeDefined();
|
||||
const trailingRunStart = new Date(new Date(lastComment!.createdAt).getTime() + 60_000);
|
||||
const trailingRun: IssueChatLinkedRun = {
|
||||
runId: "trailing-run-pap-2672",
|
||||
status: "failed",
|
||||
agentId: "agent-perf-codex",
|
||||
agentName: "TrailingRunner",
|
||||
adapterType: "codex_local",
|
||||
createdAt: trailingRunStart,
|
||||
startedAt: trailingRunStart,
|
||||
finishedAt: trailingRunStart,
|
||||
hasStoredOutput: true,
|
||||
};
|
||||
const trailingTranscriptEntries: readonly IssueChatTranscriptEntry[] = [
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: trailingRunStart.toISOString(),
|
||||
text: "Trailing run posted after the latest comment.",
|
||||
},
|
||||
];
|
||||
const transcriptsByRunId = new Map(issueChatLongThreadTranscriptsByRunId);
|
||||
transcriptsByRunId.set(trailingRun.runId, trailingTranscriptEntries);
|
||||
const linkedRuns: IssueChatLinkedRun[] = [
|
||||
...issueChatLongThreadLinkedRuns,
|
||||
trailingRun,
|
||||
];
|
||||
|
||||
container.remove();
|
||||
const scrollHost = document.createElement("main");
|
||||
scrollHost.id = "main-content";
|
||||
scrollHost.style.overflowY = "auto";
|
||||
scrollHost.style.overflow = "auto";
|
||||
scrollHost.style.height = "800px";
|
||||
Object.defineProperty(scrollHost, "scrollHeight", {
|
||||
configurable: true,
|
||||
get: () => 200_000,
|
||||
});
|
||||
Object.defineProperty(scrollHost, "clientHeight", {
|
||||
configurable: true,
|
||||
get: () => 800,
|
||||
});
|
||||
document.body.appendChild(scrollHost);
|
||||
container = document.createElement("div");
|
||||
scrollHost.appendChild(container);
|
||||
|
||||
const elementScrollToMock = vi.fn();
|
||||
scrollHost.scrollTo = elementScrollToMock as unknown as typeof scrollHost.scrollTo;
|
||||
|
||||
const root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={issueChatLongThreadComments}
|
||||
linkedRuns={linkedRuns}
|
||||
timelineEvents={issueChatLongThreadEvents}
|
||||
liveRuns={[]}
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={transcriptsByRunId}
|
||||
hasOutputForRun={(runId) => transcriptsByRunId.has(runId)}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const virtualizerEl = container.querySelector<HTMLDivElement>(
|
||||
'[data-testid="issue-chat-thread-virtualizer"]',
|
||||
);
|
||||
expect(virtualizerEl).not.toBeNull();
|
||||
const totalMergedRows = Number(virtualizerEl?.dataset.virtualCount ?? "0");
|
||||
expect(totalMergedRows).toBeGreaterThan(VIRTUALIZED_THREAD_ROW_THRESHOLD);
|
||||
|
||||
elementScrollToMock.mockClear();
|
||||
|
||||
const jump = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Jump to latest",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(jump).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
jump?.click();
|
||||
});
|
||||
|
||||
const smoothCalls = elementScrollToMock.mock.calls
|
||||
.map((call) => call[0] as ScrollToOptions)
|
||||
.filter(hasSmoothScrollBehavior);
|
||||
expect(smoothCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// For align="end" with the very last index, tanstack-virtual short-circuits
|
||||
// to getMaxScrollOffset() (= scrollHeight - clientHeight = 199_200 here).
|
||||
// A jump to the latest comment row (one slot earlier) lands at item.end -
|
||||
// clientHeight, which is strictly less. Asserting top < maxScrollOffset
|
||||
// proves the button isn't routing to the trailing run row.
|
||||
const maxScrollOffset = 200_000 - 800;
|
||||
const lastTop = smoothCalls[smoothCalls.length - 1]?.top;
|
||||
expect(typeof lastTop).toBe("number");
|
||||
expect(lastTop as number).toBeLessThan(maxScrollOffset);
|
||||
expect(lastTop as number).toBeGreaterThan(0);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
scrollHost.remove();
|
||||
});
|
||||
|
||||
it("findLatestCommentMessageIndex prefers the last comment-anchored row (PAP-2672)", () => {
|
||||
const messages = [
|
||||
{ metadata: { custom: { anchorId: "comment-a" } } },
|
||||
{ metadata: { custom: { anchorId: "run-1" } } },
|
||||
{ metadata: { custom: { anchorId: "comment-b" } } },
|
||||
{ metadata: { custom: { anchorId: "run-2" } } },
|
||||
{ metadata: { custom: { anchorId: "activity-3" } } },
|
||||
];
|
||||
expect(findLatestCommentMessageIndex(messages as never)).toBe(2);
|
||||
expect(
|
||||
findLatestCommentMessageIndex([
|
||||
{ metadata: { custom: { anchorId: "run-only" } } },
|
||||
] as never),
|
||||
).toBe(-1);
|
||||
expect(findLatestCommentMessageIndex([] as never)).toBe(-1);
|
||||
});
|
||||
|
||||
it("keeps the direct render path for short threads under the virtualization threshold", () => {
|
||||
const root = createRoot(container);
|
||||
const directComments = issueChatLongThreadComments.slice(0, 12);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={directComments}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={async () => {}}
|
||||
showComposer={false}
|
||||
showJumpToLatest={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="issue-chat-thread-virtualizer"]'),
|
||||
).toBeNull();
|
||||
const rows = container.querySelectorAll('[data-testid="issue-chat-message-row"]');
|
||||
expect(rows.length).toBe(directComments.length);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders virtualized rows with the same role/kind metadata as the direct path", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={issueChatLongThreadComments}
|
||||
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||
timelineEvents={issueChatLongThreadEvents}
|
||||
liveRuns={[]}
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={async () => {}}
|
||||
showComposer={false}
|
||||
showJumpToLatest={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const rows = container.querySelectorAll('[data-testid="issue-chat-message-row"]');
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
const roles = new Set<string>();
|
||||
const kinds = new Set<string>();
|
||||
for (const row of Array.from(rows)) {
|
||||
const element = row as HTMLDivElement;
|
||||
const role = element.dataset.messageRole;
|
||||
const kind = element.dataset.messageKind;
|
||||
if (role) roles.add(role);
|
||||
if (kind) kinds.add(kind);
|
||||
}
|
||||
expect(roles.size).toBeGreaterThan(0);
|
||||
expect(kinds.size).toBeGreaterThan(0);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not re-render long-thread markdown rows for unrelated layout updates", () => {
|
||||
const root = createRoot(container);
|
||||
const onAdd = async () => {};
|
||||
const hasOutputForRun = (runId: string) => issueChatLongThreadTranscriptsByRunId.has(runId);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={issueChatLongThreadComments}
|
||||
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||
timelineEvents={issueChatLongThreadEvents}
|
||||
liveRuns={[]}
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={onAdd}
|
||||
showComposer={false}
|
||||
showJumpToLatest={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||
hasOutputForRun={hasOutputForRun}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(markdownBodyRenderMock).toHaveBeenCalled();
|
||||
markdownBodyRenderMock.mockClear();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={issueChatLongThreadComments}
|
||||
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||
timelineEvents={issueChatLongThreadEvents}
|
||||
liveRuns={[]}
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={onAdd}
|
||||
showComposer={false}
|
||||
showJumpToLatest
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||
hasOutputForRun={hasOutputForRun}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(markdownBodyRenderMock).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not re-render unchanged markdown when feedback votes change", () => {
|
||||
const root = createRoot(container);
|
||||
const onAdd = async () => {};
|
||||
const onVote = async () => {};
|
||||
const comments = [{
|
||||
id: "comment-agent-feedback",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: null,
|
||||
body: "Agent summary with **markdown**",
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
}];
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={comments}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={onAdd}
|
||||
onVote={onVote}
|
||||
feedbackVotes={[]}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(markdownBodyRenderMock).toHaveBeenCalled();
|
||||
markdownBodyRenderMock.mockClear();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={comments}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={onAdd}
|
||||
onVote={onVote}
|
||||
feedbackVotes={[{
|
||||
id: "feedback-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
targetType: "issue_comment",
|
||||
targetId: "comment-agent-feedback",
|
||||
authorUserId: "user-1",
|
||||
vote: "up",
|
||||
reason: null,
|
||||
sharedWithLabs: false,
|
||||
sharedAt: null,
|
||||
consentVersion: null,
|
||||
redactionSummary: null,
|
||||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
}]}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(markdownBodyRenderMock).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows explicit follow-up badges and event copy", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
@@ -1258,6 +1816,127 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("warns once before sending a reply with no assignee selected", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ToastProvider>
|
||||
<ToastViewport />
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
enableReassign
|
||||
reassignOptions={[
|
||||
{ id: "", label: "No assignee" },
|
||||
{ id: "agent:agent-1", label: "Agent 1" },
|
||||
]}
|
||||
currentAssigneeValue=""
|
||||
suggestedAssigneeValue=""
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||
const submitButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent === "Send",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(editor).not.toBeNull();
|
||||
expect(submitButton).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(editor, "Reply without assignee");
|
||||
editor?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.click();
|
||||
});
|
||||
|
||||
expect(appendMock).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("No assignee selected");
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.click();
|
||||
});
|
||||
|
||||
expect(appendMock).toHaveBeenCalledTimes(1);
|
||||
expect(appendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: [{ type: "text", text: "Reply without assignee" }],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not warn when sending a reply with an assignee selected", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ToastProvider>
|
||||
<ToastViewport />
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
enableReassign
|
||||
reassignOptions={[
|
||||
{ id: "", label: "No assignee" },
|
||||
{ id: "agent:agent-1", label: "Agent 1" },
|
||||
]}
|
||||
currentAssigneeValue="agent:agent-1"
|
||||
suggestedAssigneeValue="agent:agent-1"
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||
const submitButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent === "Send",
|
||||
) as HTMLButtonElement | undefined;
|
||||
|
||||
act(() => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(editor, "Reply with assignee");
|
||||
editor?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.click();
|
||||
});
|
||||
|
||||
expect(appendMock).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.textContent).not.toContain("No assignee selected");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes a composer focus handle that forwards to the editor", () => {
|
||||
const root = createRoot(container);
|
||||
const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>();
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
createContext,
|
||||
Component,
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
@@ -26,6 +28,12 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Link, useLocation } from "@/lib/router";
|
||||
import {
|
||||
observeWindowOffset,
|
||||
observeWindowRect,
|
||||
useVirtualizer,
|
||||
windowScroll,
|
||||
} from "@tanstack/react-virtual";
|
||||
import type {
|
||||
Agent,
|
||||
FeedbackDataSharingPreference,
|
||||
@@ -88,6 +96,7 @@ import {
|
||||
shouldPreserveComposerViewport,
|
||||
} from "../lib/issue-chat-scroll";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { useOptionalToastActions } from "../context/ToastContext";
|
||||
import type { CompanyUserProfile } from "../lib/company-members";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import {
|
||||
@@ -107,24 +116,20 @@ import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loa
|
||||
import { IssueBlockedNotice } from "./IssueBlockedNotice";
|
||||
|
||||
interface IssueChatMessageContext {
|
||||
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
||||
feedbackDataSharingPreference: FeedbackDataSharingPreference;
|
||||
feedbackTermsUrl: string | null;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
activeRunIds: ReadonlySet<string>;
|
||||
onVote?: (
|
||||
commentId: string,
|
||||
vote: FeedbackVoteValue,
|
||||
options?: { allowSharing?: boolean; reason?: string },
|
||||
) => Promise<void>;
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
stoppingRunId?: string | null;
|
||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||
onCancelQueued?: (commentId: string) => void;
|
||||
interruptingQueuedRunId?: string | null;
|
||||
onImageClick?: (src: string) => void;
|
||||
onAcceptInteraction?: (
|
||||
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
|
||||
@@ -141,10 +146,8 @@ interface IssueChatMessageContext {
|
||||
}
|
||||
|
||||
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||
feedbackVoteByTargetId: new Map(),
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
feedbackTermsUrl: null,
|
||||
activeRunIds: new Set<string>(),
|
||||
});
|
||||
|
||||
export function resolveAssistantMessageFoldedState(args: {
|
||||
@@ -209,6 +212,18 @@ function useLiveElapsed(startMs: number | null | undefined, active: boolean): st
|
||||
return formatDurationWords(Date.now() - startMs);
|
||||
}
|
||||
|
||||
function useStableEvent<T extends (...args: never[]) => unknown>(callback: T | undefined): T | undefined {
|
||||
const callbackRef = useRef(callback);
|
||||
useLayoutEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!callback) return undefined;
|
||||
return ((...args: Parameters<T>) => callbackRef.current?.(...args)) as T;
|
||||
}, [Boolean(callback)]);
|
||||
}
|
||||
|
||||
interface CommentReassignment {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
@@ -538,6 +553,10 @@ function shouldImplicitlyReopenComment(issueStatus: string | undefined, assignee
|
||||
return resumesToTodo && assigneeValue.startsWith("agent:");
|
||||
}
|
||||
|
||||
function isUnassignedReassignValue(value: string): boolean {
|
||||
return !value || value === "__none__";
|
||||
}
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function commentDateLabel(date: Date | string | undefined): string {
|
||||
@@ -547,7 +566,7 @@ function commentDateLabel(date: Date | string | undefined): string {
|
||||
return formatShortDate(date);
|
||||
}
|
||||
|
||||
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
||||
const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
||||
const { onImageClick } = useContext(IssueChatCtx);
|
||||
return (
|
||||
<MarkdownBody
|
||||
@@ -559,7 +578,7 @@ function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolea
|
||||
{text}
|
||||
</MarkdownBody>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function humanizeValue(value: string | null) {
|
||||
if (!value) return "None";
|
||||
@@ -1047,7 +1066,7 @@ function getThreadMessageCopyText(message: ThreadMessage) {
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function IssueChatTextParts({
|
||||
const IssueChatTextParts = memo(function IssueChatTextParts({
|
||||
message,
|
||||
recessed = false,
|
||||
}: {
|
||||
@@ -1067,7 +1086,7 @@ function IssueChatTextParts({
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function groupAssistantParts(
|
||||
content: readonly ThreadMessage["content"][number][],
|
||||
@@ -1105,16 +1124,17 @@ function groupAssistantParts(
|
||||
return groups;
|
||||
}
|
||||
|
||||
function IssueChatAssistantParts({
|
||||
const IssueChatAssistantParts = memo(function IssueChatAssistantParts({
|
||||
message,
|
||||
hasCoT,
|
||||
}: {
|
||||
message: ThreadMessage;
|
||||
hasCoT: boolean;
|
||||
}) {
|
||||
const groupedParts = useMemo(() => groupAssistantParts(message.content), [message.content]);
|
||||
return (
|
||||
<>
|
||||
{groupAssistantParts(message.content).map((group) => {
|
||||
{groupedParts.map((group) => {
|
||||
if (group.type === "text") {
|
||||
return (
|
||||
<IssueChatTextPart
|
||||
@@ -1134,13 +1154,18 @@ function IssueChatAssistantParts({
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function IssueChatUserMessage({ message }: { message: ThreadMessage }) {
|
||||
function IssueChatUserMessage({
|
||||
message,
|
||||
isInterruptingQueuedRun,
|
||||
}: {
|
||||
message: ThreadMessage;
|
||||
isInterruptingQueuedRun: boolean;
|
||||
}) {
|
||||
const {
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
currentUserId,
|
||||
userProfileMap,
|
||||
} = useContext(IssueChatCtx);
|
||||
@@ -1201,10 +1226,10 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||
disabled={interruptingQueuedRunId === queueTargetRunId}
|
||||
disabled={isInterruptingQueuedRun}
|
||||
onClick={() => void onInterruptQueued(queueTargetRunId)}
|
||||
>
|
||||
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
|
||||
{isInterruptingQueuedRun ? "Interrupting..." : "Interrupt"}
|
||||
</Button>
|
||||
) : null}
|
||||
{onCancelQueued ? (
|
||||
@@ -1290,16 +1315,23 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) {
|
||||
);
|
||||
}
|
||||
|
||||
function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
|
||||
function IssueChatAssistantMessage({
|
||||
message,
|
||||
activeVote,
|
||||
isRunActive,
|
||||
isStoppingRun,
|
||||
}: {
|
||||
message: ThreadMessage;
|
||||
activeVote: FeedbackVoteValue | null;
|
||||
isRunActive: boolean;
|
||||
isStoppingRun: boolean;
|
||||
}) {
|
||||
const {
|
||||
feedbackVoteByTargetId,
|
||||
feedbackDataSharingPreference,
|
||||
feedbackTermsUrl,
|
||||
onVote,
|
||||
agentMap,
|
||||
activeRunIds,
|
||||
onStopRun,
|
||||
stoppingRunId,
|
||||
} = useContext(IssueChatCtx);
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
@@ -1321,7 +1353,7 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
|
||||
const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : "";
|
||||
const isRunning = message.role === "assistant" && message.status?.type === "running";
|
||||
const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
|
||||
const canStopRun = canStopIssueChatRun({ runId, runStatus, activeRunIds });
|
||||
const canStopRun = Boolean(runId) && (isRunActive || runStatus === "queued" || runStatus === "running");
|
||||
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
|
||||
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
|
||||
const isFoldable = !isRunning && !!chainOfThoughtLabel;
|
||||
@@ -1355,7 +1387,6 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
|
||||
await onVote(commentId, vote, options);
|
||||
};
|
||||
|
||||
const activeVote = commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null;
|
||||
const followUpRequested = custom.followUpRequested === true;
|
||||
|
||||
return (
|
||||
@@ -1493,14 +1524,14 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
|
||||
</DropdownMenuItem>
|
||||
{canStopRun && onStopRun && runId ? (
|
||||
<DropdownMenuItem
|
||||
disabled={stoppingRunId === runId}
|
||||
disabled={isStoppingRun}
|
||||
className="text-red-700 focus:text-red-800 dark:text-red-300 dark:focus:text-red-200"
|
||||
onSelect={() => {
|
||||
void onStopRun(runId);
|
||||
}}
|
||||
>
|
||||
<Square className="mr-2 h-3.5 w-3.5 fill-current" />
|
||||
{stoppingRunId === runId ? "Stopping…" : "Stop run"}
|
||||
{isStoppingRun ? "Stopping…" : "Stop run"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{runHref ? (
|
||||
@@ -2022,6 +2053,436 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function issueChatMessageCustom(message: ThreadMessage): Record<string, unknown> {
|
||||
return (message.metadata?.custom ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function issueChatMessageKind(message: ThreadMessage): string {
|
||||
const custom = issueChatMessageCustom(message);
|
||||
return typeof custom.kind === "string" ? custom.kind : message.role;
|
||||
}
|
||||
|
||||
function issueChatMessageCommentId(message: ThreadMessage): string | null {
|
||||
const custom = issueChatMessageCustom(message);
|
||||
return typeof custom.commentId === "string" ? custom.commentId : null;
|
||||
}
|
||||
|
||||
function issueChatMessageRunId(message: ThreadMessage): string | null {
|
||||
const custom = issueChatMessageCustom(message);
|
||||
return typeof custom.runId === "string" ? custom.runId : null;
|
||||
}
|
||||
|
||||
function issueChatMessageQueueTargetRunId(message: ThreadMessage): string | null {
|
||||
const custom = issueChatMessageCustom(message);
|
||||
return typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
|
||||
}
|
||||
|
||||
function issueChatMessageActiveVote(
|
||||
message: ThreadMessage,
|
||||
feedbackVoteByTargetId: ReadonlyMap<string, FeedbackVoteValue>,
|
||||
): FeedbackVoteValue | null {
|
||||
const commentId = issueChatMessageCommentId(message);
|
||||
return commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null;
|
||||
}
|
||||
|
||||
function issueChatMessageRunIsActive(
|
||||
message: ThreadMessage,
|
||||
activeRunIds: ReadonlySet<string>,
|
||||
): boolean {
|
||||
const runId = issueChatMessageRunId(message);
|
||||
return Boolean(runId && activeRunIds.has(runId));
|
||||
}
|
||||
|
||||
function issueChatMessageRunIsStopping(
|
||||
message: ThreadMessage,
|
||||
stoppingRunId: string | null | undefined,
|
||||
): boolean {
|
||||
const runId = issueChatMessageRunId(message);
|
||||
return Boolean(runId && stoppingRunId === runId);
|
||||
}
|
||||
|
||||
function issueChatMessageQueuedRunIsInterrupting(
|
||||
message: ThreadMessage,
|
||||
interruptingQueuedRunId: string | null | undefined,
|
||||
): boolean {
|
||||
const queueTargetRunId = issueChatMessageQueueTargetRunId(message);
|
||||
return Boolean(queueTargetRunId && interruptingQueuedRunId === queueTargetRunId);
|
||||
}
|
||||
|
||||
// Above ~150 merged rows the direct render path forces React to mount and
|
||||
// re-render hundreds of Markdown bodies, feedback controls, and avatars on
|
||||
// unrelated parent updates. Above this threshold we switch to a windowed
|
||||
// render path so only visible rows plus overscan stay mounted.
|
||||
export const VIRTUALIZED_THREAD_ROW_THRESHOLD = 150;
|
||||
const VIRTUALIZED_THREAD_OVERSCAN = 6;
|
||||
// Rough "average row" estimate. The virtualizer measures real heights as
|
||||
// rows mount, so this only affects offscreen rows it has not seen yet.
|
||||
const VIRTUALIZED_THREAD_ROW_ESTIMATE_PX = 220;
|
||||
const VIRTUALIZED_THREAD_GAP_FULL_PX = 16;
|
||||
const VIRTUALIZED_THREAD_GAP_EMBEDDED_PX = 12;
|
||||
|
||||
interface VirtualizedIssueChatThreadListProps {
|
||||
messages: readonly ThreadMessage[];
|
||||
feedbackVoteByTargetId: ReadonlyMap<string, FeedbackVoteValue>;
|
||||
activeRunIds: ReadonlySet<string>;
|
||||
stoppingRunId?: string | null;
|
||||
interruptingQueuedRunId?: string | null;
|
||||
variant: "full" | "embedded";
|
||||
}
|
||||
|
||||
interface VirtualizedIssueChatThreadListHandle {
|
||||
scrollToIndex: (
|
||||
index: number,
|
||||
options?: { align?: "start" | "center" | "end" | "auto"; behavior?: ScrollBehavior },
|
||||
) => void;
|
||||
scrollToLatest: (options?: { behavior?: ScrollBehavior }) => void;
|
||||
measure: () => void;
|
||||
}
|
||||
|
||||
function issueChatMessageAnchorId(message: ThreadMessage): string | null {
|
||||
const custom = message.metadata.custom as { anchorId?: unknown } | undefined;
|
||||
return typeof custom?.anchorId === "string" ? custom.anchorId : null;
|
||||
}
|
||||
|
||||
function findMessageAnchorIndex(messages: readonly ThreadMessage[], anchorId: string): number {
|
||||
return messages.findIndex((message) => issueChatMessageAnchorId(message) === anchorId);
|
||||
}
|
||||
|
||||
export function findLatestCommentMessageIndex(messages: readonly ThreadMessage[]): number {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const anchorId = issueChatMessageAnchorId(messages[index]);
|
||||
if (anchorId && anchorId.startsWith("comment-")) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
type VirtualizedVisibleAnchorSnapshot = {
|
||||
anchorId: string;
|
||||
index: number;
|
||||
viewportTop: number;
|
||||
};
|
||||
|
||||
type VirtualizedScrollMode =
|
||||
| { kind: "window" }
|
||||
| { kind: "element"; element: HTMLElement };
|
||||
|
||||
// The chat thread renders inside `<main id="main-content">` on the real issue
|
||||
// page (overflow-auto on desktop), but lives at document scope on mobile (main
|
||||
// is overflow-visible) and in the auth-free perf fixture. Walk the DOM to find
|
||||
// the actual scroll container so the virtualizer binds to the right offset
|
||||
// source — otherwise it stays anchored at offset 0 forever and the visible
|
||||
// chat area renders blank past the first viewport (PAP-2660).
|
||||
function findScrollContainer(el: HTMLElement | null): HTMLElement | null {
|
||||
if (!el || typeof window === "undefined") return null;
|
||||
let current: HTMLElement | null = el.parentElement;
|
||||
while (current && current !== document.body && current !== document.documentElement) {
|
||||
const overflowY = window.getComputedStyle(current).overflowY;
|
||||
if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const VirtualizedIssueChatThreadList = forwardRef<VirtualizedIssueChatThreadListHandle, VirtualizedIssueChatThreadListProps>(function VirtualizedIssueChatThreadList(props, ref) {
|
||||
const probeRef = useRef<HTMLDivElement | null>(null);
|
||||
// Default to window scroll on first render so the imperative handle is
|
||||
// available immediately for hash-target / submit-scroll effects. After mount
|
||||
// we probe the DOM and remount via key={modeKey} if the actual scroll
|
||||
// container is an element ancestor (e.g. desktop <main id="main-content">).
|
||||
const [mode, setMode] = useState<VirtualizedScrollMode>({ kind: "window" });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const detect = () => {
|
||||
const probe = probeRef.current;
|
||||
if (!probe) return;
|
||||
const container = findScrollContainer(probe);
|
||||
setMode((prev) => {
|
||||
if (container === null) {
|
||||
return prev.kind === "window" ? prev : { kind: "window" };
|
||||
}
|
||||
if (prev.kind === "element" && prev.element === container) return prev;
|
||||
return { kind: "element", element: container };
|
||||
});
|
||||
};
|
||||
detect();
|
||||
window.addEventListener("resize", detect);
|
||||
return () => {
|
||||
window.removeEventListener("resize", detect);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VirtualizedIssueChatThreadListInner
|
||||
key={mode.kind === "window" ? "window" : "element"}
|
||||
ref={ref}
|
||||
probeRef={probeRef}
|
||||
mode={mode}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
interface VirtualizedIssueChatThreadListInnerProps extends VirtualizedIssueChatThreadListProps {
|
||||
mode: VirtualizedScrollMode;
|
||||
probeRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const VirtualizedIssueChatThreadListInner = forwardRef<
|
||||
VirtualizedIssueChatThreadListHandle,
|
||||
VirtualizedIssueChatThreadListInnerProps
|
||||
>(function VirtualizedIssueChatThreadListInner({
|
||||
messages,
|
||||
feedbackVoteByTargetId,
|
||||
activeRunIds,
|
||||
stoppingRunId,
|
||||
interruptingQueuedRunId,
|
||||
variant,
|
||||
mode,
|
||||
probeRef,
|
||||
}, ref) {
|
||||
const parentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
const pendingPrependAnchorRef = useRef<VirtualizedVisibleAnchorSnapshot | null>(null);
|
||||
|
||||
const setRefs = useCallback((element: HTMLDivElement | null) => {
|
||||
parentRef.current = element;
|
||||
probeRef.current = element;
|
||||
}, [probeRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = parentRef.current;
|
||||
if (!element || typeof window === "undefined") return;
|
||||
const update = () => {
|
||||
if (!parentRef.current) return;
|
||||
const rect = parentRef.current.getBoundingClientRect();
|
||||
const offset = mode.kind === "window"
|
||||
? rect.top + window.scrollY
|
||||
: rect.top - mode.element.getBoundingClientRect().top + mode.element.scrollTop;
|
||||
setScrollMargin((previous) => (Math.abs(previous - offset) < 0.5 ? previous : offset));
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [mode]);
|
||||
|
||||
const gap = variant === "embedded"
|
||||
? VIRTUALIZED_THREAD_GAP_EMBEDDED_PX
|
||||
: VIRTUALIZED_THREAD_GAP_FULL_PX;
|
||||
|
||||
// Window-mode passes window-specific observers/scroll-fn through the same
|
||||
// useVirtualizer hook (the `Window` vs `Element` type mismatch in the
|
||||
// observer signatures is invisible at runtime — both reach into
|
||||
// instance.scrollElement/targetWindow uniformly — so we widen via `any`
|
||||
// rather than splitting into two component variants).
|
||||
const virtualizerOptions: Record<string, unknown> = mode.kind === "window"
|
||||
? {
|
||||
getScrollElement: () => (typeof document !== "undefined" ? window : null),
|
||||
observeElementRect: observeWindowRect,
|
||||
observeElementOffset: observeWindowOffset,
|
||||
scrollToFn: windowScroll,
|
||||
initialOffset: () => typeof window !== "undefined" ? window.scrollY : 0,
|
||||
}
|
||||
: {
|
||||
getScrollElement: () => mode.element,
|
||||
};
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
...(virtualizerOptions as Parameters<typeof useVirtualizer>[0]),
|
||||
count: messages.length,
|
||||
estimateSize: () => VIRTUALIZED_THREAD_ROW_ESTIMATE_PX,
|
||||
overscan: VIRTUALIZED_THREAD_OVERSCAN,
|
||||
scrollMargin,
|
||||
gap,
|
||||
getItemKey: (index) => messages[index]?.id ?? index,
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToIndex: (index, options) => {
|
||||
if (index < 0 || index >= messages.length) return;
|
||||
virtualizer.scrollToIndex(index, {
|
||||
align: options?.align ?? "center",
|
||||
behavior: options?.behavior ?? "smooth",
|
||||
});
|
||||
},
|
||||
scrollToLatest: (options) => {
|
||||
if (messages.length === 0) return;
|
||||
virtualizer.scrollToIndex(messages.length - 1, {
|
||||
align: "end",
|
||||
behavior: options?.behavior ?? "smooth",
|
||||
});
|
||||
},
|
||||
measure: () => {
|
||||
virtualizer.measure();
|
||||
},
|
||||
}), [messages.length, virtualizer]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
const element = parentRef.current;
|
||||
if (!element || typeof window === "undefined") return;
|
||||
const rows = Array.from(
|
||||
element.querySelectorAll<HTMLElement>("[data-anchor-id][data-index]"),
|
||||
);
|
||||
const visibleRow = rows.find((row) => row.getBoundingClientRect().bottom >= 0);
|
||||
if (!visibleRow) return;
|
||||
const anchorId = visibleRow.dataset.anchorId;
|
||||
const index = Number(visibleRow.dataset.index);
|
||||
if (!anchorId || !Number.isFinite(index)) return;
|
||||
pendingPrependAnchorRef.current = {
|
||||
anchorId,
|
||||
index,
|
||||
viewportTop: visibleRow.getBoundingClientRect().top,
|
||||
};
|
||||
};
|
||||
}, [messages]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pendingAnchor = pendingPrependAnchorRef.current;
|
||||
pendingPrependAnchorRef.current = null;
|
||||
virtualizer.measure();
|
||||
if (!pendingAnchor || typeof window === "undefined") return;
|
||||
const nextIndex = findMessageAnchorIndex(messages, pendingAnchor.anchorId);
|
||||
if (nextIndex <= pendingAnchor.index) return;
|
||||
|
||||
virtualizer.scrollToIndex(nextIndex, { align: "start", behavior: "auto" });
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.getElementById(pendingAnchor.anchorId);
|
||||
if (!element) return;
|
||||
const delta = element.getBoundingClientRect().top - pendingAnchor.viewportTop;
|
||||
if (Math.abs(delta) > 1) {
|
||||
if (mode.kind === "window") {
|
||||
window.scrollBy({ top: delta, behavior: "auto" });
|
||||
} else {
|
||||
mode.element.scrollBy({ top: delta, behavior: "auto" });
|
||||
}
|
||||
}
|
||||
virtualizer.measure();
|
||||
});
|
||||
}, [messages, virtualizer, mode]);
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
const totalSize = virtualizer.getTotalSize();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRefs}
|
||||
data-testid="issue-chat-thread-virtualizer"
|
||||
data-virtual-count={messages.length}
|
||||
style={{ position: "relative", width: "100%", height: totalSize }}
|
||||
>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const message = messages[virtualItem.index];
|
||||
if (!message) return null;
|
||||
const anchorId = issueChatMessageAnchorId(message);
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
data-anchor-id={anchorId ?? undefined}
|
||||
data-testid="issue-chat-thread-virtual-row"
|
||||
ref={(element) => {
|
||||
if (element) virtualizer.measureElement(element);
|
||||
}}
|
||||
onLoadCapture={(event) => {
|
||||
virtualizer.measureElement(event.currentTarget);
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
const row = event.currentTarget;
|
||||
requestAnimationFrame(() => {
|
||||
virtualizer.measureElement(row);
|
||||
});
|
||||
}}
|
||||
onTransitionEndCapture={(event) => {
|
||||
virtualizer.measureElement(event.currentTarget);
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
transform: `translateY(${virtualItem.start - scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
<IssueChatMessageRow
|
||||
message={message}
|
||||
feedbackVoteByTargetId={feedbackVoteByTargetId}
|
||||
activeRunIds={activeRunIds}
|
||||
stoppingRunId={stoppingRunId}
|
||||
interruptingQueuedRunId={interruptingQueuedRunId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface IssueChatMessageRowProps {
|
||||
message: ThreadMessage;
|
||||
feedbackVoteByTargetId: ReadonlyMap<string, FeedbackVoteValue>;
|
||||
activeRunIds: ReadonlySet<string>;
|
||||
stoppingRunId?: string | null;
|
||||
interruptingQueuedRunId?: string | null;
|
||||
}
|
||||
|
||||
const IssueChatMessageRow = memo(function IssueChatMessageRow({
|
||||
message,
|
||||
feedbackVoteByTargetId,
|
||||
activeRunIds,
|
||||
stoppingRunId,
|
||||
interruptingQueuedRunId,
|
||||
}: IssueChatMessageRowProps) {
|
||||
const kind = issueChatMessageKind(message);
|
||||
const activeVote = issueChatMessageActiveVote(message, feedbackVoteByTargetId);
|
||||
const isRunActive = issueChatMessageRunIsActive(message, activeRunIds);
|
||||
const isStoppingRun = issueChatMessageRunIsStopping(message, stoppingRunId);
|
||||
const isInterruptingQueuedRun = issueChatMessageQueuedRunIsInterrupting(message, interruptingQueuedRunId);
|
||||
const renderedMessage = message.role === "user"
|
||||
? (
|
||||
<IssueChatUserMessage
|
||||
message={message}
|
||||
isInterruptingQueuedRun={isInterruptingQueuedRun}
|
||||
/>
|
||||
)
|
||||
: message.role === "assistant"
|
||||
? (
|
||||
<IssueChatAssistantMessage
|
||||
message={message}
|
||||
activeVote={activeVote}
|
||||
isRunActive={isRunActive}
|
||||
isStoppingRun={isStoppingRun}
|
||||
/>
|
||||
)
|
||||
: <IssueChatSystemMessage message={message} />;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="issue-chat-message-row"
|
||||
data-message-role={message.role}
|
||||
data-message-kind={kind}
|
||||
>
|
||||
{renderedMessage}
|
||||
</div>
|
||||
);
|
||||
}, areIssueChatMessageRowPropsEqual);
|
||||
|
||||
function areIssueChatMessageRowPropsEqual(
|
||||
prev: IssueChatMessageRowProps,
|
||||
next: IssueChatMessageRowProps,
|
||||
) {
|
||||
if (prev.message !== next.message) return false;
|
||||
if (issueChatMessageActiveVote(prev.message, prev.feedbackVoteByTargetId) !== issueChatMessageActiveVote(next.message, next.feedbackVoteByTargetId)) return false;
|
||||
if (issueChatMessageRunIsActive(prev.message, prev.activeRunIds) !== issueChatMessageRunIsActive(next.message, next.activeRunIds)) return false;
|
||||
if (issueChatMessageRunIsStopping(prev.message, prev.stoppingRunId) !== issueChatMessageRunIsStopping(next.message, next.stoppingRunId)) return false;
|
||||
if (issueChatMessageQueuedRunIsInterrupting(prev.message, prev.interruptingQueuedRunId) !== issueChatMessageQueuedRunIsInterrupting(next.message, next.interruptingQueuedRunId)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerProps>(function IssueChatComposer({
|
||||
onImageUpload,
|
||||
onAttachImage,
|
||||
@@ -2037,6 +2498,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
issueStatus,
|
||||
}, forwardedRef) {
|
||||
const api = useAui();
|
||||
const toastActions = useOptionalToastActions();
|
||||
const [body, setBody] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
@@ -2045,6 +2507,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
const dragDepthRef = useRef(0);
|
||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
||||
const [unassignedConfirmed, setUnassignedConfirmed] = useState(false);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const composerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -2091,6 +2554,10 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [effectiveSuggestedAssigneeValue]);
|
||||
|
||||
useEffect(() => {
|
||||
setUnassignedConfirmed(false);
|
||||
}, [reassignTarget]);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: focusComposer,
|
||||
restoreDraft: (submittedBody: string) => {
|
||||
@@ -2108,6 +2575,22 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed || submitting) return;
|
||||
|
||||
const composerHasAssigneePicker = enableReassign && reassignOptions.length > 0;
|
||||
if (
|
||||
composerHasAssigneePicker
|
||||
&& isUnassignedReassignValue(reassignTarget)
|
||||
&& !unassignedConfirmed
|
||||
) {
|
||||
toastActions?.pushToast({
|
||||
title: "No assignee selected",
|
||||
body: "Pick an assignee or click Send again to post without one.",
|
||||
tone: "warn",
|
||||
dedupeKey: `issue-chat-no-assignee:${draftKey ?? ""}`,
|
||||
});
|
||||
setUnassignedConfirmed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : undefined;
|
||||
const reopen = shouldImplicitlyReopenComment(
|
||||
@@ -2119,6 +2602,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
setUnassignedConfirmed(false);
|
||||
try {
|
||||
const appendPromise = api.thread().append({
|
||||
role: "user",
|
||||
@@ -2493,7 +2977,8 @@ export function IssueChatThread({
|
||||
composerRef,
|
||||
}: IssueChatThreadProps) {
|
||||
const location = useLocation();
|
||||
const hasScrolledRef = useRef(false);
|
||||
const lastScrolledHashRef = useRef<string | null>(null);
|
||||
const virtualizedThreadRef = useRef<VirtualizedIssueChatThreadListHandle | null>(null);
|
||||
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerViewportAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerViewportSnapshotRef = useRef<ReturnType<typeof captureComposerViewportSnapshot>>(null);
|
||||
@@ -2614,6 +3099,42 @@ export function IssueChatThread({
|
||||
}
|
||||
return map;
|
||||
}, [feedbackVotes]);
|
||||
const useVirtualizedThread = messages.length >= VIRTUALIZED_THREAD_ROW_THRESHOLD;
|
||||
const messageAnchorIndex = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
messages.forEach((message, index) => {
|
||||
const anchorId = issueChatMessageAnchorId(message);
|
||||
if (anchorId) map.set(anchorId, index);
|
||||
});
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
function scrollToThreadAnchor(
|
||||
anchorId: string,
|
||||
options?: { align?: "start" | "center" | "end" | "auto"; behavior?: ScrollBehavior },
|
||||
) {
|
||||
const virtualIndex = messageAnchorIndex.get(anchorId);
|
||||
if (useVirtualizedThread && virtualIndex !== undefined) {
|
||||
if (!virtualizedThreadRef.current) return false;
|
||||
virtualizedThreadRef.current.scrollToIndex(virtualIndex, {
|
||||
align: options?.align ?? "center",
|
||||
behavior: options?.behavior ?? "smooth",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const element = document.getElementById(anchorId);
|
||||
if (!element) return false;
|
||||
element.scrollIntoView({
|
||||
behavior: options?.behavior ?? "smooth",
|
||||
block: options?.align === "start"
|
||||
? "start"
|
||||
: options?.align === "end"
|
||||
? "end"
|
||||
: "center",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const runtime = usePaperclipIssueRuntime({
|
||||
messages,
|
||||
@@ -2643,14 +3164,13 @@ export function IssueChatThread({
|
||||
spacerInitialReserveRef.current = reserve;
|
||||
setBottomSpacerHeight(reserve);
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(anchorId);
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
scrollToThreadAnchor(anchorId, { align: "start", behavior: "smooth" });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
lastUserMessageIdRef.current = lastUserId;
|
||||
}, [messages]);
|
||||
}, [messageAnchorIndex, messages, useVirtualizedThread]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchorId = spacerBaselineAnchorRef.current;
|
||||
@@ -2683,7 +3203,7 @@ export function IssueChatThread({
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
const hash = location.hash || (typeof window !== "undefined" ? window.location.hash : "");
|
||||
if (
|
||||
!(
|
||||
hash.startsWith("#comment-")
|
||||
@@ -2692,58 +3212,90 @@ export function IssueChatThread({
|
||||
|| hash.startsWith("#interaction-")
|
||||
)
|
||||
) return;
|
||||
if (messages.length === 0 || hasScrolledRef.current) return;
|
||||
if (messages.length === 0 || lastScrolledHashRef.current === hash) return;
|
||||
const targetId = hash.slice(1);
|
||||
const element = document.getElementById(targetId);
|
||||
if (!element) return;
|
||||
hasScrolledRef.current = true;
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, [location.hash, messages]);
|
||||
let cancelled = false;
|
||||
const attemptScroll = (finalAttempt = false) => {
|
||||
if (cancelled || lastScrolledHashRef.current === hash) return;
|
||||
const didScroll = scrollToThreadAnchor(targetId, { align: "center", behavior: "smooth" });
|
||||
if (!didScroll) return;
|
||||
if (finalAttempt || !useVirtualizedThread || document.getElementById(targetId)) {
|
||||
lastScrolledHashRef.current = hash;
|
||||
}
|
||||
};
|
||||
|
||||
attemptScroll();
|
||||
const frame = requestAnimationFrame(() => attemptScroll());
|
||||
const timeout = window.setTimeout(() => attemptScroll(true), 250);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(frame);
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [location.hash, messageAnchorIndex, messages, useVirtualizedThread]);
|
||||
|
||||
function handleJumpToLatest() {
|
||||
// Prefer the latest comment row over whichever run/timeline row happens to
|
||||
// sort last in the merged feed — see PAP-2672. Fall back to the trailing
|
||||
// row when no comment row exists (run-only/embedded outputs).
|
||||
const latestCommentIndex = findLatestCommentMessageIndex(messages);
|
||||
if (latestCommentIndex >= 0) {
|
||||
const latestCommentAnchor = issueChatMessageAnchorId(messages[latestCommentIndex]);
|
||||
if (
|
||||
latestCommentAnchor
|
||||
&& scrollToThreadAnchor(latestCommentAnchor, { align: "end", behavior: "smooth" })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (useVirtualizedThread) {
|
||||
virtualizedThreadRef.current?.scrollToLatest({ behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
bottomAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}
|
||||
|
||||
const stableOnVote = useStableEvent(onVote);
|
||||
const stableOnStopRun = useStableEvent(onStopRun);
|
||||
const stableOnInterruptQueued = useStableEvent(onInterruptQueued);
|
||||
const stableOnCancelQueued = useStableEvent(onCancelQueued);
|
||||
const stableOnImageClick = useStableEvent(onImageClick);
|
||||
const stableOnAcceptInteraction = useStableEvent(onAcceptInteraction);
|
||||
const stableOnRejectInteraction = useStableEvent(onRejectInteraction);
|
||||
const stableOnSubmitInteractionAnswers = useStableEvent(onSubmitInteractionAnswers);
|
||||
|
||||
const chatCtx = useMemo<IssueChatMessageContext>(
|
||||
() => ({
|
||||
feedbackVoteByTargetId,
|
||||
feedbackDataSharingPreference,
|
||||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
activeRunIds,
|
||||
onVote,
|
||||
onStopRun,
|
||||
stoppingRunId,
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
onImageClick,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onVote: stableOnVote,
|
||||
onStopRun: stableOnStopRun,
|
||||
onInterruptQueued: stableOnInterruptQueued,
|
||||
onCancelQueued: stableOnCancelQueued,
|
||||
onImageClick: stableOnImageClick,
|
||||
onAcceptInteraction: stableOnAcceptInteraction,
|
||||
onRejectInteraction: stableOnRejectInteraction,
|
||||
onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers,
|
||||
}),
|
||||
[
|
||||
feedbackVoteByTargetId,
|
||||
feedbackDataSharingPreference,
|
||||
feedbackTermsUrl,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
activeRunIds,
|
||||
onVote,
|
||||
onStopRun,
|
||||
stoppingRunId,
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
onImageClick,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
stableOnVote,
|
||||
stableOnStopRun,
|
||||
stableOnInterruptQueued,
|
||||
stableOnCancelQueued,
|
||||
stableOnImageClick,
|
||||
stableOnAcceptInteraction,
|
||||
stableOnRejectInteraction,
|
||||
stableOnSubmitInteractionAnswers,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2752,10 +3304,13 @@ export function IssueChatThread({
|
||||
?? (variant === "embedded"
|
||||
? "No run output yet."
|
||||
: "This issue conversation is empty. Start with a message below.");
|
||||
const errorBoundaryResetKey = useMemo(
|
||||
() => messages.map((message) => `${message.id}:${message.role}:${message.content.length}:${message.status?.type ?? "none"}`).join("|"),
|
||||
[messages],
|
||||
);
|
||||
const previousErrorBoundaryMessagesRef = useRef<readonly ThreadMessage[] | null>(null);
|
||||
const errorBoundaryResetVersionRef = useRef(0);
|
||||
if (previousErrorBoundaryMessagesRef.current !== messages) {
|
||||
previousErrorBoundaryMessagesRef.current = messages;
|
||||
errorBoundaryResetVersionRef.current += 1;
|
||||
}
|
||||
const errorBoundaryResetKey = String(errorBoundaryResetVersionRef.current);
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
@@ -2793,19 +3348,30 @@ export function IssueChatThread({
|
||||
)}>
|
||||
{resolvedEmptyMessage}
|
||||
</div>
|
||||
) : messages.length >= VIRTUALIZED_THREAD_ROW_THRESHOLD ? (
|
||||
<VirtualizedIssueChatThreadList
|
||||
ref={virtualizedThreadRef}
|
||||
messages={messages}
|
||||
feedbackVoteByTargetId={feedbackVoteByTargetId}
|
||||
activeRunIds={activeRunIds}
|
||||
stoppingRunId={stoppingRunId}
|
||||
interruptingQueuedRunId={interruptingQueuedRunId}
|
||||
variant={variant}
|
||||
/>
|
||||
) : (
|
||||
// Keep transcript rendering independent from assistant-ui's
|
||||
// index-scoped message providers; live transcripts can shrink
|
||||
// or regroup while the runtime still holds stale indices.
|
||||
messages.map((message) => {
|
||||
if (message.role === "user") {
|
||||
return <IssueChatUserMessage key={message.id} message={message} />;
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
return <IssueChatAssistantMessage key={message.id} message={message} />;
|
||||
}
|
||||
return <IssueChatSystemMessage key={message.id} message={message} />;
|
||||
})
|
||||
messages.map((message) => (
|
||||
<IssueChatMessageRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
feedbackVoteByTargetId={feedbackVoteByTargetId}
|
||||
activeRunIds={activeRunIds}
|
||||
stoppingRunId={stoppingRunId}
|
||||
interruptingQueuedRunId={interruptingQueuedRunId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{showComposer ? (
|
||||
<div data-testid="issue-chat-thread-notices" className="space-y-2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { X } from "lucide-react";
|
||||
import { Eye, X } from "lucide-react";
|
||||
import {
|
||||
createIssueDetailPath,
|
||||
rememberIssueDetailLocationState,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { cn } from "../lib/utils";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
|
||||
|
||||
type UnreadState = "hidden" | "visible" | "fading";
|
||||
|
||||
@@ -63,6 +64,19 @@ export function IssueRow({
|
||||
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
||||
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
|
||||
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
|
||||
const productivityReview = issue.productivityReview ?? null;
|
||||
const productivityReviewIndicator = productivityReview ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-300",
|
||||
selected ? "border-muted-foreground text-muted-foreground" : null,
|
||||
)}
|
||||
title={`Productivity review: ${productivityReviewTriggerLabel(productivityReview.trigger)}`}
|
||||
aria-label="Productivity review open"
|
||||
>
|
||||
<Eye className="h-2.5 w-2.5" aria-hidden />
|
||||
</span>
|
||||
) : null;
|
||||
const hasChecklistStep = checklistStepNumber !== null;
|
||||
const checklistStep = hasChecklistStep ? (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground" aria-hidden="true">
|
||||
@@ -87,8 +101,9 @@ export function IssueRow({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 pt-px sm:hidden">
|
||||
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
|
||||
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
|
||||
{productivityReviewIndicator}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
||||
<span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}>
|
||||
@@ -105,8 +120,9 @@ export function IssueRow({
|
||||
) : null}
|
||||
{desktopMetaLeading ?? (
|
||||
<>
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
|
||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />
|
||||
{productivityReviewIndicator}
|
||||
</span>
|
||||
{checklistStep}
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("../context/CompanyContext", () => ({
|
||||
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => dialogState,
|
||||
useDialogActions: () => dialogState,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
@@ -221,6 +222,20 @@ async function waitForMicrotaskAssertion(assertion: () => void, attempts = 20) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function setDocumentScrollMetrics({
|
||||
innerHeight,
|
||||
scrollY,
|
||||
scrollHeight,
|
||||
}: {
|
||||
innerHeight: number;
|
||||
scrollY: number;
|
||||
scrollHeight: number;
|
||||
}) {
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: innerHeight });
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, value: scrollY });
|
||||
Object.defineProperty(document.documentElement, "scrollHeight", { configurable: true, value: scrollHeight });
|
||||
}
|
||||
|
||||
function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
@@ -268,6 +283,7 @@ describe("IssuesList", () => {
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 2400 });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
@@ -507,6 +523,100 @@ describe("IssuesList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses multiple workflow blocker chips to the first blocker and a count", async () => {
|
||||
const issueDone = createIssue({
|
||||
id: "issue-done",
|
||||
identifier: "PAP-1",
|
||||
title: "Done first",
|
||||
status: "done",
|
||||
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
});
|
||||
const firstBlocker = createIssue({
|
||||
id: "issue-first-blocker",
|
||||
identifier: "PAP-2",
|
||||
title: "First blocker",
|
||||
status: "todo",
|
||||
createdAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||
});
|
||||
const secondBlocker = createIssue({
|
||||
id: "issue-second-blocker",
|
||||
identifier: "PAP-3",
|
||||
title: "Second blocker",
|
||||
status: "todo",
|
||||
createdAt: new Date("2026-04-03T00:00:00.000Z"),
|
||||
});
|
||||
const thirdBlocker = createIssue({
|
||||
id: "issue-third-blocker",
|
||||
identifier: "PAP-4",
|
||||
title: "Third blocker",
|
||||
status: "todo",
|
||||
createdAt: new Date("2026-04-04T00:00:00.000Z"),
|
||||
});
|
||||
const issueBlocked = createIssue({
|
||||
id: "issue-blocked",
|
||||
identifier: "PAP-5",
|
||||
title: "Blocked issue",
|
||||
status: "blocked",
|
||||
blockedBy: [
|
||||
{
|
||||
id: "issue-first-blocker",
|
||||
identifier: "PAP-2",
|
||||
title: "First blocker",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
{
|
||||
id: "issue-second-blocker",
|
||||
identifier: "PAP-3",
|
||||
title: "Second blocker",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
{
|
||||
id: "issue-third-blocker",
|
||||
identifier: "PAP-4",
|
||||
title: "Third blocker",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
createdAt: new Date("2026-04-05T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[issueBlocked, thirdBlocker, secondBlocker, firstBlocker, issueDone]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
defaultSortField="workflow"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("blocked by PAP-2");
|
||||
expect(container.textContent).toContain("... and 2 more");
|
||||
expect(container.textContent).not.toContain("blocked by PAP-3");
|
||||
expect(container.textContent).not.toContain("blocked by PAP-4");
|
||||
const blockerButtons = Array.from(container.querySelectorAll("button"))
|
||||
.filter((button) => button.textContent?.includes("blocked by"));
|
||||
expect(blockerButtons).toHaveLength(1);
|
||||
expect(blockerButtons[0]?.textContent).toBe("blocked by PAP-2 · step 2 ... and 2 more");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses hierarchical checklist step numbers when nested rows render inline", async () => {
|
||||
const firstRoot = createIssue({
|
||||
id: "issue-first-root",
|
||||
@@ -853,6 +963,86 @@ describe("IssuesList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps rendering local issue batches while the user stays near the bottom", async () => {
|
||||
const manyIssues = Array.from({ length: 420 }, (_, index) =>
|
||||
createIssue({
|
||||
id: `issue-${index + 1}`,
|
||||
identifier: `PAP-${index + 1}`,
|
||||
title: `Issue ${index + 1}`,
|
||||
}),
|
||||
);
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={manyIssues}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 1500, scrollHeight: 2000 });
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(420);
|
||||
expect(container.textContent).not.toContain("Rendering 250 of 420 issues");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("requests more server issues after scrolling past the rendered rows", async () => {
|
||||
const visibleIssues = Array.from({ length: 100 }, (_, index) =>
|
||||
createIssue({
|
||||
id: `issue-${index + 1}`,
|
||||
identifier: `PAP-${index + 1}`,
|
||||
title: `Issue ${index + 1}`,
|
||||
}),
|
||||
);
|
||||
const onLoadMoreIssues = vi.fn();
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={visibleIssues}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
hasMoreIssues
|
||||
onLoadMoreIssues={onLoadMoreIssues}
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 1500, scrollHeight: 2000 });
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(onLoadMoreIssues).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips deferred row sizing for expanded parent rows with visible children", async () => {
|
||||
const parentIssue = createIssue({
|
||||
id: "issue-parent",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { Link } from "@/lib/router";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
@@ -72,7 +72,7 @@ const ISSUE_SEARCH_RESULT_LIMIT = 200;
|
||||
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
|
||||
const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100;
|
||||
const ISSUE_ROW_RENDER_BATCH_SIZE = 150;
|
||||
const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0;
|
||||
const ISSUE_SCROLL_LOAD_THRESHOLD_PX = 320;
|
||||
const boardIssueStatuses = ISSUE_STATUSES;
|
||||
const issueStatusLabels: Record<IssueStatus, string> = {
|
||||
backlog: "Backlog",
|
||||
@@ -306,8 +306,11 @@ interface IssuesListProps {
|
||||
defaultSortField?: IssueSortField;
|
||||
showProgressSummary?: boolean;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
hasMoreIssues?: boolean;
|
||||
isLoadingMoreIssues?: boolean;
|
||||
mutedIssueIds?: Set<string>;
|
||||
issueBadgeById?: Map<string, string>;
|
||||
onLoadMoreIssues?: () => void;
|
||||
onSearchChange?: (search: string) => void;
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
}
|
||||
@@ -475,13 +478,16 @@ export function IssuesList({
|
||||
defaultSortField,
|
||||
showProgressSummary = false,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
hasMoreIssues = false,
|
||||
isLoadingMoreIssues = false,
|
||||
mutedIssueIds,
|
||||
issueBadgeById,
|
||||
onLoadMoreIssues,
|
||||
onSearchChange,
|
||||
onUpdateIssue,
|
||||
}: IssuesListProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
@@ -512,6 +518,7 @@ export function IssuesList({
|
||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||
const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT);
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(() => loadIssueColumns(scopedKey));
|
||||
const renderedIssueIdsRef = useRef("");
|
||||
const deferredIssueSearch = useDeferredValue(issueSearch);
|
||||
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
||||
|
||||
@@ -966,23 +973,74 @@ export function IssuesList({
|
||||
|
||||
useEffect(() => {
|
||||
if (viewState.viewMode !== "list") return;
|
||||
setRenderedIssueRowLimit(Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT));
|
||||
const nextIssueIds = filtered.map((issue) => issue.id).join("|");
|
||||
const previousIssueIds = renderedIssueIdsRef.current;
|
||||
renderedIssueIdsRef.current = nextIssueIds;
|
||||
|
||||
setRenderedIssueRowLimit((current) => {
|
||||
const nextInitialLimit = Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT);
|
||||
const listAppended = previousIssueIds.length > 0
|
||||
&& nextIssueIds.startsWith(previousIssueIds)
|
||||
&& filtered.length >= current;
|
||||
if (listAppended) return Math.min(filtered.length, Math.max(current, nextInitialLimit));
|
||||
return nextInitialLimit;
|
||||
});
|
||||
}, [filtered, viewState.viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasMoreRenderedRows = viewState.viewMode === "list" && renderedIssueRowLimit < filtered.length;
|
||||
const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0);
|
||||
const loadMoreIssueRows = useCallback(() => {
|
||||
if (viewState.viewMode !== "list") return;
|
||||
if (renderedIssueRowLimit >= filtered.length) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (hasMoreRenderedRows) {
|
||||
startTransition(() => {
|
||||
setRenderedIssueRowLimit((current) => Math.min(filtered.length, current + ISSUE_ROW_RENDER_BATCH_SIZE));
|
||||
});
|
||||
}, ISSUE_ROW_RENDER_BATCH_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
if (hasMoreIssues && !isLoadingMoreIssues) {
|
||||
onLoadMoreIssues?.();
|
||||
}
|
||||
}, [
|
||||
filtered.length,
|
||||
hasMoreIssues,
|
||||
hasMoreRenderedRows,
|
||||
isLoadingMoreIssues,
|
||||
onLoadMoreIssues,
|
||||
viewState.viewMode,
|
||||
]);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [filtered.length, renderedIssueRowLimit, viewState.viewMode]);
|
||||
const canLoadMoreIssues = viewState.viewMode === "list"
|
||||
&& !isLoading
|
||||
&& (hasMoreRenderedRows || (hasMoreIssues && !isLoadingMoreIssues));
|
||||
|
||||
const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0);
|
||||
useEffect(() => {
|
||||
if (!canLoadMoreIssues) return;
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
const checkScrollPosition = () => {
|
||||
if (animationFrameId !== null) return;
|
||||
animationFrameId = window.requestAnimationFrame(() => {
|
||||
animationFrameId = null;
|
||||
const documentElement = document.documentElement;
|
||||
if (documentElement.scrollHeight === 0) return;
|
||||
const scrollBottom = window.scrollY + window.innerHeight;
|
||||
const threshold = documentElement.scrollHeight - ISSUE_SCROLL_LOAD_THRESHOLD_PX;
|
||||
if (scrollBottom >= threshold) {
|
||||
loadMoreIssueRows();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", checkScrollPosition, { passive: true });
|
||||
window.addEventListener("resize", checkScrollPosition);
|
||||
checkScrollPosition();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", checkScrollPosition);
|
||||
window.removeEventListener("resize", checkScrollPosition);
|
||||
if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [canLoadMoreIssues, loadMoreIssueRows, renderedIssueRowLimit]);
|
||||
|
||||
const newIssueDefaults = useCallback((groupKey?: string) => {
|
||||
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
|
||||
@@ -1303,36 +1361,49 @@ export function IssuesList({
|
||||
const doneRowTitleClass = checklistMeta && issue.status === "done"
|
||||
? "text-muted-foreground"
|
||||
: undefined;
|
||||
const checklistDependencyChips = checklistMeta && unresolvedVisibleBlockers.length > 0 ? (
|
||||
<>
|
||||
{unresolvedVisibleBlockers.map((blockerId) => {
|
||||
const blockerIssue = issueById.get(blockerId);
|
||||
if (!blockerIssue) return null;
|
||||
const label = blockerIssue.identifier ?? blockerIssue.id.slice(0, 8);
|
||||
const blockerStep = checklistMeta.stepNumberByIssueId.get(blockerId);
|
||||
const blockerStepSuffix = blockerStep ? ` \u00b7 step ${blockerStep}` : "";
|
||||
const chipLabel = `blocked by ${label}${blockerStepSuffix}`;
|
||||
return (
|
||||
<button
|
||||
key={blockerId}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const target = document.getElementById(`issue-workflow-row-${blockerId}`);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
target.focus?.();
|
||||
}}
|
||||
className="inline-flex items-center rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 hover:bg-amber-100/80 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
|
||||
title={chipLabel}
|
||||
aria-label={chipLabel}
|
||||
>
|
||||
{chipLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
const visibleBlockerChips = unresolvedVisibleBlockers
|
||||
.map((blockerId) => {
|
||||
const blockerIssue = issueById.get(blockerId);
|
||||
if (!blockerIssue) return null;
|
||||
const label = blockerIssue.identifier ?? blockerIssue.id.slice(0, 8);
|
||||
const blockerStep = checklistMeta?.stepNumberByIssueId.get(blockerId);
|
||||
const blockerStepSuffix = blockerStep ? ` \u00b7 step ${blockerStep}` : "";
|
||||
return { blockerId, chipLabel: `blocked by ${label}${blockerStepSuffix}` };
|
||||
})
|
||||
.filter((chip): chip is { blockerId: string; chipLabel: string } => chip !== null);
|
||||
const firstVisibleBlockerChip = visibleBlockerChips[0] ?? null;
|
||||
const additionalVisibleBlockerCount = Math.max(visibleBlockerChips.length - 1, 0);
|
||||
const additionalVisibleBlockerLabel = additionalVisibleBlockerCount > 0
|
||||
? ` ... and ${additionalVisibleBlockerCount} more`
|
||||
: "";
|
||||
const firstVisibleBlockerDisplayLabel = firstVisibleBlockerChip
|
||||
? `${firstVisibleBlockerChip.chipLabel}${additionalVisibleBlockerLabel}`
|
||||
: "";
|
||||
const hiddenVisibleBlockerLabels = visibleBlockerChips
|
||||
.slice(1)
|
||||
.map((chip) => chip.chipLabel)
|
||||
.join(", ");
|
||||
const firstVisibleBlockerTitle = additionalVisibleBlockerCount > 0
|
||||
? `${firstVisibleBlockerDisplayLabel}: ${hiddenVisibleBlockerLabels}`
|
||||
: firstVisibleBlockerDisplayLabel;
|
||||
const checklistDependencyChips = checklistMeta && firstVisibleBlockerChip ? (
|
||||
<button
|
||||
key={firstVisibleBlockerChip.blockerId}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const target = document.getElementById(`issue-workflow-row-${firstVisibleBlockerChip.blockerId}`);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
target.focus?.();
|
||||
}}
|
||||
className="inline-flex items-center rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 hover:bg-amber-100/80 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
|
||||
title={firstVisibleBlockerTitle}
|
||||
aria-label={firstVisibleBlockerTitle}
|
||||
>
|
||||
{firstVisibleBlockerDisplayLabel}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
@@ -1556,10 +1627,16 @@ export function IssuesList({
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
{remainingIssueRowCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Rendering {Math.min(renderedIssueRowLimit, filtered.length)} of {filtered.length} issues
|
||||
</p>
|
||||
{(remainingIssueRowCount > 0 || hasMoreIssues || isLoadingMoreIssues) && (
|
||||
<div className="py-2" data-testid="issues-load-more-sentinel">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingMoreIssues
|
||||
? "Loading more issues..."
|
||||
: remainingIssueRowCount > 0
|
||||
? `Rendering ${Math.min(renderedIssueRowLimit, filtered.length)} of ${filtered.length} issues`
|
||||
: "Scroll to load more issues"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -100,6 +100,10 @@ vi.mock("../context/DialogContext", () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
openOnboarding: vi.fn(),
|
||||
}),
|
||||
useDialogActions: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
openOnboarding: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/PanelContext", () => ({
|
||||
|
||||
@@ -18,7 +18,7 @@ import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -54,7 +54,7 @@ function readRememberedInstanceSettingsPath(): string {
|
||||
|
||||
export function Layout() {
|
||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||
const { openNewIssue, openOnboarding } = useDialog();
|
||||
const { openNewIssue, openOnboarding } = useDialogActions();
|
||||
const { togglePanelVisible } = usePanel();
|
||||
const {
|
||||
companies,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Inbox,
|
||||
} from "lucide-react";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||
@@ -37,7 +37,7 @@ type MobileNavItem = MobileNavLinkItem | MobileNavActionItem;
|
||||
export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
||||
const location = useLocation();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||
|
||||
const items = useMemo<MobileNavItem[]>(
|
||||
|
||||
@@ -372,6 +372,56 @@ describe("NewIssueDialog", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("submits the latest locally typed title and description", async () => {
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
const titleInput = container.querySelector('textarea[placeholder="Issue title"]') as HTMLTextAreaElement | null;
|
||||
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement | null;
|
||||
expect(titleInput).not.toBeNull();
|
||||
expect(descriptionInput).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(titleInput, "Typed issue");
|
||||
titleInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
await act(async () => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(descriptionInput, "Typed description");
|
||||
descriptionInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const submitButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Create Issue"));
|
||||
expect(submitButton).not.toBeUndefined();
|
||||
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
title: "Typed issue",
|
||||
description: "Typed description",
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("submits the parent assignee when a sub-issue opens with inherited defaults", async () => {
|
||||
dialogState.newIssueDefaults = {
|
||||
parentId: "issue-1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react";
|
||||
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent, type RefObject } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
@@ -269,6 +269,111 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
const IssueTitleTextarea = memo(function IssueTitleTextarea({
|
||||
value,
|
||||
pending,
|
||||
assigneeValue,
|
||||
projectId,
|
||||
descriptionEditorRef,
|
||||
assigneeSelectorRef,
|
||||
projectSelectorRef,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
pending: boolean;
|
||||
assigneeValue: string;
|
||||
projectId: string;
|
||||
descriptionEditorRef: RefObject<MarkdownEditorRef | null>;
|
||||
assigneeSelectorRef: RefObject<HTMLButtonElement | null>;
|
||||
projectSelectorRef: RefObject<HTMLButtonElement | null>;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const [draftValue, setDraftValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftValue(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
|
||||
placeholder="Issue title"
|
||||
rows={1}
|
||||
value={draftValue}
|
||||
onChange={(e) => {
|
||||
const nextValue = e.target.value;
|
||||
setDraftValue(nextValue);
|
||||
onChange(nextValue);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
readOnly={pending}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.nativeEvent.isComposing
|
||||
) {
|
||||
e.preventDefault();
|
||||
descriptionEditorRef.current?.focus();
|
||||
}
|
||||
if (e.key === "Tab" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (assigneeValue) {
|
||||
if (projectId) {
|
||||
descriptionEditorRef.current?.focus();
|
||||
} else {
|
||||
projectSelectorRef.current?.focus();
|
||||
}
|
||||
} else {
|
||||
assigneeSelectorRef.current?.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const IssueDescriptionEditor = memo(function IssueDescriptionEditor({
|
||||
value,
|
||||
expanded,
|
||||
mentions,
|
||||
descriptionEditorRef,
|
||||
imageUploadHandler,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
expanded: boolean;
|
||||
mentions: MentionOption[];
|
||||
descriptionEditorRef: RefObject<MarkdownEditorRef | null>;
|
||||
imageUploadHandler: (file: File) => Promise<string>;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const [draftValue, setDraftValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftValue(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
value={draftValue}
|
||||
onChange={(nextValue) => {
|
||||
setDraftValue(nextValue);
|
||||
onChange(nextValue);
|
||||
}}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
mentions={mentions}
|
||||
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function issueExecutionWorkspaceModeForExistingWorkspace(mode: string | null | undefined) {
|
||||
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
|
||||
return mode;
|
||||
@@ -286,6 +391,10 @@ export function NewIssueDialog() {
|
||||
const { pushToast } = useToastActions();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const titleRef = useRef("");
|
||||
const descriptionRef = useRef("");
|
||||
const [titleHasText, setTitleHasText] = useState(false);
|
||||
const [draftHasText, setDraftHasText] = useState(false);
|
||||
const [status, setStatus] = useState("todo");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [assigneeValue, setAssigneeValue] = useState("");
|
||||
@@ -463,6 +572,10 @@ export function NewIssueDialog() {
|
||||
return assetsApi.uploadImage(effectiveCompanyId, file, "issues/drafts");
|
||||
},
|
||||
});
|
||||
const uploadDescriptionImageHandler = useCallback(async (file: File) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
return asset.contentPath;
|
||||
}, [uploadDescriptionImage.mutateAsync]);
|
||||
|
||||
// Debounced draft saving
|
||||
const scheduleSave = useCallback(
|
||||
@@ -475,12 +588,22 @@ export function NewIssueDialog() {
|
||||
[],
|
||||
);
|
||||
|
||||
// Save draft on meaningful changes
|
||||
useEffect(() => {
|
||||
const setIssueText = useCallback((nextTitle: string, nextDescription: string) => {
|
||||
titleRef.current = nextTitle;
|
||||
descriptionRef.current = nextDescription;
|
||||
setTitle(nextTitle);
|
||||
setDescription(nextDescription);
|
||||
setTitleHasText(nextTitle.trim().length > 0);
|
||||
setDraftHasText(nextTitle.trim().length > 0 || nextDescription.trim().length > 0);
|
||||
}, []);
|
||||
|
||||
const queueDraftSave = useCallback((overrides: { title?: string; description?: string } = {}) => {
|
||||
if (!newIssueOpen) return;
|
||||
const nextTitle = overrides.title ?? titleRef.current;
|
||||
const nextDescription = overrides.description ?? descriptionRef.current;
|
||||
scheduleSave({
|
||||
title,
|
||||
description,
|
||||
title: nextTitle,
|
||||
description: nextDescription,
|
||||
status,
|
||||
priority,
|
||||
assigneeValue,
|
||||
@@ -495,8 +618,43 @@ export function NewIssueDialog() {
|
||||
selectedExecutionWorkspaceId,
|
||||
});
|
||||
}, [
|
||||
title,
|
||||
description,
|
||||
newIssueOpen,
|
||||
scheduleSave,
|
||||
status,
|
||||
priority,
|
||||
assigneeValue,
|
||||
reviewerValue,
|
||||
approverValue,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeChrome,
|
||||
executionWorkspaceMode,
|
||||
selectedExecutionWorkspaceId,
|
||||
]);
|
||||
|
||||
const handleTitleChange = useCallback((nextTitle: string) => {
|
||||
titleRef.current = nextTitle;
|
||||
const nextTitleHasText = nextTitle.trim().length > 0;
|
||||
const nextDraftHasText = nextTitleHasText || descriptionRef.current.trim().length > 0;
|
||||
setTitleHasText((current) => current === nextTitleHasText ? current : nextTitleHasText);
|
||||
setDraftHasText((current) => current === nextDraftHasText ? current : nextDraftHasText);
|
||||
queueDraftSave({ title: nextTitle });
|
||||
}, [queueDraftSave]);
|
||||
|
||||
const handleDescriptionChange = useCallback((nextDescription: string) => {
|
||||
descriptionRef.current = nextDescription;
|
||||
const nextDraftHasText = titleRef.current.trim().length > 0 || nextDescription.trim().length > 0;
|
||||
setDraftHasText((current) => current === nextDraftHasText ? current : nextDraftHasText);
|
||||
queueDraftSave({ description: nextDescription });
|
||||
}, [queueDraftSave]);
|
||||
|
||||
// Save draft on meaningful changes
|
||||
useEffect(() => {
|
||||
if (!newIssueOpen) return;
|
||||
queueDraftSave();
|
||||
}, [
|
||||
status,
|
||||
priority,
|
||||
assigneeValue,
|
||||
@@ -510,7 +668,7 @@ export function NewIssueDialog() {
|
||||
executionWorkspaceMode,
|
||||
selectedExecutionWorkspaceId,
|
||||
newIssueOpen,
|
||||
scheduleSave,
|
||||
queueDraftSave,
|
||||
]);
|
||||
|
||||
// Restore draft or apply defaults when dialog opens
|
||||
@@ -528,8 +686,7 @@ export function NewIssueDialog() {
|
||||
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
|
||||
? "reuse_existing"
|
||||
: (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject));
|
||||
setTitle(newIssueDefaults.title ?? "");
|
||||
setDescription(newIssueDefaults.description ?? "");
|
||||
setIssueText(newIssueDefaults.title ?? "", newIssueDefaults.description ?? "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
setProjectId(defaultProjectId);
|
||||
@@ -542,8 +699,7 @@ export function NewIssueDialog() {
|
||||
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
|
||||
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
|
||||
} else if (newIssueDefaults.title) {
|
||||
setTitle(newIssueDefaults.title);
|
||||
setDescription(newIssueDefaults.description ?? "");
|
||||
setIssueText(newIssueDefaults.title, newIssueDefaults.description ?? "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||
@@ -564,8 +720,7 @@ export function NewIssueDialog() {
|
||||
} else if (draft && draft.title.trim()) {
|
||||
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
|
||||
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
|
||||
setTitle(draft.title);
|
||||
setDescription(draft.description);
|
||||
setIssueText(draft.title, draft.description);
|
||||
setStatus(draft.status || "todo");
|
||||
setPriority(draft.priority);
|
||||
setAssigneeValue(
|
||||
@@ -591,6 +746,7 @@ export function NewIssueDialog() {
|
||||
} else {
|
||||
const defaultProjectId = newIssueDefaults.projectId ?? "";
|
||||
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
|
||||
setIssueText("", "");
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
setProjectId(defaultProjectId);
|
||||
@@ -607,7 +763,7 @@ export function NewIssueDialog() {
|
||||
setSelectedExecutionWorkspaceId("");
|
||||
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults, orderedProjects]);
|
||||
}, [newIssueOpen, newIssueDefaults, orderedProjects, setIssueText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsAssigneeOverrides) {
|
||||
@@ -637,8 +793,7 @@ export function NewIssueDialog() {
|
||||
}, []);
|
||||
|
||||
function reset() {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setIssueText("", "");
|
||||
setStatus("todo");
|
||||
setPriority("");
|
||||
setAssigneeValue("");
|
||||
@@ -687,7 +842,9 @@ export function NewIssueDialog() {
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!effectiveCompanyId || !title.trim() || createIssue.isPending) return;
|
||||
const currentTitle = titleRef.current.trim();
|
||||
const currentDescription = descriptionRef.current.trim();
|
||||
if (!effectiveCompanyId || !currentTitle || createIssue.isPending) return;
|
||||
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
|
||||
adapterType: assigneeAdapterType,
|
||||
modelOverride: assigneeModelOverride,
|
||||
@@ -716,8 +873,8 @@ export function NewIssueDialog() {
|
||||
createIssue.mutate({
|
||||
companyId: effectiveCompanyId,
|
||||
stagedFiles,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
title: currentTitle,
|
||||
description: currentDescription || undefined,
|
||||
status,
|
||||
priority: priority || "medium",
|
||||
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
|
||||
@@ -806,7 +963,7 @@ export function NewIssueDialog() {
|
||||
setStagedFiles((current) => current.filter((file) => file.id !== id));
|
||||
}
|
||||
|
||||
const hasDraft = title.trim().length > 0 || description.trim().length > 0 || stagedFiles.length > 0;
|
||||
const hasDraft = draftHasText || stagedFiles.length > 0;
|
||||
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
||||
const currentPriority = priorities.find((p) => p.value === priority);
|
||||
const currentAssignee = selectedAssigneeAgentId
|
||||
@@ -884,7 +1041,7 @@ export function NewIssueDialog() {
|
||||
})),
|
||||
[orderedProjects],
|
||||
);
|
||||
const savedDraft = loadDraft();
|
||||
const savedDraft = useMemo(() => newIssueOpen ? loadDraft() : null, [newIssueOpen]);
|
||||
const hasSavedDraft = Boolean(savedDraft?.title.trim() || savedDraft?.description.trim());
|
||||
const canDiscardDraft = hasDraft || hasSavedDraft;
|
||||
const createIssueErrorMessage =
|
||||
@@ -1056,42 +1213,15 @@ export function NewIssueDialog() {
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
{/* Title */}
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<textarea
|
||||
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
|
||||
placeholder="Issue title"
|
||||
rows={1}
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
readOnly={createIssue.isPending}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.nativeEvent.isComposing
|
||||
) {
|
||||
e.preventDefault();
|
||||
descriptionEditorRef.current?.focus();
|
||||
}
|
||||
if (e.key === "Tab" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (assigneeValue) {
|
||||
// Assignee already set — skip to project or description
|
||||
if (projectId) {
|
||||
descriptionEditorRef.current?.focus();
|
||||
} else {
|
||||
projectSelectorRef.current?.focus();
|
||||
}
|
||||
} else {
|
||||
assigneeSelectorRef.current?.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
<IssueTitleTextarea
|
||||
value={title}
|
||||
pending={createIssue.isPending}
|
||||
assigneeValue={assigneeValue}
|
||||
projectId={projectId}
|
||||
descriptionEditorRef={descriptionEditorRef}
|
||||
assigneeSelectorRef={assigneeSelectorRef}
|
||||
projectSelectorRef={projectSelectorRef}
|
||||
onChange={handleTitleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1466,18 +1596,13 @@ export function NewIssueDialog() {
|
||||
isFileDragOver && "bg-accent/20",
|
||||
)}
|
||||
>
|
||||
<MarkdownEditor
|
||||
ref={descriptionEditorRef}
|
||||
<IssueDescriptionEditor
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
expanded={expanded}
|
||||
mentions={mentionOptions}
|
||||
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={async (file) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
return asset.contentPath;
|
||||
}}
|
||||
descriptionEditorRef={descriptionEditorRef}
|
||||
imageUploadHandler={uploadDescriptionImageHandler}
|
||||
onChange={handleDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
{stagedFiles.length > 0 ? (
|
||||
@@ -1682,7 +1807,7 @@ export function NewIssueDialog() {
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-w-[8.5rem] disabled:opacity-100"
|
||||
disabled={!title.trim() || createIssue.isPending}
|
||||
disabled={!titleHasText || createIssue.isPending}
|
||||
onClick={handleSubmit}
|
||||
aria-busy={createIssue.isPending}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Eye } from "lucide-react";
|
||||
import type { IssueProductivityReview } from "@paperclipai/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
no_comment_streak: "No-comment streak",
|
||||
long_active_duration: "Long active duration",
|
||||
high_churn: "High churn",
|
||||
};
|
||||
|
||||
const REVIEW_STATUS_LABELS: Record<string, string> = {
|
||||
todo: "Open",
|
||||
in_progress: "In progress",
|
||||
in_review: "In review",
|
||||
blocked: "Blocked",
|
||||
backlog: "Open",
|
||||
};
|
||||
|
||||
export function productivityReviewTriggerLabel(
|
||||
trigger: IssueProductivityReview["trigger"],
|
||||
): string {
|
||||
if (!trigger) return "Productivity review";
|
||||
return TRIGGER_LABELS[trigger] ?? "Productivity review";
|
||||
}
|
||||
|
||||
export function ProductivityReviewBadge({
|
||||
review,
|
||||
className,
|
||||
hideLabel = false,
|
||||
}: {
|
||||
review: IssueProductivityReview;
|
||||
className?: string;
|
||||
hideLabel?: boolean;
|
||||
}) {
|
||||
const label = productivityReviewTriggerLabel(review.trigger);
|
||||
const reviewIdentifier = review.reviewIdentifier ?? review.reviewIssueId.slice(0, 8);
|
||||
const reviewPath = createIssueDetailPath(review.reviewIdentifier ?? review.reviewIssueId);
|
||||
const statusLabel = REVIEW_STATUS_LABELS[review.status] ?? review.status.replace(/_/g, " ");
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
to={reviewPath}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0 hover:bg-amber-500/20 transition-colors",
|
||||
className,
|
||||
)}
|
||||
aria-label={`Under review · productivity review ${reviewIdentifier} (${label})`}
|
||||
>
|
||||
<Eye className="h-3 w-3" aria-hidden />
|
||||
{hideLabel ? null : <span>Under review</span>}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="font-semibold">Productivity review open</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Trigger:</span> {label}
|
||||
</div>
|
||||
{typeof review.noCommentStreak === "number" && review.noCommentStreak > 0 ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">No-comment streak:</span>{" "}
|
||||
{review.noCommentStreak} runs
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<span className="text-muted-foreground">Review:</span> {reviewIdentifier} ({statusLabel})
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, HelpCircle } from "lucide-react";
|
||||
import { syncRoutineVariablesWithTemplate, type RoutineVariable } from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -65,8 +72,8 @@ export function RoutineVariablesEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-border/70 px-3 py-2 text-left">
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="overflow-hidden rounded-lg border border-border/70">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Variables</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -75,9 +82,9 @@ export function RoutineVariablesEditor({
|
||||
</div>
|
||||
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-3">
|
||||
<CollapsibleContent className="divide-y divide-border/70 border-t border-border/70">
|
||||
{syncedVariables.map((variable) => (
|
||||
<div key={variable.name} className="rounded-lg border border-border/70 p-4">
|
||||
<div key={variable.name} className="p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{`{{${variable.name}}}`}
|
||||
@@ -225,10 +232,108 @@ export function RoutineVariablesEditor({
|
||||
);
|
||||
}
|
||||
|
||||
type BuiltinVariableDoc = {
|
||||
name: string;
|
||||
example: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const BUILTIN_VARIABLE_DOCS: BuiltinVariableDoc[] = [
|
||||
{
|
||||
name: "date",
|
||||
example: "2026-04-28",
|
||||
description: "Current date in YYYY-MM-DD format (UTC) at the time the routine runs.",
|
||||
},
|
||||
{
|
||||
name: "timestamp",
|
||||
example: "April 28, 2026 at 12:17 PM UTC",
|
||||
description: "Human-readable date and time (UTC) at the time the routine runs.",
|
||||
},
|
||||
];
|
||||
|
||||
export function RoutineVariablesHint() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground">
|
||||
Use `{"{{variable_name}}"}` placeholders in the instructions to prompt for inputs when the routine runs.
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-dashed border-border/70 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Use `{"{{variable_name}}"}` placeholders in the instructions to prompt for inputs when the routine runs.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
className="shrink-0 rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Show variable help"
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Dialog open={helpOpen} onOpenChange={setHelpOpen}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Routine variables</DialogTitle>
|
||||
<DialogDescription>
|
||||
How to prompt for inputs and which variables Paperclip fills in automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 text-sm">
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Custom variables
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Type{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground">
|
||||
{"{{variable_name}}"}
|
||||
</code>{" "}
|
||||
anywhere in the title or instructions. Paperclip detects each placeholder, lists it
|
||||
under <span className="font-medium text-foreground">Variables</span>, and prompts
|
||||
for a value before each run.
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-muted-foreground">
|
||||
<li>Names must start with a letter and may use letters, numbers, and underscores.</li>
|
||||
<li>Pick a type (text, textarea, number, boolean, select), default value, and whether it is required.</li>
|
||||
<li>The same name reused across the title and instructions is treated as one variable.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Built-in variables
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
These are filled in automatically — no setup needed and they will not appear in the
|
||||
Variables list.
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-lg border border-border/70">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-muted/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Placeholder</th>
|
||||
<th className="px-3 py-2 font-medium">Example</th>
|
||||
<th className="px-3 py-2 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/70">
|
||||
{BUILTIN_VARIABLE_DOCS.map((entry) => (
|
||||
<tr key={entry.name} className="align-top">
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">{`{{${entry.name}}}`}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground">{entry.example}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{entry.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,9 @@ vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
}),
|
||||
useDialogActions: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
|
||||
@@ -18,7 +18,7 @@ import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { SidebarAgents } from "./SidebarAgents";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
@@ -29,7 +29,7 @@ import { PluginSlotOutlet } from "@/plugins/slots";
|
||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
|
||||
export function Sidebar() {
|
||||
const { openNewIssue } = useDialog();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
|
||||
@@ -61,6 +61,9 @@ vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => ({
|
||||
openNewAgent: mockOpenNewAgent,
|
||||
}),
|
||||
useDialogActions: () => ({
|
||||
openNewAgent: mockOpenNewAgent,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -158,7 +158,7 @@ export function SidebarAgents() {
|
||||
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewAgent } = useDialog();
|
||||
const { openNewAgent } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { pushToast } = useToastActions();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
@@ -124,7 +124,7 @@ function SortableProjectItem({
|
||||
export function SidebarProjects() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { openNewProject } = useDialog();
|
||||
const { openNewProject } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DialogProvider, useDialogActions, useDialogState } from "./DialogContext";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("DialogContext", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("keeps action-only consumers from rerendering when dialog state changes", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
let actionRenderCount = 0;
|
||||
let stateRenderCount = 0;
|
||||
|
||||
function ActionOnlyConsumer() {
|
||||
actionRenderCount += 1;
|
||||
const { openNewIssue } = useDialogActions();
|
||||
return <button onClick={() => openNewIssue()}>Open issue</button>;
|
||||
}
|
||||
|
||||
function StateConsumer() {
|
||||
stateRenderCount += 1;
|
||||
const { newIssueOpen } = useDialogState();
|
||||
return <span>{newIssueOpen ? "open" : "closed"}</span>;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<DialogProvider>
|
||||
<ActionOnlyConsumer />
|
||||
<StateConsumer />
|
||||
</DialogProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(actionRenderCount).toBe(1);
|
||||
expect(stateRenderCount).toBe(1);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
act(() => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("open");
|
||||
expect(actionRenderCount).toBe(1);
|
||||
expect(stateRenderCount).toBe(2);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
interface NewIssueDefaults {
|
||||
status?: string;
|
||||
@@ -48,7 +48,22 @@ interface DialogContextValue {
|
||||
closeOnboarding: () => void;
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextValue | null>(null);
|
||||
type DialogStateValue = Pick<
|
||||
DialogContextValue,
|
||||
| "newIssueOpen"
|
||||
| "newIssueDefaults"
|
||||
| "newProjectOpen"
|
||||
| "newGoalOpen"
|
||||
| "newGoalDefaults"
|
||||
| "newAgentOpen"
|
||||
| "onboardingOpen"
|
||||
| "onboardingOptions"
|
||||
>;
|
||||
|
||||
type DialogActionsValue = Omit<DialogContextValue, keyof DialogStateValue>;
|
||||
|
||||
const DialogStateContext = createContext<DialogStateValue | null>(null);
|
||||
const DialogActionsContext = createContext<DialogActionsValue | null>(null);
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [newIssueOpen, setNewIssueOpen] = useState(false);
|
||||
@@ -106,38 +121,84 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
setOnboardingOptions({});
|
||||
}, []);
|
||||
|
||||
const stateValue = useMemo<DialogStateValue>(
|
||||
() => ({
|
||||
newIssueOpen,
|
||||
newIssueDefaults,
|
||||
newProjectOpen,
|
||||
newGoalOpen,
|
||||
newGoalDefaults,
|
||||
newAgentOpen,
|
||||
onboardingOpen,
|
||||
onboardingOptions,
|
||||
}),
|
||||
[
|
||||
newIssueOpen,
|
||||
newIssueDefaults,
|
||||
newProjectOpen,
|
||||
newGoalOpen,
|
||||
newGoalDefaults,
|
||||
newAgentOpen,
|
||||
onboardingOpen,
|
||||
onboardingOptions,
|
||||
],
|
||||
);
|
||||
|
||||
const actionsValue = useMemo<DialogActionsValue>(
|
||||
() => ({
|
||||
openNewIssue,
|
||||
closeNewIssue,
|
||||
openNewProject,
|
||||
closeNewProject,
|
||||
openNewGoal,
|
||||
closeNewGoal,
|
||||
openNewAgent,
|
||||
closeNewAgent,
|
||||
openOnboarding,
|
||||
closeOnboarding,
|
||||
}),
|
||||
[
|
||||
openNewIssue,
|
||||
closeNewIssue,
|
||||
openNewProject,
|
||||
closeNewProject,
|
||||
openNewGoal,
|
||||
closeNewGoal,
|
||||
openNewAgent,
|
||||
closeNewAgent,
|
||||
openOnboarding,
|
||||
closeOnboarding,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider
|
||||
value={{
|
||||
newIssueOpen,
|
||||
newIssueDefaults,
|
||||
openNewIssue,
|
||||
closeNewIssue,
|
||||
newProjectOpen,
|
||||
openNewProject,
|
||||
closeNewProject,
|
||||
newGoalOpen,
|
||||
newGoalDefaults,
|
||||
openNewGoal,
|
||||
closeNewGoal,
|
||||
newAgentOpen,
|
||||
openNewAgent,
|
||||
closeNewAgent,
|
||||
onboardingOpen,
|
||||
onboardingOptions,
|
||||
openOnboarding,
|
||||
closeOnboarding,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
<DialogActionsContext.Provider value={actionsValue}>
|
||||
<DialogStateContext.Provider value={stateValue}>
|
||||
{children}
|
||||
</DialogStateContext.Provider>
|
||||
</DialogActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDialog() {
|
||||
const ctx = useContext(DialogContext);
|
||||
export function useDialogActions() {
|
||||
const ctx = useContext(DialogActionsContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useDialog must be used within DialogProvider");
|
||||
throw new Error("useDialogActions must be used within DialogProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useDialogState() {
|
||||
const ctx = useContext(DialogStateContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useDialogState must be used within DialogProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useDialog() {
|
||||
return {
|
||||
...useDialogState(),
|
||||
...useDialogActions(),
|
||||
};
|
||||
}
|
||||
@@ -186,6 +186,10 @@ export function useToastActions() {
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useOptionalToastActions() {
|
||||
return useContext(ToastActionsContext);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const toasts = useToastState();
|
||||
const actions = useToastActions();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
issueChatLongThreadAgentMap,
|
||||
issueChatLongThreadComments,
|
||||
issueChatLongThreadEvents,
|
||||
issueChatLongThreadLinkedRuns,
|
||||
issueChatLongThreadMarkdownCommentIds,
|
||||
issueChatLongThreadTranscriptsByRunId,
|
||||
LONG_THREAD_COMMENT_COUNT,
|
||||
LONG_THREAD_MARKDOWN_COMMENT_COUNT,
|
||||
} from "./issueChatLongThreadFixture";
|
||||
import { buildIssueChatMessages } from "../lib/issue-chat-messages";
|
||||
|
||||
describe("issueChatLongThreadFixture", () => {
|
||||
it("builds a deterministic long issue-thread shape", () => {
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: issueChatLongThreadComments,
|
||||
timelineEvents: issueChatLongThreadEvents,
|
||||
linkedRuns: issueChatLongThreadLinkedRuns,
|
||||
liveRuns: [],
|
||||
agentMap: issueChatLongThreadAgentMap,
|
||||
currentUserId: "user-board",
|
||||
transcriptsByRunId: issueChatLongThreadTranscriptsByRunId,
|
||||
hasOutputForRun: (runId) => issueChatLongThreadTranscriptsByRunId.has(runId),
|
||||
});
|
||||
|
||||
expect(issueChatLongThreadComments).toHaveLength(LONG_THREAD_COMMENT_COUNT);
|
||||
expect(issueChatLongThreadMarkdownCommentIds.size).toBe(LONG_THREAD_MARKDOWN_COMMENT_COUNT);
|
||||
expect(messages.length).toBeGreaterThanOrEqual(450);
|
||||
expect(messages.filter((message) => message.role === "assistant").length).toBeGreaterThanOrEqual(
|
||||
LONG_THREAD_MARKDOWN_COMMENT_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps markdown rows markdown-heavy enough to exercise MarkdownBody", () => {
|
||||
const markdownComments = issueChatLongThreadComments.filter((comment) =>
|
||||
issueChatLongThreadMarkdownCommentIds.has(comment.id),
|
||||
);
|
||||
|
||||
expect(markdownComments).toHaveLength(LONG_THREAD_MARKDOWN_COMMENT_COUNT);
|
||||
for (const comment of markdownComments.slice(0, 5)) {
|
||||
expect(comment.body).toContain("## Baseline note");
|
||||
expect(comment.body).toContain("```ts");
|
||||
expect(comment.body).toContain("| Metric | Value |");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import type { LiveRunForIssue } from "../api/heartbeats";
|
||||
import type {
|
||||
IssueChatComment,
|
||||
IssueChatLinkedRun,
|
||||
IssueChatTranscriptEntry,
|
||||
} from "../lib/issue-chat-messages";
|
||||
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
|
||||
export const LONG_THREAD_COMMENT_COUNT = 469;
|
||||
export const LONG_THREAD_MARKDOWN_COMMENT_COUNT = 150;
|
||||
export const LONG_THREAD_EVENT_COUNT = 12;
|
||||
export const LONG_THREAD_LINKED_RUN_COUNT = 6;
|
||||
|
||||
const baseTime = new Date("2026-04-28T14:00:00.000Z").getTime();
|
||||
|
||||
function atMinute(offset: number) {
|
||||
return new Date(baseTime + offset * 60_000);
|
||||
}
|
||||
|
||||
function createAgent(id: string, name: string, icon: string, urlKey: string): Agent {
|
||||
const now = new Date("2026-04-28T14:00:00.000Z");
|
||||
return {
|
||||
id,
|
||||
companyId: "company-long-thread",
|
||||
name,
|
||||
urlKey,
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
};
|
||||
}
|
||||
|
||||
const primaryAgent = createAgent("agent-perf-codex", "CodexCoder", "code", "codexcoder");
|
||||
const reviewerAgent = createAgent("agent-perf-reviewer", "ReviewBot", "sparkles", "reviewbot");
|
||||
|
||||
export const issueChatLongThreadAgentMap = new Map<string, Agent>([
|
||||
[primaryAgent.id, primaryAgent],
|
||||
[reviewerAgent.id, reviewerAgent],
|
||||
]);
|
||||
|
||||
function markdownBody(index: number) {
|
||||
return [
|
||||
`## Baseline note ${index}`,
|
||||
"",
|
||||
`This assistant update captures a deterministic markdown-heavy row for long-thread rendering. It references [PAP-${2600 + index}](/PAP/issues/PAP-${2600 + index}) and includes enough structure to exercise markdown parsing.`,
|
||||
"",
|
||||
"- Parsed checklist item one with inline `code`",
|
||||
"- Parsed checklist item two with **bold** and _italic_ text",
|
||||
"- Parsed checklist item three with a link to [Paperclip](/PAP/dashboard)",
|
||||
"",
|
||||
"| Metric | Value |",
|
||||
"| --- | ---: |",
|
||||
`| Fixture row | ${index} |`,
|
||||
`| Synthetic tokens | ${1200 + index} |`,
|
||||
"",
|
||||
"```ts",
|
||||
`const fixtureRow${index} = { markdown: true, deterministic: true };`,
|
||||
"```",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function plainUserBody(index: number) {
|
||||
return `Board checkpoint ${index}: keep the issue-detail page responsive while the thread is full of historical comments.`;
|
||||
}
|
||||
|
||||
function plainAssistantBody(index: number) {
|
||||
return `Processed checkpoint ${index}. The current direct-render path should keep this row mounted with the rest of the thread.`;
|
||||
}
|
||||
|
||||
function createComment(index: number): IssueChatComment {
|
||||
const isMarkdown = index < LONG_THREAD_MARKDOWN_COMMENT_COUNT;
|
||||
const isAssistant = isMarkdown || index % 4 === 1 || index % 4 === 2;
|
||||
const authorAgentId = isAssistant
|
||||
? (index % 7 === 0 ? reviewerAgent.id : primaryAgent.id)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: `long-thread-comment-${String(index + 1).padStart(3, "0")}`,
|
||||
companyId: "company-long-thread",
|
||||
issueId: "issue-long-thread",
|
||||
authorAgentId,
|
||||
authorUserId: authorAgentId ? null : "user-board",
|
||||
body: isMarkdown
|
||||
? markdownBody(index + 1)
|
||||
: authorAgentId
|
||||
? plainAssistantBody(index + 1)
|
||||
: plainUserBody(index + 1),
|
||||
createdAt: atMinute(index),
|
||||
updatedAt: atMinute(index),
|
||||
};
|
||||
}
|
||||
|
||||
export const issueChatLongThreadComments: IssueChatComment[] = Array.from(
|
||||
{ length: LONG_THREAD_COMMENT_COUNT },
|
||||
(_, index) => createComment(index),
|
||||
);
|
||||
|
||||
export const issueChatLongThreadMarkdownCommentIds = new Set(
|
||||
issueChatLongThreadComments
|
||||
.slice(0, LONG_THREAD_MARKDOWN_COMMENT_COUNT)
|
||||
.map((comment) => comment.id),
|
||||
);
|
||||
|
||||
export const issueChatLongThreadEvents: IssueTimelineEvent[] = Array.from(
|
||||
{ length: LONG_THREAD_EVENT_COUNT },
|
||||
(_, index) => ({
|
||||
id: `long-thread-event-${index + 1}`,
|
||||
createdAt: atMinute(index * 36 + 18),
|
||||
actorType: index % 3 === 0 ? "user" : "agent",
|
||||
actorId: index % 3 === 0 ? "user-board" : primaryAgent.id,
|
||||
statusChange: index % 2 === 0
|
||||
? { from: index === 0 ? "todo" : "in_progress", to: "in_progress" }
|
||||
: undefined,
|
||||
assigneeChange: index % 2 === 1
|
||||
? {
|
||||
from: { agentId: null, userId: null },
|
||||
to: { agentId: index % 4 === 1 ? primaryAgent.id : reviewerAgent.id, userId: null },
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
export const issueChatLongThreadLinkedRuns: IssueChatLinkedRun[] = Array.from(
|
||||
{ length: LONG_THREAD_LINKED_RUN_COUNT },
|
||||
(_, index) => ({
|
||||
runId: `long-thread-run-${index + 1}`,
|
||||
status: index % 3 === 0 ? "failed" : index % 3 === 1 ? "timed_out" : "succeeded",
|
||||
agentId: index % 2 === 0 ? primaryAgent.id : reviewerAgent.id,
|
||||
agentName: index % 2 === 0 ? primaryAgent.name : reviewerAgent.name,
|
||||
adapterType: "codex_local",
|
||||
createdAt: atMinute(index * 72 + 12),
|
||||
startedAt: atMinute(index * 72 + 12),
|
||||
finishedAt: atMinute(index * 72 + 16),
|
||||
hasStoredOutput: true,
|
||||
}),
|
||||
);
|
||||
|
||||
export const issueChatLongThreadLiveRuns: LiveRunForIssue[] = [];
|
||||
|
||||
export const issueChatLongThreadTranscriptsByRunId = new Map<string, readonly IssueChatTranscriptEntry[]>(
|
||||
issueChatLongThreadLinkedRuns.map((run, index) => [
|
||||
run.runId,
|
||||
[
|
||||
{
|
||||
kind: "thinking",
|
||||
ts: atMinute(index * 72 + 13).toISOString(),
|
||||
text: `Inspecting long-thread segment ${index + 1}.`,
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: atMinute(index * 72 + 14).toISOString(),
|
||||
name: "read_file",
|
||||
toolUseId: `long-thread-tool-${index + 1}`,
|
||||
input: { path: "ui/src/components/IssueChatThread.tsx" },
|
||||
},
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts: atMinute(index * 72 + 15).toISOString(),
|
||||
toolUseId: `long-thread-tool-${index + 1}`,
|
||||
content: "Confirmed the direct-render fixture keeps the full message subtree mounted.",
|
||||
isError: run.status !== "succeeded",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: atMinute(index * 72 + 16).toISOString(),
|
||||
text: `Run ${index + 1} produced a compact transcript row for adjacent run context.`,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
export const issueChatLongThreadFixtureContext = {
|
||||
issue: {
|
||||
identifier: "PAP-PERF",
|
||||
title: "Long-thread rendering baseline fixture",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
projectName: "Paperclip App",
|
||||
},
|
||||
documents: [
|
||||
"Implementation Plan",
|
||||
"Profiler Notes",
|
||||
"Release Checklist",
|
||||
"QA Readout",
|
||||
],
|
||||
subIssues: [
|
||||
"Phase 1: Add long-thread perf fixture and baseline",
|
||||
"Phase 2: Isolate issue-thread row rendering and Markdown work",
|
||||
"Phase 3: Apply virtualization and guard scroll behavior",
|
||||
"Phase 4: Verify production issue profile improvement",
|
||||
],
|
||||
sidebarStats: [
|
||||
["Comments", String(LONG_THREAD_COMMENT_COUNT)],
|
||||
["Markdown bodies", String(LONG_THREAD_MARKDOWN_COMMENT_COUNT)],
|
||||
["Timeline events", String(LONG_THREAD_EVENT_COUNT)],
|
||||
["Linked runs", String(LONG_THREAD_LINKED_RUN_COUNT)],
|
||||
],
|
||||
} as const;
|
||||
+14
-8
@@ -442,13 +442,15 @@
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin: 0 0.1rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
padding: 0 0.625rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
line-height: 1.25;
|
||||
text-decoration: none;
|
||||
vertical-align: baseline;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -860,15 +862,19 @@ span.paperclip-project-mention-chip {
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin: 0 0.1rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
padding: 0 0.625rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
line-height: 1.25;
|
||||
text-decoration: none;
|
||||
/* Align the pill relative to the surrounding text baseline instead of its
|
||||
x-height midpoint so it sits on the text line rather than floating above. */
|
||||
vertical-align: baseline;
|
||||
/* Center the pill on the surrounding x-height so it sits on the text line
|
||||
instead of hanging below it. inline-flex baseline alignment is unreliable
|
||||
across browsers, so use vertical-align: middle for a predictable result.
|
||||
Nudge up 1px so it visually centers with the cap height of the text. */
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { usePanel } from "../context/PanelContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||
@@ -626,7 +626,7 @@ export function AgentDetail() {
|
||||
}>();
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { closePanel } = usePanel();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { agentsApi, type OrgNode } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
@@ -55,7 +55,7 @@ function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean
|
||||
|
||||
export function Agents() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewAgent } = useDialog();
|
||||
const { openNewAgent } = useDialogActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
@@ -36,7 +36,7 @@ export function Companies() {
|
||||
loading,
|
||||
error,
|
||||
} = useCompany();
|
||||
const { openOnboarding } = useDialog();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AGENT_ADAPTER_TYPES,
|
||||
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
|
||||
getAdapterEnvironmentSupport,
|
||||
type Environment,
|
||||
type EnvironmentProbeResult,
|
||||
@@ -54,6 +56,9 @@ const ENVIRONMENT_SUPPORT_ROWS = AGENT_ADAPTER_TYPES.map((adapterType) => ({
|
||||
adapterType,
|
||||
support: getAdapterEnvironmentSupport(adapterType),
|
||||
}));
|
||||
const BYTES_PER_MIB = 1024 * 1024;
|
||||
const DEFAULT_COMPANY_ATTACHMENT_MAX_MIB = DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
|
||||
const MAX_COMPANY_ATTACHMENT_MAX_MIB = MAX_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
|
||||
|
||||
function buildEnvironmentPayload(form: EnvironmentFormState) {
|
||||
return {
|
||||
@@ -183,6 +188,7 @@ export function CompanySettings() {
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [brandColor, setBrandColor] = useState("");
|
||||
const [attachmentMaxMiB, setAttachmentMaxMiB] = useState(String(DEFAULT_COMPANY_ATTACHMENT_MAX_MIB));
|
||||
const [logoUrl, setLogoUrl] = useState("");
|
||||
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
||||
const [editingEnvironmentId, setEditingEnvironmentId] = useState<string | null>(null);
|
||||
@@ -195,6 +201,7 @@ export function CompanySettings() {
|
||||
setCompanyName(selectedCompany.name);
|
||||
setDescription(selectedCompany.description ?? "");
|
||||
setBrandColor(selectedCompany.brandColor ?? "");
|
||||
setAttachmentMaxMiB(String(Math.round((selectedCompany.attachmentMaxBytes ?? DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES) / BYTES_PER_MIB)));
|
||||
setLogoUrl(selectedCompany.logoUrl ?? "");
|
||||
}, [selectedCompany]);
|
||||
|
||||
@@ -227,17 +234,24 @@ export function CompanySettings() {
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
|
||||
const attachmentMaxBytes = Number.parseInt(attachmentMaxMiB, 10) * BYTES_PER_MIB;
|
||||
const attachmentMaxValid =
|
||||
Number.isInteger(attachmentMaxBytes)
|
||||
&& attachmentMaxBytes >= BYTES_PER_MIB
|
||||
&& attachmentMaxBytes <= MAX_COMPANY_ATTACHMENT_MAX_BYTES;
|
||||
const generalDirty =
|
||||
!!selectedCompany &&
|
||||
(companyName !== selectedCompany.name ||
|
||||
description !== (selectedCompany.description ?? "") ||
|
||||
brandColor !== (selectedCompany.brandColor ?? ""));
|
||||
brandColor !== (selectedCompany.brandColor ?? "") ||
|
||||
attachmentMaxBytes !== (selectedCompany.attachmentMaxBytes ?? DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES));
|
||||
|
||||
const generalMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
name: string;
|
||||
description: string | null;
|
||||
brandColor: string | null;
|
||||
attachmentMaxBytes: number;
|
||||
}) => companiesApi.update(selectedCompanyId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
@@ -477,7 +491,8 @@ export function CompanySettings() {
|
||||
generalMutation.mutate({
|
||||
name: companyName.trim(),
|
||||
description: description.trim() || null,
|
||||
brandColor: brandColor || null
|
||||
brandColor: brandColor || null,
|
||||
attachmentMaxBytes
|
||||
});
|
||||
}
|
||||
|
||||
@@ -716,6 +731,30 @@ export function CompanySettings() {
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label="Attachment size limit"
|
||||
hint={`Accepted range: 1-${MAX_COMPANY_ATTACHMENT_MAX_MIB} MiB.`}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_COMPANY_ATTACHMENT_MAX_MIB}
|
||||
step={1}
|
||||
value={attachmentMaxMiB}
|
||||
onChange={(e) => setAttachmentMaxMiB(e.target.value)}
|
||||
className="w-28 rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">MiB</span>
|
||||
</div>
|
||||
{!attachmentMaxValid && (
|
||||
<span className="text-xs text-destructive">
|
||||
Enter a whole number from 1 to {MAX_COMPANY_ATTACHMENT_MAX_MIB}.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -727,7 +766,7 @@ export function CompanySettings() {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveGeneral}
|
||||
disabled={generalMutation.isPending || !companyName.trim()}
|
||||
disabled={generalMutation.isPending || !companyName.trim() || !attachmentMaxValid}
|
||||
>
|
||||
{generalMutation.isPending ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { buildCompanyUserProfileMap } from "../lib/company-members";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
@@ -36,7 +36,7 @@ function getRecentIssues(issues: Issue[]): Issue[] {
|
||||
|
||||
export function Dashboard() {
|
||||
const { selectedCompanyId, companies } = useCompany();
|
||||
const { openOnboarding } = useDialog();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
|
||||
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
@@ -6,7 +6,7 @@ import { projectsApi } from "../api/projects";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { GoalProperties } from "../components/GoalProperties";
|
||||
@@ -49,7 +49,7 @@ export function GoalPropertiesToggleButton({
|
||||
export function GoalDetail() {
|
||||
const { goalId } = useParams<{ goalId: string }>();
|
||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { openNewGoal } = useDialog();
|
||||
const { openNewGoal } = useDialogActions();
|
||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { GoalTree } from "../components/GoalTree";
|
||||
@@ -13,7 +13,7 @@ import { Target, Plus } from "lucide-react";
|
||||
|
||||
export function Goals() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewGoal } = useDialog();
|
||||
const { openNewGoal } = useDialogActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Profiler, useEffect, useLayoutEffect, useMemo, useRef, useState, type ProfilerOnRenderCallback } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { IssueChatThread } from "../components/IssueChatThread";
|
||||
import {
|
||||
issueChatLongThreadAgentMap,
|
||||
issueChatLongThreadComments,
|
||||
issueChatLongThreadEvents,
|
||||
issueChatLongThreadFixtureContext,
|
||||
issueChatLongThreadLinkedRuns,
|
||||
issueChatLongThreadLiveRuns,
|
||||
issueChatLongThreadMarkdownCommentIds,
|
||||
issueChatLongThreadTranscriptsByRunId,
|
||||
LONG_THREAD_COMMENT_COUNT,
|
||||
LONG_THREAD_MARKDOWN_COMMENT_COUNT,
|
||||
} from "../fixtures/issueChatLongThreadFixture";
|
||||
|
||||
const noop = async () => {};
|
||||
|
||||
type RenderMetrics = {
|
||||
commitCount: number;
|
||||
mountActualDuration: number | null;
|
||||
latestActualDuration: number | null;
|
||||
maxActualDuration: number;
|
||||
totalActualDuration: number;
|
||||
};
|
||||
|
||||
const initialMetrics: RenderMetrics = {
|
||||
commitCount: 0,
|
||||
mountActualDuration: null,
|
||||
latestActualDuration: null,
|
||||
maxActualDuration: 0,
|
||||
totalActualDuration: 0,
|
||||
};
|
||||
|
||||
function formatMs(value: number | null) {
|
||||
if (value === null || !Number.isFinite(value)) return "pending";
|
||||
return `${value.toFixed(1)} ms`;
|
||||
}
|
||||
|
||||
function MetricTile({ label, value, testId }: { label: string; value: string; testId: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-background px-3 py-2">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div data-testid={testId} className="mt-1 font-mono text-sm text-foreground">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueChatLongThreadPerf() {
|
||||
const [metrics, setMetrics] = useState<RenderMetrics>(initialMetrics);
|
||||
const metricsRef = useRef<RenderMetrics>(initialMetrics);
|
||||
const renderStartedAtRef = useRef(performance.now());
|
||||
const publishTimerRef = useRef<number | null>(null);
|
||||
const publishedRef = useRef(false);
|
||||
const fixture = issueChatLongThreadFixtureContext;
|
||||
const rowTarget = useMemo(
|
||||
() => LONG_THREAD_COMMENT_COUNT + issueChatLongThreadEvents.length + issueChatLongThreadLinkedRuns.length,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (publishTimerRef.current !== null) {
|
||||
window.clearTimeout(publishTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (publishedRef.current || metricsRef.current.commitCount > 0) return;
|
||||
const mountDuration = performance.now() - renderStartedAtRef.current;
|
||||
const next = {
|
||||
commitCount: 1,
|
||||
mountActualDuration: mountDuration,
|
||||
latestActualDuration: mountDuration,
|
||||
maxActualDuration: mountDuration,
|
||||
totalActualDuration: mountDuration,
|
||||
};
|
||||
metricsRef.current = next;
|
||||
publishedRef.current = true;
|
||||
setMetrics(next);
|
||||
}, []);
|
||||
|
||||
const handleRender: ProfilerOnRenderCallback = (_id, phase, actualDuration) => {
|
||||
const current = metricsRef.current;
|
||||
metricsRef.current = {
|
||||
commitCount: current.commitCount + 1,
|
||||
mountActualDuration: phase === "mount" && current.mountActualDuration === null
|
||||
? actualDuration
|
||||
: current.mountActualDuration,
|
||||
latestActualDuration: actualDuration,
|
||||
maxActualDuration: Math.max(current.maxActualDuration, actualDuration),
|
||||
totalActualDuration: current.totalActualDuration + actualDuration,
|
||||
};
|
||||
|
||||
if (publishedRef.current || publishTimerRef.current !== null) return;
|
||||
publishTimerRef.current = window.setTimeout(() => {
|
||||
publishTimerRef.current = null;
|
||||
publishedRef.current = true;
|
||||
setMetrics(metricsRef.current);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="issue-chat-long-thread-perf" className="space-y-5">
|
||||
<div className="flex flex-col gap-3 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-[11px]">
|
||||
{fixture.issue.identifier}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{fixture.issue.status.replace(/_/g, " ")}</Badge>
|
||||
<Badge variant="outline">{fixture.issue.projectName}</Badge>
|
||||
</div>
|
||||
<h1 className="mt-3 text-2xl font-semibold tracking-tight">{fixture.issue.title}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
Deterministic local fixture for measuring the current direct-render issue chat path with
|
||||
hundreds of merged thread rows, markdown-heavy assistant bodies, linked runs, documents,
|
||||
sub-issues, and sidebar context.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid min-w-[280px] grid-cols-2 gap-2">
|
||||
<MetricTile label="Fixture rows" value={String(rowTarget)} testId="perf-fixture-row-target" />
|
||||
<MetricTile label="Markdown rows" value={String(LONG_THREAD_MARKDOWN_COMMENT_COUNT)} testId="perf-fixture-markdown-rows" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<main className="min-w-0 space-y-4">
|
||||
<Card className="border-border/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Issue documents</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 sm:grid-cols-2">
|
||||
{fixture.documents.map((document) => (
|
||||
<div key={document} className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||
{document}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Sub-issues</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{fixture.subIssues.map((subIssue, index) => (
|
||||
<div key={subIssue} className="flex items-center gap-3 rounded-md border border-border bg-background px-3 py-2 text-sm">
|
||||
<span className="font-mono text-xs text-muted-foreground">#{index + 1}</span>
|
||||
<span>{subIssue}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Profiler id="issue-chat-long-thread" onRender={handleRender}>
|
||||
<IssueChatThread
|
||||
comments={issueChatLongThreadComments}
|
||||
linkedRuns={issueChatLongThreadLinkedRuns}
|
||||
timelineEvents={issueChatLongThreadEvents}
|
||||
liveRuns={issueChatLongThreadLiveRuns}
|
||||
issueStatus="in_progress"
|
||||
agentMap={issueChatLongThreadAgentMap}
|
||||
currentUserId="user-board"
|
||||
onAdd={noop}
|
||||
showComposer={false}
|
||||
showJumpToLatest={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
|
||||
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
|
||||
/>
|
||||
</Profiler>
|
||||
</main>
|
||||
|
||||
<aside className="space-y-4 xl:sticky xl:top-4 xl:self-start">
|
||||
<Card className="border-border/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Baseline metrics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
<MetricTile label="Profiler commits" value={String(metrics.commitCount)} testId="perf-commit-count" />
|
||||
<MetricTile label="Mount duration" value={formatMs(metrics.mountActualDuration)} testId="perf-mount-duration" />
|
||||
<MetricTile label="Latest duration" value={formatMs(metrics.latestActualDuration)} testId="perf-latest-duration" />
|
||||
<MetricTile label="Max duration" value={formatMs(metrics.maxActualDuration)} testId="perf-max-duration" />
|
||||
<MetricTile label="Total duration" value={formatMs(metrics.totalActualDuration)} testId="perf-total-duration" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Fixture shape</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{fixture.sidebarStats.map(([label, value]) => (
|
||||
<div key={label} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-mono">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="hidden" data-testid="perf-markdown-comment-id-sample">
|
||||
{[...issueChatLongThreadMarkdownCommentIds].slice(0, 3).join(",")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,6 +134,9 @@ vi.mock("../context/DialogContext", () => ({
|
||||
useDialog: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
}),
|
||||
useDialogActions: () => ({
|
||||
openNewIssue: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/PanelContext", () => ({
|
||||
|
||||
@@ -13,7 +13,7 @@ import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
@@ -77,6 +77,7 @@ import { ImageGalleryModal } from "../components/ImageGalleryModal";
|
||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { ProductivityReviewBadge } from "../components/ProductivityReviewBadge";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
@@ -108,6 +109,7 @@ import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Hexagon,
|
||||
ListTree,
|
||||
@@ -1023,7 +1025,7 @@ function IssueDetailActivityTab({
|
||||
export function IssueDetail() {
|
||||
const { issueId } = useParams<{ issueId: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||
const { setBreadcrumbs, setMobileToolbar } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -2906,6 +2908,20 @@ export function IssueDetail() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{issue.productivityReview ? (
|
||||
<ProductivityReviewBadge review={issue.productivityReview} />
|
||||
) : null}
|
||||
|
||||
{issue.originKind === "issue_productivity_review" ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0"
|
||||
title="This task is a productivity review."
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
Productivity review
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{issue.projectId ? (
|
||||
<Link
|
||||
to={`/projects/${issue.projectId}`}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildIssuesSearchUrl } from "./Issues";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { buildIssuesSearchUrl, getNextIssuesPageOffset, mergeIssuePagesStable } from "./Issues";
|
||||
|
||||
function createIssue(id: string, title: string): Issue {
|
||||
return { id, title } as Issue;
|
||||
}
|
||||
|
||||
describe("buildIssuesSearchUrl", () => {
|
||||
it("preserves trailing spaces in the synced search param", () => {
|
||||
@@ -14,3 +19,29 @@ describe("buildIssuesSearchUrl", () => {
|
||||
expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug+", "bug ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("issues page pagination helpers", () => {
|
||||
it("advances to the next offset when the current page is full", () => {
|
||||
expect(getNextIssuesPageOffset(500, 0)).toBe(500);
|
||||
expect(getNextIssuesPageOffset(500, 500)).toBe(1000);
|
||||
expect(getNextIssuesPageOffset(1000, 2000, 1000)).toBe(3000);
|
||||
});
|
||||
|
||||
it("stops requesting issue pages when the current page is partial", () => {
|
||||
expect(getNextIssuesPageOffset(499, 0)).toBeUndefined();
|
||||
expect(getNextIssuesPageOffset(999, 2000, 1000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("dedupes overlapping pages without moving the original issue position", () => {
|
||||
const first = createIssue("issue-1", "Original first");
|
||||
const second = createIssue("issue-2", "Second");
|
||||
const duplicateFirst = createIssue("issue-1", "Duplicate first");
|
||||
const third = createIssue("issue-3", "Third");
|
||||
|
||||
expect(mergeIssuePagesStable([[first, second], [duplicateFirst, third]])).toEqual([
|
||||
first,
|
||||
second,
|
||||
third,
|
||||
]);
|
||||
});
|
||||
});
|
||||
+68
-6
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useCallback } from "react";
|
||||
import { useEffect, useMemo, useCallback, useRef, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
@@ -13,8 +13,33 @@ import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { CircleDot } from "lucide-react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
|
||||
const WORKSPACE_FILTER_ISSUE_LIMIT = 1000;
|
||||
const ISSUES_PAGE_SIZE = 500;
|
||||
|
||||
export function getNextIssuesPageOffset(
|
||||
loadedPageSize: number,
|
||||
currentOffset: number,
|
||||
pageSize: number = ISSUES_PAGE_SIZE,
|
||||
): number | undefined {
|
||||
return loadedPageSize >= pageSize ? currentOffset + pageSize : undefined;
|
||||
}
|
||||
|
||||
export function mergeIssuePagesStable(pages: Issue[][]): Issue[] {
|
||||
const seenIssueIds = new Set<string>();
|
||||
const merged: Issue[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
for (const issue of page) {
|
||||
if (seenIssueIds.has(issue.id)) continue;
|
||||
seenIssueIds.add(issue.id);
|
||||
merged.push(issue);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function buildIssuesSearchUrl(currentHref: string, search: string): string | null {
|
||||
const url = new URL(currentHref);
|
||||
@@ -36,12 +61,15 @@ export function Issues() {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const fetchNextPageInFlightRef = useRef(false);
|
||||
|
||||
const initialSearch = searchParams.get("q") ?? "";
|
||||
const [syncedSearch, setSyncedSearch] = useState(initialSearch);
|
||||
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
|
||||
const initialWorkspaces = searchParams.getAll("workspace").filter((workspaceId) => workspaceId.length > 0);
|
||||
const workspaceIdFilter = initialWorkspaces.length === 1 ? initialWorkspaces[0] : undefined;
|
||||
const handleSearchChange = useCallback((search: string) => {
|
||||
setSyncedSearch(search);
|
||||
const nextUrl = buildIssuesSearchUrl(window.location.href, search);
|
||||
if (!nextUrl) return;
|
||||
window.history.replaceState(window.history.state, "", nextUrl);
|
||||
@@ -82,7 +110,20 @@ export function Issues() {
|
||||
setBreadcrumbs([{ label: "Issues" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
const issuePageSize = workspaceIdFilter ? WORKSPACE_FILTER_ISSUE_LIMIT : ISSUES_PAGE_SIZE;
|
||||
|
||||
useEffect(() => {
|
||||
setSyncedSearch(initialSearch);
|
||||
}, [initialSearch]);
|
||||
|
||||
const {
|
||||
data: issuePages,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
error,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [
|
||||
...queryKeys.issues.list(selectedCompanyId!),
|
||||
"participant-agent",
|
||||
@@ -90,16 +131,34 @@ export function Issues() {
|
||||
"workspace",
|
||||
workspaceIdFilter ?? "__all__",
|
||||
"with-routine-executions",
|
||||
"infinite",
|
||||
issuePageSize,
|
||||
],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, {
|
||||
queryFn: ({ pageParam }) => issuesApi.list(selectedCompanyId!, {
|
||||
participantAgentId,
|
||||
workspaceId: workspaceIdFilter,
|
||||
includeRoutineExecutions: true,
|
||||
...(workspaceIdFilter ? { limit: WORKSPACE_FILTER_ISSUE_LIMIT } : {}),
|
||||
limit: issuePageSize,
|
||||
offset: pageParam,
|
||||
}),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, _allPages, lastPageParam) =>
|
||||
getNextIssuesPageOffset(lastPage.length, lastPageParam, issuePageSize),
|
||||
enabled: !!selectedCompanyId,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
const issues = useMemo(() => mergeIssuePagesStable(issuePages?.pages ?? []), [issuePages]);
|
||||
const hasMoreServerIssues = syncedSearch.trim().length === 0
|
||||
&& hasNextPage === true;
|
||||
const loadMoreServerIssues = useCallback(() => {
|
||||
if (!hasNextPage || isFetchingNextPage || fetchNextPageInFlightRef.current) return;
|
||||
fetchNextPageInFlightRef.current = true;
|
||||
void fetchNextPage({ cancelRefetch: false }).finally(() => {
|
||||
fetchNextPageInFlightRef.current = false;
|
||||
});
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
issuesApi.update(id, data),
|
||||
@@ -116,6 +175,7 @@ export function Issues() {
|
||||
<IssuesList
|
||||
issues={issues ?? []}
|
||||
isLoading={isLoading}
|
||||
isLoadingMoreIssues={isFetchingNextPage}
|
||||
error={error as Error | null}
|
||||
agents={agents}
|
||||
projects={projects}
|
||||
@@ -124,9 +184,11 @@ export function Issues() {
|
||||
issueLinkState={issueLinkState}
|
||||
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
|
||||
initialWorkspaces={initialWorkspaces.length > 0 ? initialWorkspaces : undefined}
|
||||
initialSearch={initialSearch}
|
||||
initialSearch={syncedSearch}
|
||||
onSearchChange={handleSearchChange}
|
||||
enableRoutineVisibilityFilter
|
||||
hasMoreIssues={hasMoreServerIssues}
|
||||
onLoadMoreIssues={loadMoreServerIssues}
|
||||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||
searchFilters={participantAgentId || workspaceIdFilter ? { participantAgentId, workspaceId: workspaceIdFilter } : undefined}
|
||||
/>
|
||||
|
||||
@@ -317,9 +317,9 @@ export function ProjectWorkspaceDetail() {
|
||||
request.action === "run"
|
||||
? "Workspace job completed."
|
||||
: request.action === "stop"
|
||||
? "Workspace service stopped."
|
||||
? "Workspace service stopped. Issue execution is not paused."
|
||||
: request.action === "restart"
|
||||
? "Workspace service restarted."
|
||||
? "Workspace service restarted. Issue execution is not paused."
|
||||
: "Workspace service started.",
|
||||
);
|
||||
},
|
||||
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user