From cc44d309c0a5f5bbc6d718d901f6e9ab27bae34d Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Tue, 7 Apr 2026 09:41:13 +0200 Subject: [PATCH 01/32] feat(backups): gzip compress backups and add retention config to Instance Settings Compress database backups with gzip (.sql.gz), reducing file size ~83%. Add backup retention configuration to Instance Settings UI with preset options (7 days, 2 weeks, 1 month). The backup scheduler now reads retention from the database on each tick so changes take effect without restart. Default retention changed from 30 to 7 days. --- packages/db/src/backup-lib.test.ts | 4 +- packages/db/src/backup-lib.ts | 24 +++++++++-- packages/shared/src/config-schema.ts | 4 +- packages/shared/src/index.ts | 6 +++ packages/shared/src/types/index.ts | 3 +- packages/shared/src/types/instance.ts | 5 +++ packages/shared/src/validators/instance.ts | 8 ++++ server/src/config.ts | 2 +- server/src/index.ts | 16 ++++--- server/src/services/instance-settings.ts | 3 ++ ui/src/pages/InstanceGeneralSettings.tsx | 49 +++++++++++++++++++++- 11 files changed, 107 insertions(+), 17 deletions(-) diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index dcdc87c50e4..2367d26d54e 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -129,8 +129,8 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { filenamePrefix: "paperclip-test", }); - expect(result.backupFile).toMatch(/paperclip-test-.*\.sql$/); - expect(result.sizeBytes).toBeGreaterThan(1024 * 1024); + expect(result.backupFile).toMatch(/paperclip-test-.*\.sql\.gz$/); + expect(result.sizeBytes).toBeGreaterThan(0); expect(fs.existsSync(result.backupFile)).toBe(true); await runDatabaseRestore({ diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index ea76a2b6d3c..36b5ee9805b 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -1,6 +1,8 @@ import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { basename, resolve } from "node:path"; import { createInterface } from "node:readline"; +import { pipeline } from "node:stream/promises"; +import { createGunzip, createGzip } from "node:zlib"; import postgres from "postgres"; export type RunDatabaseBackupOptions = { @@ -82,7 +84,8 @@ function pruneOldBackups(backupDir: string, retentionDays: number, filenamePrefi let pruned = 0; for (const name of readdirSync(backupDir)) { - if (!name.startsWith(`${filenamePrefix}-`) || !name.endsWith(".sql")) continue; + if (!name.startsWith(`${filenamePrefix}-`)) continue; + if (!name.endsWith(".sql") && !name.endsWith(".sql.gz")) continue; const fullPath = resolve(backupDir, name); const stat = statSync(fullPath); if (stat.mtimeMs < cutoff) { @@ -148,7 +151,9 @@ function tableKey(schemaName: string, tableName: string): string { } async function* readRestoreStatements(backupFile: string): AsyncGenerator { - const stream = createReadStream(backupFile, { encoding: "utf8" }); + const raw = createReadStream(backupFile); + const stream = backupFile.endsWith(".gz") ? raw.pipe(createGunzip()) : raw; + stream.setEncoding("utf8"); const reader = createInterface({ input: stream, crlfDelay: Infinity, @@ -180,6 +185,7 @@ async function* readRestoreStatements(backupFile: string): AsyncGenerator + (BACKUP_RETENTION_PRESETS as readonly number[]).includes(v), + { message: `Must be one of: ${BACKUP_RETENTION_PRESETS.join(", ")}` }, +); + export const instanceGeneralSettingsSchema = z.object({ censorUsernameInLogs: z.boolean().default(false), keyboardShortcuts: z.boolean().default(false), feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default( DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, ), + backupRetentionDays: backupRetentionDaysSchema.default(DEFAULT_BACKUP_RETENTION_DAYS), }).strict(); export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial(); diff --git a/server/src/config.ts b/server/src/config.ts index 71084cc097d..5ae50400efc 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -216,7 +216,7 @@ export function loadConfig(): Config { 1, Number(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) || fileDatabaseBackup?.retentionDays || - 30, + 7, ); const databaseBackupDir = resolveHomeAwarePath( process.env.PAPERCLIP_DB_BACKUP_DIR ?? diff --git a/server/src/index.ts b/server/src/index.ts index a384342f5e5..768d1fd6a63 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -31,6 +31,7 @@ import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { feedbackService, heartbeatService, + instanceSettingsService, reconcilePersistedRuntimeServicesOnStartup, routineService, } from "./services/index.js"; @@ -628,20 +629,25 @@ export async function startServer(): Promise { if (config.databaseBackupEnabled) { const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000; + const settingsSvc = instanceSettingsService(db); let backupInFlight = false; - + const runScheduledBackup = async () => { if (backupInFlight) { logger.warn("Skipping scheduled database backup because a previous backup is still running"); return; } - + backupInFlight = true; try { + // Read retention from Instance Settings (DB) so changes take effect without restart + const generalSettings = await settingsSvc.getGeneral(); + const retentionDays = generalSettings.backupRetentionDays; + const result = await runDatabaseBackup({ connectionString: activeDatabaseConnectionString, backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, + retentionDays, filenamePrefix: "paperclip", }); logger.info( @@ -650,7 +656,7 @@ export async function startServer(): Promise { sizeBytes: result.sizeBytes, prunedCount: result.prunedCount, backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, + retentionDays, }, `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, ); @@ -660,7 +666,7 @@ export async function startServer(): Promise { backupInFlight = false; } }; - + logger.info( { intervalMinutes: config.databaseBackupIntervalMinutes, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 7856591d689..5c7db61dfb0 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -2,6 +2,7 @@ import type { Db } from "@paperclipai/db"; import { companies, instanceSettings } from "@paperclipai/db"; import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + DEFAULT_BACKUP_RETENTION_DAYS, instanceGeneralSettingsSchema, type InstanceGeneralSettings, instanceExperimentalSettingsSchema, @@ -22,12 +23,14 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { keyboardShortcuts: parsed.data.keyboardShortcuts ?? false, feedbackDataSharingPreference: parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + backupRetentionDays: parsed.data.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS, }; } return { censorUsernameInLogs: false, keyboardShortcuts: false, feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + backupRetentionDays: DEFAULT_BACKUP_RETENTION_DAYS, }; } diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index 28e00b29f05..9004e418bc2 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { PatchInstanceGeneralSettings } from "@paperclipai/shared"; -import { LogOut, SlidersHorizontal } from "lucide-react"; +import type { PatchInstanceGeneralSettings, BackupRetentionDays } from "@paperclipai/shared"; +import { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "@paperclipai/shared"; +import { Database, LogOut, SlidersHorizontal } from "lucide-react"; import { authApi } from "@/api/auth"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { Button } from "../components/ui/button"; @@ -67,6 +68,7 @@ export function InstanceGeneralSettings() { const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true; const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true; const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt"; + const backupRetentionDays: BackupRetentionDays = generalQuery.data?.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS; return (
@@ -123,6 +125,49 @@ export function InstanceGeneralSettings() {
+
+
+
+ +
+

Backup retention

+

+ How long to keep automatic database backups before pruning. Backups are compressed + with gzip to minimize disk usage. +

+
+
+
+ {BACKUP_RETENTION_PRESETS.map((days) => { + const active = backupRetentionDays === days; + const label = + days === 7 ? "7 days" : days === 14 ? "2 weeks" : "1 month"; + return ( + + ); + })} +
+
+
+
From fcbae62baf1e821e2b65f64545381defccda3230 Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Tue, 7 Apr 2026 09:54:39 +0200 Subject: [PATCH 02/32] feat(backups): tiered daily/weekly/monthly retention with UI controls Replace single retentionDays with a three-tier BackupRetentionPolicy: - Daily: keep all backups (presets: 3, 7, 14 days; default 7) - Weekly: keep one per calendar week (presets: 1, 2, 4 weeks; default 4) - Monthly: keep one per calendar month (presets: 1, 3, 6 months; default 1) Pruning sorts backups newest-first and applies each tier's cutoff, keeping only the newest entry per ISO week/month bucket. The Instance Settings General page now shows three preset selectors (no icon, matches existing page design). Remove Database icon import. --- cli/src/commands/db-backup.ts | 2 +- cli/src/commands/worktree.ts | 2 +- packages/db/src/backup-lib.test.ts | 2 +- packages/db/src/backup-lib.ts | 97 ++++++++++++-- packages/db/src/backup.ts | 4 +- packages/db/src/index.ts | 1 + packages/shared/src/index.ts | 8 +- packages/shared/src/types/index.ts | 4 +- packages/shared/src/types/instance.ts | 20 ++- packages/shared/src/validators/instance.ts | 26 +++- server/src/index.ts | 6 +- server/src/services/instance-settings.ts | 6 +- ui/src/pages/InstanceGeneralSettings.tsx | 144 +++++++++++++++------ 13 files changed, 243 insertions(+), 79 deletions(-) diff --git a/cli/src/commands/db-backup.ts b/cli/src/commands/db-backup.ts index bdbf739f44b..7e1adce546a 100644 --- a/cli/src/commands/db-backup.ts +++ b/cli/src/commands/db-backup.ts @@ -73,7 +73,7 @@ export async function dbBackupCommand(opts: DbBackupOptions): Promise { const result = await runDatabaseBackup({ connectionString: connection.value, backupDir, - retentionDays, + retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix, }); spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 3025e955f94..d67bd15488a 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -903,7 +903,7 @@ async function seedWorktreeDatabase(input: { const backup = await runDatabaseBackup({ connectionString: sourceConnectionString, backupDir: path.resolve(input.targetPaths.backupDir, "seed"), - retentionDays: 7, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: `${input.instanceId}-seed`, includeMigrationJournal: true, excludeTables: seedPlan.excludedTables, diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index 2367d26d54e..2ea9b07001c 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -125,7 +125,7 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { const result = await runDatabaseBackup({ connectionString: sourceConnectionString, backupDir, - retentionDays: 7, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: "paperclip-test", }); diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 36b5ee9805b..0fe70f132af 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -5,10 +5,16 @@ import { pipeline } from "node:stream/promises"; import { createGunzip, createGzip } from "node:zlib"; import postgres from "postgres"; +export type BackupRetentionPolicy = { + dailyDays: number; + weeklyWeeks: number; + monthlyMonths: number; +}; + export type RunDatabaseBackupOptions = { connectionString: string; backupDir: string; - retentionDays: number; + retention: BackupRetentionPolicy; filenamePrefix?: string; connectTimeoutSeconds?: number; includeMigrationJournal?: boolean; @@ -77,24 +83,91 @@ function timestamp(date: Date = new Date()): string { return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; } -function pruneOldBackups(backupDir: string, retentionDays: number, filenamePrefix: string): number { +/** + * ISO week key for grouping backups by calendar week (ISO 8601). + */ +function isoWeekKey(date: Date): string { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`; +} + +function monthKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +/** + * Tiered backup pruning: + * - Daily tier: keep ALL backups from the last `dailyDays` days + * - Weekly tier: keep the NEWEST backup per calendar week for `weeklyWeeks` weeks + * - Monthly tier: keep the NEWEST backup per calendar month for `monthlyMonths` months + * - Everything else is deleted + */ +function pruneOldBackups(backupDir: string, retention: BackupRetentionPolicy, filenamePrefix: string): number { if (!existsSync(backupDir)) return 0; - const safeRetention = Math.max(1, Math.trunc(retentionDays)); - const cutoff = Date.now() - safeRetention * 24 * 60 * 60 * 1000; - let pruned = 0; + + const now = Date.now(); + const dailyCutoff = now - Math.max(1, retention.dailyDays) * 24 * 60 * 60 * 1000; + const weeklyCutoff = now - Math.max(1, retention.weeklyWeeks) * 7 * 24 * 60 * 60 * 1000; + const monthlyCutoff = now - Math.max(1, retention.monthlyMonths) * 30 * 24 * 60 * 60 * 1000; + + type BackupEntry = { name: string; fullPath: string; mtimeMs: number }; + const entries: BackupEntry[] = []; for (const name of readdirSync(backupDir)) { if (!name.startsWith(`${filenamePrefix}-`)) continue; if (!name.endsWith(".sql") && !name.endsWith(".sql.gz")) continue; const fullPath = resolve(backupDir, name); const stat = statSync(fullPath); - if (stat.mtimeMs < cutoff) { - unlinkSync(fullPath); - pruned++; - } + entries.push({ name, fullPath, mtimeMs: stat.mtimeMs }); } - return pruned; + // Sort newest first so the first entry per week/month bucket is the one we keep + entries.sort((a, b) => b.mtimeMs - a.mtimeMs); + + const keepWeekBuckets = new Set(); + const keepMonthBuckets = new Set(); + const toDelete: string[] = []; + + for (const entry of entries) { + // Daily tier — keep everything within dailyDays + if (entry.mtimeMs >= dailyCutoff) continue; + + const date = new Date(entry.mtimeMs); + const week = isoWeekKey(date); + const month = monthKey(date); + + // Weekly tier — keep newest per calendar week + if (entry.mtimeMs >= weeklyCutoff) { + if (keepWeekBuckets.has(week)) { + toDelete.push(entry.fullPath); + } else { + keepWeekBuckets.add(week); + } + continue; + } + + // Monthly tier — keep newest per calendar month + if (entry.mtimeMs >= monthlyCutoff) { + if (keepMonthBuckets.has(month)) { + toDelete.push(entry.fullPath); + } else { + keepMonthBuckets.add(month); + } + continue; + } + + // Beyond all retention tiers — delete + toDelete.push(entry.fullPath); + } + + for (const filePath of toDelete) { + unlinkSync(filePath); + } + + return toDelete.length; } function formatBackupSize(sizeBytes: number): string { @@ -287,7 +360,7 @@ export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise { const filenamePrefix = opts.filenamePrefix ?? "paperclip"; - const retentionDays = Math.max(1, Math.trunc(opts.retentionDays)); + const retention = opts.retention; const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); const includeMigrationJournal = opts.includeMigrationJournal === true; const excludedTableNames = normalizeTableNameSet(opts.excludeTables); @@ -678,7 +751,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise unlinkSync(sqlFile); const sizeBytes = statSync(backupFile).size; - const prunedCount = pruneOldBackups(opts.backupDir, retentionDays, filenamePrefix); + const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix); return { backupFile, diff --git a/packages/db/src/backup.ts b/packages/db/src/backup.ts index f07dc646f36..c11822c9503 100644 --- a/packages/db/src/backup.ts +++ b/packages/db/src/backup.ts @@ -85,7 +85,7 @@ function resolveBackupDir(config: PartialConfig | null): string { } function resolveRetentionDays(config: PartialConfig | null): number { - return asPositiveInt(config?.database?.backup?.retentionDays) ?? 30; + return asPositiveInt(config?.database?.backup?.retentionDays) ?? 7; } async function main() { @@ -103,7 +103,7 @@ async function main() { const result = await runDatabaseBackup({ connectionString, backupDir, - retentionDays, + retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: "paperclip", }); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index cf4a26335fd..200e29813d1 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -21,6 +21,7 @@ export { runDatabaseBackup, runDatabaseRestore, formatDatabaseBackupResult, + type BackupRetentionPolicy, type RunDatabaseBackupOptions, type RunDatabaseBackupResult, type RunDatabaseRestoreOptions, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ef9ce5baa61..e9075356067 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -189,7 +189,7 @@ export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, - BackupRetentionDays, + BackupRetentionPolicy, Agent, AgentAccessState, AgentChainOfCommandEntry, @@ -371,8 +371,10 @@ export { } from "./types/feedback.js"; export { - BACKUP_RETENTION_PRESETS, - DEFAULT_BACKUP_RETENTION_DAYS, + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, } from "./types/instance.js"; export { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 20d38a7aff9..1f0ec2c757d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -11,8 +11,8 @@ export type { FeedbackTraceBundleFile, FeedbackTraceBundle, } from "./feedback.js"; -export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, BackupRetentionDays } from "./instance.js"; -export { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "./instance.js"; +export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, BackupRetentionPolicy } from "./instance.js"; +export { DAILY_RETENTION_PRESETS, WEEKLY_RETENTION_PRESETS, MONTHLY_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION } from "./instance.js"; export type { CompanySkillSourceType, CompanySkillTrustLevel, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 30f07f7dd39..4e83c92529d 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -1,14 +1,26 @@ import type { FeedbackDataSharingPreference } from "./feedback.js"; -export const BACKUP_RETENTION_PRESETS = [7, 14, 30] as const; -export type BackupRetentionDays = (typeof BACKUP_RETENTION_PRESETS)[number]; -export const DEFAULT_BACKUP_RETENTION_DAYS: BackupRetentionDays = 7; +export const DAILY_RETENTION_PRESETS = [3, 7, 14] as const; +export const WEEKLY_RETENTION_PRESETS = [1, 2, 4] as const; +export const MONTHLY_RETENTION_PRESETS = [1, 3, 6] as const; + +export interface BackupRetentionPolicy { + dailyDays: (typeof DAILY_RETENTION_PRESETS)[number]; + weeklyWeeks: (typeof WEEKLY_RETENTION_PRESETS)[number]; + monthlyMonths: (typeof MONTHLY_RETENTION_PRESETS)[number]; +} + +export const DEFAULT_BACKUP_RETENTION: BackupRetentionPolicy = { + dailyDays: 7, + weeklyWeeks: 4, + monthlyMonths: 1, +}; export interface InstanceGeneralSettings { censorUsernameInLogs: boolean; keyboardShortcuts: boolean; feedbackDataSharingPreference: FeedbackDataSharingPreference; - backupRetentionDays: BackupRetentionDays; + backupRetention: BackupRetentionPolicy; } export interface InstanceExperimentalSettings { diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index a62f19966d4..930e183f9bd 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -1,13 +1,25 @@ import { z } from "zod"; import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE } from "../types/feedback.js"; -import { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "../types/instance.js"; +import { + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, +} from "../types/instance.js"; import { feedbackDataSharingPreferenceSchema } from "./feedback.js"; -export const backupRetentionDaysSchema = z.number().refine( - (v): v is (typeof BACKUP_RETENTION_PRESETS)[number] => - (BACKUP_RETENTION_PRESETS as readonly number[]).includes(v), - { message: `Must be one of: ${BACKUP_RETENTION_PRESETS.join(", ")}` }, -); +function presetSchema(presets: T, label: string) { + return z.number().refine( + (v): v is T[number] => (presets as readonly number[]).includes(v), + { message: `${label} must be one of: ${presets.join(", ")}` }, + ); +} + +export const backupRetentionPolicySchema = z.object({ + dailyDays: presetSchema(DAILY_RETENTION_PRESETS, "dailyDays").default(DEFAULT_BACKUP_RETENTION.dailyDays), + weeklyWeeks: presetSchema(WEEKLY_RETENTION_PRESETS, "weeklyWeeks").default(DEFAULT_BACKUP_RETENTION.weeklyWeeks), + monthlyMonths: presetSchema(MONTHLY_RETENTION_PRESETS, "monthlyMonths").default(DEFAULT_BACKUP_RETENTION.monthlyMonths), +}); export const instanceGeneralSettingsSchema = z.object({ censorUsernameInLogs: z.boolean().default(false), @@ -15,7 +27,7 @@ export const instanceGeneralSettingsSchema = z.object({ feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default( DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, ), - backupRetentionDays: backupRetentionDaysSchema.default(DEFAULT_BACKUP_RETENTION_DAYS), + backupRetention: backupRetentionPolicySchema.default(DEFAULT_BACKUP_RETENTION), }).strict(); export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial(); diff --git a/server/src/index.ts b/server/src/index.ts index 768d1fd6a63..b63789a2051 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -642,12 +642,12 @@ export async function startServer(): Promise { try { // Read retention from Instance Settings (DB) so changes take effect without restart const generalSettings = await settingsSvc.getGeneral(); - const retentionDays = generalSettings.backupRetentionDays; + const retention = generalSettings.backupRetention; const result = await runDatabaseBackup({ connectionString: activeDatabaseConnectionString, backupDir: config.databaseBackupDir, - retentionDays, + retention, filenamePrefix: "paperclip", }); logger.info( @@ -656,7 +656,7 @@ export async function startServer(): Promise { sizeBytes: result.sizeBytes, prunedCount: result.prunedCount, backupDir: config.databaseBackupDir, - retentionDays, + retention, }, `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, ); diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 5c7db61dfb0..65a1263226d 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -2,7 +2,7 @@ import type { Db } from "@paperclipai/db"; import { companies, instanceSettings } from "@paperclipai/db"; import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, - DEFAULT_BACKUP_RETENTION_DAYS, + DEFAULT_BACKUP_RETENTION, instanceGeneralSettingsSchema, type InstanceGeneralSettings, instanceExperimentalSettingsSchema, @@ -23,14 +23,14 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { keyboardShortcuts: parsed.data.keyboardShortcuts ?? false, feedbackDataSharingPreference: parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, - backupRetentionDays: parsed.data.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS, + backupRetention: parsed.data.backupRetention ?? DEFAULT_BACKUP_RETENTION, }; } return { censorUsernameInLogs: false, keyboardShortcuts: false, feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, - backupRetentionDays: DEFAULT_BACKUP_RETENTION_DAYS, + backupRetention: DEFAULT_BACKUP_RETENTION, }; } diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index 9004e418bc2..04c3638d523 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -1,8 +1,13 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { PatchInstanceGeneralSettings, BackupRetentionDays } from "@paperclipai/shared"; -import { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "@paperclipai/shared"; -import { Database, LogOut, SlidersHorizontal } from "lucide-react"; +import type { PatchInstanceGeneralSettings, BackupRetentionPolicy } from "@paperclipai/shared"; +import { + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, +} from "@paperclipai/shared"; +import { LogOut, SlidersHorizontal } from "lucide-react"; import { authApi } from "@/api/auth"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { Button } from "../components/ui/button"; @@ -68,7 +73,7 @@ export function InstanceGeneralSettings() { const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true; const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true; const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt"; - const backupRetentionDays: BackupRetentionDays = generalQuery.data?.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS; + const backupRetention: BackupRetentionPolicy = generalQuery.data?.backupRetention ?? DEFAULT_BACKUP_RETENTION; return (
@@ -126,44 +131,103 @@ export function InstanceGeneralSettings() {
-
-
- -
-

Backup retention

-

- How long to keep automatic database backups before pruning. Backups are compressed - with gzip to minimize disk usage. -

+
+
+

Backup retention

+

+ Configure how long to keep automatic database backups at each tier. Daily backups + are kept in full, then thinned to one per week and one per month. Backups are + compressed with gzip. +

+
+ +
+

Daily

+
+ {DAILY_RETENTION_PRESETS.map((days) => { + const active = backupRetention.dailyDays === days; + return ( + + ); + })}
-
- {BACKUP_RETENTION_PRESETS.map((days) => { - const active = backupRetentionDays === days; - const label = - days === 7 ? "7 days" : days === 14 ? "2 weeks" : "1 month"; - return ( - - ); - })} + +
+

Weekly

+
+ {WEEKLY_RETENTION_PRESETS.map((weeks) => { + const active = backupRetention.weeklyWeeks === weeks; + const label = weeks === 1 ? "1 week" : `${weeks} weeks`; + return ( + + ); + })} +
+
+ +
+

Monthly

+
+ {MONTHLY_RETENTION_PRESETS.map((months) => { + const active = backupRetention.monthlyMonths === months; + const label = months === 1 ? "1 month" : `${months} months`; + return ( + + ); + })} +
From b1e457365b5ab2df0721b58417df2c70ef3e1168 Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Tue, 7 Apr 2026 10:55:32 +0200 Subject: [PATCH 03/32] fix: clean up orphaned .sql on compression failure and fix stale startup log - backup-lib: delete uncompressed .sql file in catch block when gzip compression fails, preventing silent disk usage accumulation - server: replace stale retentionDays scalar with retentionSource in startup log since retention is now read from DB on each backup tick --- packages/db/src/backup-lib.ts | 3 +++ server/src/index.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 0fe70f132af..e1e88724f8a 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -763,6 +763,9 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise if (existsSync(backupFile)) { try { unlinkSync(backupFile); } catch { /* ignore */ } } + if (existsSync(sqlFile)) { + try { unlinkSync(sqlFile); } catch { /* ignore */ } + } throw error; } finally { await sql.end(); diff --git a/server/src/index.ts b/server/src/index.ts index b63789a2051..c3f4a8b136c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -670,7 +670,7 @@ export async function startServer(): Promise { logger.info( { intervalMinutes: config.databaseBackupIntervalMinutes, - retentionDays: config.databaseBackupRetentionDays, + retentionSource: "instance-settings-db", backupDir: config.databaseBackupDir, }, "Automatic database backups enabled", From 03dff1a29afaebe8705027e17926175aec9ec5bc Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 9 Apr 2026 10:26:17 -0500 Subject: [PATCH 04/32] Refine issue workflow surfaces and live updates --- server/src/__tests__/activity-routes.test.ts | 3 +- .../issue-execution-policy-routes.test.ts | 77 +- ...issue-update-comment-wakeup-routes.test.ts | 202 +++++ server/src/routes/issues.ts | 74 +- server/src/services/activity.ts | 11 +- server/src/services/heartbeat.ts | 12 - ui/src/api/activity.ts | 2 + ui/src/components/ActiveAgentsPanel.tsx | 4 +- ui/src/components/CommentThread.tsx | 423 +++++------ ui/src/components/InstanceSidebar.tsx | 2 + ui/src/components/IssueChatThread.tsx | 99 ++- .../components/IssueDocumentsSection.test.tsx | 47 ++ ui/src/components/IssueDocumentsSection.tsx | 8 +- ui/src/components/IssueFiltersPopover.tsx | 232 ++++++ ui/src/components/IssueRow.tsx | 11 +- ui/src/components/IssuesList.test.tsx | 63 ++ ui/src/components/IssuesList.tsx | 291 ++----- ui/src/components/IssuesQuicklook.tsx | 3 +- ui/src/components/Layout.tsx | 29 +- ui/src/components/LiveRunWidget.tsx | 24 +- ui/src/components/MarkdownEditor.test.tsx | 101 ++- ui/src/components/MarkdownEditor.tsx | 225 ++++-- ui/src/components/MobileBottomNav.tsx | 2 + ui/src/components/NewIssueDialog.test.tsx | 19 + ui/src/components/NewIssueDialog.tsx | 6 +- .../transcript/useLiveRunTranscripts.test.tsx | 73 ++ .../transcript/useLiveRunTranscripts.ts | 58 +- ui/src/context/LiveUpdatesProvider.test.ts | 76 ++ ui/src/context/LiveUpdatesProvider.tsx | 17 +- ui/src/fixtures/issueChatUxFixtures.ts | 17 + ui/src/lib/inbox.test.ts | 32 +- ui/src/lib/inbox.ts | 79 ++ ui/src/lib/issue-chat-messages.test.ts | 64 ++ ui/src/lib/issue-chat-messages.ts | 66 +- ui/src/lib/issue-filters.ts | 89 +++ ui/src/lib/issueChatTranscriptRuns.test.ts | 30 + ui/src/lib/issueChatTranscriptRuns.ts | 42 ++ ui/src/lib/issueDetailBreadcrumb.test.ts | 130 ++++ ui/src/lib/issueDetailBreadcrumb.ts | 73 ++ ui/src/lib/mention-aware-link-node.ts | 2 +- ui/src/lib/navigation-scroll.test.ts | 86 +++ ui/src/lib/navigation-scroll.ts | 45 ++ ui/src/lib/optimistic-issue-runs.test.ts | 3 + ui/src/lib/optimistic-issue-runs.ts | 2 + ui/src/pages/Inbox.tsx | 710 +++++++++++------- ui/src/pages/IssueChatUxLab.tsx | 22 + ui/src/pages/IssueDetail.tsx | 265 ++++--- ui/src/pages/Issues.tsx | 10 +- 48 files changed, 2799 insertions(+), 1162 deletions(-) create mode 100644 server/src/__tests__/issue-update-comment-wakeup-routes.test.ts create mode 100644 ui/src/components/IssueFiltersPopover.tsx create mode 100644 ui/src/lib/issue-filters.ts create mode 100644 ui/src/lib/issueChatTranscriptRuns.test.ts create mode 100644 ui/src/lib/issueChatTranscriptRuns.ts create mode 100644 ui/src/lib/navigation-scroll.test.ts create mode 100644 ui/src/lib/navigation-scroll.ts diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 0235fdbce9c..86ee374dbdd 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -62,6 +62,7 @@ describe("activity routes", () => { mockActivityService.runsForIssue.mockResolvedValue([ { runId: "run-1", + adapterType: "codex_local", }, ]); @@ -72,6 +73,6 @@ describe("activity routes", () => { expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475"); expect(mockIssueService.getById).not.toHaveBeenCalled(); expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1"); - expect(res.body).toEqual([{ runId: "run-1" }]); + expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]); }); }); diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index 190cb077af9..4f8d9fc626c 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -60,19 +60,17 @@ vi.mock("../services/index.js", () => ({ workProductService: () => ({}), })); -function createApp( - actor: Record = { - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }, -) { +function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = actor; + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; next(); }); app.use("/api", issueRoutes({} as any, {} as any)); @@ -139,63 +137,4 @@ describe("issue execution policy routes", () => { expect(updatePatch.executionState).toBeUndefined(); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); - - it("rejects agent stage advances from non-participants", async () => { - const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const approverAgentId = "44444444-4444-4444-8444-444444444444"; - const executorAgentId = "22222222-2222-4222-8222-222222222222"; - const policy = normalizeIssueExecutionPolicy({ - stages: [ - { - id: "11111111-1111-4111-8111-111111111111", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - { - id: "55555555-5555-4555-8555-555555555555", - type: "approval", - participants: [{ type: "agent", agentId: approverAgentId }], - }, - ], - })!; - const issue = { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - companyId: "company-1", - status: "in_review", - assigneeAgentId: reviewerAgentId, - assigneeUserId: null, - createdByUserId: "local-board", - identifier: "PAP-1000", - title: "Execution policy guard", - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: "11111111-1111-4111-8111-111111111111", - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { type: "agent", agentId: executorAgentId }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; - mockIssueService.getById.mockResolvedValue(issue); - - const res = await request( - createApp({ - type: "agent", - agentId: approverAgentId, - companyId: "company-1", - source: "api_key", - runId: "run-1", - }), - ) - .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") - .send({ status: "done", comment: "Skipping review." }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("active review participant"); - expect(mockIssueService.update).not.toHaveBeenCalled(); - }); }); diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts new file mode 100644 index 00000000000..c6bf91779df --- /dev/null +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -0,0 +1,202 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; + +const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), + addComment: vi.fn(), + findMentionedAgents: vi.fn(), + getRelationSummaries: vi.fn(), + listWakeableBlockedDependents: vi.fn(), + getWakeableParentAfterChildCompletion: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), +})); + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(async () => true), + hasPermission: vi.fn(async () => true), + }), + agentService: () => ({ + getById: vi.fn(async () => null), + }), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +function makeIssue(overrides: Record = {}) { + return { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId: "company-1", + status: "todo", + priority: "medium", + projectId: null, + goalId: null, + parentId: null, + assigneeAgentId: null, + assigneeUserId: "local-board", + createdByUserId: "local-board", + identifier: "PAP-999", + title: "Wake test", + executionPolicy: null, + executionState: null, + hiddenAt: null, + ...overrides, + }; +} + +describe("issue update comment wakeups", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIssueService.findMentionedAgents.mockResolvedValue([]); + mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); + mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); + mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); + }); + + it("includes the new comment in assignment wakes from issue updates", async () => { + const existing = makeIssue(); + const updated = makeIssue({ + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + }); + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.update.mockResolvedValue(updated); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-1", + issueId: existing.id, + companyId: existing.companyId, + body: "write the whole thing", + }); + + const res = await request(createApp()) + .patch(`/api/issues/${existing.id}`) + .send({ + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + comment: "write the whole thing", + }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + source: "assignment", + reason: "issue_assigned", + payload: expect.objectContaining({ + issueId: existing.id, + commentId: "comment-1", + mutation: "update", + }), + contextSnapshot: expect.objectContaining({ + issueId: existing.id, + taskId: existing.id, + commentId: "comment-1", + wakeCommentId: "comment-1", + source: "issue.update", + }), + }), + ); + }); + + it("wakes the assignee on comment-only issue updates", async () => { + const existing = makeIssue({ + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + status: "in_progress", + }); + const updated = { ...existing }; + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.update.mockResolvedValue(updated); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-2", + issueId: existing.id, + companyId: existing.companyId, + body: "please revise this", + }); + + const res = await request(createApp()) + .patch(`/api/issues/${existing.id}`) + .send({ + comment: "please revise this", + }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + source: "automation", + reason: "issue_commented", + payload: expect.objectContaining({ + issueId: existing.id, + commentId: "comment-2", + mutation: "comment", + }), + contextSnapshot: expect.objectContaining({ + issueId: existing.id, + taskId: existing.id, + commentId: "comment-2", + wakeCommentId: "comment-2", + wakeReason: "issue_commented", + source: "issue.comment", + }), + }), + ); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 344c262c738..6ffd524e660 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -96,13 +96,6 @@ function executionPrincipalsEqual( return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId; } -function executionParticipantMatchesAgent( - participant: ParsedExecutionState["currentParticipant"] | null, - agentId: string | null | undefined, -) { - return Boolean(agentId) && participant?.type === "agent" && participant.agentId === agentId; -} - function buildExecutionStageWakeContext(input: { state: ParsedExecutionState; wakeRole: ExecutionStageWakeContext["wakeRole"]; @@ -1386,14 +1379,10 @@ export function issueRoutes( ? (updateFields.executionPolicy as NormalizedExecutionPolicy | null) : previousExecutionPolicy; - const requestedStatus = typeof updateFields.status === "string" ? updateFields.status : undefined; - const requestedAssigneePatchProvided = - req.body.assigneeAgentId !== undefined || req.body.assigneeUserId !== undefined; - const transition = applyIssueExecutionPolicyTransition({ issue: existing, policy: nextExecutionPolicy, - requestedStatus, + requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined, requestedAssigneePatch: { assigneeAgentId: req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null), @@ -1419,27 +1408,6 @@ export function issueRoutes( } Object.assign(updateFields, transition.patch); - const effectiveExecutionState = parseIssueExecutionState( - transition.patch.executionState !== undefined ? transition.patch.executionState : existing.executionState, - ); - const isUnauthorizedAgentStageMutation = - req.actor.type === "agent" && - req.actor.agentId && - existing.status === "in_review" && - transition.workflowControlledAssignment && - !transition.decision && - effectiveExecutionState?.status === "pending" && - ( - (requestedStatus !== undefined && requestedStatus !== "in_review") || - requestedAssigneePatchProvided - ) && - !executionParticipantMatchesAgent(effectiveExecutionState.currentParticipant, req.actor.agentId); - if (isUnauthorizedAgentStageMutation) { - const stageLabel = effectiveExecutionState.currentStageType ?? "execution"; - res.status(403).json({ error: `Only the active ${stageLabel} participant can update this stage` }); - return; - } - const nextAssigneeAgentId = updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null); const nextAssigneeUserId = @@ -1733,6 +1701,7 @@ export function issueRoutes( reason: "issue_assigned", payload: { issueId: issue.id, + ...(comment ? { commentId: comment.id } : {}), mutation: "update", ...(interruptedRunId ? { interruptedRunId } : {}), }, @@ -1740,6 +1709,13 @@ export function issueRoutes( requestedByActorId: actor.actorId, contextSnapshot: { issueId: issue.id, + ...(comment + ? { + taskId: issue.id, + commentId: comment.id, + wakeCommentId: comment.id, + } + : {}), source: "issue.update", ...(interruptedRunId ? { interruptedRunId } : {}), }, @@ -1767,6 +1743,38 @@ export function issueRoutes( } if (commentBody && comment) { + const assigneeId = issue.assigneeAgentId; + const actorIsAgent = actor.actorType === "agent"; + const selfComment = actorIsAgent && actor.actorId === assigneeId; + const skipAssigneeCommentWake = selfComment || isClosed; + + if (assigneeId && !assigneeChanged && !skipAssigneeCommentWake) { + addWakeup(assigneeId, { + source: "automation", + triggerDetail: "system", + reason: reopened ? "issue_reopened_via_comment" : "issue_commented", + payload: { + issueId: id, + commentId: comment.id, + mutation: "comment", + ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: id, + taskId: id, + commentId: comment.id, + wakeCommentId: comment.id, + source: reopened ? "issue.comment.reopen" : "issue.comment", + wakeReason: reopened ? "issue_reopened_via_comment" : "issue_commented", + ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + }); + } + let mentionedIds: string[] = []; try { mentionedIds = await svc.findMentionedAgents(issue.companyId, commentBody); diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index a86d7f68ba0..62e995307d3 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -1,6 +1,6 @@ import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { activityLog, heartbeatRuns, issues } from "@paperclipai/db"; +import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db"; export interface ActivityFilters { companyId: string; @@ -66,14 +66,23 @@ export function activityService(db: Db) { runId: heartbeatRuns.id, status: heartbeatRuns.status, agentId: heartbeatRuns.agentId, + adapterType: agents.adapterType, startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, invocationSource: heartbeatRuns.invocationSource, usageJson: heartbeatRuns.usageJson, resultJson: heartbeatRuns.resultJson, + logBytes: heartbeatRuns.logBytes, }) .from(heartbeatRuns) + .innerJoin( + agents, + and( + eq(agents.id, heartbeatRuns.agentId), + eq(agents.companyId, heartbeatRuns.companyId), + ), + ) .where( and( eq(heartbeatRuns.companyId, companyId), diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index d94922a0f76..954fe51b595 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2011,18 +2011,6 @@ export function heartbeatService(db: Db) { return { outcome: "not_applicable" as const, queuedRun: null }; } - const wakeReason = readNonEmptyString(contextSnapshot.wakeReason); - if (wakeReason === "issue_commented" || wakeReason === "issue_comment_mentioned" || wakeReason === "issue_reopened_via_comment") { - if (run.issueCommentStatus !== "not_applicable") { - await patchRunIssueCommentStatus(run.id, { - issueCommentStatus: "not_applicable", - issueCommentSatisfiedByCommentId: null, - issueCommentRetryQueuedAt: null, - }); - } - return { outcome: "not_applicable" as const, queuedRun: null }; - } - const postedComment = await findRunIssueComment(run.id, run.companyId, issueId); if (postedComment) { await patchRunIssueCommentStatus(run.id, { diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index b1f43d49dfa..46f887ae28e 100644 --- a/ui/src/api/activity.ts +++ b/ui/src/api/activity.ts @@ -5,12 +5,14 @@ export interface RunForIssue { runId: string; status: string; agentId: string; + adapterType: string; startedAt: string | null; finishedAt: string | null; createdAt: string; invocationSource: string; usageJson: Record | null; resultJson: Record | null; + logBytes?: number | null; } export interface IssueForRun { diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 26c969e3c14..feca6161a84 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -30,8 +30,8 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { const runs = liveRuns ?? []; const { data: issues } = useQuery({ - queryKey: queryKeys.issues.list(companyId), - queryFn: () => issuesApi.list(companyId), + queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"], + queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }), enabled: runs.length > 0, }); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 062cbcf7a34..02e0d4b40ee 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; +import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { Link, useLocation } from "react-router-dom"; import type { Agent, @@ -631,7 +631,7 @@ const TimelineList = memo(function TimelineList({ ); }); -export const CommentThread = memo(function CommentThread({ +export function CommentThread({ comments, queuedComments = [], linkedApprovals = [], @@ -662,9 +662,17 @@ export const CommentThread = memo(function CommentThread({ interruptingQueuedRunId = null, composerDisabledReason = null, }: CommentThreadProps) { + const [body, setBody] = useState(""); + const [reopen, setReopen] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [attaching, setAttaching] = useState(false); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; + const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); const [highlightCommentId, setHighlightCommentId] = useState(null); const [votingTargetId, setVotingTargetId] = useState(null); + const editorRef = useRef(null); + const attachInputRef = useRef(null); + const draftTimer = useRef | null>(null); const location = useLocation(); const hasScrolledRef = useRef(false); @@ -730,6 +738,29 @@ export const CommentThread = memo(function CommentThread({ })); }, [agentMap, providedMentions]); + useEffect(() => { + if (!draftKey) return; + setBody(loadDraft(draftKey)); + }, [draftKey]); + + useEffect(() => { + if (!draftKey) return; + if (draftTimer.current) clearTimeout(draftTimer.current); + draftTimer.current = setTimeout(() => { + saveDraft(draftKey, body); + }, DRAFT_DEBOUNCE_MS); + }, [body, draftKey]); + + useEffect(() => { + return () => { + if (draftTimer.current) clearTimeout(draftTimer.current); + }; + }, []); + + useEffect(() => { + setReassignTarget(effectiveSuggestedAssigneeValue); + }, [effectiveSuggestedAssigneeValue]); + // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { const hash = location.hash; @@ -748,25 +779,72 @@ export const CommentThread = memo(function CommentThread({ } }, [location.hash, comments, queuedComments]); - const handleFeedbackVote = useCallback( - async ( - commentId: string, - vote: FeedbackVoteValue, - options?: { allowSharing?: boolean; reason?: string }, - ) => { - if (!onVote) return; - setVotingTargetId(commentId); - try { - await onVote(commentId, vote, options); - } finally { - setVotingTargetId(null); + async function handleSubmit() { + const trimmed = body.trim(); + if (!trimmed) return; + const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; + const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null; + const submittedBody = trimmed; + + setSubmitting(true); + setBody(""); + try { + await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined); + if (draftKey) clearDraft(draftKey); + setReopen(true); + setReassignTarget(effectiveSuggestedAssigneeValue); + } catch { + setBody((current) => + restoreSubmittedCommentDraft({ + currentBody: current, + submittedBody, + }), + ); + // Parent mutation handlers surface the failure and the draft is restored for retry. + } finally { + setSubmitting(false); + } + } + + async function handleAttachFile(evt: ChangeEvent) { + const file = evt.target.files?.[0]; + if (!file) return; + setAttaching(true); + try { + if (imageUploadHandler) { + const url = await imageUploadHandler(file); + const safeName = file.name.replace(/[[\]]/g, "\\$&"); + const markdown = `![${safeName}](${url})`; + setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown); + } else if (onAttachImage) { + await onAttachImage(file); } - }, - [onVote], - ); + } finally { + setAttaching(false); + if (attachInputRef.current) attachInputRef.current.value = ""; + } + } + + async function handleFeedbackVote( + commentId: string, + vote: FeedbackVoteValue, + options?: { allowSharing?: boolean; reason?: string }, + ) { + if (!onVote) return; + setVotingTargetId(commentId); + try { + await onVote(commentId, vote, options); + } finally { + setVotingTargetId(null); + } + } + + const canSubmit = !submitting && !!body.trim(); + + return ( +
+

Timeline ({timeline.length + queuedComments.length})

- const timelineSection = useMemo( - () => ( - ), - [ - timeline, agentMap, currentUserId, companyId, projectId, - onApproveApproval, onRejectApproval, pendingApprovalAction, - feedbackVoteByTargetId, feedbackDataSharingPreference, - onVote, handleFeedbackVote, votingTargetId, highlightCommentId, - feedbackTermsUrl, - ], - ); - - return ( -
-

Timeline ({timeline.length + queuedComments.length})

- - {timelineSection} {liveRunSlot} @@ -840,216 +903,92 @@ export const CommentThread = memo(function CommentThread({ {composerDisabledReason}
) : ( - +
+ +
+ {(imageUploadHandler || onAttachImage) && ( +
+ + +
+ )} + + {enableReassign && reassignOptions.length > 0 && ( + { + if (!option) return Assignee; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + renderOption={(option) => { + if (!option.id) return {option.label}; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + /> + )} + +
+
)}
); -}); - -CommentThread.displayName = "CommentThread"; - -/* ---- Isolated Composer (body state lives here, not in CommentThread) ---- */ - -interface CommentComposerProps { - onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; - mentions: MentionOption[]; - imageUploadHandler?: (file: File) => Promise; - onAttachImage?: (file: File) => Promise; - draftKey?: string; - enableReassign: boolean; - reassignOptions: InlineEntityOption[]; - currentAssigneeValue: string; - suggestedAssigneeValue: string; - agentMap?: Map; } - -const CommentComposer = memo(function CommentComposer({ - onAdd, - mentions, - imageUploadHandler, - onAttachImage, - draftKey, - enableReassign, - reassignOptions, - currentAssigneeValue, - suggestedAssigneeValue, - agentMap, -}: CommentComposerProps) { - const [body, setBody] = useState(""); - const [reopen, setReopen] = useState(true); - const [submitting, setSubmitting] = useState(false); - const [attaching, setAttaching] = useState(false); - const [reassignTarget, setReassignTarget] = useState(suggestedAssigneeValue); - const editorRef = useRef(null); - const attachInputRef = useRef(null); - const draftTimer = useRef | null>(null); - - useEffect(() => { - if (!draftKey) return; - setBody(loadDraft(draftKey)); - }, [draftKey]); - - useEffect(() => { - if (!draftKey) return; - if (draftTimer.current) clearTimeout(draftTimer.current); - draftTimer.current = setTimeout(() => { - saveDraft(draftKey, body); - }, DRAFT_DEBOUNCE_MS); - }, [body, draftKey]); - - useEffect(() => { - return () => { - if (draftTimer.current) clearTimeout(draftTimer.current); - }; - }, []); - - useEffect(() => { - setReassignTarget(suggestedAssigneeValue); - }, [suggestedAssigneeValue]); - - async function handleSubmit() { - const trimmed = body.trim(); - if (!trimmed) return; - const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; - const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null; - const submittedBody = trimmed; - - setSubmitting(true); - setBody(""); - try { - await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined); - if (draftKey) clearDraft(draftKey); - setReopen(true); - setReassignTarget(suggestedAssigneeValue); - } catch { - setBody((current) => - restoreSubmittedCommentDraft({ - currentBody: current, - submittedBody, - }), - ); - } finally { - setSubmitting(false); - } - } - - async function handleAttachFile(evt: ChangeEvent) { - const file = evt.target.files?.[0]; - if (!file) return; - setAttaching(true); - try { - if (imageUploadHandler) { - const url = await imageUploadHandler(file); - const safeName = file.name.replace(/[[\]]/g, "\\$&"); - const markdown = `![${safeName}](${url})`; - setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown); - } else if (onAttachImage) { - await onAttachImage(file); - } - } finally { - setAttaching(false); - if (attachInputRef.current) attachInputRef.current.value = ""; - } - } - - const canSubmit = !submitting && !!body.trim(); - - return ( -
- -
- {(imageUploadHandler || onAttachImage) && ( -
- - -
- )} - - {enableReassign && reassignOptions.length > 0 && ( - { - if (!option) return Assignee; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - renderOption={(option) => { - if (!option.id) return {option.label}; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - /> - )} - -
-
- ); -}); diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx index 076b9702652..b39b959c656 100644 --- a/ui/src/components/InstanceSidebar.tsx +++ b/ui/src/components/InstanceSidebar.tsx @@ -3,6 +3,7 @@ import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from " import { NavLink } from "@/lib/router"; import { pluginsApi } from "@/api/plugins"; import { queryKeys } from "@/lib/queryKeys"; +import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll"; import { SidebarNavItem } from "./SidebarNavItem"; export function InstanceSidebar() { @@ -33,6 +34,7 @@ export function InstanceSidebar() { [ "rounded-md px-2 py-1.5 text-xs transition-colors", diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 884517b4404..1313b8e79fe 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -41,6 +41,7 @@ import { type IssueChatTranscriptEntry, type SegmentTiming, } from "../lib/issue-chat-messages"; +import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -907,8 +908,6 @@ function IssueChatUserMessage() { ) : null} ) : null} - {pending ?
Sending...
: null} -
-
- - - - {message.createdAt ? commentDateLabel(message.createdAt) : ""} - - - - {message.createdAt ? formatDateTime(message.createdAt) : ""} - - - -
+ {pending ? ( +
Sending...
+ ) : ( +
+ + + + {message.createdAt ? commentDateLabel(message.createdAt) : ""} + + + + {message.createdAt ? formatDateTime(message.createdAt) : ""} + + + +
+ )} @@ -1820,26 +1823,12 @@ export function IssueChatThread({ return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); }, [activeRun, liveRuns]); const transcriptRuns = useMemo(() => { - const combined = new Map(); - for (const run of displayLiveRuns) { - combined.set(run.id, { - id: run.id, - status: run.status, - adapterType: run.adapterType, - }); - } - for (const run of linkedRuns) { - if (combined.has(run.runId)) continue; - const adapterType = agentMap?.get(run.agentId)?.adapterType; - if (!adapterType) continue; - combined.set(run.runId, { - id: run.runId, - status: run.status, - adapterType, - }); - } - return [...combined.values()]; - }, [agentMap, displayLiveRuns, linkedRuns]); + return resolveIssueChatTranscriptRuns({ + linkedRuns, + liveRuns: displayLiveRuns, + activeRun, + }); + }, [activeRun, displayLiveRuns, linkedRuns]); const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs: enableLiveTranscriptPolling ? transcriptRuns : [], companyId, diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 117e5b13e05..99f0cbd157f 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -351,4 +351,51 @@ describe("IssueDocumentsSection", () => { }); queryClient.clear(); }); + + it("wraps the documents header actions so mobile layouts do not overflow", async () => { + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([createIssueDocument()]); + + await act(async () => { + root.render( + + + + + + )} + /> + , + ); + }); + + await flush(); + await flush(); + + const heading = container.querySelector("h3"); + expect(heading).toBeTruthy(); + expect(heading?.parentElement?.className).toContain("flex-wrap"); + expect(heading?.nextElementSibling?.className).toContain("flex-wrap"); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); }); diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 26db7266093..c3f0a9d783f 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -683,7 +683,7 @@ export function IssueDocumentsSection({ return (
{isEmpty && !draft?.isNew ? ( -
+
{extraActions}
) : ( -
-

Documents

-
+
+

Documents

+
{extraActions} + + +
+
+ Filters + {activeFilterCount > 0 ? ( + + ) : null} +
+ +
+ Quick filters +
+ {issueQuickFilterPresets.map((preset) => { + const isActive = issueFilterArraysEqual(state.statuses, preset.statuses); + return ( + + ); + })} +
+
+ +
+ +
+
+ Status +
+ {issueStatusOrder.map((status) => ( + + ))} +
+
+ +
+
+ Priority +
+ {issuePriorityOrder.map((priority) => ( + + ))} +
+
+ +
+ Assignee +
+ + {currentUserId ? ( + + ) : null} + {(agents ?? []).map((agent) => ( + + ))} +
+
+ + {labels && labels.length > 0 ? ( +
+ Labels +
+ {labels.map((label) => ( + + ))} +
+
+ ) : null} + + {projects && projects.length > 0 ? ( +
+ Project +
+ {projects.map((project) => ( + + ))} +
+
+ ) : null} + + {enableRoutineVisibilityFilter ? ( +
+ Visibility + +
+ ) : null} +
+
+
+ + + ); +} diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 09df3f038b5..1348848909b 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -2,7 +2,11 @@ import type { ReactNode } from "react"; import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { X } from "lucide-react"; -import { createIssueDetailPath, rememberIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; +import { + createIssueDetailPath, + rememberIssueDetailLocationState, + withIssueDetailHeaderSeed, +} from "../lib/issueDetailBreadcrumb"; import { cn } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; @@ -48,13 +52,14 @@ export function IssueRow({ const showUnreadSlot = unreadState !== null; const showUnreadDot = unreadState === "visible" || unreadState === "fading"; const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined; + const detailState = withIssueDetailHeaderSeed(issueLinkState, issue); return ( rememberIssueDetailLocationState(issuePathId, issueLinkState)} + onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)} className={cn( "group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1", selected ? "hover:bg-transparent" : "hover:bg-accent/50", diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 16e6faf585c..96ea92e825e 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -307,4 +307,67 @@ describe("IssuesList", () => { root.unmount(); }); }); + + it("hides routine-backed issues by default and reveals them when the routine filter is enabled", async () => { + const manualIssue = createIssue({ + id: "issue-manual", + identifier: "PAP-10", + title: "Manual issue", + originKind: "manual", + }); + const routineIssue = createIssue({ + id: "issue-routine", + identifier: "PAP-11", + title: "Routine issue", + originKind: "routine_execution", + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Manual issue"); + expect(container.textContent).not.toContain("Routine issue"); + }); + + await act(async () => { + const filterButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Filter"), + ); + filterButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + await waitForAssertion(() => { + const toggle = Array.from(document.body.querySelectorAll("label")).find( + (label) => label.textContent?.includes("Show routine runs"), + ); + expect(toggle).not.toBeUndefined(); + }); + + await act(async () => { + const toggle = Array.from(document.body.querySelectorAll("label")).find( + (label) => label.textContent?.includes("Show routine runs"), + ); + toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Routine issue"); + }); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 198a1a1619e..576a9edeb69 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -9,6 +9,15 @@ import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { groupBy } from "../lib/groupBy"; +import { + applyIssueFilters, + countActiveIssueFilters, + defaultIssueFilterState, + issueFilterLabel, + issuePriorityOrder, + issueStatusOrder, + type IssueFilterState, +} from "../lib/issue-filters"; import { DEFAULT_INBOX_ISSUE_COLUMNS, getAvailableInboxIssueColumns, @@ -27,39 +36,24 @@ import { issueTrailingColumns, } from "./IssueColumns"; import { StatusIcon } from "./StatusIcon"; -import { PriorityIcon } from "./PriorityIcon"; import { EmptyState } from "./EmptyState"; import { Identity } from "./Identity"; +import { IssueFiltersPopover } from "./IssueFiltersPopover"; import { IssueRow } from "./IssueRow"; import { PageSkeleton } from "./PageSkeleton"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; -import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; -import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react"; +import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, Columns3, User, Search } from "lucide-react"; import { KanbanBoard } from "./KanbanBoard"; import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import type { Issue, Project } from "@paperclipai/shared"; - -/* ── Helpers ── */ - -const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"]; -const priorityOrder = ["critical", "high", "medium", "low"]; const ISSUE_SEARCH_DEBOUNCE_MS = 150; -function statusLabel(status: string): string { - return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); -} - /* ── View state ── */ -export type IssueViewState = { - statuses: string[]; - priorities: string[]; - assignees: string[]; - labels: string[]; - projects: string[]; +export type IssueViewState = IssueFilterState & { sortField: "status" | "priority" | "title" | "created" | "updated"; sortDir: "asc" | "desc"; groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none"; @@ -69,11 +63,7 @@ export type IssueViewState = { }; const defaultViewState: IssueViewState = { - statuses: [], - priorities: [], - assignees: [], - labels: [], - projects: [], + ...defaultIssueFilterState, sortField: "updated", sortDir: "desc", groupBy: "none", @@ -81,13 +71,6 @@ const defaultViewState: IssueViewState = { collapsedGroups: [], collapsedParents: [], }; - -const quickFilterPresets = [ - { label: "All", statuses: [] as string[] }, - { label: "Active", statuses: ["todo", "in_progress", "in_review", "blocked"] }, - { label: "Backlog", statuses: ["backlog"] }, - { label: "Done", statuses: ["done", "cancelled"] }, -]; function getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); @@ -100,45 +83,15 @@ function saveViewState(key: string, state: IssueViewState) { localStorage.setItem(key, JSON.stringify(state)); } -function arraysEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - const sa = [...a].sort(); - const sb = [...b].sort(); - return sa.every((v, i) => v === sb[i]); -} - -function toggleInArray(arr: string[], value: string): string[] { - return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; -} - -function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] { - let result = issues; - if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status)); - if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority)); - if (state.assignees.length > 0) { - result = result.filter((issue) => { - for (const assignee of state.assignees) { - if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true; - if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true; - if (issue.assigneeAgentId === assignee) return true; - } - return false; - }); - } - if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id))); - if (state.projects.length > 0) result = result.filter((i) => i.projectId != null && state.projects.includes(i.projectId)); - return result; -} - function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { const sorted = [...issues]; const dir = state.sortDir === "asc" ? 1 : -1; sorted.sort((a, b) => { switch (state.sortField) { case "status": - return dir * (statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status)); + return dir * (issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status)); case "priority": - return dir * (priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority)); + return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.indexOf(b.priority)); case "title": return dir * a.title.localeCompare(b.title); case "created": @@ -152,16 +105,6 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { return sorted; } -function countActiveFilters(state: IssueViewState): number { - let count = 0; - if (state.statuses.length > 0) count++; - if (state.priorities.length > 0) count++; - if (state.assignees.length > 0) count++; - if (state.labels.length > 0) count++; - if (state.projects.length > 0) count++; - return count; -} - /* ── Component ── */ interface Agent { @@ -186,6 +129,7 @@ interface IssuesListProps { searchFilters?: { participantAgentId?: string; }; + enableRoutineVisibilityFilter?: boolean; onSearchChange?: (search: string) => void; onUpdateIssue: (id: string, data: Record) => void; } @@ -247,6 +191,7 @@ export function IssuesList({ initialAssignees, initialSearch, searchFilters, + enableRoutineVisibilityFilter = false, onSearchChange, onUpdateIssue, }: IssuesListProps) { @@ -319,8 +264,15 @@ export function IssuesList({ queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), searchFilters ?? {}, + enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions", ], - queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), + queryFn: () => + issuesApi.list(selectedCompanyId!, { + q: normalizedIssueSearch, + projectId, + ...searchFilters, + ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), + }), enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, placeholderData: (previousData) => previousData, }); @@ -423,9 +375,9 @@ export function IssuesList({ const filtered = useMemo(() => { const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; - const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); + const filteredByControls = applyIssueFilters(sourceIssues, viewState, currentUserId, enableRoutineVisibilityFilter); return sortIssues(filteredByControls, viewState); - }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]); + }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -433,7 +385,7 @@ export function IssuesList({ enabled: !!selectedCompanyId, }); - const activeFilterCount = countActiveFilters(viewState); + const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter); const groupedContent = useMemo(() => { if (viewState.groupBy === "none") { @@ -441,15 +393,15 @@ export function IssuesList({ } if (viewState.groupBy === "status") { const groups = groupBy(filtered, (i) => i.status); - return statusOrder + return issueStatusOrder .filter((s) => groups[s]?.length) - .map((s) => ({ key: s, label: statusLabel(s), items: groups[s]! })); + .map((s) => ({ key: s, label: issueFilterLabel(s), items: groups[s]! })); } if (viewState.groupBy === "priority") { const groups = groupBy(filtered, (i) => i.priority); - return priorityOrder + return issuePriorityOrder .filter((p) => groups[p]?.length) - .map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! })); + .map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! })); } if (viewState.groupBy === "workspace") { const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace"); @@ -581,175 +533,16 @@ export function IssuesList({ title="Choose which issue columns stay visible" /> - {/* Filter */} - - - - - -
-
- Filters - {activeFilterCount > 0 && ( - - )} -
- - {/* Quick filters */} -
- Quick filters -
- {quickFilterPresets.map((preset) => { - const isActive = arraysEqual(viewState.statuses, preset.statuses); - return ( - - ); - })} -
-
- -
- - {/* Multi-column filter sections */} -
- {/* Status */} -
- Status -
- {statusOrder.map((s) => ( - - ))} -
-
- - {/* Priority + Assignee stacked in right column */} -
- {/* Priority */} -
- Priority -
- {priorityOrder.map((p) => ( - - ))} -
-
- - {/* Assignee */} -
- Assignee -
- - {currentUserId && ( - - )} - {(agents ?? []).map((agent) => ( - - ))} -
-
- - {labels && labels.length > 0 && ( -
- Labels -
- {labels.map((label) => ( - - ))} -
-
- )} - - {projects && projects.length > 0 && ( -
- Project -
- {projects.map((project) => ( - - ))} -
-
- )} -
-
-
- - + ({ id: project.id, name: project.name }))} + labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} + currentUserId={currentUserId} + enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} + /> {/* Sort (list view only) */} {viewState.viewMode === "list" && ( diff --git a/ui/src/components/IssuesQuicklook.tsx b/ui/src/components/IssuesQuicklook.tsx index c4e02e49b5a..ba89d1cdf31 100644 --- a/ui/src/components/IssuesQuicklook.tsx +++ b/ui/src/components/IssuesQuicklook.tsx @@ -3,7 +3,7 @@ import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { StatusIcon } from "./StatusIcon"; -import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; +import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb"; import { timeAgo } from "../lib/timeAgo"; interface IssuesQuicklookProps { @@ -36,6 +36,7 @@ export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) { {issue.title} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index b5809f464bf..461d429ce3e 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Moon, Settings, Sun } from "lucide-react"; -import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; +import { Link, Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; import { InstanceSidebar } from "./InstanceSidebar"; @@ -32,6 +32,11 @@ import { DEFAULT_INSTANCE_SETTINGS_PATH, normalizeRememberedInstanceSettingsPath, } from "../lib/instance-settings"; +import { + resetNavigationScroll, + SIDEBAR_SCROLL_RESET_STATE, + shouldResetScrollOnNavigation, +} from "../lib/navigation-scroll"; import { queryKeys } from "../lib/queryKeys"; import { scheduleMainContentFocus } from "../lib/main-content-focus"; import { cn } from "../lib/utils"; @@ -66,9 +71,12 @@ export function Layout() { const { companyPrefix } = useParams<{ companyPrefix: string }>(); const navigate = useNavigate(); const location = useLocation(); + const navigationType = useNavigationType(); const isInstanceSettingsRoute = location.pathname.startsWith("/instance/"); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); + const previousPathname = useRef(null); + const mainContentRef = useRef(null); const [mobileNavVisible, setMobileNavVisible] = useState(true); const [instanceSettingsTarget, setInstanceSettingsTarget] = useState(() => readRememberedInstanceSettingsPath()); const [shortcutsOpen, setShortcutsOpen] = useState(false); @@ -271,10 +279,24 @@ export function Layout() { useEffect(() => { if (typeof document === "undefined") return; - const mainContent = document.getElementById("main-content"); + const mainContent = mainContentRef.current; return scheduleMainContentFocus(mainContent); }, [location.pathname]); + useEffect(() => { + const shouldResetScroll = shouldResetScrollOnNavigation({ + previousPathname: previousPathname.current, + pathname: location.pathname, + navigationType, + state: location.state, + }); + + previousPathname.current = location.pathname; + + if (!shouldResetScroll) return; + resetNavigationScroll(mainContentRef.current); + }, [location.pathname, navigationType]); + return (
{ @@ -392,6 +415,7 @@ export function Layout() { + ({ id: project.id, name: project.name }))} + labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} + currentUserId={currentUserId} + enableRoutineVisibilityFilter + /> + + + + + +
+ {([ + ["none", "None"], + ["type", "Type"], + ] as const).map(([value, label]) => ( + + ))} +
+
+
{(() => { - // Pre-compute flat nav index for each top-level item and child issue + // Pre-compute flat nav index for each top-level item and child issue. let flatIdx = 0; - const topFlatIndex = new Map(); + const topFlatIndex = new Map(); const childFlatIndex = new Map(); - for (let ti = 0; ti < nestedWorkItems.length; ti++) { - topFlatIndex.set(ti, flatIdx); - flatIdx++; - const topItem = nestedWorkItems[ti]; - if (topItem.kind === "issue") { - const children = childrenByIssueId.get(topItem.issue.id); - const isExp = children?.length && !collapsedInboxParents.has(topItem.issue.id); - if (isExp) { - for (const c of children) { - childFlatIndex.set(c.id, flatIdx); - flatIdx++; + for (const group of groupedSections) { + for (const topItem of group.displayItems) { + const itemKey = `${group.key}:${getWorkItemKey(topItem)}`; + topFlatIndex.set(itemKey, flatIdx); + flatIdx++; + if (topItem.kind === "issue") { + const children = group.childrenByIssueId.get(topItem.issue.id); + const isExpanded = children?.length && !collapsedInboxParents.has(topItem.issue.id); + if (isExpanded) { + for (const child of children) { + childFlatIndex.set(child.id, flatIdx); + flatIdx++; + } } } } } - return nestedWorkItems.flatMap((item, index) => { - const navIdx = topFlatIndex.get(index) ?? index; - const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => ( -
setSelectedIndex(navIdx)} - > - {child} -
- ); - const todayCutoff = Date.now() - 24 * 60 * 60 * 1000; - const showTodayDivider = - index > 0 && - item.timestamp > 0 && - item.timestamp < todayCutoff && - nestedWorkItems[index - 1].timestamp >= todayCutoff; - const elements: ReactNode[] = []; - if (showTodayDivider) { - elements.push( -
-
- - Earlier - -
, - ); - } - const isSelected = selectedIndex === navIdx; - - if (item.kind === "approval") { - const approvalKey = `approval:${item.approval.id}`; - const isArchiving = archivingNonIssueIds.has(approvalKey); - const row = ( - approveMutation.mutate(item.approval.id)} - onReject={() => rejectMutation.mutate(item.approval.id)} - isPending={approveMutation.isPending || rejectMutation.isPending} - unreadState={nonIssueUnreadState(approvalKey)} - onMarkRead={() => handleMarkNonIssueRead(approvalKey)} - onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined} - archiveDisabled={isArchiving} - className={ - isArchiving - ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" - : "transition-all duration-200 ease-out" - } - /> - ); - elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? ( - handleArchiveNonIssue(approvalKey)} - > - {row} - - ) : row)); - return elements; - } - - if (item.kind === "failed_run") { - const runKey = `run:${item.run.id}`; - const isArchiving = archivingNonIssueIds.has(runKey); - const row = ( - dismissInboxItem(runKey)} - onRetry={() => retryRunMutation.mutate(item.run)} - isRetrying={retryingRunIds.has(item.run.id)} - unreadState={nonIssueUnreadState(runKey)} - onMarkRead={() => handleMarkNonIssueRead(runKey)} - onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined} - archiveDisabled={isArchiving} - className={ - isArchiving - ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" - : "transition-all duration-200 ease-out" - } - /> - ); - elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? ( - handleArchiveNonIssue(runKey)} - > - {row} - - ) : row)); - return elements; - } - - if (item.kind === "join_request") { - const joinKey = `join:${item.joinRequest.id}`; - const isArchiving = archivingNonIssueIds.has(joinKey); - const row = ( - approveJoinMutation.mutate(item.joinRequest)} - onReject={() => rejectJoinMutation.mutate(item.joinRequest)} - isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending} - unreadState={nonIssueUnreadState(joinKey)} - onMarkRead={() => handleMarkNonIssueRead(joinKey)} - onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined} - archiveDisabled={isArchiving} - className={ - isArchiving - ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" - : "transition-all duration-200 ease-out" - } - /> - ); - elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? ( - handleArchiveNonIssue(joinKey)} - > - {row} - - ) : row)); - return elements; - } - - const issue = item.issue; - const childIssues = childrenByIssueId.get(issue.id) ?? []; - const hasChildren = childIssues.length > 0; - const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id); - - const renderInboxIssue = (iss: Issue, depth: number, sel: boolean) => { - const isUnread = iss.isUnreadForMe && !fadingOutIssues.has(iss.id); - const isFading = fadingOutIssues.has(iss.id); - const isArch = archivingIssueIds.has(iss.id); - const proj = iss.projectId ? projectById.get(iss.projectId) ?? null : null; + const renderInboxIssue = ({ + issue, + depth, + selected, + hasChildren = false, + isExpanded = false, + childCount = 0, + collapseParentId = null, + }: { + issue: Issue; + depth: number; + selected: boolean; + hasChildren?: boolean; + isExpanded?: boolean; + childCount?: number; + collapseParentId?: string | null; + }) => { + const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); + const isFading = fadingOutIssues.has(issue.id); + const isArchiving = archivingIssueIds.has(issue.id); + const project = issue.projectId ? projectById.get(issue.projectId) ?? null : null; return ( {nestingEnabled ? ( - depth === 0 && hasChildren ? ( + depth === 0 && hasChildren && collapseParentId ? ( ) : undefined } - unreadState={ - isUnread ? "visible" : isFading ? "fading" : "hidden" - } - onMarkRead={() => markReadMutation.mutate(iss.id)} - onArchive={ - canArchiveFromTab - ? () => archiveIssueMutation.mutate(iss.id) - : undefined - } - archiveDisabled={isArch || archiveIssueMutation.isPending} + unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"} + onMarkRead={() => markReadMutation.mutate(issue.id)} + onArchive={canArchiveFromTab ? () => archiveIssueMutation.mutate(issue.id) : undefined} + archiveDisabled={isArchiving || archiveIssueMutation.isPending} desktopTrailing={ visibleTrailingIssueColumns.length > 0 ? ( ) : undefined } @@ -1893,49 +1882,224 @@ export function Inbox() { ); }; - // Render parent issue - const parentRow = renderInboxIssue(issue, 0, isSelected); - elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? ( - archiveIssueMutation.mutate(issue.id)} - > - {parentRow} - - ) : parentRow)); - - // Render children if expanded - if (isExpanded) { - for (const child of childIssues) { - const cNavIdx = childFlatIndex.get(child.id) ?? -1; - const isChildSelected = selectedIndex === cNavIdx; - const childRow = renderInboxIssue(child, 1, isChildSelected); - const isChildArchiving = archivingIssueIds.has(child.id); + let previousTimestamp = Number.POSITIVE_INFINITY; + return groupedSections.flatMap((group, groupIndex) => { + const elements: ReactNode[] = []; + if (group.label) { elements.push(
setSelectedIndex(cNavIdx)} + key={`group-${group.key}`} + className={cn( + "border-b border-border/70 bg-muted/30 px-4 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", + groupIndex > 0 && "border-t border-border", + )} > - {canArchiveFromTab ? ( - archiveIssueMutation.mutate(child.id)} - > - {childRow} - - ) : childRow} + {group.label}
, ); } - } - return elements; - }); + + for (let index = 0; index < group.displayItems.length; index += 1) { + const item = group.displayItems[index]!; + const navIdx = topFlatIndex.get(`${group.key}:${getWorkItemKey(item)}`) ?? 0; + const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => ( +
setSelectedIndex(navIdx)} + > + {child} +
+ ); + const todayCutoff = Date.now() - 24 * 60 * 60 * 1000; + const showTodayDivider = + groupBy === "none" && + item.timestamp > 0 && + item.timestamp < todayCutoff && + previousTimestamp >= todayCutoff; + previousTimestamp = item.timestamp > 0 ? item.timestamp : previousTimestamp; + if (showTodayDivider) { + elements.push( +
+
+ + Earlier + +
, + ); + } + const isSelected = selectedIndex === navIdx; + + if (item.kind === "approval") { + const approvalKey = `approval:${item.approval.id}`; + const isArchiving = archivingNonIssueIds.has(approvalKey); + const row = ( + approveMutation.mutate(item.approval.id)} + onReject={() => rejectMutation.mutate(item.approval.id)} + isPending={approveMutation.isPending || rejectMutation.isPending} + unreadState={nonIssueUnreadState(approvalKey)} + onMarkRead={() => handleMarkNonIssueRead(approvalKey)} + onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined} + archiveDisabled={isArchiving} + className={ + isArchiving + ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" + : "transition-all duration-200 ease-out" + } + /> + ); + elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? ( + handleArchiveNonIssue(approvalKey)} + > + {row} + + ) : row)); + continue; + } + + if (item.kind === "failed_run") { + const runKey = `run:${item.run.id}`; + const isArchiving = archivingNonIssueIds.has(runKey); + const row = ( + dismissInboxItem(runKey)} + onRetry={() => retryRunMutation.mutate(item.run)} + isRetrying={retryingRunIds.has(item.run.id)} + unreadState={nonIssueUnreadState(runKey)} + onMarkRead={() => handleMarkNonIssueRead(runKey)} + onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined} + archiveDisabled={isArchiving} + className={ + isArchiving + ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" + : "transition-all duration-200 ease-out" + } + /> + ); + elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? ( + handleArchiveNonIssue(runKey)} + > + {row} + + ) : row)); + continue; + } + + if (item.kind === "join_request") { + const joinKey = `join:${item.joinRequest.id}`; + const isArchiving = archivingNonIssueIds.has(joinKey); + const row = ( + approveJoinMutation.mutate(item.joinRequest)} + onReject={() => rejectJoinMutation.mutate(item.joinRequest)} + isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending} + unreadState={nonIssueUnreadState(joinKey)} + onMarkRead={() => handleMarkNonIssueRead(joinKey)} + onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined} + archiveDisabled={isArchiving} + className={ + isArchiving + ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" + : "transition-all duration-200 ease-out" + } + /> + ); + elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? ( + handleArchiveNonIssue(joinKey)} + > + {row} + + ) : row)); + continue; + } + + const issue = item.issue; + const childIssues = group.childrenByIssueId.get(issue.id) ?? []; + const hasChildren = childIssues.length > 0; + const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id); + const parentRow = renderInboxIssue({ + issue, + depth: 0, + selected: isSelected, + hasChildren, + isExpanded, + childCount: childIssues.length, + collapseParentId: issue.id, + }); + + elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? ( + archiveIssueMutation.mutate(issue.id)} + > + {parentRow} + + ) : parentRow)); + + if (isExpanded) { + for (const child of childIssues) { + const childNavIdx = childFlatIndex.get(child.id) ?? -1; + const isChildSelected = selectedIndex === childNavIdx; + const childRow = renderInboxIssue({ + issue: child, + depth: 1, + selected: isChildSelected, + }); + const isChildArchiving = archivingIssueIds.has(child.id); + elements.push( +
setSelectedIndex(childNavIdx)} + > + {canArchiveFromTab ? ( + archiveIssueMutation.mutate(child.id)} + > + {childRow} + + ) : childRow} +
, + ); + } + } + } + + return elements; + }); })()}
diff --git a/ui/src/pages/IssueChatUxLab.tsx b/ui/src/pages/IssueChatUxLab.tsx index fa9a889b5ee..1027ff4c35f 100644 --- a/ui/src/pages/IssueChatUxLab.tsx +++ b/ui/src/pages/IssueChatUxLab.tsx @@ -14,6 +14,7 @@ import { issueChatUxReassignOptions, issueChatUxReviewComments, issueChatUxReviewEvents, + issueChatUxSubmittingComments, issueChatUxTranscriptsByRunId, } from "../fixtures/issueChatUxFixtures"; import { cn } from "../lib/utils"; @@ -25,6 +26,7 @@ const highlights = [ "Running assistant replies with streamed text, reasoning, tool cards, and background status notes", "Historical issue events and linked runs rendered inline with the chat timeline", "Queued user messages, settled assistant comments, and feedback controls", + "Submitting (pending) message bubble with Sending... label and reduced opacity", "Empty and disabled-composer states without relying on live backend data", ]; @@ -285,6 +287,26 @@ export function IssueChatUxLab() { /> + + + +
(previousData: T | undefined) { @@ -284,6 +281,87 @@ function IssueChatSkeleton() { ); } +function IssueDetailLoadingState({ + headerSeed, +}: { + headerSeed: ReturnType; +}) { + const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null; + + return ( +
+
+ + +
+ {headerSeed ? ( + <> + + + {identifier ? ( + {identifier} + ) : null} + {headerSeed.originKind === "routine_execution" && headerSeed.originId ? ( + + + Routine + + ) : null} + {headerSeed.projectId ? ( + + + + {headerSeed.projectName ?? headerSeed.projectId.slice(0, 8)} + + + ) : ( + + + No project + + )} + + ) : ( + <> + + + + + + )} +
+ + {headerSeed ? ( + <> +

{headerSeed.title}

+
+ + +
+ + ) : ( + <> + + + + )} +
+ + + +
+
+ + +
+ +
+ + +
+ ); +} + export function IssueDetail() { const { issueId } = useParams<{ issueId: string }>(); const { selectedCompanyId } = useCompany(); @@ -309,10 +387,15 @@ export function IssueDetail() { const [galleryIndex, setGalleryIndex] = useState(0); const [optimisticComments, setOptimisticComments] = useState([]); const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0); + const [issueChatInitialTranscriptReady, setIssueChatInitialTranscriptReady] = useState(false); const fileInputRef = useRef(null); const lastMarkedReadIssueIdRef = useRef(null); const commentComposerRef = useRef(null); + useEffect(() => { + setIssueChatInitialTranscriptReady(false); + }, [issueId]); + const { data: issue, isLoading, error } = useQuery({ queryKey: queryKeys.issues.detail(issueId!), queryFn: () => issuesApi.get(issueId!), @@ -358,6 +441,14 @@ export function IssueDetail() { placeholderData: keepPreviousData, }); + const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({ + queryKey: queryKeys.issues.runs(issueId!), + queryFn: () => activityApi.runsForIssue(issueId!), + enabled: !!issueId, + refetchInterval: 5000, + placeholderData: keepPreviousData, + }); + const { data: linkedApprovals } = useQuery({ queryKey: queryKeys.issues.approvals(issueId!), queryFn: () => issuesApi.listApprovals(issueId!), @@ -376,12 +467,7 @@ export function IssueDetail() { queryKey: queryKeys.issues.liveRuns(issueId!), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!), enabled: !!issueId, - refetchInterval: (query) => { - const data = query.state.data as Array | undefined; - return data && data.length > 0 - ? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS - : IDLE_ISSUE_RUN_POLL_INTERVAL_MS; - }, + refetchInterval: 3000, placeholderData: keepPreviousData, }); @@ -389,25 +475,11 @@ export function IssueDetail() { queryKey: queryKeys.issues.activeRun(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"), - refetchInterval: (query) => - (liveRuns?.length ?? 0) > 0 - ? false - : query.state.data - ? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS - : IDLE_ISSUE_RUN_POLL_INTERVAL_MS, + refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000, placeholderData: keepPreviousData, }); const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; - const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({ - queryKey: queryKeys.issues.runs(issueId!), - queryFn: () => activityApi.runsForIssue(issueId!), - enabled: !!issueId, - refetchInterval: hasLiveRuns - ? ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS - : IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS, - placeholderData: keepPreviousData, - }); const runningIssueRun = useMemo( () => ( activeRun?.status === "running" @@ -420,6 +492,10 @@ export function IssueDetail() { () => readIssueDetailLocationState(issueId, location.state, location.search), [issueId, location.state, location.search], ); + const issueHeaderSeed = useMemo( + () => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState), + [location.state, resolvedIssueDetailState], + ); const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" }, [issueId, location.state, location.search], @@ -430,8 +506,14 @@ export function IssueDetail() { const liveIds = new Set(); for (const r of liveRuns ?? []) liveIds.add(r.id); if (activeRun) liveIds.add(activeRun.id); - if (liveIds.size === 0) return linkedRuns ?? []; - return (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId)); + const historicalRuns = liveIds.size === 0 + ? (linkedRuns ?? []) + : (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId)); + return historicalRuns.map((run) => ({ + ...run, + adapterType: run.adapterType, + hasStoredOutput: (run.logBytes ?? 0) > 0, + })); }, [linkedRuns, liveRuns, activeRun]); const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({ @@ -500,6 +582,23 @@ export function IssueDetail() { for (const a of agents ?? []) map.set(a.id, a); return map; }, [agents]); + const transcriptRuns = useMemo( + () => + resolveIssueChatTranscriptRuns({ + linkedRuns: timelineRuns, + liveRuns: liveRuns ?? [], + activeRun, + }), + [activeRun, liveRuns, timelineRuns], + ); + const { + transcriptByRun: issueChatTranscriptByRun, + hasOutputForRun: issueChatHasOutputForRun, + isInitialHydrating: issueChatTranscriptHydrating, + } = useLiveRunTranscripts({ + runs: transcriptRuns, + companyId: issue?.companyId ?? selectedCompanyId, + }); const mentionOptions = useMemo(() => { const options: MentionOption[] = []; @@ -699,6 +798,10 @@ export function IssueDetail() { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); }, [issueId, queryClient]); + const invalidateIssueThreadLazily = useCallback(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), refetchType: "inactive" }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!), refetchType: "inactive" }); + }, [issueId, queryClient]); const invalidateIssueRunState = useCallback(() => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }); @@ -885,6 +988,10 @@ export function IssueDetail() { current.filter((entry) => entry.clientId !== context.optimisticCommentId), ); } + queryClient.setQueryData( + queryKeys.issues.detail(issueId!), + (current) => current ? { ...current, updatedAt: comment.createdAt } : current, + ); queryClient.setQueryData>( queryKeys.issues.comments(issueId!), (current) => current ? { @@ -912,7 +1019,7 @@ export function IssueDetail() { }); }, onSettled: (_result, _error, variables) => { - invalidateIssueDetail(); + invalidateIssueThreadLazily(); if (variables.interrupt) { invalidateIssueRunState(); } @@ -1011,7 +1118,7 @@ export function IssueDetail() { }); }, onSettled: (_result, _error, variables) => { - invalidateIssueDetail(); + invalidateIssueThreadLazily(); if (variables.interrupt) { invalidateIssueRunState(); } @@ -1213,53 +1320,6 @@ export function IssueDetail() { }, }); - const handleInterruptQueued = useCallback( - async (runId: string) => { - await interruptQueuedComment.mutateAsync(runId); - }, - [interruptQueuedComment.mutateAsync], - ); - - const handleCommentImageUpload = useCallback( - async (file: File) => { - const attachment = await uploadAttachment.mutateAsync(file); - return attachment.contentPath; - }, - [uploadAttachment.mutateAsync], - ); - - const handleCommentAttachImage = useCallback( - async (file: File) => { - await uploadAttachment.mutateAsync(file); - }, - [uploadAttachment.mutateAsync], - ); - - const handleCommentAdd = useCallback( - async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => { - if (reassignment) { - await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); - return; - } - await addComment.mutateAsync({ body, reopen }); - }, - [addComment.mutateAsync, addCommentAndReassign.mutateAsync], - ); - - const handleCommentVote = useCallback( - async (commentId: string, vote: FeedbackVoteValue, options?: { reason?: string; allowSharing?: boolean }) => { - await feedbackVoteMutation.mutateAsync({ - targetType: "issue_comment", - targetId: commentId, - vote, - reason: options?.reason, - allowSharing: options?.allowSharing, - sharingPreferenceAtSubmit: feedbackDataSharingPreference, - }); - }, - [feedbackVoteMutation.mutateAsync, feedbackDataSharingPreference], - ); - useEffect(() => { const titleLabel = issue?.title ?? issueId ?? "Issue"; setBreadcrumbs([ @@ -1480,18 +1540,26 @@ export function IssueDetail() { setTimeout(() => setCopied(false), 2000); }; - const issueChatInitialLoading = + const issueChatCoreInitialLoading = (commentsLoading && commentPages === undefined) || (activityLoading && activity === undefined) || (linkedRunsLoading && linkedRuns === undefined) || (liveRunsLoading && liveRuns === undefined) || (activeRunLoading && activeRun === undefined); + useEffect(() => { + if (issueChatInitialTranscriptReady) return; + if (issueChatCoreInitialLoading || issueChatTranscriptHydrating) return; + setIssueChatInitialTranscriptReady(true); + }, [issueChatCoreInitialLoading, issueChatInitialTranscriptReady, issueChatTranscriptHydrating]); + const issueChatInitialLoading = + issueChatCoreInitialLoading + || (!issueChatInitialTranscriptReady && issueChatTranscriptHydrating); const activityInitialLoading = (activityLoading && activity === undefined) || (linkedRunsLoading && linkedRuns === undefined); const attachmentsInitialLoading = attachmentsLoading && attachments === undefined; - if (isLoading) return ; + if (isLoading) return ; if (error) return

{error.message}

; if (!issue) return null; @@ -2075,19 +2143,44 @@ export function IssueDetail() { issueStatus={issue.status} agentMap={agentMap} currentUserId={currentUserId} + enableLiveTranscriptPolling={false} + transcriptsByRunId={issueChatTranscriptByRun} + hasOutputForRun={issueChatHasOutputForRun} draftKey={`paperclip:issue-comment-draft:${issue.id}`} enableReassign reassignOptions={commentReassignOptions} currentAssigneeValue={actualAssigneeValue} suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentionOptions} - onInterruptQueued={handleInterruptQueued} - interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} composerDisabledReason={commentComposerDisabledReason} - onVote={handleCommentVote} - onAdd={handleCommentAdd} - imageUploadHandler={handleCommentImageUpload} - onAttachImage={handleCommentAttachImage} + onVote={async (commentId, vote, options) => { + await feedbackVoteMutation.mutateAsync({ + targetType: "issue_comment", + targetId: commentId, + vote, + reason: options?.reason, + allowSharing: options?.allowSharing, + sharingPreferenceAtSubmit: feedbackDataSharingPreference, + }); + }} + onAdd={async (body, reopen, reassignment) => { + if (reassignment) { + await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); + return; + } + await addComment.mutateAsync({ body, reopen }); + }} + imageUploadHandler={async (file) => { + const attachment = await uploadAttachment.mutateAsync(file); + return attachment.contentPath; + }} + onAttachImage={async (file) => { + await uploadAttachment.mutateAsync(file); + }} + onInterruptQueued={async (runId) => { + await interruptQueuedComment.mutateAsync(runId); + }} + interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} onCancelRun={runningIssueRun ? async () => { await interruptQueuedComment.mutateAsync(runningIssueRun.id); diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 607197856dd..e1ecffc456f 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -80,8 +80,13 @@ export function Issues() { }, [setBreadcrumbs]); const { data: issues, isLoading, error } = useQuery({ - queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"], - queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }), + queryKey: [ + ...queryKeys.issues.list(selectedCompanyId!), + "participant-agent", + participantAgentId ?? "__all__", + "with-routine-executions", + ], + queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId, includeRoutineExecutions: true }), enabled: !!selectedCompanyId, }); @@ -110,6 +115,7 @@ export function Issues() { initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined} initialSearch={initialSearch} onSearchChange={handleSearchChange} + enableRoutineVisibilityFilter onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} searchFilters={participantAgentId ? { participantAgentId } : undefined} /> From 44d94d0add78b3390874ab90c3f3a1a303dc8499 Mon Sep 17 00:00:00 2001 From: Asish Kumar Date: Thu, 9 Apr 2026 17:50:14 +0000 Subject: [PATCH 05/32] fix(ui): persist cleared agent env bindings on save Agent configuration edits already had an API path for replacing the full adapterConfig, but the edit form was still sending merge-style patches. That meant clearing the last environment variable serialized as undefined, the key disappeared from JSON, and the server merged the old env bindings back into the saved config. Build adapter config save payloads as full replacement patches, strip undefined keys before send, and reuse the existing replaceAdapterConfig contract so explicit clears persist correctly. Add regression coverage for the cleared-env case and for adapter-type changes that still need to preserve adapter-agnostic fields. Fixes #3179 --- ui/src/components/AgentConfigForm.tsx | 62 ++------------- ui/src/lib/agent-config-patch.test.ts | 108 ++++++++++++++++++++++++++ ui/src/lib/agent-config-patch.ts | 70 +++++++++++++++++ 3 files changed, 185 insertions(+), 55 deletions(-) create mode 100644 ui/src/lib/agent-config-patch.test.ts create mode 100644 ui/src/lib/agent-config-patch.ts diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index dd74c37626a..065405f9a2d 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -49,6 +49,7 @@ import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-confi import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata"; import { getAdapterLabel } from "../adapters/adapter-display-registry"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; +import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch"; /* ---- Create mode values ---- */ @@ -89,15 +90,7 @@ type AgentConfigFormProps = { /* ---- Edit mode overlay (dirty tracking) ---- */ -interface Overlay { - identity: Record; - adapterType?: string; - adapterConfig: Record; - heartbeat: Record; - runtime: Record; -} - -const emptyOverlay: Overlay = { +const emptyOverlay: AgentConfigOverlay = { identity: {}, adapterConfig: {}, heartbeat: {}, @@ -107,7 +100,7 @@ const emptyOverlay: Overlay = { /** Stable empty object used as fallback for missing env config to avoid new-object-per-render. */ const EMPTY_ENV: Record = {}; -function isOverlayDirty(o: Overlay): boolean { +function isOverlayDirty(o: AgentConfigOverlay): boolean { return ( Object.keys(o.identity).length > 0 || o.adapterType !== undefined || @@ -211,7 +204,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }); // ---- Edit mode: overlay for dirty tracking ---- - const [overlay, setOverlay] = useState(emptyOverlay); + const [overlay, setOverlay] = useState(emptyOverlay); const agentRef = useRef(null); // Clear overlay when agent data refreshes (after save) @@ -227,14 +220,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const isDirty = !isCreate && isOverlayDirty(overlay); /** Read effective value: overlay if dirty, else original */ - function eff(group: keyof Omit, field: string, original: T): T { + function eff(group: keyof Omit, field: string, original: T): T { const o = overlay[group]; if (field in o) return o[field] as T; return original; } /** Mark field dirty in overlay */ - function mark(group: keyof Omit, field: string, value: unknown) { + function mark(group: keyof Omit, field: string, value: unknown) { setOverlay((prev) => ({ ...prev, [group]: { ...prev[group], [field]: value }, @@ -248,48 +241,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const handleSave = useCallback(() => { if (isCreate || !isDirty) return; - const agent = props.agent; - const patch: Record = {}; - - if (Object.keys(overlay.identity).length > 0) { - Object.assign(patch, overlay.identity); - } - if (overlay.adapterType !== undefined) { - patch.adapterType = overlay.adapterType; - // When adapter type changes, replace adapter-specific fields but preserve - // adapter-agnostic fields (env, promptTemplate, etc.) that are shared - // across all adapter types. - const existing = (agent.adapterConfig ?? {}) as Record; - const adapterAgnosticKeys = [ - "env", - "promptTemplate", - "instructionsFilePath", - "cwd", - "timeoutSec", - "graceSec", - "bootstrapPromptTemplate", - ]; - const preserved: Record = {}; - for (const key of adapterAgnosticKeys) { - if (key in existing) { - preserved[key] = existing[key]; - } - } - patch.adapterConfig = { ...preserved, ...overlay.adapterConfig }; - } else if (Object.keys(overlay.adapterConfig).length > 0) { - const existing = (agent.adapterConfig ?? {}) as Record; - patch.adapterConfig = { ...existing, ...overlay.adapterConfig }; - } - if (Object.keys(overlay.heartbeat).length > 0) { - const existingRc = (agent.runtimeConfig ?? {}) as Record; - const existingHb = (existingRc.heartbeat ?? {}) as Record; - patch.runtimeConfig = { ...existingRc, heartbeat: { ...existingHb, ...overlay.heartbeat } }; - } - if (Object.keys(overlay.runtime).length > 0) { - Object.assign(patch, overlay.runtime); - } - - props.onSave(patch); + props.onSave(buildAgentUpdatePatch(props.agent, overlay)); }, [isCreate, isDirty, overlay, props]); useEffect(() => { diff --git a/ui/src/lib/agent-config-patch.test.ts b/ui/src/lib/agent-config-patch.test.ts new file mode 100644 index 00000000000..f655e8e502f --- /dev/null +++ b/ui/src/lib/agent-config-patch.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import type { Agent } from "@paperclipai/shared"; +import { buildAgentUpdatePatch, type AgentConfigOverlay } from "./agent-config-patch"; + +function makeAgent(): Agent { + return { + id: "agent-1", + companyId: "company-1", + name: "Agent", + role: "engineer", + title: "Engineer", + icon: null, + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "claude_local", + adapterConfig: { + model: "claude-sonnet-4-6", + env: { + OPENAI_API_KEY: { + type: "plain", + value: "secret", + }, + }, + promptTemplate: "Work the issue.", + }, + runtimeConfig: { + heartbeat: { + enabled: true, + intervalSec: 300, + }, + }, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + lastHeartbeatAt: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + urlKey: "agent", + permissions: { + canCreateAgents: false, + }, + metadata: null, + }; +} + +function makeOverlay(patch?: Partial): AgentConfigOverlay { + return { + identity: {}, + adapterConfig: {}, + heartbeat: {}, + runtime: {}, + ...patch, + }; +} + +describe("buildAgentUpdatePatch", () => { + it("replaces adapter config and drops env when the last env binding is cleared", () => { + const patch = buildAgentUpdatePatch( + makeAgent(), + makeOverlay({ + adapterConfig: { + env: undefined, + }, + }), + ); + + expect(patch).toEqual({ + adapterConfig: { + model: "claude-sonnet-4-6", + promptTemplate: "Work the issue.", + }, + replaceAdapterConfig: true, + }); + }); + + it("preserves adapter-agnostic keys when changing adapter types", () => { + const patch = buildAgentUpdatePatch( + makeAgent(), + makeOverlay({ + adapterType: "codex_local", + adapterConfig: { + model: "gpt-5.4", + dangerouslyBypassApprovalsAndSandbox: true, + }, + }), + ); + + expect(patch).toEqual({ + adapterType: "codex_local", + adapterConfig: { + env: { + OPENAI_API_KEY: { + type: "plain", + value: "secret", + }, + }, + promptTemplate: "Work the issue.", + model: "gpt-5.4", + dangerouslyBypassApprovalsAndSandbox: true, + }, + replaceAdapterConfig: true, + }); + }); +}); diff --git a/ui/src/lib/agent-config-patch.ts b/ui/src/lib/agent-config-patch.ts new file mode 100644 index 00000000000..323f9bf59db --- /dev/null +++ b/ui/src/lib/agent-config-patch.ts @@ -0,0 +1,70 @@ +import type { Agent } from "@paperclipai/shared"; + +export interface AgentConfigOverlay { + identity: Record; + adapterType?: string; + adapterConfig: Record; + heartbeat: Record; + runtime: Record; +} + +const ADAPTER_AGNOSTIC_KEYS = [ + "env", + "promptTemplate", + "instructionsFilePath", + "cwd", + "timeoutSec", + "graceSec", + "bootstrapPromptTemplate", +] as const; + +function omitUndefinedEntries(value: Record) { + return Object.fromEntries( + Object.entries(value).filter(([, entryValue]) => entryValue !== undefined), + ); +} + +export function buildAgentUpdatePatch(agent: Agent, overlay: AgentConfigOverlay) { + const patch: Record = {}; + + if (Object.keys(overlay.identity).length > 0) { + Object.assign(patch, overlay.identity); + } + + if (overlay.adapterType !== undefined) { + patch.adapterType = overlay.adapterType; + } + + if (overlay.adapterType !== undefined || Object.keys(overlay.adapterConfig).length > 0) { + const existing = (agent.adapterConfig ?? {}) as Record; + const nextAdapterConfig = + overlay.adapterType !== undefined + ? { + ...Object.fromEntries( + ADAPTER_AGNOSTIC_KEYS + .filter((key) => existing[key] !== undefined) + .map((key) => [key, existing[key]]), + ), + ...overlay.adapterConfig, + } + : { + ...existing, + ...overlay.adapterConfig, + }; + + patch.adapterConfig = omitUndefinedEntries(nextAdapterConfig); + patch.replaceAdapterConfig = true; + } + + if (Object.keys(overlay.heartbeat).length > 0) { + const existingRc = (agent.runtimeConfig ?? {}) as Record; + const existingHb = (existingRc.heartbeat ?? {}) as Record; + patch.runtimeConfig = { ...existingRc, heartbeat: { ...existingHb, ...overlay.heartbeat } }; + } + + if (Object.keys(overlay.runtime).length > 0) { + Object.assign(patch, overlay.runtime); + } + + return patch; +} From 4077ccd343464d651469e80164b9a2b522d422f1 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 9 Apr 2026 14:48:12 -0500 Subject: [PATCH 06/32] Fix signoff stage access and comment wake retries --- .../__tests__/issue-execution-policy.test.ts | 64 ++++++++----------- server/src/services/heartbeat.ts | 23 +++++++ server/src/services/issue-execution-policy.ts | 16 +++-- 3 files changed, 60 insertions(+), 43 deletions(-) diff --git a/server/src/__tests__/issue-execution-policy.test.ts b/server/src/__tests__/issue-execution-policy.test.ts index 7271b49935c..3d8a649b7d4 100644 --- a/server/src/__tests__/issue-execution-policy.test.ts +++ b/server/src/__tests__/issue-execution-policy.test.ts @@ -413,45 +413,33 @@ describe("issue execution policy transitions", () => { const policy = twoStagePolicy(); const reviewStageId = policy.stages[0].id; - it("non-participant stage updates are coerced back to the active stage", () => { - const result = applyIssueExecutionPolicyTransition({ - issue: { - status: "in_review", - assigneeAgentId: qaAgentId, - assigneeUserId: null, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: reviewStageId, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: qaAgentId }, - returnAssignee: { type: "agent", agentId: coderAgentId }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, + it("non-participant cannot advance the active stage", () => { + expect(() => + applyIssueExecutionPolicyTransition({ + issue: { + status: "in_review", + assigneeAgentId: qaAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: { + status: "pending", + currentStageId: reviewStageId, + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: qaAgentId }, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, }, - }, - policy, - requestedStatus: "done", - requestedAssigneePatch: { assigneeUserId: boardUserId }, - actor: { agentId: coderAgentId }, - commentBody: "Trying to bypass review", - }); - - expect(result.patch).toMatchObject({ - status: "in_review", - assigneeAgentId: qaAgentId, - assigneeUserId: null, - executionState: { - status: "pending", - currentStageId: reviewStageId, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: qaAgentId }, - returnAssignee: { type: "agent", agentId: coderAgentId }, - }, - }); - expect(result.decision).toBeUndefined(); + policy, + requestedStatus: "done", + requestedAssigneePatch: { assigneeUserId: boardUserId }, + actor: { agentId: coderAgentId }, + commentBody: "Trying to bypass review", + }), + ).toThrow("Only the active reviewer or approver can advance"); }); it("non-participant can still post non-advancing updates", () => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 954fe51b595..45c5efd224b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -707,6 +707,18 @@ export function shouldResetTaskSessionForWake( return false; } +function shouldRequireIssueCommentForWake( + contextSnapshot: Record | null | undefined, +) { + const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); + return ( + wakeReason === "issue_assigned" || + wakeReason === "execution_review_requested" || + wakeReason === "execution_approval_requested" || + wakeReason === "execution_changes_requested" + ); +} + export function formatRuntimeWorkspaceWarningLog(warning: string) { return { stream: "stdout" as const, @@ -2035,6 +2047,17 @@ export function heartbeatService(db: Db) { return { outcome: "retry_exhausted" as const, queuedRun: null }; } + if (!shouldRequireIssueCommentForWake(contextSnapshot)) { + if (run.issueCommentStatus !== "not_applicable") { + await patchRunIssueCommentStatus(run.id, { + issueCommentStatus: "not_applicable", + issueCommentSatisfiedByCommentId: null, + issueCommentRetryQueuedAt: null, + }); + } + return { outcome: "not_applicable" as const, queuedRun: null }; + } + const queuedRun = await enqueueMissingIssueCommentRetry(run, agent, issueId); if (queuedRun) { await appendRunEvent(run, await nextRunEventSeq(run.id), { diff --git a/server/src/services/issue-execution-policy.ts b/server/src/services/issue-execution-policy.ts index 6f4ba7b5557..6a3045a1e05 100644 --- a/server/src/services/issue-execution-policy.ts +++ b/server/src/services/issue-execution-policy.ts @@ -393,13 +393,19 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra } } - if ( + const attemptedStageAdvance = + (requestedStatus !== undefined && requestedStatus !== "in_review") || + (requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant)); + const stageStateDrifted = input.issue.status !== "in_review" || !principalsEqual(currentAssignee, currentParticipant) || - !principalsEqual(existingState?.currentParticipant ?? null, currentParticipant) || - (requestedStatus !== undefined && requestedStatus !== "in_review") || - (requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant)) - ) { + !principalsEqual(existingState?.currentParticipant ?? null, currentParticipant); + + if (attemptedStageAdvance && !stageStateDrifted) { + throw unprocessable("Only the active reviewer or approver can advance the current execution stage"); + } + + if (stageStateDrifted) { buildPendingStagePatch({ patch, previous: existingState, From 7c42345177567882ca643f8d7d8caf5c89738d5e Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Fri, 10 Apr 2026 12:16:25 +0200 Subject: [PATCH 07/32] chore: re-trigger CI to refresh PR base SHA From 724893ad5b5dc5164a2538881fdde52508230be8 Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Fri, 10 Apr 2026 14:22:48 +0200 Subject: [PATCH 08/32] fix claude instruction sibling path hint --- packages/adapters/claude-local/src/server/execute.ts | 6 +++++- server/src/__tests__/claude-local-execute.test.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index ae5ea3abd4d..7116be449dc 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -387,7 +387,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(captured[1]?.appendedSystemPromptFilePath).not.toBe(instructionsFile); expect(captured[1]?.appendedSystemPromptFileContents).toContain("# Agent instructions"); expect(captured[1]?.appendedSystemPromptFileContents).toContain( - `The above agent instructions were loaded from ${instructionsFile}. Resolve any relative file references from ${path.dirname(instructionsFile)}/.`, + `The above agent instructions were loaded from ${instructionsFile}. ` + + `Resolve any relative file references from ${path.dirname(instructionsFile)}/. ` + + `This base directory is authoritative for sibling instruction files such as ` + + `./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`, ); expect(metaEvents).toHaveLength(2); expect(metaEvents[0]?.commandNotes).toHaveLength(0); From ac664df8e48326135a913e97ee7ed937d913586b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:55:27 -0500 Subject: [PATCH 09/32] fix(authz): scope import, approvals, activity, and heartbeat routes (#3315) ## Thinking Path > - Paperclip orchestrates AI agents and company-scoped control-plane actions for zero-human companies. > - This change touches the server authz boundary around company portability, approvals, activity, and heartbeat-run operations. > - The vulnerability was that board-authenticated callers could cross company boundaries or create new companies through import paths without the same authorization checks enforced elsewhere. > - Once that gap existed, an attacker could chain it into higher-impact behavior through agent execution paths. > - The fix needed to harden every confirmed authorization gap in the reported chain, not just the first route that exposed it. > - This pull request adds the missing instance-admin and company-access checks and adds regression tests for each affected route. > - The benefit is that cross-company actions and new-company import flows now follow the same control-plane authorization rules as the rest of the product. ## What Changed - Required instance-admin access for `new_company` import preview/apply flows in `server/src/routes/companies.ts`. - Required company access before approval decision routes in `server/src/routes/approvals.ts`. - Required company access for activity creation and heartbeat-run issue listing in `server/src/routes/activity.ts`. - Required company access before heartbeat cancellation in `server/src/routes/agents.ts`. - Added regression coverage in the corresponding server route tests. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/company-portability-routes.test.ts src/__tests__/approval-routes-idempotency.test.ts src/__tests__/activity-routes.test.ts src/__tests__/agent-permissions-routes.test.ts` - `pnpm --filter @paperclipai/server typecheck` - Prior verification on the original security patch branch also included `pnpm build`. ## Risks - Low code risk: the change is narrow and only adds missing authorization gates to existing routes. - Operational risk: the advisory is already public, so this PR should be merged quickly to minimize the public unpatched window. - Residual product risk remains around open signup / bootstrap defaults, which was intentionally left out of this patch because the current first-user onboarding flow depends on it. ## Model Used - OpenAI GPT-5 Codex coding agent with tool use and local code execution in the Codex CLI environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Forgotten --- server/src/__tests__/activity-routes.test.ts | 33 ++++++++++++ .../agent-permissions-routes.test.ts | 24 +++++++++ .../approval-routes-idempotency.test.ts | 52 ++++++++++++++++++- .../company-portability-routes.test.ts | 46 ++++++++++++++++ server/src/routes/activity.ts | 10 +++- server/src/routes/agents.ts | 4 ++ server/src/routes/approvals.ts | 23 +++++++- server/src/routes/companies.ts | 21 +++++--- 8 files changed, 203 insertions(+), 10 deletions(-) diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 86ee374dbdd..ffd343dffe4 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -10,6 +10,10 @@ const mockActivityService = vi.hoisted(() => ({ create: vi.fn(), })); +const mockHeartbeatService = vi.hoisted(() => ({ + getRun: vi.fn(), +})); + const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), getByIdentifier: vi.fn(), @@ -22,6 +26,7 @@ function registerRouteMocks() { vi.doMock("../services/index.js", () => ({ issueService: () => mockIssueService, + heartbeatService: () => mockHeartbeatService, })); } @@ -75,4 +80,32 @@ describe("activity routes", () => { expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1"); expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]); }); + + it("requires company access before creating activity events", async () => { + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-2/activity") + .send({ + actorId: "user-1", + action: "test.event", + entityType: "issue", + entityId: "issue-1", + }); + + expect(res.status).toBe(403); + expect(mockActivityService.create).not.toHaveBeenCalled(); + }); + + it("requires company access before listing issues for another company's run", async () => { + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-2", + companyId: "company-2", + }); + + const app = await createApp(); + const res = await request(app).get("/api/heartbeat-runs/run-2/issues"); + + expect(res.status).toBe(403); + expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 6ef45db164f..c5593f035fd 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -59,6 +59,8 @@ const mockBudgetService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({ listTaskSessions: vi.fn(), resetRuntimeSession: vi.fn(), + getRun: vi.fn(), + cancelRun: vi.fn(), })); const mockIssueApprovalService = vi.hoisted(() => ({ @@ -397,4 +399,26 @@ describe("agent permission routes", () => { }, ]); }); + + it("rejects heartbeat cancellation outside the caller company scope", async () => { + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "33333333-3333-4333-8333-333333333333", + agentId, + status: "running", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app).post("/api/heartbeat-runs/run-1/cancel").send({}); + + expect(res.status).toBe(403); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts index 83d34cf1f93..cecee0e8ba9 100644 --- a/server/src/__tests__/approval-routes-idempotency.test.ts +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -39,7 +39,7 @@ vi.mock("../services/index.js", () => ({ secretService: () => mockSecretService, })); -function createApp() { +function createApp(actorOverrides: Record = {}) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -49,6 +49,7 @@ function createApp() { companyIds: ["company-1"], source: "session", isInstanceAdmin: false, + ...actorOverrides, }; next(); }); @@ -84,6 +85,14 @@ describe("approval routes idempotent retries", () => { }); it("does not emit duplicate approval side effects when approve is already resolved", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "approved", + payload: {}, + requestedByAgentId: "agent-1", + }); mockApprovalService.approve.mockResolvedValue({ approval: { id: "approval-1", @@ -107,6 +116,13 @@ describe("approval routes idempotent retries", () => { }); it("does not emit duplicate rejection logs when reject is already resolved", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "rejected", + payload: {}, + }); mockApprovalService.reject.mockResolvedValue({ approval: { id: "approval-1", @@ -126,6 +142,40 @@ describe("approval routes idempotent retries", () => { expect(mockLogActivity).not.toHaveBeenCalled(); }); + it("rejects approval decisions for companies outside the caller scope", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-2", + companyId: "company-2", + type: "hire_agent", + status: "pending", + payload: {}, + }); + + const res = await request(createApp()) + .post("/api/approvals/approval-2/approve") + .send({}); + + expect(res.status).toBe(403); + expect(mockApprovalService.approve).not.toHaveBeenCalled(); + }); + + it("rejects approval revision requests for companies outside the caller scope", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-3", + companyId: "company-2", + type: "hire_agent", + status: "pending", + payload: {}, + }); + + const res = await request(createApp()) + .post("/api/approvals/approval-3/request-revision") + .send({ decisionNote: "Need changes" }); + + expect(res.status).toBe(403); + expect(mockApprovalService.requestRevision).not.toHaveBeenCalled(); + }); + it("lets agents create generic issue-linked board approval requests", async () => { mockApprovalService.create.mockResolvedValue({ id: "approval-1", diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 8649f631f63..075f2d9bc60 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -175,4 +175,50 @@ describe("company portability routes", () => { expect(res.status).toBe(403); expect(res.body.error).toContain("Board access required"); }); + + it("requires instance admin for new-company import preview", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["11111111-1111-4111-8111-111111111111"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/companies/import/preview") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "new_company", newCompanyName: "Imported Test" }, + collisionStrategy: "rename", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Instance admin"); + expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled(); + }); + + it("requires instance admin for new-company import apply", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["11111111-1111-4111-8111-111111111111"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/companies/import") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "new_company", newCompanyName: "Imported Test" }, + collisionStrategy: "rename", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Instance admin"); + expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 0884b455df3..20f2ed534ed 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db"; import { validate } from "../middleware/validate.js"; import { activityService } from "../services/activity.js"; import { assertBoard, assertCompanyAccess } from "./authz.js"; -import { issueService } from "../services/index.js"; +import { heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; const createActivitySchema = z.object({ @@ -20,6 +20,7 @@ const createActivitySchema = z.object({ export function activityRoutes(db: Db) { const router = Router(); const svc = activityService(db); + const heartbeat = heartbeatService(db); const issueSvc = issueService(db); async function resolveIssueByRef(rawId: string) { @@ -46,6 +47,7 @@ export function activityRoutes(db: Db) { router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); const event = await svc.create({ companyId, ...req.body, @@ -80,6 +82,12 @@ export function activityRoutes(db: Db) { router.get("/heartbeat-runs/:runId/issues", async (req, res) => { const runId = req.params.runId as string; + const run = await heartbeat.getRun(runId); + if (!run) { + res.json([]); + return; + } + assertCompanyAccess(req, run.companyId); const result = await svc.issuesForRun(runId); res.json(result); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 266803ecd9b..3d982cdc0f4 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2296,6 +2296,10 @@ export function agentRoutes(db: Db) { router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { assertBoard(req); const runId = req.params.runId as string; + const existing = await heartbeat.getRun(runId); + if (existing) { + assertCompanyAccess(req, existing.companyId); + } const run = await heartbeat.cancelRun(runId); if (run) { diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index 99d33abd86a..3ed20374700 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -1,4 +1,4 @@ -import { Router } from "express"; +import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { addApprovalCommentSchema, @@ -34,6 +34,15 @@ export function approvalRoutes(db: Db) { const secretsSvc = secretService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; + async function requireApprovalAccess(req: Request, id: string) { + const approval = await svc.getById(id); + if (!approval) { + return null; + } + assertCompanyAccess(req, approval.companyId); + return approval; + } + router.get("/companies/:companyId/approvals", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -121,6 +130,10 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const { approval, applied } = await svc.approve( id, req.body.decidedByUserId ?? "board", @@ -216,6 +229,10 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const { approval, applied } = await svc.reject( id, req.body.decidedByUserId ?? "board", @@ -243,6 +260,10 @@ export function approvalRoutes(db: Db) { async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const approval = await svc.requestRevision( id, req.body.decidedByUserId ?? "board", diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index d1978c9d6f7..22be5f863ca 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -24,7 +24,7 @@ import { logActivity, } from "../services/index.js"; import type { StorageService } from "../storage/types.js"; -import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; export function companyRoutes(db: Db, storage?: StorageService) { const router = Router(); @@ -48,6 +48,17 @@ export function companyRoutes(db: Db, storage?: StorageService) { return parsed; } + function assertImportTargetAccess( + req: Request, + target: { mode: "new_company" } | { mode: "existing_company"; companyId: string }, + ) { + if (target.mode === "new_company") { + assertInstanceAdmin(req); + return; + } + assertCompanyAccess(req, target.companyId); + } + async function assertCanUpdateBranding(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") return; @@ -160,18 +171,14 @@ export function companyRoutes(db: Db, storage?: StorageService) { router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => { assertBoard(req); - if (req.body.target.mode === "existing_company") { - assertCompanyAccess(req, req.body.target.companyId); - } + assertImportTargetAccess(req, req.body.target); const preview = await portability.previewImport(req.body); res.json(preview); }); router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => { assertBoard(req); - if (req.body.target.mode === "existing_company") { - assertCompanyAccess(req, req.body.target.companyId); - } + assertImportTargetAccess(req, req.body.target); const actor = getActorInfo(req); const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null); await logActivity(db, { From f4a05dc35c2eb80511d56c68c0dd270b16799040 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 10 Apr 2026 17:01:06 -0700 Subject: [PATCH 10/32] fix(cli): prepare plugin sdk before cli dev boot (#3343) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The company import/export e2e exercises the local CLI startup path that boots the dev server inside a workspace > - That startup path loads server and plugin code which depends on built workspace package artifacts such as `@paperclipai/shared` and `@paperclipai/plugin-sdk` > - In a clean worktree those `dist/*` artifacts may not exist yet even though `paperclipai run` can still attempt to import the local server entry > - That mismatch caused the import/export e2e to fail before the actual company package flow ran > - This pull request adds a CLI preflight step that prepares the needed workspace build dependencies before the local server import and fails closed if that preflight is interrupted or stalls > - The benefit is that clean worktrees can boot `paperclipai run` reliably without silently continuing after incomplete dependency preparation ## What Changed - Updated `cli/src/commands/run.ts` to execute `scripts/ensure-plugin-build-deps.mjs` before importing `server/src/index.ts` for local dev startup. - Ensured `paperclipai run` can materialize missing workspace artifacts such as `packages/shared/dist` and `packages/plugins/sdk/dist` automatically in clean worktrees. - Made the preflight fail closed when the child process exits via signal and bounded it with a 120-second timeout so the CLI does not hang indefinitely. - Kept the fix isolated to the CLI startup path; no API contract, schema, or UI behavior changed. - Reused the existing `cli/src/__tests__/company-import-export-e2e.test.ts` coverage that already exercises the failing boot path, so no additional test file was needed. ## Verification - `pnpm test:run cli/src/__tests__/company-import-export-e2e.test.ts` - `pnpm --filter paperclipai typecheck` - On the isolated branch, confirmed `packages/shared/dist/index.js` and `packages/plugins/sdk/dist/index.js` were absent before the run, then reran the targeted e2e and observed a passing result. ## Risks - Low risk: the change only affects the local CLI dev startup path before the server import. - Residual risk: other entrypoints still rely on their own preflight/build behavior, so this does not normalize every workspace startup path. - The 120-second timeout is intentionally generous, but unusually slow machines could still hit it and surface a startup error instead of waiting forever. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment, with shell/tool execution enabled. The exact runtime revision and context window are not exposed by this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- cli/src/commands/run.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 04743b482b1..b39ab06cee6 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; import * as p from "@clack/prompts"; import pc from "picocolors"; @@ -146,11 +147,35 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void { } } +function ensureDevWorkspaceBuildDeps(projectRoot: string): void { + const buildScript = path.resolve(projectRoot, "scripts/ensure-plugin-build-deps.mjs"); + if (!fs.existsSync(buildScript)) return; + + const result = spawnSync(process.execPath, [buildScript], { + cwd: projectRoot, + stdio: "inherit", + timeout: 120_000, + }); + + if (result.error) { + throw new Error( + `Failed to prepare workspace build artifacts before starting the Paperclip dev server.\n${formatError(result.error)}`, + ); + } + + if ((result.status ?? 1) !== 0) { + throw new Error( + "Failed to prepare workspace build artifacts before starting the Paperclip dev server.", + ); + } +} + async function importServerEntry(): Promise { // Dev mode: try local workspace path (monorepo with tsx) const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const devEntry = path.resolve(projectRoot, "server/src/index.ts"); if (fs.existsSync(devEntry)) { + ensureDevWorkspaceBuildDeps(projectRoot); maybeEnableUiDevMiddleware(devEntry); const mod = await import(pathToFileURL(devEntry).href); return await startServerFromModule(mod, devEntry); From 548721248e4a0740e33033e223b0ca82db6a7142 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 10 Apr 2026 17:14:06 -0700 Subject: [PATCH 11/32] fix(ui): keep latest issue document revision current (#3342) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Board users and agents collaborate on issue-scoped documents such as plans and revisions need to be trustworthy because they are the audit trail for those artifacts. > - The issue document UI now supports revision history and restore, so the UI has to distinguish the current revision from historical revisions correctly even while multiple queries are refreshing. > - In `PAPA-72`, the newest content could appear under an older revision label because the current document snapshot and the revision-history query could temporarily disagree after an edit. > - That made the UI treat the newest revision like a historical restore target, which is the opposite of the intended behavior. > - This pull request derives one authoritative revision view from both sources, sorts revisions newest-first, and keeps the freshest revision marked current. > - The benefit is that revision history stays stable and trustworthy immediately after edits instead of briefly presenting the newest content as an older revision. ## What Changed - Added a `document-revisions` helper that merges the current document snapshot with fetched revision history into one normalized revision state. - Updated `IssueDocumentsSection` to render from that normalized state instead of trusting either query in isolation. - Added focused tests covering the current-revision selection and ordering behavior. ## Verification - `pnpm -r typecheck` - `pnpm build` - Targeted revision tests passed locally. - Manual reviewer check: - Open an issue document with revision history. - Edit and save the document. - Immediately open the revision selector. - Confirm the newest revision remains marked current and older revisions remain the restore targets. ## Risks - Low risk. The change is isolated to issue document revision presentation in the UI. - Main risk is merging the current snapshot with fetched history incorrectly for edge cases, which is why the helper has focused unit coverage. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../components/IssueDocumentsSection.test.tsx | 182 ++++++++++++++++++ ui/src/components/IssueDocumentsSection.tsx | 26 +-- ui/src/lib/document-revisions.test.ts | 95 +++++++++ ui/src/lib/document-revisions.ts | 72 +++++++ 4 files changed, 364 insertions(+), 11 deletions(-) create mode 100644 ui/src/lib/document-revisions.test.ts create mode 100644 ui/src/lib/document-revisions.ts diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 99f0cbd157f..9f04df9f433 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -119,6 +119,35 @@ vi.mock("@/components/ui/dropdown-menu", async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +const localStorageEntries = new Map(); + +function ensureLocalStorageMock() { + if ( + typeof window.localStorage?.getItem === "function" + && typeof window.localStorage?.setItem === "function" + && typeof window.localStorage?.removeItem === "function" + && typeof window.localStorage?.clear === "function" + ) { + return; + } + + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => localStorageEntries.get(key) ?? null, + setItem: (key: string, value: string) => { + localStorageEntries.set(key, value); + }, + removeItem: (key: string) => { + localStorageEntries.delete(key); + }, + clear: () => { + localStorageEntries.clear(); + }, + }, + }); +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((res) => { @@ -221,6 +250,7 @@ describe("IssueDocumentsSection", () => { beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); + ensureLocalStorageMock(); window.localStorage.clear(); vi.clearAllMocks(); markdownEditorMockState.emitMountEmptyChange = false; @@ -311,6 +341,158 @@ describe("IssueDocumentsSection", () => { queryClient.clear(); }); + it("returns from a historical preview when the current revision only exists in derived state", async () => { + const currentDocument = createIssueDocument({ + body: "Current plan body", + latestRevisionId: "revision-4", + latestRevisionNumber: 4, + updatedAt: new Date("2026-03-31T12:05:00.000Z"), + }); + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([currentDocument]); + queryClient.setQueryData( + queryKeys.issues.documentRevisions(issue.id, "plan"), + [ + createRevision({ + id: "revision-3", + revisionNumber: 3, + body: "Historical plan body", + createdAt: new Date("2026-03-31T11:00:00.000Z"), + }), + ], + ); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + expect(container.textContent).toContain("Current plan body"); + + const revisionButtons = Array.from(container.querySelectorAll("button")); + const historicalRevisionButton = revisionButtons.find((button) => button.textContent?.includes("rev 3")); + expect(historicalRevisionButton).toBeTruthy(); + + await act(async () => { + historicalRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Viewing revision 3"); + expect(container.textContent).toContain("Historical plan body"); + + const currentRevisionButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("rev 4")); + expect(currentRevisionButton).toBeTruthy(); + + await act(async () => { + currentRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).not.toContain("Viewing revision 3"); + expect(container.textContent).toContain("Current plan body"); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); + + it("returns from a historical preview when fetched history is newer than the document summary", async () => { + const staleDocument = createIssueDocument({ + body: "Original plan body", + latestRevisionId: "revision-2", + latestRevisionNumber: 2, + updatedAt: new Date("2026-03-31T12:00:00.000Z"), + }); + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([staleDocument]); + queryClient.setQueryData( + queryKeys.issues.documentRevisions(issue.id, "plan"), + [ + createRevision({ + id: "revision-3", + revisionNumber: 3, + body: "Current plan body", + createdAt: new Date("2026-03-31T12:05:00.000Z"), + }), + createRevision({ + id: "revision-2", + revisionNumber: 2, + body: "Original plan body", + createdAt: new Date("2026-03-31T12:00:00.000Z"), + }), + ], + ); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + expect(container.textContent).toContain("Current plan body"); + + const revisionButtons = Array.from(container.querySelectorAll("button")); + const historicalRevisionButton = revisionButtons.find((button) => button.textContent?.includes("rev 2")); + expect(historicalRevisionButton).toBeTruthy(); + + await act(async () => { + historicalRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Viewing revision 2"); + expect(container.textContent).toContain("Original plan body"); + + const currentRevisionButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("rev 3")); + expect(currentRevisionButton).toBeTruthy(); + + await act(async () => { + currentRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).not.toContain("Viewing revision 2"); + expect(container.textContent).toContain("Current plan body"); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); + it("ignores mount-time editor change noise before a document is actively being edited", async () => { markdownEditorMockState.emitMountEmptyChange = true; diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index c3f0a9d783f..6923befab07 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -12,6 +12,7 @@ import { useLocation } from "@/lib/router"; import { ApiError } from "../api/client"; import { issuesApi } from "../api/issues"; import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator"; +import { deriveDocumentRevisionState } from "../lib/document-revisions"; import { queryKeys } from "../lib/queryKeys"; import { cn, relativeTime } from "../lib/utils"; import { MarkdownBody } from "./MarkdownBody"; @@ -536,10 +537,10 @@ export function IssueDocumentsSection({ }, []); const previewRevision = useCallback((doc: IssueDocument, revisionId: string) => { - const revisions = getDocumentRevisions(doc.key); - const selectedRevision = revisions.find((revision) => revision.id === revisionId); + const revisionState = deriveDocumentRevisionState(doc, getDocumentRevisions(doc.key)); + const selectedRevision = revisionState.revisions.find((revision) => revision.id === revisionId); if (!selectedRevision) return; - if (selectedRevision.id === doc.latestRevisionId) { + if (selectedRevision.id === revisionState.currentRevision.id) { returnToLatestRevision(doc.key); return; } @@ -787,7 +788,10 @@ export function IssueDocumentsSection({ const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null; const activeConflict = documentConflict?.key === doc.key ? documentConflict : null; const isFolded = foldedDocumentKeys.includes(doc.key); - const revisionHistory = getDocumentRevisions(doc.key); + const rawRevisionHistory = getDocumentRevisions(doc.key); + const revisionState = deriveDocumentRevisionState(doc, rawRevisionHistory); + const revisionHistory = revisionState.revisions; + const currentRevision = revisionState.currentRevision; const selectedRevisionId = selectedRevisionIds[doc.key] ?? null; const selectedHistoricalRevision = selectedRevisionId ? revisionHistory.find((revision) => revision.id === selectedRevisionId) ?? null @@ -795,10 +799,10 @@ export function IssueDocumentsSection({ const isHistoricalPreview = Boolean(selectedHistoricalRevision); const displayedTitle = selectedHistoricalRevision ? selectedHistoricalRevision.title ?? "" - : activeDraft?.title ?? doc.title ?? ""; - const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? doc.body; - const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? doc.latestRevisionNumber; - const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? doc.updatedAt; + : activeDraft?.title ?? currentRevision.title ?? ""; + const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? currentRevision.body; + const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? currentRevision.revisionNumber; + const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? currentRevision.createdAt; const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key); const canVoteOnDocument = Boolean(doc.latestRevisionId && doc.updatedByAgentId && !doc.updatedByUserId && onVote); @@ -845,12 +849,12 @@ export function IssueDocumentsSection({ Revision history - {revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && revisionHistory.length === 0 ? ( + {revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && rawRevisionHistory.length === 0 ? ( Loading revisions... ) : revisionHistory.length > 0 ? ( - + {revisionHistory.map((revision) => { - const isCurrentRevision = revision.id === doc.latestRevisionId; + const isCurrentRevision = revision.id === currentRevision.id; return ( = {}): IssueDocument { + return { + id: "document-1", + companyId: "company-1", + issueId: "issue-1", + key: "plan", + title: "Plan", + format: "markdown", + body: "# Current plan", + latestRevisionId: "revision-2", + latestRevisionNumber: 2, + createdByAgentId: "agent-1", + createdByUserId: null, + updatedByAgentId: "agent-1", + updatedByUserId: null, + createdAt: new Date("2026-04-10T15:00:00.000Z"), + updatedAt: new Date("2026-04-10T16:00:00.000Z"), + ...overrides, + }; +} + +function createRevision(overrides: Partial = {}): DocumentRevision { + return { + id: "revision-1", + companyId: "company-1", + documentId: "document-1", + issueId: "issue-1", + key: "plan", + revisionNumber: 1, + title: "Plan", + format: "markdown", + body: "# Revision body", + changeSummary: null, + createdByAgentId: "agent-1", + createdByUserId: null, + createdAt: new Date("2026-04-10T15:00:00.000Z"), + ...overrides, + }; +} + +describe("deriveDocumentRevisionState", () => { + it("falls back to a synthetic current revision when no revision history has been fetched yet", () => { + const state = deriveDocumentRevisionState(createDocument({ + latestRevisionId: null, + latestRevisionNumber: 0, + body: "# Draft plan", + }), []); + + expect(state.currentRevision.id).toBe("document-1-latest"); + expect(state.currentRevision.body).toBe("# Draft plan"); + expect(state.revisions.map((revision) => revision.id)).toEqual(["document-1-latest"]); + }); + + it("sorts fetched revisions newest-first even when the API payload is out of order", () => { + const state = deriveDocumentRevisionState(createDocument(), [ + createRevision({ id: "revision-1", revisionNumber: 1, createdAt: new Date("2026-04-10T15:00:00.000Z") }), + createRevision({ id: "revision-2", revisionNumber: 2, body: "# Current plan", createdAt: new Date("2026-04-10T16:00:00.000Z") }), + ]); + + expect(state.currentRevision.id).toBe("revision-2"); + expect(state.revisions.map((revision) => revision.id)).toEqual(["revision-2", "revision-1"]); + }); + + it("keeps the latest document revision current when the revision history cache is stale", () => { + const state = deriveDocumentRevisionState(createDocument(), [ + createRevision({ id: "revision-1", revisionNumber: 1, body: "# Original plan" }), + ]); + + expect(state.currentRevision.id).toBe("revision-2"); + expect(state.currentRevision.body).toBe("# Current plan"); + expect(state.revisions.map((revision) => revision.id)).toEqual(["revision-2", "revision-1"]); + }); + + it("trusts the fetched revision history when it is newer than the document summary cache", () => { + const staleDocument = createDocument({ + body: "# Original plan", + latestRevisionId: "revision-1", + latestRevisionNumber: 1, + updatedAt: new Date("2026-04-10T15:00:00.000Z"), + }); + + const state = deriveDocumentRevisionState(staleDocument, [ + createRevision({ id: "revision-2", revisionNumber: 2, body: "# Current plan", createdAt: new Date("2026-04-10T16:00:00.000Z") }), + createRevision({ id: "revision-1", revisionNumber: 1, body: "# Original plan", createdAt: new Date("2026-04-10T15:00:00.000Z") }), + ]); + + expect(state.currentRevision.id).toBe("revision-2"); + expect(state.currentRevision.body).toBe("# Current plan"); + expect(state.revisions.map((revision) => revision.id)).toEqual(["revision-2", "revision-1"]); + }); +}); diff --git a/ui/src/lib/document-revisions.ts b/ui/src/lib/document-revisions.ts new file mode 100644 index 00000000000..5759d4af300 --- /dev/null +++ b/ui/src/lib/document-revisions.ts @@ -0,0 +1,72 @@ +import type { DocumentRevision, IssueDocument } from "@paperclipai/shared"; + +type DocumentRevisionState = { + currentRevision: DocumentRevision; + revisions: DocumentRevision[]; +}; + +function toTimestamp(value: Date | string | null | undefined) { + if (!value) return 0; + const timestamp = new Date(value).getTime(); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function sortRevisionsDescending(revisions: DocumentRevision[]) { + return [...revisions].sort((a, b) => { + if (a.revisionNumber !== b.revisionNumber) { + return b.revisionNumber - a.revisionNumber; + } + const createdAtDelta = toTimestamp(b.createdAt) - toTimestamp(a.createdAt); + if (createdAtDelta !== 0) return createdAtDelta; + return b.id.localeCompare(a.id); + }); +} + +function createCurrentRevisionSnapshot(document: IssueDocument): DocumentRevision { + return { + id: document.latestRevisionId ?? `${document.id}-latest`, + companyId: document.companyId, + documentId: document.id, + issueId: document.issueId, + key: document.key, + revisionNumber: document.latestRevisionNumber, + title: document.title, + format: document.format, + body: document.body, + changeSummary: null, + createdByAgentId: document.updatedByAgentId ?? document.createdByAgentId, + createdByUserId: document.updatedByUserId ?? document.createdByUserId, + createdAt: document.updatedAt, + }; +} + +export function deriveDocumentRevisionState( + document: IssueDocument, + revisions: DocumentRevision[], +): DocumentRevisionState { + const sortedRevisions = sortRevisionsDescending(revisions); + const currentSnapshot = createCurrentRevisionSnapshot(document); + const highestFetchedRevision = sortedRevisions[0] ?? null; + const documentAppearsStale = Boolean( + highestFetchedRevision && highestFetchedRevision.revisionNumber > document.latestRevisionNumber, + ); + + const currentRevision = documentAppearsStale + ? highestFetchedRevision! + : sortedRevisions.find((revision) => revision.id === document.latestRevisionId) ?? currentSnapshot; + + const revisionsWithCurrent = sortRevisionsDescending([currentRevision, ...sortedRevisions]); + const dedupedRevisions: DocumentRevision[] = []; + const seenRevisionIds = new Set(); + + for (const revision of revisionsWithCurrent) { + if (seenRevisionIds.has(revision.id)) continue; + seenRevisionIds.add(revision.id); + dedupedRevisions.push(revision); + } + + return { + currentRevision, + revisions: dedupedRevisions, + }; +} From dab95740be90dc133d345dc7e7ed6626bddccc7a Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 10 Apr 2026 22:26:21 -0500 Subject: [PATCH 12/32] feat: polish inbox and issue list workflows --- .../issue-document-restore-routes.test.ts | 16 +- server/src/__tests__/routines-e2e.test.ts | 99 ++++--- server/src/__tests__/routines-routes.test.ts | 52 ++-- ui/src/components/AgentProperties.tsx | 8 +- ui/src/components/GoalProperties.tsx | 6 +- ui/src/components/IssueColumns.tsx | 61 +++- ui/src/components/IssueFiltersPopover.tsx | 40 ++- ui/src/components/IssueLinkQuicklook.tsx | 135 +++++++++ ui/src/components/IssueProperties.test.tsx | 115 ++++++++ ui/src/components/IssueProperties.tsx | 200 +++++++++---- ui/src/components/IssueRow.test.tsx | 26 +- ui/src/components/IssueRow.tsx | 1 + ui/src/components/IssuesList.test.tsx | 154 +++++++++- ui/src/components/IssuesList.tsx | 54 +++- ui/src/components/IssuesQuicklook.tsx | 30 +- ui/src/components/KanbanBoard.tsx | 1 + .../KeyboardShortcutsCheatsheet.tsx | 1 + ui/src/components/Layout.tsx | 9 + ui/src/components/NewIssueDialog.test.tsx | 33 +-- ui/src/components/NewIssueDialog.tsx | 138 ++++----- ui/src/components/ProjectProperties.tsx | 14 +- ui/src/components/PropertiesPanel.tsx | 4 +- ui/src/components/Sidebar.tsx | 2 +- ui/src/components/SidebarAgents.tsx | 2 + ui/src/components/SidebarNavItem.tsx | 2 + ui/src/components/SidebarProjects.tsx | 2 + ui/src/hooks/useKeyboardShortcuts.test.tsx | 51 ++++ ui/src/hooks/useKeyboardShortcuts.ts | 23 +- ui/src/lib/inbox.test.ts | 150 ++++++++++ ui/src/lib/inbox.ts | 198 ++++++++++++- ui/src/lib/issue-filters.ts | 15 + ui/src/lib/keyboardShortcuts.test.ts | 72 ++++- ui/src/lib/keyboardShortcuts.ts | 51 ++++ ui/src/lib/optimistic-issue-runs.test.ts | 24 -- ui/src/pages/Inbox.test.tsx | 1 + ui/src/pages/Inbox.tsx | 270 +++++++++++++----- ui/src/pages/Routines.tsx | 25 +- 37 files changed, 1674 insertions(+), 411 deletions(-) create mode 100644 ui/src/components/IssueLinkQuicklook.tsx diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index d576d3ac4bd..386da1e2ae1 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { issueRoutes } from "../routes/issues.js"; +import { errorHandler } from "../middleware/index.js"; const issueId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -50,11 +52,7 @@ vi.mock("../services/index.js", () => ({ workProductService: () => ({}), })); -async function createApp() { - const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/issues.js"), - import("../middleware/index.js"), - ]); +function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -74,7 +72,6 @@ async function createApp() { describe("issue document revision routes", () => { beforeEach(() => { - vi.resetModules(); vi.resetAllMocks(); mockIssueService.getById.mockResolvedValue({ id: issueId, @@ -125,10 +122,9 @@ describe("issue document revision routes", () => { }); it("returns revision snapshots including title and format", async () => { - const res = await request(await createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`); + const res = await request(createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`); expect(res.status).toBe(200); - expect(mockDocumentsService.listIssueDocumentRevisions).toHaveBeenCalledWith(issueId, "plan"); expect(res.body).toEqual([ expect.objectContaining({ revisionNumber: 2, @@ -140,7 +136,7 @@ describe("issue document revision routes", () => { }); it("restores a revision through the append-only route and logs the action", async () => { - const res = await request(await createApp()) + const res = await request(createApp()) .post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`) .send({}); @@ -172,7 +168,7 @@ describe("issue document revision routes", () => { }); it("rejects invalid document keys before attempting restore", async () => { - const res = await request(await createApp()) + const res = await request(createApp()) .post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`) .send({}); diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index 12a3d837202..deb3e1108b0 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -26,56 +26,53 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { errorHandler } from "../middleware/index.js"; import { accessService } from "../services/access.js"; -function registerServiceMocks() { - vi.doMock("../services/index.js", async () => { - const actual = await vi.importActual("../services/index.js"); +vi.mock("../services/index.js", async () => { + const actual = await vi.importActual("../services/index.js"); - return { - ...actual, - routineService: (db: any) => - actual.routineService(db, { - heartbeat: { - wakeup: async (agentId: string, wakeupOpts: any) => { - const issueId = - (typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) || - (typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) || - null; - if (!issueId) return null; + return { + ...actual, + routineService: (db: any) => + actual.routineService(db, { + heartbeat: { + wakeup: async (agentId: string, wakeupOpts: any) => { + const issueId = + (typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) || + (typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) || + null; + if (!issueId) return null; - const issue = await db - .select({ companyId: issues.companyId }) - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows: Array<{ companyId: string }>) => rows[0] ?? null); - if (!issue) return null; + const issue = await db + .select({ companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows: Array<{ companyId: string }>) => rows[0] ?? null); + if (!issue) return null; - const queuedRunId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: queuedRunId, - companyId: issue.companyId, - agentId, - invocationSource: wakeupOpts?.source ?? "assignment", - triggerDetail: wakeupOpts?.triggerDetail ?? null, - status: "queued", - contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId }, - }); - await db - .update(issues) - .set({ - executionRunId: queuedRunId, - executionLockedAt: new Date(), - }) - .where(eq(issues.id, issueId)); - return { id: queuedRunId }; - }, + const queuedRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: queuedRunId, + companyId: issue.companyId, + agentId, + invocationSource: wakeupOpts?.source ?? "assignment", + triggerDetail: wakeupOpts?.triggerDetail ?? null, + status: "queued", + contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId }, + }); + await db + .update(issues) + .set({ + executionRunId: queuedRunId, + executionLockedAt: new Date(), + }) + .where(eq(issues.id, issueId)); + return { id: queuedRunId }; }, - }), - }; - }); -} + }, + }), + }; +}); const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -95,11 +92,6 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { db = createDb(tempDb.connectionString); }, 20_000); - beforeEach(() => { - vi.resetModules(); - registerServiceMocks(); - }); - afterEach(async () => { await db.delete(activityLog); await db.delete(routineRuns); @@ -123,8 +115,15 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { await tempDb?.cleanup(); }); + beforeEach(() => { + vi.resetModules(); + }); + async function createApp(actor: Record) { - const { routineRoutes } = await import("../routes/routines.js"); + const [{ routineRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/routines.js"), + import("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index ce1db54950f..d3a9edea15c 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { routineRoutes } from "../routes/routines.js"; const companyId = "22222222-2222-4222-8222-222222222222"; const agentId = "11111111-1111-4111-8111-111111111111"; @@ -83,28 +85,22 @@ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackRoutineCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -function registerRouteMocks() { - vi.doMock("@paperclipai/shared/telemetry", () => ({ - trackRoutineCreated: mockTrackRoutineCreated, - trackErrorHandlerCrash: vi.fn(), - })); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackRoutineCreated: mockTrackRoutineCreated, + trackErrorHandlerCrash: vi.fn(), +})); - vi.doMock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, - })); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); - vi.doMock("../services/index.js", () => ({ - accessService: () => mockAccessService, - logActivity: mockLogActivity, - routineService: () => mockRoutineService, - })); -} +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + logActivity: mockLogActivity, + routineService: () => mockRoutineService, +})); -async function createApp(actor: Record) { - const [{ routineRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/routines.js"), - import("../middleware/index.js"), - ]); +function createApp(actor: Record) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -118,9 +114,7 @@ async function createApp(actor: Record) { describe("routine routes", () => { beforeEach(() => { - vi.resetModules(); - registerRouteMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockRoutineService.create.mockResolvedValue(routine); mockRoutineService.get.mockResolvedValue(routine); @@ -136,7 +130,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission for non-admin board routine creation", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -158,7 +152,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to retarget a routine assignee", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -179,7 +173,7 @@ describe("routine routes", () => { it("requires tasks:assign permission to reactivate a routine", async () => { mockRoutineService.get.mockResolvedValue(pausedRoutine); - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -199,7 +193,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to create a trigger", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -221,7 +215,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to update a trigger", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -241,7 +235,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to manually run a routine", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -260,7 +254,7 @@ describe("routine routes", () => { it("allows routine creation when the board user has tasks:assign", async () => { mockAccessService.canUser.mockResolvedValue(true); - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index 911a109a4be..b10f69e6504 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -19,9 +19,9 @@ const roleLabels = AGENT_ROLE_LABELS as Record; function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
- {label} -
{children}
+
+ {label} +
{children}
); } @@ -68,7 +68,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) { )} {runtimeState?.lastError && ( - {runtimeState.lastError} + {runtimeState.lastError} )} {agent.lastHeartbeatAt && ( diff --git a/ui/src/components/GoalProperties.tsx b/ui/src/components/GoalProperties.tsx index fdc4da2ab4c..27700a0eb35 100644 --- a/ui/src/components/GoalProperties.tsx +++ b/ui/src/components/GoalProperties.tsx @@ -20,9 +20,9 @@ interface GoalPropertiesProps { function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
- {label} -
{children}
+
+ {label} +
{children}
); } diff --git a/ui/src/components/IssueColumns.tsx b/ui/src/components/IssueColumns.tsx index a1489a7e02b..516a8c4506b 100644 --- a/ui/src/components/IssueColumns.tsx +++ b/ui/src/components/IssueColumns.tsx @@ -12,6 +12,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { formatAssigneeUserLabel } from "../lib/assignees"; import type { InboxIssueColumn } from "../lib/inbox"; import { cn } from "../lib/utils"; @@ -50,12 +51,12 @@ export function issueActivityText(issue: Issue): string { function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string { return columns .map((column) => { - if (column === "assignee") return "minmax(7.5rem, 9.5rem)"; - if (column === "project") return "minmax(6.5rem, 8.5rem)"; - if (column === "workspace") return "minmax(9rem, 12rem)"; - if (column === "parent") return "minmax(5rem, 7rem)"; - if (column === "labels") return "minmax(8rem, 10rem)"; - return "minmax(4rem, 5.5rem)"; + if (column === "assignee") return "minmax(6rem, 8rem)"; + if (column === "project") return "minmax(4.5rem, 7rem)"; + if (column === "workspace") return "minmax(6rem, 9rem)"; + if (column === "parent") return "minmax(3.5rem, 5.5rem)"; + if (column === "labels") return "minmax(3rem, 6rem)"; + return "minmax(3.5rem, 4.5rem)"; }) .join(" "); } @@ -66,24 +67,27 @@ export function IssueColumnPicker({ onToggleColumn, onResetColumns, title, + iconOnly = false, }: { availableColumns: InboxIssueColumn[]; visibleColumnSet: ReadonlySet; onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void; onResetColumns: () => void; title: string; + iconOnly?: boolean; }) { return ( @@ -189,23 +193,27 @@ export function InboxIssueTrailingColumns({ columns, projectName, projectColor, + workspaceId, workspaceName, assigneeName, currentUserId, parentIdentifier, parentTitle, assigneeContent, + onFilterWorkspace, }: { issue: Issue; columns: InboxIssueColumn[]; projectName: string | null; projectColor: string | null; + workspaceId?: string | null; workspaceName: string | null; assigneeName: string | null; currentUserId: string | null; parentIdentifier: string | null; parentTitle: string | null; assigneeContent?: ReactNode; + onFilterWorkspace?: (workspaceId: string) => void; }) { const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt); const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"; @@ -276,20 +284,22 @@ export function InboxIssueTrailingColumns({ if (column === "labels") { if ((issue.labels ?? []).length > 0) { return ( - + {(issue.labels ?? []).slice(0, 2).map((label) => ( {label.name} ))} {(issue.labels ?? []).length > 2 ? ( - + +{(issue.labels ?? []).length - 2} ) : null} @@ -307,7 +317,28 @@ export function InboxIssueTrailingColumns({ return ( - {workspaceName} + {workspaceId && onFilterWorkspace ? ( + + + + + + Filter by workspace + + + ) : ( + workspaceName + )} ); } diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index ac35a308353..02f63acf9c8 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -1,7 +1,7 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Filter, X, User } from "lucide-react"; +import { Filter, X, User, HardDrive } from "lucide-react"; import { PriorityIcon } from "./PriorityIcon"; import { StatusIcon } from "./StatusIcon"; import { @@ -31,6 +31,11 @@ type LabelOption = { color: string; }; +type WorkspaceOption = { + id: string; + name: string; +}; + export function IssueFiltersPopover({ state, onChange, @@ -41,6 +46,8 @@ export function IssueFiltersPopover({ currentUserId, enableRoutineVisibilityFilter = false, buttonVariant = "ghost", + iconOnly = false, + workspaces, }: { state: IssueFilterState; onChange: (patch: Partial) => void; @@ -51,15 +58,18 @@ export function IssueFiltersPopover({ currentUserId?: string | null; enableRoutineVisibilityFilter?: boolean; buttonVariant?: "ghost" | "outline"; + iconOnly?: boolean; + workspaces?: WorkspaceOption[]; }) { return ( -
) : null} + {workspaces && workspaces.length > 0 ? ( +
+ Workspace +
+ {workspaces.map((workspace) => ( + + ))} +
+
+ ) : null} + {enableRoutineVisibilityFilter ? (
Visibility diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx new file mode 100644 index 00000000000..4bb0048f98d --- /dev/null +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -0,0 +1,135 @@ +import * as React from "react"; +import { useMemo, useState } from "react"; +import * as RouterDom from "react-router-dom"; +import type { Issue } from "@paperclipai/shared"; +import { useQuery } from "@tanstack/react-query"; +import { issuesApi } from "@/api/issues"; +import { queryKeys } from "@/lib/queryKeys"; +import { timeAgo } from "@/lib/timeAgo"; +import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb"; +import { cn } from "@/lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { StatusIcon } from "@/components/StatusIcon"; + +function summarizeIssueDescription(description: string | null | undefined) { + if (!description) return null; + const summary = description + .replace(/!\[[^\]]*]\([^)]+\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[#>*_`~-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + + if (!summary) return null; + return summary.length > 180 ? `${summary.slice(0, 177).trimEnd()}...` : summary; +} + +export function IssueQuicklookCard({ + issue, + linkTo, + linkState, + compact = false, +}: { + issue: Issue; + linkTo: RouterDom.To; + linkState?: unknown; + compact?: boolean; +}) { + const description = useMemo(() => summarizeIssueDescription(issue.description), [issue.description]); + + return ( +
+
+ + + {issue.title} + +
+
+ {issue.identifier ?? issue.id.slice(0, 8)} + · + {issue.status.replace(/_/g, " ")} + · + {timeAgo(new Date(issue.updatedAt))} +
+ {description ? ( +

+ {description} +

+ ) : null} +
+ ); +} + +export const IssueLinkQuicklook = React.forwardRef< + HTMLAnchorElement, + React.ComponentProps & { issuePathId: string } +>(function IssueLinkQuicklookImpl( + { + issuePathId, + to, + children, + className, + onClick, + ...props + }, + ref, +) { + const [open, setOpen] = useState(false); + const { data, isLoading } = useQuery({ + queryKey: queryKeys.issues.detail(issuePathId), + queryFn: () => issuesApi.get(issuePathId), + enabled: open, + staleTime: 60_000, + }); + + const detailPath = createIssueDetailPath(issuePathId); + + return ( + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + { + setOpen(false); + onClick?.(event); + }} + {...props} + > + {children} + + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + onOpenAutoFocus={(event) => event.preventDefault()} + > + {data ? ( + + ) : ( +
+
+
+
+ {!isLoading ? ( +

Unable to load issue preview.

+ ) : null} +
+ )} + + + ); +}); diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index a1fe7e1c341..3c92683a7b0 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -18,6 +18,7 @@ const mockProjectsApi = vi.hoisted(() => ({ })); const mockIssuesApi = vi.hoisted(() => ({ + list: vi.fn(), listLabels: vi.fn(), })); @@ -193,6 +194,7 @@ describe("IssueProperties", () => { document.body.appendChild(container); mockAgentsApi.list.mockResolvedValue([]); mockProjectsApi.list.mockResolvedValue([]); + mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } }); }); @@ -227,6 +229,119 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("shows an add-label button when labels already exist and opens the picker", async () => { + const root = renderProperties(container, { + issue: createIssue({ + labels: [{ id: "label-1", companyId: "company-1", name: "Bug", color: "#ef4444", createdAt: new Date("2026-04-06T12:00:00.000Z"), updatedAt: new Date("2026-04-06T12:00:00.000Z") }], + labelIds: ["label-1"], + }), + childIssues: [], + onUpdate: vi.fn(), + inline: true, + }); + await flush(); + + const addLabelButton = container.querySelector('button[aria-label="Add label"]'); + expect(addLabelButton).not.toBeNull(); + expect(container.querySelector('input[placeholder="Search labels..."]')).toBeNull(); + + await act(async () => { + addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(container.querySelector('input[placeholder="Search labels..."]')).not.toBeNull(); + expect(container.querySelector('button[title="Delete Bug"]')).toBeNull(); + + act(() => root.unmount()); + }); + + it("allows setting and clearing a parent issue from the properties pane", async () => { + const onUpdate = vi.fn(); + mockIssuesApi.list.mockResolvedValue([ + createIssue({ id: "issue-2", identifier: "PAP-2", title: "Candidate parent", status: "in_progress" }), + ]); + + const root = renderProperties(container, { + issue: createIssue(), + childIssues: [], + onUpdate, + inline: true, + }); + await flush(); + + const parentTrigger = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("No parent")); + expect(parentTrigger).not.toBeUndefined(); + + await act(async () => { + parentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const candidateButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("PAP-2 Candidate parent")); + expect(candidateButton).not.toBeUndefined(); + + await act(async () => { + candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ parentId: "issue-2" }); + + onUpdate.mockClear(); + const rerenderedIssue = createIssue({ + parentId: "issue-2", + ancestors: [ + { + id: "issue-2", + identifier: "PAP-2", + title: "Candidate parent", + description: null, + status: "in_progress", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + projectId: null, + goalId: null, + project: null, + goal: null, + }, + ], + }); + + act(() => root.unmount()); + + const rerenderedRoot = renderProperties(container, { + issue: rerenderedIssue, + childIssues: [], + onUpdate, + inline: true, + }); + await flush(); + + const selectedParentTrigger = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("PAP-2 Candidate parent")); + expect(selectedParentTrigger).not.toBeUndefined(); + + await act(async () => { + selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const clearParentButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("No parent")); + expect(clearParentButton).not.toBeUndefined(); + + await act(async () => { + clearParentButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ parentId: null }); + + act(() => rerenderedRoot.unmount()); + }); + it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => { const onUpdate = vi.fn(); const root = renderProperties(container, { diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index c6d33ff2dff..ed7a5c31fd9 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -20,7 +20,7 @@ import { formatDate, cn, projectUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, GitBranch, FolderOpen, Copy, Check } from "lucide-react"; +import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Copy, Check } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) { @@ -82,9 +82,9 @@ interface IssuePropertiesProps { function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
- {label} -
{children}
+
+ {label} +
{children}
); } @@ -114,7 +114,7 @@ function PropertyPicker({ children: React.ReactNode; }) { const btnCn = cn( - "inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors", + "inline-flex items-start gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors min-w-0 max-w-full text-left", triggerClassName, ); @@ -167,6 +167,8 @@ export function IssueProperties({ const [projectSearch, setProjectSearch] = useState(""); const [blockedByOpen, setBlockedByOpen] = useState(false); const [blockedBySearch, setBlockedBySearch] = useState(""); + const [parentOpen, setParentOpen] = useState(false); + const [parentSearch, setParentSearch] = useState(""); const [reviewersOpen, setReviewersOpen] = useState(false); const [reviewerSearch, setReviewerSearch] = useState(""); const [approversOpen, setApproversOpen] = useState(false); @@ -212,7 +214,7 @@ export function IssueProperties({ const { data: allIssues } = useQuery({ queryKey: queryKeys.issues.list(companyId!), queryFn: () => issuesApi.list(companyId!), - enabled: !!companyId && blockedByOpen, + enabled: !!companyId && (blockedByOpen || parentOpen), }); const createLabel = useMutation({ @@ -224,15 +226,6 @@ export function IssueProperties({ }, }); - const deleteLabel = useMutation({ - mutationFn: (labelId: string) => issuesApi.deleteLabel(labelId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); - }, - }); - const toggleLabel = (labelId: string) => { const ids = issue.labelIds ?? []; const next = ids.includes(labelId) @@ -304,10 +297,10 @@ export function IssueProperties({ return value; }; const reviewerTrigger = reviewerValues.length > 0 - ? {reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")} + ? {reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")} : None; const approverTrigger = approverValues.length > 0 - ? {approverValues.map((value) => executionParticipantLabel(value)).join(", ")} + ? {approverValues.map((value) => executionParticipantLabel(value)).join(", ")} : None; const nextRunnableExecutionStage = (() => { if (issue.executionState?.status === "changes_requested" && issue.executionState.currentStageType) { @@ -369,6 +362,17 @@ export function IssueProperties({ No labels ); + const labelsExtra = (issue.labelIds ?? []).length > 0 ? ( + + ) : undefined; const labelsContent = ( <> @@ -388,26 +392,17 @@ export function IssueProperties({ .map((label) => { const selected = (issue.labelIds ?? []).includes(label.id); return ( -
- - -
+ ); })}
@@ -609,7 +604,7 @@ export function IssueProperties({ className="shrink-0 h-3 w-3 rounded-sm" style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }} /> - {projectName(issue.projectId)} + {projectName(issue.projectId)} ) : ( <> @@ -685,6 +680,100 @@ export function IssueProperties({ ); const blockedByIds = issue.blockedBy?.map((relation) => relation.id) ?? []; + const descendantIssueIds = useMemo(() => { + if (!allIssues?.length) return new Set(); + const childrenByParentId = new Map(); + for (const candidate of allIssues) { + if (!candidate.parentId) continue; + const children = childrenByParentId.get(candidate.parentId) ?? []; + children.push(candidate.id); + childrenByParentId.set(candidate.parentId, children); + } + + const descendants = new Set(); + const stack = [...(childrenByParentId.get(issue.id) ?? [])]; + while (stack.length > 0) { + const candidateId = stack.pop(); + if (!candidateId || descendants.has(candidateId)) continue; + descendants.add(candidateId); + stack.push(...(childrenByParentId.get(candidateId) ?? [])); + } + return descendants; + }, [allIssues, issue.id]); + const currentParentIssue = useMemo(() => { + if (!issue.parentId) return null; + return allIssues?.find((candidate) => candidate.id === issue.parentId) ?? null; + }, [allIssues, issue.parentId]); + const parentTrigger = issue.parentId ? ( + + {issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier + ? `${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier} ` + : ""} + {issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId.slice(0, 8)} + + ) : ( + No parent + ); + const parentOptions = (allIssues ?? []) + .filter((candidate) => candidate.id !== issue.id) + .filter((candidate) => !descendantIssueIds.has(candidate.id)) + .filter((candidate) => { + if (!parentSearch.trim()) return true; + const query = parentSearch.toLowerCase(); + return ( + (candidate.identifier ?? "").toLowerCase().includes(query) || + candidate.title.toLowerCase().includes(query) + ); + }) + .sort((a, b) => { + const aLabel = `${a.identifier ?? ""} ${a.title}`.trim(); + const bLabel = `${b.identifier ?? ""} ${b.title}`.trim(); + return aLabel.localeCompare(bLabel); + }); + const parentContent = ( + <> + setParentSearch(e.target.value)} + autoFocus={!inline} + /> +
+ + {parentOptions.map((candidate) => ( + + ))} +
+ + ); const blockedByTrigger = blockedByIds.length > 0 ? (
{(issue.blockedBy ?? []).slice(0, 2).map((relation) => ( @@ -793,6 +882,7 @@ export function IssueProperties({ triggerContent={labelsTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName="w-64" + extra={labelsExtra} > {labelsContent} @@ -838,6 +928,30 @@ export function IssueProperties({ {projectContent} + { + setParentOpen(open); + if (!open) setParentSearch(""); + }} + triggerContent={parentTrigger} + triggerClassName="min-w-0 max-w-full" + popoverClassName="w-72" + extra={issue.parentId ? ( + e.stopPropagation()} + > + + + ) : undefined} + > + {parentContent} + + )} - {issue.parentId && ( - - - {issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)} - - - )} {issue.requestDepth > 0 && ( {issue.requestDepth} diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 4ba0cee2e6a..2b52a042b8c 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -7,8 +7,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssueRow } from "./IssueRow"; vi.mock("@/lib/router", () => ({ - Link: ({ children, className, ...props }: React.ComponentProps<"a">) => ( - {children} + Link: ({ children, className, disableIssueQuicklook: _disableIssueQuicklook, ...props }: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean }) => ( + + {children} + ), })); @@ -135,6 +141,22 @@ describe("IssueRow", () => { }); }); + it("opts issue quicklook out for dense inbox rows", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null; + expect(link).not.toBeNull(); + expect(link?.getAttribute("data-disable-issue-quicklook")).toBe("true"); + + act(() => { + root.unmount(); + }); + }); + it("renders titleSuffix inline after the issue title", () => { const root = createRoot(container); const issue = createIssue({ title: "Parent task" }); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 1348848909b..2ba4e92ea10 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -58,6 +58,7 @@ export function IssueRow({ rememberIssueDetailLocationState(issuePathId, detailState)} className={cn( diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 96ea92e825e..e87fa41c8ba 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssuesList } from "./IssuesList"; +import { TooltipProvider } from "@/components/ui/tooltip"; const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1", @@ -161,7 +162,9 @@ function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) { act(() => { root.render( - {node} + + {node} + , ); }); @@ -297,7 +300,10 @@ describe("IssuesList", () => { ); await waitForAssertion(() => { - expect(container.textContent).toContain("Columns"); + const columnsButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.getAttribute("title") === "Columns", + ); + expect(columnsButton).not.toBeUndefined(); expect(container.textContent).toContain("PAP-9"); expect(container.textContent).toContain("Agent One"); expect(container.textContent).not.toContain("Updated"); @@ -308,6 +314,77 @@ describe("IssuesList", () => { }); }); + it("filters the list to a single workspace when a workspace name is clicked", async () => { + localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["id", "workspace"])); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + mockExecutionWorkspacesApi.list.mockResolvedValue([ + { + id: "workspace-alpha", + name: "Alpha", + mode: "isolated_workspace", + status: "active", + projectWorkspaceId: null, + }, + { + id: "workspace-beta", + name: "Beta", + mode: "isolated_workspace", + status: "active", + projectWorkspaceId: null, + }, + ]); + + const alphaIssue = createIssue({ + id: "issue-alpha", + identifier: "PAP-20", + title: "Alpha issue", + executionWorkspaceId: "workspace-alpha", + }); + const betaIssue = createIssue({ + id: "issue-beta", + identifier: "PAP-21", + title: "Beta issue", + executionWorkspaceId: "workspace-beta", + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Alpha issue"); + expect(container.textContent).toContain("Beta issue"); + const workspaceButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Alpha", + ); + expect(workspaceButton).not.toBeUndefined(); + }); + + await act(async () => { + const workspaceButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Alpha", + ); + workspaceButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Alpha issue"); + expect(container.textContent).not.toContain("Beta issue"); + }); + + act(() => { + root.unmount(); + }); + }); + it("hides routine-backed issues by default and reveals them when the routine filter is enabled", async () => { const manualIssue = createIssue({ id: "issue-manual", @@ -341,7 +418,7 @@ describe("IssuesList", () => { await act(async () => { const filterButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.includes("Filter"), + (button) => button.getAttribute("title") === "Filter", ); filterButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); @@ -370,4 +447,75 @@ describe("IssuesList", () => { root.unmount(); }); }); + + it("blurs the search input on Enter without clearing the query", async () => { + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null; + expect(input).not.toBeNull(); + input?.focus(); + expect(document.activeElement).toBe(input); + }); + + const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement; + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + })); + }); + + expect(document.activeElement).not.toBe(input); + expect(input.value).toBe("bug"); + + act(() => { + root.unmount(); + }); + }); + + it("blurs the search input on Escape once the field is empty", async () => { + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null; + expect(input).not.toBeNull(); + input?.focus(); + expect(document.activeElement).toBe(input); + }); + + const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement; + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + })); + }); + + expect(document.activeElement).not.toBe(input); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 576a9edeb69..9dde9e08807 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -7,6 +7,10 @@ import { issuesApi } from "../api/issues"; import { authApi } from "../api/auth"; import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; +import { + shouldBlurPageSearchOnEnter, + shouldBlurPageSearchOnEscape, +} from "../lib/keyboardShortcuts"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { groupBy } from "../lib/groupBy"; import { @@ -15,6 +19,7 @@ import { defaultIssueFilterState, issueFilterLabel, issuePriorityOrder, + resolveIssueFilterWorkspaceId, issueStatusOrder, type IssueFilterState, } from "../lib/issue-filters"; @@ -170,9 +175,27 @@ function IssueSearchInput({ onChange={(e) => { setDraftValue(e.target.value); }} + onKeyDown={(e) => { + if (shouldBlurPageSearchOnEnter({ + key: e.key, + isComposing: e.nativeEvent.isComposing, + })) { + e.currentTarget.blur(); + return; + } + + if (shouldBlurPageSearchOnEscape({ + key: e.key, + isComposing: e.nativeEvent.isComposing, + currentValue: e.currentTarget.value, + })) { + e.currentTarget.blur(); + } + }} placeholder="Search issues..." className="pl-7 text-xs sm:text-sm" aria-label="Search issues" + data-page-search-target="true" />
); @@ -346,6 +369,16 @@ export function IssuesList({ return map; }, [executionWorkspaceById, projectWorkspaceById]); + const workspaceOptions = useMemo(() => { + const options = new Map(); + for (const [workspaceId, workspaceName] of workspaceNameMap) { + options.set(workspaceId, workspaceName); + } + return [...options.entries()] + .sort((a, b) => a[1].localeCompare(b[1])) + .map(([id, name]) => ({ id, name })); + }, [workspaceNameMap]); + const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]); const availableIssueColumns = useMemo( () => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled), @@ -404,7 +437,7 @@ export function IssuesList({ .map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! })); } if (viewState.groupBy === "workspace") { - const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace"); + const groups = groupBy(filtered, (issue) => resolveIssueFilterWorkspaceId(issue) ?? "__no_workspace"); return Object.keys(groups) .sort((a, b) => { // Groups with items first, "no workspace" last @@ -467,6 +500,10 @@ export function IssuesList({ return defaults; }, [projectId, viewState.groupBy]); + const filterToWorkspace = useCallback((workspaceId: string) => { + updateView({ workspaces: [workspaceId] }); + }, [updateView]); + const setIssueColumns = useCallback((next: InboxIssueColumn[]) => { const normalized = normalizeInboxIssueColumns(next); setVisibleIssueColumns(normalized); @@ -531,6 +568,7 @@ export function IssuesList({ onToggleColumn={toggleIssueColumn} onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} title="Choose which issue columns stay visible" + iconOnly /> ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} + iconOnly + workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined} /> {/* Sort (list view only) */} {viewState.viewMode === "list" && ( - @@ -592,9 +631,8 @@ export function IssuesList({ {viewState.viewMode === "list" && ( - @@ -751,11 +789,13 @@ export function IssuesList({ columns={visibleTrailingIssueColumns} projectName={issueProject?.name ?? null} projectColor={issueProject?.color ?? null} + workspaceId={resolveIssueFilterWorkspaceId(issue)} workspaceName={resolveIssueWorkspaceName(issue, { executionWorkspaceById, projectWorkspaceById, defaultProjectWorkspaceIdByProjectId, })} + onFilterWorkspace={filterToWorkspace} assigneeName={agentName(issue.assigneeAgentId)} currentUserId={currentUserId} parentIdentifier={parentIssue?.identifier ?? null} diff --git a/ui/src/components/IssuesQuicklook.tsx b/ui/src/components/IssuesQuicklook.tsx index ba89d1cdf31..f8a12ce67d7 100644 --- a/ui/src/components/IssuesQuicklook.tsx +++ b/ui/src/components/IssuesQuicklook.tsx @@ -1,10 +1,8 @@ import { useState } from "react"; import type { Issue } from "@paperclipai/shared"; -import { Link } from "@/lib/router"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { StatusIcon } from "./StatusIcon"; import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb"; -import { timeAgo } from "../lib/timeAgo"; +import { IssueQuicklookCard } from "./IssueLinkQuicklook"; interface IssuesQuicklookProps { issue: Issue; @@ -24,32 +22,18 @@ export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) { {children} setOpen(true)} onMouseLeave={() => setOpen(false)} onOpenAutoFocus={(e) => e.preventDefault()} > -
-
- - - {issue.title} - -
-
- {issue.identifier ?? issue.id.slice(0, 8)} - · - {issue.status.replace(/_/g, " ")} - · - {timeAgo(new Date(issue.updatedAt))} -
-
+
); diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx index 8b2bad2cec0..3e1ef1fb6bf 100644 --- a/ui/src/components/KanbanBoard.tsx +++ b/ui/src/components/KanbanBoard.tsx @@ -148,6 +148,7 @@ function KanbanCard({ > { // Prevent navigation during drag diff --git a/ui/src/components/KeyboardShortcutsCheatsheet.tsx b/ui/src/components/KeyboardShortcutsCheatsheet.tsx index 937292adccd..45d6858ded3 100644 --- a/ui/src/components/KeyboardShortcutsCheatsheet.tsx +++ b/ui/src/components/KeyboardShortcutsCheatsheet.tsx @@ -34,6 +34,7 @@ const sections: ShortcutSection[] = [ { title: "Global", shortcuts: [ + { keys: ["/"], label: "Search current page or quick search" }, { keys: ["c"], label: "New issue" }, { keys: ["["], label: "Toggle sidebar" }, { keys: ["]"], label: "Toggle panel" }, diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 461d429ce3e..cd6972180c9 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -154,12 +154,21 @@ export function Layout() { ]); const togglePanel = togglePanelVisible; + const openSearch = useCallback(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { + key: "k", + metaKey: true, + bubbles: true, + cancelable: true, + })); + }, []); useCompanyPageMemory(); useKeyboardShortcuts({ enabled: keyboardShortcutsEnabled, onNewIssue: () => openNewIssue(), + onSearch: openSearch, onToggleSidebar: toggleSidebar, onTogglePanel: togglePanel, onShowShortcuts: () => setShortcutsOpen(true), diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index b1c29f7081a..b81b30e4327 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -222,18 +222,6 @@ async function flush() { }); } -async function waitForValue(getValue: () => T | null | undefined, attempts = 10): Promise { - for (let attempt = 0; attempt < attempts; attempt += 1) { - const value = getValue(); - if (value != null) { - return value; - } - await flush(); - } - - throw new Error("Timed out waiting for value"); -} - function renderDialog(container: HTMLDivElement) { const queryClient = new QueryClient({ defaultOptions: { @@ -394,10 +382,15 @@ describe("NewIssueDialog", () => { expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]"); expect(dialogContent?.className).toContain("overflow-hidden"); + const titleInput = container.querySelector('textarea[placeholder="Issue title"]'); const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]'); - const descriptionScrollRegion = descriptionInput?.parentElement?.parentElement; - expect(descriptionScrollRegion?.className).toContain("flex-1"); - expect(descriptionScrollRegion?.className).toContain("overflow-y-auto"); + const bodyScrollRegion = Array.from(container.querySelectorAll("div")).find((element) => + typeof element.className === "string" && element.className.includes("overscroll-contain"), + ); + expect(bodyScrollRegion?.className).toContain("flex-1"); + expect(bodyScrollRegion?.className).toContain("overflow-y-auto"); + expect(bodyScrollRegion?.contains(titleInput ?? null)).toBe(true); + expect(bodyScrollRegion?.contains(descriptionInput ?? null)).toBe(true); act(() => root.unmount()); }); @@ -452,13 +445,13 @@ describe("NewIssueDialog", () => { expect(container.textContent).not.toContain("will no longer use the parent issue workspace"); - const modeSelect = await waitForValue( - () => container.querySelector("select") as HTMLSelectElement | null, - ); + const selects = Array.from(container.querySelectorAll("select")); + const modeSelect = selects[0] as HTMLSelectElement | undefined; + expect(modeSelect).not.toBeUndefined(); await act(async () => { - modeSelect.value = "shared_workspace"; - modeSelect.dispatchEvent(new Event("change", { bubbles: true })); + modeSelect!.value = "shared_workspace"; + modeSelect!.dispatchEvent(new Event("change", { bubbles: true })); }); await flush(); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 618428d763a..7798c2348aa 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1056,9 +1056,10 @@ export function NewIssueDialog() {
- {/* Title */} -
-