diff --git a/README.md b/README.md index f7ade1b3..42f24cd1 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,8 @@ Open source. Self-hosted. No Paperclip account required. npx paperclipai onboard --yes ``` +If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings. + Or manually: ```bash diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..1826e376 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,292 @@ +

+ Paperclip — runs your business +

+ +

+ Quickstart · + Docs · + GitHub · + Discord +

+ +

+ MIT License + Stars + Discord +

+ +
+ +
+ +
+ +
+ +## What is Paperclip? + +# Open-source orchestration for zero-human companies + +**If OpenClaw is an _employee_, Paperclip is the _company_** + +Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track your agents' work and costs from one dashboard. + +It looks like a task manager — but under the hood it has org charts, budgets, governance, goal alignment, and agent coordination. + +**Manage business goals, not pull requests.** + +| | Step | Example | +| ------ | --------------- | ------------------------------------------------------------------ | +| **01** | Define the goal | _"Build the #1 AI note-taking app to $1M MRR."_ | +| **02** | Hire the team | CEO, CTO, engineers, designers, marketers — any bot, any provider. | +| **03** | Approve and run | Review strategy. Set budgets. Hit go. Monitor from the dashboard. | + +
+ +> **COMING SOON: Clipmart** — Download and run entire companies with one click. Browse pre-built company templates — full org structures, agent configs, and skills — and import them into your Paperclip instance in seconds. + +
+ +
+ + + + + + + + + + +
Works
with
OpenClaw
OpenClaw
Claude
Claude Code
Codex
Codex
Cursor
Cursor
Bash
Bash
HTTP
HTTP
+ +If it can receive a heartbeat, it's hired. + +
+ +
+ +## Paperclip is right for you if + +- ✅ You want to build **autonomous AI companies** +- ✅ You **coordinate many different agents** (OpenClaw, Codex, Claude, Cursor) toward a common goal +- ✅ You have **20 simultaneous Claude Code terminals** open and lose track of what everyone is doing +- ✅ You want agents running **autonomously 24/7**, but still want to audit work and chime in when needed +- ✅ You want to **monitor costs** and enforce budgets +- ✅ You want a process for managing agents that **feels like using a task manager** +- ✅ You want to manage your autonomous businesses **from your phone** + +
+ +## Features + + + + + + + + + + + + + + + + + +
+

🔌 Bring Your Own Agent

+Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired. +
+

🎯 Goal Alignment

+Every task traces back to the company mission. Agents know what to do and why. +
+

💓 Heartbeats

+Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart. +
+

💰 Cost Control

+Monthly budgets per agent. When they hit the limit, they stop. No runaway costs. +
+

🏢 Multi-Company

+One deployment, many companies. Complete data isolation. One control plane for your portfolio. +
+

🎫 Ticket System

+Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log. +
+

🛡️ Governance

+You're the board. Approve hires, override strategy, pause or terminate any agent — at any time. +
+

📊 Org Chart

+Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description. +
+

📱 Mobile Ready

+Monitor and manage your autonomous businesses from anywhere. +
+ +
+ +## Problems Paperclip solves + +| Without Paperclip | With Paperclip | +| ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| ❌ You have 20 Claude Code tabs open and can't track which one does what. On reboot you lose everything. | ✅ Tasks are ticket-based, conversations are threaded, sessions persist across reboots. | +| ❌ You manually gather context from several places to remind your bot what you're actually doing. | ✅ Context flows from the task up through the project and company goals — your agent always knows what to do and why. | +| ❌ Folders of agent configs are disorganized and you're re-inventing task management, communication, and coordination between agents. | ✅ Paperclip gives you org charts, ticketing, delegation, and governance out of the box — so you run a company, not a pile of scripts. | +| ❌ Runaway loops waste hundreds of dollars of tokens and max your quota before you even know what happened. | ✅ Cost tracking surfaces token budgets and throttles agents when they're out. Management prioritizes with budgets. | +| ❌ You have recurring jobs (customer support, social, reports) and have to remember to manually kick them off. | ✅ Heartbeats handle regular work on a schedule. Management supervises. | +| ❌ You have an idea, you have to find your repo, fire up Claude Code, keep a tab open, and babysit it. | ✅ Add a task in Paperclip. Your coding agent works on it until it's done. Management reviews their work. | + +
+ +## Why Paperclip is special + +Paperclip handles the hard orchestration details correctly. + +| | | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| **Atomic execution.** | Task checkout and budget enforcement are atomic, so no double-work and no runaway spend. | +| **Persistent agent state.** | Agents resume the same task context across heartbeats instead of restarting from scratch. | +| **Runtime skill injection.** | Agents can learn Paperclip workflows and project context at runtime, without retraining. | +| **Governance with rollback.** | Approval gates are enforced, config changes are revisioned, and bad changes can be rolled back safely. | +| **Goal-aware execution.** | Tasks carry full goal ancestry so agents consistently see the "why," not just a title. | +| **Portable company templates.** | Export/import orgs, agents, and skills with secret scrubbing and collision handling. | +| **True multi-company isolation.** | Every entity is company-scoped, so one deployment can run many companies with separate data and audit trails. | + +
+ +## What Paperclip is not + +| | | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| **Not a chatbot.** | Agents have jobs, not chat windows. | +| **Not an agent framework.** | We don't tell you how to build agents. We tell you how to run a company made of them. | +| **Not a workflow builder.** | No drag-and-drop pipelines. Paperclip models companies — with org charts, goals, budgets, and governance. | +| **Not a prompt manager.** | Agents bring their own prompts, models, and runtimes. Paperclip manages the organization they work in. | +| **Not a single-agent tool.** | This is for teams. If you have one agent, you probably don't need Paperclip. If you have twenty — you definitely do. | +| **Not a code review tool.** | Paperclip orchestrates work, not pull requests. Bring your own review process. | + +
+ +## Quickstart + +Open source. Self-hosted. No Paperclip account required. + +```bash +npx paperclipai onboard --yes +``` + +If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings. + +Or manually: + +```bash +git clone https://github.com/paperclipai/paperclip.git +cd paperclip +pnpm install +pnpm dev +``` + +This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required. + +> **Requirements:** Node.js 20+, pnpm 9.15+ + +
+ +## FAQ + +**What does a typical setup look like?** +Locally, a single Node.js process manages an embedded Postgres and local file storage. For production, point it at your own Postgres and deploy however you like. Configure projects, agents, and goals — the agents take care of the rest. + +If you're a solo-entreprenuer you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it. + +**Can I run multiple companies?** +Yes. A single deployment can run an unlimited number of companies with complete data isolation. + +**How is Paperclip different from agents like OpenClaw or Claude Code?** +Paperclip _uses_ those agents. It orchestrates them into a company — with org charts, budgets, goals, governance, and accountability. + +**Why should I use Paperclip instead of just pointing my OpenClaw to Asana or Trello?** +Agent orchestration has subtleties in how you coordinate who has work checked out, how to maintain sessions, monitoring costs, establishing governance - Paperclip does this for you. + +(Bring-your-own-ticket-system is on the Roadmap) + +**Do agents run continuously?** +By default, agents run on scheduled heartbeats and event-based triggers (task assignment, @-mentions). You can also hook in continuous agents like OpenClaw. You bring your agent and Paperclip coordinates. + +
+ +## Development + +```bash +pnpm dev # Full dev (API + UI, watch mode) +pnpm dev:once # Full dev without file watching +pnpm dev:server # Server only +pnpm build # Build all +pnpm typecheck # Type checking +pnpm test:run # Run tests +pnpm db:generate # Generate DB migration +pnpm db:migrate # Apply migrations +``` + +See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide. + +
+ +## Roadmap + +- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc) +- ✅ Get OpenClaw / claw-style agent employees +- ✅ companies.sh - import and export entire organizations +- ✅ Easy AGENTS.md configurations +- ✅ Skills Manager +- ✅ Scheduled Routines +- ✅ Better Budgeting +- ⚪ Artifacts & Deployments +- ⚪ CEO Chat +- ⚪ MAXIMIZER MODE +- ⚪ Multiple Human Users +- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents) +- ⚪ Cloud deployments +- ⚪ Desktop App + +
+ +## Community & Plugins + +Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip) + +## Contributing + +We welcome contributions. See the [contributing guide](https://github.com/paperclipai/paperclip/blob/master/CONTRIBUTING.md) for details. + +
+ +## Community + +- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community +- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests +- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC + +
+ +## License + +MIT © 2026 Paperclip + +## Star History + +[![Star History Chart](https://api.star-history.com/image?repos=paperclipai/paperclip&type=date&legend=top-left)](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left) + +
+ +--- + +

+ +

+ +

+ Open source under MIT. Built for people who want to run companies, not babysit agents. +

diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts new file mode 100644 index 00000000..a5ffe44a --- /dev/null +++ b/cli/src/__tests__/onboard.test.ts @@ -0,0 +1,105 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { onboard } from "../commands/onboard.js"; +import type { PaperclipConfig } from "../config/schema.js"; + +const ORIGINAL_ENV = { ...process.env }; + +function createExistingConfigFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-")); + const runtimeRoot = path.join(root, "runtime"); + const configPath = path.join(root, ".paperclip", "config.json"); + const config: PaperclipConfig = { + $meta: { + version: 1, + updatedAt: "2026-03-29T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(runtimeRoot, "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(runtimeRoot, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(runtimeRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(runtimeRoot, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(runtimeRoot, "secrets", "master.key"), + }, + }, + }; + + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + + return { configPath, configText: fs.readFileSync(configPath, "utf8") }; +} + +describe("onboard", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("preserves an existing config when rerun without flags", async () => { + const fixture = createExistingConfigFixture(); + + await onboard({ config: fixture.configPath }); + + expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText); + expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false); + expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true); + }); + + it("preserves an existing config when rerun with --yes", async () => { + const fixture = createExistingConfigFixture(); + + await onboard({ config: fixture.configPath, yes: true, invokedByRun: true }); + + expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText); + expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false); + expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true); + }); +}); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 523484f3..d470354f 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -244,11 +244,12 @@ export async function onboard(opts: OnboardOptions): Promise { ), ); + let existingConfig: PaperclipConfig | null = null; if (configExists(opts.config)) { - p.log.message(pc.dim(`${configPath} exists, updating config`)); + p.log.message(pc.dim(`${configPath} exists`)); try { - readConfig(opts.config); + existingConfig = readConfig(opts.config); } catch (err) { p.log.message( pc.yellow( @@ -258,6 +259,76 @@ export async function onboard(opts: OnboardOptions): Promise { } } + if (existingConfig) { + p.log.message( + pc.dim("Existing Paperclip install detected; keeping the current configuration unchanged."), + ); + p.log.message(pc.dim(`Use ${pc.cyan("paperclipai configure")} if you want to change settings.`)); + + const jwtSecret = ensureAgentJwtSecret(configPath); + const envFilePath = resolveAgentJwtEnvFile(configPath); + if (jwtSecret.created) { + p.log.success(`Created ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); + } else if (process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()) { + p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from environment`); + } else { + p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); + } + + const keyResult = ensureLocalSecretsKeyFile(existingConfig, configPath); + if (keyResult.status === "created") { + p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`); + } else if (keyResult.status === "existing") { + p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`)); + } + + p.note( + [ + "Existing config preserved", + `Database: ${existingConfig.database.mode}`, + existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured", + `Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`, + `Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${existingConfig.server.host}:${existingConfig.server.port}`, + `Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`, + `Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`, + `Storage: ${existingConfig.storage.provider}`, + `Secrets: ${existingConfig.secrets.provider} (strict mode ${existingConfig.secrets.strictMode ? "on" : "off"})`, + "Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured", + ].join("\n"), + "Configuration ready", + ); + + p.note( + [ + `Run: ${pc.cyan("paperclipai run")}`, + `Reconfigure later: ${pc.cyan("paperclipai configure")}`, + `Diagnose setup: ${pc.cyan("paperclipai doctor")}`, + ].join("\n"), + "Next commands", + ); + + let shouldRunNow = opts.run === true || opts.yes === true; + if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) { + const answer = await p.confirm({ + message: "Start Paperclip now?", + initialValue: true, + }); + if (!p.isCancel(answer)) { + shouldRunNow = answer; + } + } + + if (shouldRunNow && !opts.invokedByRun) { + process.env.PAPERCLIP_OPEN_ON_LISTEN = "true"; + const { runCommand } = await import("./run.js"); + await runCommand({ config: configPath, repair: true, yes: true }); + return; + } + + p.outro("Existing Paperclip setup is ready."); + return; + } + let setupMode: SetupMode = "quickstart"; if (opts.yes) { p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults.")); diff --git a/docs/cli/setup-commands.md b/docs/cli/setup-commands.md index 7dc5cd6a..448ab7bb 100644 --- a/docs/cli/setup-commands.md +++ b/docs/cli/setup-commands.md @@ -33,6 +33,8 @@ Interactive first-time setup: pnpm paperclipai onboard ``` +If Paperclip is already configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to change settings on an existing install. + First prompt: 1. `Quickstart` (recommended): local defaults (embedded database, no LLM provider, local disk storage, default secrets) @@ -50,6 +52,8 @@ Non-interactive defaults + immediate start (opens browser on server listen): pnpm paperclipai onboard --yes ``` +On an existing install, `--yes` now preserves the current config and just starts Paperclip with that setup. + ## `paperclipai doctor` Health checks with optional auto-repair: diff --git a/docs/start/quickstart.md b/docs/start/quickstart.md index 1ad30fcd..2abe538b 100644 --- a/docs/start/quickstart.md +++ b/docs/start/quickstart.md @@ -13,6 +13,8 @@ npx paperclipai onboard --yes This walks you through setup, configures your environment, and gets Paperclip running. +If you already have a Paperclip install, rerunning `onboard` keeps your current config and data paths intact. Use `paperclipai configure` if you want to edit settings. + To start Paperclip again later: ```sh diff --git a/package.json b/package.json index d7718aa3..311a092f 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed", "evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval", "test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts", - "test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed" + "test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed", + "metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts" }, "devDependencies": { "@playwright/test": "^1.58.2", diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0c5aa424..6ace12a4 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -119,6 +119,16 @@ export const ISSUE_STATUSES = [ ] as const; export type IssueStatus = (typeof ISSUE_STATUSES)[number]; +export const INBOX_MINE_ISSUE_STATUSES = [ + "backlog", + "todo", + "in_progress", + "in_review", + "blocked", + "done", +] as const; +export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join(","); + export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 89d24f21..7886f493 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,8 @@ export { AGENT_ROLE_LABELS, AGENT_ICON_NAMES, ISSUE_STATUSES, + INBOX_MINE_ISSUE_STATUSES, + INBOX_MINE_ISSUE_STATUS_FILTER, ISSUE_PRIORITIES, ISSUE_ORIGIN_KINDS, GOAL_LEVELS, @@ -353,6 +355,7 @@ export { upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, createAgentKeySchema, + agentMineInboxQuerySchema, wakeAgentSchema, resetAgentSessionSchema, testAdapterEnvironmentSchema, @@ -365,6 +368,7 @@ export { type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, type CreateAgentKey, + type AgentMineInboxQuery, type WakeAgent, type ResetAgentSession, type TestAdapterEnvironment, diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 8c29150b..288ae683 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -4,6 +4,7 @@ import { AGENT_ICON_NAMES, AGENT_ROLES, AGENT_STATUSES, + INBOX_MINE_ISSUE_STATUS_FILTER, } from "../constants.js"; import { envConfigSchema } from "./secret.js"; @@ -93,6 +94,13 @@ export const createAgentKeySchema = z.object({ export type CreateAgentKey = z.infer; +export const agentMineInboxQuerySchema = z.object({ + userId: z.string().trim().min(1), + status: z.string().trim().min(1).optional().default(INBOX_MINE_ISSUE_STATUS_FILTER), +}); + +export type AgentMineInboxQuery = z.infer; + export const wakeAgentSchema = z.object({ source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"), triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 9b94438d..2c8a5c84 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -85,6 +85,7 @@ export { upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, createAgentKeySchema, + agentMineInboxQuerySchema, wakeAgentSchema, resetAgentSessionSchema, testAdapterEnvironmentSchema, @@ -97,6 +98,7 @@ export { type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, type CreateAgentKey, + type AgentMineInboxQuery, type WakeAgent, type ResetAgentSession, type TestAdapterEnvironment, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 3715e4e6..22ae43d2 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -66,6 +66,7 @@ export type CreateIssueLabel = z.infer; export const updateIssueSchema = createIssueSchema.partial().extend({ comment: z.string().min(1).optional(), reopen: z.boolean().optional(), + interrupt: z.boolean().optional(), hiddenAt: z.string().datetime().nullable().optional(), }); diff --git a/scripts/paperclip-commit-metrics.ts b/scripts/paperclip-commit-metrics.ts new file mode 100644 index 00000000..8a648afa --- /dev/null +++ b/scripts/paperclip-commit-metrics.ts @@ -0,0 +1,883 @@ +#!/usr/bin/env npx tsx + +import { execFile } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const DEFAULT_QUERY = "\"Co-Authored-By: Paperclip \""; +const DEFAULT_CACHE_FILE = path.resolve("data/paperclip-commit-metrics-cache.json"); +const DEFAULT_SEARCH_START = "2008-01-01T00:00:00Z"; +const SEARCH_WINDOW_LIMIT = 900; +const MIN_WINDOW_MS = 60_000; +const DEFAULT_STATS_FETCH_LIMIT = 250; +const DEFAULT_STATS_CONCURRENCY = 4; +const DEFAULT_SEARCH_FIELD = "committer-date"; +const PAPERCLIP_EMAIL = "noreply@paperclip.ing"; +const PAPERCLIP_NAME = "paperclip"; + +interface CliOptions { + cacheFile: string; + end: Date; + excludeOwners: string[]; + exportFormat: "csv" | "json"; + includePrivate: boolean; + json: boolean; + output: string | null; + query: string; + refreshSearch: boolean; + refreshStats: boolean; + searchField: "author-date" | "committer-date"; + start: Date; + statsConcurrency: number; + statsFetchLimit: number; + skipStats: boolean; +} + +interface SearchCommitItem { + author: { + login?: string; + } | null; + commit: { + author: { + date: string; + email: string | null; + name: string | null; + } | null; + message: string; + }; + html_url: string; + repository: { + full_name: string; + html_url: string; + }; + sha: string; +} + +interface CommitStats { + additions: number; + deletions: number; + total: number; +} + +interface CachedCommit { + authorEmail: string | null; + authorLogin: string | null; + authorName: string | null; + committedAt: string | null; + contributors: ContributorRecord[]; + htmlUrl: string; + repositoryFullName: string; + repositoryUrl: string; + sha: string; +} + +interface CachedCommitStats extends CommitStats { + fetchedAt: string; +} + +interface ContributorRecord { + displayName: string; + email: string | null; + key: string; + login: string | null; +} + +interface WindowCacheEntry { + completedAt: string; + key: string; + shas: string[]; + totalCount: number; +} + +interface CacheFile { + commits: Record; + queryKey: string; + searchField: CliOptions["searchField"]; + stats: Record; + updatedAt: string | null; + version: number; + windows: Record; +} + +interface SearchResponse { + incomplete_results: boolean; + items: SearchCommitItem[]; + total_count: number; +} + +interface SearchWindowResult { + shas: Set; + totalCount: number; +} + +interface Summary { + cacheFile: string; + contributors: { + count: number; + sample: ContributorRecord[]; + }; + detectedQuery: string; + lineStats: { + additions: number; + complete: boolean; + coveredCommits: number; + deletions: number; + missingCommits: number; + totalChanges: number; + }; + range: { + end: string; + searchField: CliOptions["searchField"]; + start: string; + }; + filters: { + excludedOwners: string[]; + }; + repos: { + count: number; + sample: string[]; + }; + statsFetch: { + fetchedThisRun: number; + skipped: boolean; + }; + totals: { + commits: number; + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const cache = await loadCache(options.cacheFile, options); + const client = new GitHubClient(await resolveGitHubToken()); + + const { shas } = await searchWindow(client, cache, options, options.start, options.end); + const sortedShas = [...shas].sort(); + + let fetchedThisRun = 0; + if (!options.skipStats) { + fetchedThisRun = await enrichCommitStats(client, cache, options, sortedShas); + } + + cache.updatedAt = new Date().toISOString(); + await saveCache(options.cacheFile, cache); + + const filteredShas = sortFilteredShas(cache, filterShas(cache, sortedShas, options)); + const summary = buildSummary(cache, options, filteredShas, fetchedThisRun); + + if (options.output) { + await writeExport(options.output, options.exportFormat, cache, filteredShas, summary); + } + + if (options.json) { + console.log(JSON.stringify(summary, null, 2)); + return; + } + + printSummary(summary); +} + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { + cacheFile: DEFAULT_CACHE_FILE, + end: new Date(), + excludeOwners: [], + exportFormat: "csv", + includePrivate: false, + json: false, + output: null, + query: DEFAULT_QUERY, + refreshSearch: false, + refreshStats: false, + searchField: DEFAULT_SEARCH_FIELD, + start: new Date(DEFAULT_SEARCH_START), + statsConcurrency: DEFAULT_STATS_CONCURRENCY, + statsFetchLimit: DEFAULT_STATS_FETCH_LIMIT, + skipStats: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case "--cache-file": + options.cacheFile = requireValue(argv, ++index, arg); + break; + case "--end": + options.end = parseDateArg(requireValue(argv, ++index, arg), arg); + break; + case "--exclude-owner": + options.excludeOwners.push(requireValue(argv, ++index, arg).toLowerCase()); + break; + case "--export-format": { + const value = requireValue(argv, ++index, arg); + if (value !== "csv" && value !== "json") { + throw new Error(`Invalid --export-format value: ${value}`); + } + options.exportFormat = value; + break; + } + case "--include-private": + options.includePrivate = true; + break; + case "--json": + options.json = true; + break; + case "--output": + options.output = requireValue(argv, ++index, arg); + break; + case "--query": + options.query = requireValue(argv, ++index, arg); + break; + case "--refresh-search": + options.refreshSearch = true; + break; + case "--refresh-stats": + options.refreshStats = true; + break; + case "--search-field": { + const value = requireValue(argv, ++index, arg); + if (value !== "author-date" && value !== "committer-date") { + throw new Error(`Invalid --search-field value: ${value}`); + } + options.searchField = value; + break; + } + case "--skip-stats": + options.skipStats = true; + break; + case "--start": + options.start = parseDateArg(requireValue(argv, ++index, arg), arg); + break; + case "--stats-concurrency": + options.statsConcurrency = parsePositiveInt(requireValue(argv, ++index, arg), arg); + break; + case "--stats-fetch-limit": + options.statsFetchLimit = parseNonNegativeInt(requireValue(argv, ++index, arg), arg); + break; + case "--help": + printHelp(); + process.exit(0); + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (Number.isNaN(options.start.getTime()) || Number.isNaN(options.end.getTime())) { + throw new Error("Invalid start or end date"); + } + if (options.start >= options.end) { + throw new Error("--start must be earlier than --end"); + } + + return options; +} + +function requireValue(argv: string[], index: number, flag: string): string { + const value = argv[index]; + if (!value) { + throw new Error(`Missing value for ${flag}`); + } + return value; +} + +function parseDateArg(value: string, flag: string): Date { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`Invalid date for ${flag}: ${value}`); + } + return parsed; +} + +function parsePositiveInt(value: string, flag: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid positive integer for ${flag}: ${value}`); + } + return parsed; +} + +function parseNonNegativeInt(value: string, flag: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid non-negative integer for ${flag}: ${value}`); + } + return parsed; +} + +function printHelp() { + console.log(`Usage: tsx scripts/paperclip-commit-metrics.ts [options] + +Options: + --start ISO date/time lower bound (default: ${DEFAULT_SEARCH_START}) + --end ISO date/time upper bound (default: now) + --query Commit search string (default: ${DEFAULT_QUERY}) + --search-field author-date | committer-date (default: ${DEFAULT_SEARCH_FIELD}) + --include-private Include repos visible to the current token + --exclude-owner Exclude repositories owned by this GitHub owner/org (repeatable) + --cache-file Cache path (default: ${DEFAULT_CACHE_FILE}) + --skip-stats Skip additions/deletions enrichment + --stats-fetch-limit Max uncached commit stats to fetch this run (default: ${DEFAULT_STATS_FETCH_LIMIT}) + --stats-concurrency Parallel commit stat requests (default: ${DEFAULT_STATS_CONCURRENCY}) + --output Write the full filtered result set to a file + --export-format csv | json for --output exports (default: csv) + --refresh-search Ignore cached search windows + --refresh-stats Re-fetch cached commit stats + --json Print JSON summary + --help Show this help +`); +} + +async function resolveGitHubToken(): Promise { + const envToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (envToken) { + return envToken; + } + + const { stdout } = await execFileAsync("gh", ["auth", "token"]); + const token = stdout.trim(); + if (!token) { + throw new Error("Unable to resolve a GitHub token. Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`."); + } + return token; +} + +async function loadCache(cacheFile: string, options: CliOptions): Promise { + try { + const raw = await fs.readFile(cacheFile, "utf8"); + const parsed = JSON.parse(raw) as CacheFile; + if (parsed.version !== 1 || parsed.queryKey !== buildQueryKey(options) || parsed.searchField !== options.searchField) { + return createEmptyCache(options); + } + return parsed; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return createEmptyCache(options); + } + throw error; + } +} + +function createEmptyCache(options: CliOptions): CacheFile { + return { + commits: {}, + queryKey: buildQueryKey(options), + searchField: options.searchField, + stats: {}, + updatedAt: null, + version: 1, + windows: {}, + }; +} + +function buildQueryKey(options: CliOptions): string { + const visibility = options.includePrivate ? "all" : "public"; + return JSON.stringify({ + query: options.query, + searchField: options.searchField, + visibility, + }); +} + +async function saveCache(cacheFile: string, cache: CacheFile): Promise { + await fs.mkdir(path.dirname(cacheFile), { recursive: true }); + await fs.writeFile(cacheFile, JSON.stringify(cache, null, 2), "utf8"); +} + +async function searchWindow( + client: GitHubClient, + cache: CacheFile, + options: CliOptions, + start: Date, + end: Date, +): Promise { + const windowKey = makeWindowKey(start, end); + if (!options.refreshSearch) { + const cached = cache.windows[windowKey]; + if (cached) { + return { shas: new Set(cached.shas), totalCount: cached.totalCount }; + } + } + + const firstPage = await searchPage(client, options, start, end, 1, 100); + if (firstPage.incomplete_results) { + throw new Error(`GitHub returned incomplete search results for window ${windowKey}`); + } + + if (firstPage.total_count > SEARCH_WINDOW_LIMIT) { + const durationMs = end.getTime() - start.getTime(); + if (durationMs <= MIN_WINDOW_MS) { + throw new Error( + `Search window ${windowKey} still has ${firstPage.total_count} results after splitting to ${durationMs}ms.`, + ); + } + + const midpoint = new Date(start.getTime() + Math.floor(durationMs / 2)); + const left = await searchWindow(client, cache, options, start, midpoint); + const right = await searchWindow(client, cache, options, new Date(midpoint.getTime() + 1), end); + const shas = new Set([...left.shas, ...right.shas]); + + cache.windows[windowKey] = { + completedAt: new Date().toISOString(), + key: windowKey, + shas: [...shas], + totalCount: shas.size, + }; + + return { shas, totalCount: shas.size }; + } + + const pageCount = Math.ceil(firstPage.total_count / 100); + const shas = new Set(); + ingestSearchItems(cache, firstPage.items, shas); + + for (let page = 2; page <= pageCount; page += 1) { + const response = await searchPage(client, options, start, end, page, 100); + if (response.incomplete_results) { + throw new Error(`GitHub returned incomplete search results for window ${windowKey} on page ${page}`); + } + ingestSearchItems(cache, response.items, shas); + } + + cache.windows[windowKey] = { + completedAt: new Date().toISOString(), + key: windowKey, + shas: [...shas], + totalCount: firstPage.total_count, + }; + + return { shas, totalCount: firstPage.total_count }; +} + +async function searchPage( + client: GitHubClient, + options: CliOptions, + start: Date, + end: Date, + page: number, + perPage: number, +): Promise { + const searchQuery = buildSearchQuery(options, start, end); + const params = new URLSearchParams({ + page: String(page), + per_page: String(perPage), + q: searchQuery, + }); + + return client.getJson(`/search/commits?${params.toString()}`); +} + +function buildSearchQuery(options: CliOptions, start: Date, end: Date): string { + const qualifiers = [`${options.searchField}:${formatQueryDate(start)}..${formatQueryDate(end)}`]; + if (!options.includePrivate) { + qualifiers.push("is:public"); + } + return `${options.query} ${qualifiers.join(" ")}`.trim(); +} + +function filterShas(cache: CacheFile, shas: string[], options: CliOptions): string[] { + if (options.excludeOwners.length === 0) { + return shas; + } + + const excludedOwners = new Set(options.excludeOwners); + return shas.filter((sha) => { + const commit = cache.commits[sha]; + if (!commit) { + return false; + } + return !excludedOwners.has(getRepoOwner(commit.repositoryFullName)); + }); +} + +function sortFilteredShas(cache: CacheFile, shas: string[]): string[] { + return [...shas].sort((leftSha, rightSha) => { + const left = cache.commits[leftSha]; + const right = cache.commits[rightSha]; + const leftTime = left?.committedAt ? Date.parse(left.committedAt) : 0; + const rightTime = right?.committedAt ? Date.parse(right.committedAt) : 0; + if (rightTime !== leftTime) { + return rightTime - leftTime; + } + + const repoCompare = (left?.repositoryFullName ?? "").localeCompare(right?.repositoryFullName ?? ""); + if (repoCompare !== 0) { + return repoCompare; + } + return leftSha.localeCompare(rightSha); + }); +} + +function formatQueryDate(value: Date): string { + return new Date(Math.floor(value.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z"); +} + +function ingestSearchItems(cache: CacheFile, items: SearchCommitItem[], shas: Set) { + for (const item of items) { + shas.add(item.sha); + cache.commits[item.sha] = { + authorEmail: item.commit.author?.email ?? null, + authorLogin: item.author?.login ?? null, + authorName: item.commit.author?.name ?? null, + committedAt: item.commit.author?.date ?? null, + contributors: extractContributors(item), + htmlUrl: item.html_url, + repositoryFullName: item.repository.full_name, + repositoryUrl: item.repository.html_url, + sha: item.sha, + }; + } +} + +function extractContributors(item: SearchCommitItem): ContributorRecord[] { + const contributors = new Map(); + + const primaryAuthor = normalizeContributor({ + email: item.commit.author?.email ?? null, + login: item.author?.login ?? null, + name: item.commit.author?.name ?? null, + }); + if (primaryAuthor) { + contributors.set(primaryAuthor.key, primaryAuthor); + } + + const coAuthorPattern = /^co-authored-by:\s*(.+?)\s*<([^>]+)>\s*$/gim; + for (const match of item.commit.message.matchAll(coAuthorPattern)) { + const contributor = normalizeContributor({ + email: match[2] ?? null, + login: null, + name: match[1] ?? null, + }); + if (contributor) { + contributors.set(contributor.key, contributor); + } + } + + return [...contributors.values()]; +} + +function normalizeContributor(input: { + email: string | null; + login: string | null; + name: string | null; +}): ContributorRecord | null { + const email = normalizeOptional(input.email); + const login = normalizeOptional(input.login); + const displayName = normalizeOptional(input.name) ?? login ?? email; + + if (!displayName && !email && !login) { + return null; + } + if ((email && email === PAPERCLIP_EMAIL) || (displayName && displayName.toLowerCase() === PAPERCLIP_NAME)) { + return null; + } + + const key = login ? `login:${login}` : email ? `email:${email}` : `name:${displayName!.toLowerCase()}`; + return { + displayName: displayName ?? email ?? login ?? "unknown", + email, + key, + login, + }; +} + +function normalizeOptional(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function getRepoOwner(repositoryFullName: string): string { + return repositoryFullName.split("/", 1)[0]?.toLowerCase() ?? ""; +} + +async function enrichCommitStats( + client: GitHubClient, + cache: CacheFile, + options: CliOptions, + shas: string[], +): Promise { + const pending = shas.filter((sha) => options.refreshStats || !cache.stats[sha]).slice(0, options.statsFetchLimit); + let nextIndex = 0; + let fetched = 0; + + const workers = Array.from({ length: Math.min(options.statsConcurrency, pending.length) }, async () => { + while (true) { + const currentIndex = nextIndex; + nextIndex += 1; + const sha = pending[currentIndex]; + if (!sha) { + return; + } + const commit = cache.commits[sha]; + if (!commit) { + continue; + } + const stats = await fetchCommitStats(client, commit.repositoryFullName, sha); + cache.stats[sha] = { + ...stats, + fetchedAt: new Date().toISOString(), + }; + fetched += 1; + } + }); + + await Promise.all(workers); + return fetched; +} + +async function fetchCommitStats(client: GitHubClient, repositoryFullName: string, sha: string): Promise { + const response = await client.getJson<{ stats?: CommitStats }>( + `/repos/${repositoryFullName}/commits/${sha}`, + ); + return { + additions: response.stats?.additions ?? 0, + deletions: response.stats?.deletions ?? 0, + total: response.stats?.total ?? 0, + }; +} + +function buildSummary(cache: CacheFile, options: CliOptions, shas: string[], fetchedThisRun: number): Summary { + const repoNames = new Set(); + const contributors = new Map(); + let additions = 0; + let deletions = 0; + let coveredCommits = 0; + + for (const sha of shas) { + const commit = cache.commits[sha]; + if (!commit) { + continue; + } + repoNames.add(commit.repositoryFullName); + for (const contributor of commit.contributors) { + contributors.set(contributor.key, contributor); + } + + const stats = cache.stats[sha]; + if (stats) { + additions += stats.additions; + deletions += stats.deletions; + coveredCommits += 1; + } + } + + const contributorSample = [...contributors.values()] + .sort((left, right) => left.displayName.localeCompare(right.displayName)) + .slice(0, 10); + const repoSample = [...repoNames].sort((left, right) => left.localeCompare(right)).slice(0, 10); + + return { + cacheFile: options.cacheFile, + contributors: { + count: contributors.size, + sample: contributorSample, + }, + detectedQuery: buildSearchQuery(options, options.start, options.end), + lineStats: { + additions, + complete: coveredCommits === shas.length, + coveredCommits, + deletions, + missingCommits: shas.length - coveredCommits, + totalChanges: additions + deletions, + }, + range: { + end: options.end.toISOString(), + searchField: options.searchField, + start: options.start.toISOString(), + }, + filters: { + excludedOwners: [...options.excludeOwners].sort(), + }, + repos: { + count: repoNames.size, + sample: repoSample, + }, + statsFetch: { + fetchedThisRun, + skipped: options.skipStats, + }, + totals: { + commits: shas.length, + }, + }; +} + +function printSummary(summary: Summary) { + console.log("Paperclip commit metrics"); + console.log(`Query: ${summary.detectedQuery}`); + console.log(`Range: ${summary.range.start} -> ${summary.range.end} (${summary.range.searchField})`); + if (summary.filters.excludedOwners.length > 0) { + console.log(`Excluded owners: ${summary.filters.excludedOwners.join(", ")}`); + } + console.log(`Commits: ${summary.totals.commits}`); + console.log(`Distinct repos: ${summary.repos.count}`); + console.log(`Distinct contributors: ${summary.contributors.count}`); + console.log( + `Line stats: +${summary.lineStats.additions} / -${summary.lineStats.deletions} / ${summary.lineStats.totalChanges} total`, + ); + console.log( + `Line stat coverage: ${summary.lineStats.coveredCommits}/${summary.totals.commits}` + + (summary.lineStats.complete ? " (complete)" : " (partial; rerun to hydrate more commits)"), + ); + console.log(`Stats fetched this run: ${summary.statsFetch.fetchedThisRun}${summary.statsFetch.skipped ? " (skipped)" : ""}`); + console.log(`Cache: ${summary.cacheFile}`); + + if (summary.repos.sample.length > 0) { + console.log(`Sample repos: ${summary.repos.sample.join(", ")}`); + } + if (summary.contributors.sample.length > 0) { + console.log( + `Sample contributors: ${summary.contributors.sample + .map((contributor) => contributor.login ?? contributor.displayName) + .join(", ")}`, + ); + } +} + +async function writeExport( + outputPath: string, + format: CliOptions["exportFormat"], + cache: CacheFile, + shas: string[], + summary: Summary, +): Promise { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + if (format === "json") { + const report = { + summary, + commits: shas.map((sha) => buildExportRow(cache, sha)), + }; + await fs.writeFile(outputPath, JSON.stringify(report, null, 2), "utf8"); + return; + } + + const header = [ + "committedAt", + "repository", + "repositoryUrl", + "sha", + "commitUrl", + "authorLogin", + "authorName", + "authorEmail", + "contributors", + "additions", + "deletions", + "totalChanges", + ]; + const rows = [header.join(",")]; + for (const sha of shas) { + const row = buildExportRow(cache, sha); + rows.push( + [ + row.committedAt, + row.repository, + row.repositoryUrl, + row.sha, + row.commitUrl, + row.authorLogin, + row.authorName, + row.authorEmail, + row.contributors, + String(row.additions), + String(row.deletions), + String(row.totalChanges), + ] + .map(escapeCsv) + .join(","), + ); + } + await fs.writeFile(outputPath, `${rows.join("\n")}\n`, "utf8"); +} + +function buildExportRow(cache: CacheFile, sha: string) { + const commit = cache.commits[sha]; + if (!commit) { + throw new Error(`Missing cached commit for sha ${sha}`); + } + const stats = cache.stats[sha]; + return { + additions: stats?.additions ?? 0, + authorEmail: commit.authorEmail ?? "", + authorLogin: commit.authorLogin ?? "", + authorName: commit.authorName ?? "", + commitUrl: commit.htmlUrl, + committedAt: commit.committedAt ?? "", + contributors: commit.contributors.map((contributor) => contributor.login ?? contributor.displayName).join(" | "), + deletions: stats?.deletions ?? 0, + repository: commit.repositoryFullName, + repositoryUrl: commit.repositoryUrl, + sha: commit.sha, + totalChanges: stats?.total ?? 0, + }; +} + +function escapeCsv(value: string): string { + if (value.includes(",") || value.includes("\"") || value.includes("\n")) { + return `"${value.replaceAll("\"", "\"\"")}"`; + } + return value; +} + +function makeWindowKey(start: Date, end: Date): string { + return `${start.toISOString()}..${end.toISOString()}`; +} + +class GitHubClient { + private readonly apiBase = "https://api.github.com"; + private readonly token: string; + + constructor(token: string) { + this.token = token; + } + + async getJson(pathname: string): Promise { + while (true) { + const response = await fetch(`${this.apiBase}${pathname}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${this.token}`, + "User-Agent": "paperclip-commit-metrics", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (response.ok) { + return (await response.json()) as T; + } + + const retryAfter = response.headers.get("retry-after"); + if ((response.status === 403 || response.status === 429) && retryAfter) { + const waitMs = Math.max(Number.parseInt(retryAfter, 10) * 1000, 1_000); + console.error(`GitHub secondary rate limit hit for ${pathname}; waiting ${Math.ceil(waitMs / 1000)}s...`); + await sleep(waitMs); + continue; + } + + const remaining = response.headers.get("x-ratelimit-remaining"); + const resetAt = response.headers.get("x-ratelimit-reset"); + if ((response.status === 403 || response.status === 429) && remaining === "0" && resetAt) { + const waitMs = Math.max(Number.parseInt(resetAt, 10) * 1000 - Date.now() + 1_000, 1_000); + console.error(`GitHub rate limit hit for ${pathname}; waiting ${Math.ceil(waitMs / 1000)}s...`); + await sleep(waitMs); + continue; + } + + const body = await response.text(); + throw new Error(`GitHub API request failed (${response.status}) for ${pathname}: ${body}`); + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 08941f77..7bd79f76 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1,6 +1,7 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; import { agentRoutes } from "../routes/agents.js"; import { errorHandler } from "../middleware/index.js"; @@ -272,4 +273,42 @@ describe("agent permission routes", () => { expect(res.body.access.canAssignTasks).toBe(true); expect(res.body.access.taskAssignSource).toBe("agent_creator"); }); + + it("exposes a dedicated agent route for the inbox mine view", async () => { + mockIssueService.list.mockResolvedValue([ + { + id: "issue-1", + identifier: "PAP-910", + title: "Inbox follow-up", + status: "todo", + }, + ]); + + const app = createApp({ + type: "agent", + agentId, + companyId, + runId: "run-1", + source: "agent_key", + }); + + const res = await request(app) + .get("/api/agents/me/inbox/mine") + .query({ userId: "board-user" }); + + expect(res.status).toBe(200); + expect(mockIssueService.list).toHaveBeenCalledWith(companyId, { + touchedByUserId: "board-user", + inboxArchivedByUserId: "board-user", + status: INBOX_MINE_ISSUE_STATUS_FILTER, + }); + expect(res.body).toEqual([ + { + id: "issue-1", + identifier: "PAP-910", + title: "Inbox follow-up", + status: "todo", + }, + ]); + }); }); diff --git a/server/src/__tests__/board-mutation-guard.test.ts b/server/src/__tests__/board-mutation-guard.test.ts index 62e1e68e..9a4789b2 100644 --- a/server/src/__tests__/board-mutation-guard.test.ts +++ b/server/src/__tests__/board-mutation-guard.test.ts @@ -84,6 +84,28 @@ describe("boardMutationGuard", () => { expect(res.status).toBe(204); }); + it("allows board mutations when x-forwarded-host matches origin", async () => { + const app = createApp("board"); + const res = await request(app) + .post("/mutate") + .set("Host", "127.0.0.1") + .set("X-Forwarded-Host", "10.90.10.20:3443") + .set("Origin", "https://10.90.10.20:3443") + .send({ ok: true }); + expect(res.status).toBe(204); + }); + + it("blocks board mutations when x-forwarded-host does not match origin", async () => { + const app = createApp("board"); + const res = await request(app) + .post("/mutate") + .set("Host", "127.0.0.1") + .set("X-Forwarded-Host", "10.90.10.20:3443") + .set("Origin", "https://evil.example.com") + .send({ ok: true }); + expect(res.status).toBe(403); + }); + it("does not block authenticated agent mutations", async () => { const middleware = boardMutationGuard(); const req = { diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 42c4cb0d..21bb44aa 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -19,6 +19,9 @@ const mockAccessService = vi.hoisted(() => ({ 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), })); const mockAgentService = vi.hoisted(() => ({ @@ -143,4 +146,46 @@ describe("issue comment reopen routes", () => { }), ); }); + + it("interrupts an active run before a combined comment update", async () => { + const issue = { + ...makeIssue("todo"), + executionRunId: "run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + })); + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "running", + }); + mockHeartbeatService.cancelRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "cancelled", + }); + + const res = await request(createApp()) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1"); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1"); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "heartbeat.cancelled", + details: expect.objectContaining({ + source: "issue_comment_interrupt", + issueId: "11111111-1111-4111-8111-111111111111", + }), + }), + ); + }); }); diff --git a/server/src/middleware/board-mutation-guard.ts b/server/src/middleware/board-mutation-guard.ts index de66a4ce..feff3b40 100644 --- a/server/src/middleware/board-mutation-guard.ts +++ b/server/src/middleware/board-mutation-guard.ts @@ -18,7 +18,8 @@ function parseOrigin(value: string | undefined) { function trustedOriginsForRequest(req: Request) { const origins = new Set(DEFAULT_DEV_ORIGINS.map((value) => value.toLowerCase())); - const host = req.header("host")?.trim(); + const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim(); + const host = forwardedHost || req.header("host")?.trim(); if (host) { origins.add(`http://${host}`.toLowerCase()); origins.add(`https://${host}`.toLowerCase()); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index b4964578..2ad85e63 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -6,6 +6,7 @@ import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { agentSkillSyncSchema, + agentMineInboxQuerySchema, createAgentKeySchema, createAgentHireSchema, createAgentSchema, @@ -1006,6 +1007,23 @@ export function agentRoutes(db: Db) { ); }); + router.get("/agents/me/inbox/mine", async (req, res) => { + if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) { + res.status(401).json({ error: "Agent authentication required" }); + return; + } + + const query = agentMineInboxQuerySchema.parse(req.query); + const issuesSvc = issueService(db); + const rows = await issuesSvc.list(req.actor.companyId, { + touchedByUserId: query.userId, + inboxArchivedByUserId: query.userId, + status: query.status, + }); + + res.json(rows); + }); + router.get("/agents/:id", async (req, res) => { const id = req.params.id as string; const agent = await svc.getById(id); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 1fb20fa5..688547ae 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,5 +1,6 @@ import { Router, type Request, type Response } from "express"; import multer from "multer"; +import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { addIssueCommentSchema, @@ -38,6 +39,9 @@ import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types. import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; const MAX_ISSUE_COMMENT_LIMIT = 500; +const updateIssueRouteSchema = updateIssueSchema.extend({ + interrupt: z.boolean().optional(), +}); export function issueRoutes(db: Db, storage: StorageService) { const router = Router(); @@ -161,6 +165,30 @@ export function issueRoutes(db: Db, storage: StorageService) { return true; } + async function resolveActiveIssueRun(issue: { + id: string; + assigneeAgentId: string | null; + executionRunId?: string | null; + }) { + let runToInterrupt = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null; + + if ((!runToInterrupt || runToInterrupt.status !== "running") && issue.assigneeAgentId) { + const activeRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId); + const activeIssueId = + activeRun && + activeRun.contextSnapshot && + typeof activeRun.contextSnapshot === "object" && + typeof (activeRun.contextSnapshot as Record).issueId === "string" + ? ((activeRun.contextSnapshot as Record).issueId as string) + : null; + if (activeRun && activeRun.status === "running" && activeIssueId === issue.id) { + runToInterrupt = activeRun; + } + } + + return runToInterrupt?.status === "running" ? runToInterrupt : null; + } + async function normalizeIssueIdentifier(rawId: string): Promise { if (/^[A-Z]+-\d+$/i.test(rawId)) { const issue = await svc.getByIdentifier(rawId); @@ -714,6 +742,38 @@ export function issueRoutes(db: Db, storage: StorageService) { res.json(readState); }); + router.delete("/issues/:id/read", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return; + } + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return; + } + const removed = await svc.markUnread(issue.companyId, issue.id, req.actor.userId); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.read_unmarked", + entityType: "issue", + entityId: issue.id, + details: { userId: req.actor.userId }, + }); + res.json({ id: issue.id, removed }); + }); + router.post("/issues/:id/inbox-archive", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); @@ -888,7 +948,7 @@ export function issueRoutes(db: Db, storage: StorageService) { res.status(201).json(issue); }); - router.patch("/issues/:id", validate(updateIssueSchema), async (req, res) => { + router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { @@ -918,7 +978,45 @@ export function issueRoutes(db: Db, storage: StorageService) { const actor = getActorInfo(req); const isClosed = existing.status === "done" || existing.status === "cancelled"; - const { comment: commentBody, reopen: reopenRequested, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; + const { + comment: commentBody, + reopen: reopenRequested, + interrupt: interruptRequested, + hiddenAt: hiddenAtRaw, + ...updateFields + } = req.body; + let interruptedRunId: string | null = null; + + if (interruptRequested) { + if (!commentBody) { + res.status(400).json({ error: "Interrupt is only supported when posting a comment" }); + return; + } + if (req.actor.type !== "board") { + res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" }); + return; + } + + const runToInterrupt = await resolveActiveIssueRun(existing); + if (runToInterrupt) { + const cancelled = await heartbeat.cancelRun(runToInterrupt.id); + if (cancelled) { + interruptedRunId = cancelled.id; + await logActivity(db, { + companyId: cancelled.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "heartbeat.cancelled", + entityType: "heartbeat_run", + entityId: cancelled.id, + details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: existing.id }, + }); + } + } + } + if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } @@ -993,6 +1091,7 @@ export function issueRoutes(db: Db, storage: StorageService) { identifier: issue.identifier, ...(commentBody ? { source: "comment" } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), _previous: hasFieldChanges ? previous : undefined, }, }); @@ -1019,6 +1118,7 @@ export function issueRoutes(db: Db, storage: StorageService) { identifier: issue.identifier, issueTitle: issue.title, ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), ...(hasFieldChanges ? { updated: true } : {}), }, }); @@ -1040,10 +1140,18 @@ export function issueRoutes(db: Db, storage: StorageService) { source: "assignment", triggerDetail: "system", reason: "issue_assigned", - payload: { issueId: issue.id, mutation: "update" }, + payload: { + issueId: issue.id, + mutation: "update", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, - contextSnapshot: { issueId: issue.id, source: "issue.update" }, + contextSnapshot: { + issueId: issue.id, + source: "issue.update", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, }); } @@ -1052,10 +1160,18 @@ export function issueRoutes(db: Db, storage: StorageService) { source: "automation", triggerDetail: "system", reason: "issue_status_changed", - payload: { issueId: issue.id, mutation: "update" }, + payload: { + issueId: issue.id, + mutation: "update", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, - contextSnapshot: { issueId: issue.id, source: "issue.status_change" }, + contextSnapshot: { + issueId: issue.id, + source: "issue.status_change", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, }); } @@ -1348,28 +1464,8 @@ export function issueRoutes(db: Db, storage: StorageService) { return; } - let runToInterrupt = currentIssue.executionRunId - ? await heartbeat.getRun(currentIssue.executionRunId) - : null; - - if ( - (!runToInterrupt || runToInterrupt.status !== "running") && - currentIssue.assigneeAgentId - ) { - const activeRun = await heartbeat.getActiveRunForAgent(currentIssue.assigneeAgentId); - const activeIssueId = - activeRun && - activeRun.contextSnapshot && - typeof activeRun.contextSnapshot === "object" && - typeof (activeRun.contextSnapshot as Record).issueId === "string" - ? ((activeRun.contextSnapshot as Record).issueId as string) - : null; - if (activeRun && activeRun.status === "running" && activeIssueId === currentIssue.id) { - runToInterrupt = activeRun; - } - } - - if (runToInterrupt && runToInterrupt.status === "running") { + const runToInterrupt = await resolveActiveIssueRun(currentIssue); + if (runToInterrupt) { const cancelled = await heartbeat.cancelRun(runToInterrupt.id); if (cancelled) { interruptedRunId = cancelled.id; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 29f6ca49..0e1defe3 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -795,6 +795,20 @@ export function issueService(db: Db) { return row; }, + markUnread: async (companyId: string, issueId: string, userId: string) => { + const deleted = await db + .delete(issueReadStates) + .where( + and( + eq(issueReadStates.companyId, companyId), + eq(issueReadStates.issueId, issueId), + eq(issueReadStates.userId, userId), + ), + ) + .returning(); + return deleted.length > 0; + }, + archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => { const now = new Date(); const [row] = await db diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 407f08da..142ee63a 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -255,6 +255,7 @@ PATCH /api/agents/{agentId}/instructions-path | ----------------------------------------- | ------------------------------------------------------------------------------------------ | | My identity | `GET /api/agents/me` | | My compact inbox | `GET /api/agents/me/inbox-lite` | +| Report a user's Mine inbox view | `GET /api/agents/me/inbox/mine?userId=:userId` | | My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` | | Checkout task | `POST /api/issues/:issueId/checkout` | | Get task + ancestors | `GET /api/issues/:issueId` | diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 63293725..aea4250c 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -226,6 +226,34 @@ PATCH /api/issues/issue-99 { "comment": "JWT signing done. Still need token refresh logic. Will continue next heartbeat." } ``` +### Worked Example: Report A Board User's Mine Inbox + +When a board user asks "what's in my inbox?", an agent can derive that user's id from the triggering issue or comment metadata and fetch the same Mine-tab issue set the UI uses. + +``` +# Board user created the requesting issue. +GET /api/issues/issue-200 +-> { id: "issue-200", createdByUserId: "user-7", ... } + +# Fetch the board user's Mine inbox issues. +GET /api/agents/me/inbox/mine?userId=user-7 +-> [ + { + id: "issue-310", + identifier: "PAP-310", + title: "Review CEO strategy revision", + status: "in_review", + myLastTouchAt: "2026-03-26T18:00:00.000Z", + lastExternalCommentAt: "2026-03-26T19:10:00.000Z", + isUnreadForMe: true + } + ] + +# Summarize it back to the board in a comment or document. +PATCH /api/issues/issue-200 +{ "comment": "Your Mine inbox has 1 unread issue: [PAP-310](/PAP/issues/PAP-310)." } +``` + --- ## Worked Example: Manager Heartbeat @@ -566,6 +594,7 @@ Terminal states: `done`, `cancelled` | Method | Path | Description | | ------ | ---------------------------------- | ------------------------------------ | | GET | `/api/agents/me` | Your agent record + chain of command | +| GET | `/api/agents/me/inbox/mine?userId=:userId` | Mine-tab issue list for a specific board user | | GET | `/api/agents/:agentId` | Agent details + chain of command | | GET | `/api/companies/:companyId/agents` | List all agents in company | | GET | `/api/companies/:companyId/org` | Org chart tree | diff --git a/ui/index.html b/ui/index.html index 1bb9152e..70a8550e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,6 +4,7 @@ + diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 7f47817f..8d4834c3 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -11,6 +11,10 @@ import type { } from "@paperclipai/shared"; import { api } from "./client"; +export type IssueUpdateResponse = Issue & { + comment?: IssueComment | null; +}; + export const issuesApi = { list: ( companyId: string, @@ -55,13 +59,15 @@ export const issuesApi = { deleteLabel: (id: string) => api.delete(`/labels/${id}`), get: (id: string) => api.get(`/issues/${id}`), markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}), + markUnread: (id: string) => api.delete<{ id: string; removed: boolean }>(`/issues/${id}/read`), archiveFromInbox: (id: string) => api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}), unarchiveFromInbox: (id: string) => api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`), create: (companyId: string, data: Record) => api.post(`/companies/${companyId}/issues`, data), - update: (id: string, data: Record) => api.patch(`/issues/${id}`, data), + update: (id: string, data: Record) => + api.patch(`/issues/${id}`, data), remove: (id: string) => api.delete(`/issues/${id}`), checkout: (id: string, agentId: string) => api.post(`/issues/${id}/checkout`, { diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index cdf0ddd2..84041401 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -15,6 +15,10 @@ import { PluginSlotOutlet } from "@/plugins/slots"; interface CommentWithRunMeta extends IssueComment { runId?: string | null; runAgentId?: string | null; + clientId?: string; + clientStatus?: "pending" | "queued"; + queueState?: "queued"; + queueTargetRunId?: string | null; } interface LinkedRunItem { @@ -32,6 +36,7 @@ interface CommentReassignment { interface CommentThreadProps { comments: CommentWithRunMeta[]; + queuedComments?: CommentWithRunMeta[]; linkedRuns?: LinkedRunItem[]; companyId?: string | null; projectId?: string | null; @@ -48,6 +53,8 @@ interface CommentThreadProps { currentAssigneeValue?: string; suggestedAssigneeValue?: string; mentions?: MentionOption[]; + onInterruptQueued?: (runId: string) => Promise; + interruptingQueuedRunId?: string | null; } const DRAFT_DEBOUNCE_MS = 800; @@ -114,6 +121,122 @@ function CopyMarkdownButton({ text }: { text: string }) { ); } +function CommentCard({ + comment, + agentMap, + companyId, + projectId, + highlightCommentId, + queued = false, +}: { + comment: CommentWithRunMeta; + agentMap?: Map; + companyId?: string | null; + projectId?: string | null; + highlightCommentId?: string | null; + queued?: boolean; +}) { + const isHighlighted = highlightCommentId === comment.id; + const isPending = comment.clientStatus === "pending"; + const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued"; + + return ( +
+
+ {comment.authorAgentId ? ( + + + + ) : ( + + )} + + {isQueued ? ( + + Queued + + ) : null} + {companyId && !isPending ? ( + + ) : null} + {isPending ? ( + {isQueued ? "Queueing..." : "Sending..."} + ) : ( + + {formatDateTime(comment.createdAt)} + + )} + + +
+ {comment.body} + {companyId && !isPending ? ( +
+ +
+ ) : null} + {comment.runId && !isPending ? ( +
+ {comment.runAgentId ? ( + + run {comment.runId.slice(0, 8)} + + ) : ( + + run {comment.runId.slice(0, 8)} + + )} +
+ ) : null} +
+ ); +} + type TimelineItem = | { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta } | { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem }; @@ -168,86 +291,15 @@ const TimelineList = memo(function TimelineList({ } const comment = item.comment; - const isHighlighted = highlightCommentId === comment.id; return ( -
-
- {comment.authorAgentId ? ( - - - - ) : ( - - )} - - {companyId ? ( - - ) : null} - - {formatDateTime(comment.createdAt)} - - - -
- {comment.body} - {companyId ? ( -
- -
- ) : null} - {comment.runId && ( -
- {comment.runAgentId ? ( - - run {comment.runId.slice(0, 8)} - - ) : ( - - run {comment.runId.slice(0, 8)} - - )} -
- )} -
+ comment={comment} + agentMap={agentMap} + companyId={companyId} + projectId={projectId} + highlightCommentId={highlightCommentId} + /> ); })} @@ -256,6 +308,7 @@ const TimelineList = memo(function TimelineList({ export function CommentThread({ comments, + queuedComments = [], linkedRuns = [], companyId, projectId, @@ -270,6 +323,8 @@ export function CommentThread({ currentAssigneeValue = "", suggestedAssigneeValue, mentions: providedMentions, + onInterruptQueued, + interruptingQueuedRunId = null, }: CommentThreadProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); @@ -345,7 +400,7 @@ export function CommentThread({ // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { const hash = location.hash; - if (!hash.startsWith("#comment-") || comments.length === 0) return; + if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return; const commentId = hash.slice("#comment-".length); // Only scroll once per hash if (hasScrolledRef.current) return; @@ -358,7 +413,7 @@ export function CommentThread({ const timer = setTimeout(() => setHighlightCommentId(null), 3000); return () => clearTimeout(timer); } - }, [location.hash, comments]); + }, [location.hash, comments, queuedComments]); async function handleSubmit() { const trimmed = body.trim(); @@ -368,11 +423,14 @@ export function CommentThread({ setSubmitting(true); try { + // TODO: wire an explicit "send + interrupt" action through the composer if we expose it in the UI. await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined); setBody(""); if (draftKey) clearDraft(draftKey); setReopen(true); setReassignTarget(effectiveSuggestedAssigneeValue); + } catch { + // Parent mutation handlers surface the failure and keep the draft intact. } finally { setSubmitting(false); } @@ -401,18 +459,54 @@ export function CommentThread({ return (
-

Comments & Runs ({timeline.length})

+

Comments & Runs ({timeline.length + queuedComments.length})

- + {timeline.length > 0 ? ( + + ) : null} {liveRunSlot} + {queuedComments.length > 0 && ( +
+
+

+ Queued Comments ({queuedComments.length}) +

+ {onInterruptQueued && queuedComments[0]?.queueTargetRunId ? ( + + ) : null} +
+
+ {queuedComments.map((comment) => ( + + ))} +
+
+ )} +
({ + Link: ({ children, className, ...props }: React.ComponentProps<"a">) => ( + {children} + ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Inbox item", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + isUnreadForMe: false, + ...overrides, + }; +} + +describe("IssueRow", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("suppresses accent hover styling when the row is selected", () => { + const root = createRoot(container); + const issue = createIssue(); + + act(() => { + root.render(); + }); + + const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null; + expect(link).not.toBeNull(); + expect(link?.className).toContain("hover:bg-transparent"); + expect(link?.className).not.toContain("hover:bg-accent/50"); + + act(() => { + root.unmount(); + }); + }); + + it("neutralizes selected status and unread dot accents", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const markReadButton = container.querySelector('button[aria-label="Mark as read"]'); + const unreadDot = markReadButton?.querySelector("span"); + const statusIcon = container.querySelector('span[class*="border-muted-foreground"]'); + + expect(markReadButton).not.toBeNull(); + expect(markReadButton?.className).toContain("hover:bg-muted/80"); + expect(markReadButton?.className).not.toContain("hover:bg-blue-500/20"); + expect(unreadDot).not.toBeNull(); + expect(unreadDot?.className).toContain("bg-muted-foreground/70"); + expect(unreadDot?.className).not.toContain("bg-blue-600"); + expect(statusIcon).not.toBeNull(); + expect(statusIcon?.className).toContain("!border-muted-foreground"); + expect(statusIcon?.className).toContain("!text-muted-foreground"); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index d3bcfcff..8a01e585 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { X } from "lucide-react"; +import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { cn } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; @@ -10,6 +11,7 @@ type UnreadState = "hidden" | "visible" | "fading"; interface IssueRowProps { issue: Issue; issueLinkState?: unknown; + selected?: boolean; mobileLeading?: ReactNode; desktopMetaLeading?: ReactNode; desktopLeadingSpacer?: boolean; @@ -26,6 +28,7 @@ interface IssueRowProps { export function IssueRow({ issue, issueLinkState, + selected = false, mobileLeading, desktopMetaLeading, desktopLeadingSpacer = false, @@ -42,18 +45,21 @@ export function IssueRow({ const identifier = issue.identifier ?? issue.id.slice(0, 8); const showUnreadSlot = unreadState !== null; const showUnreadDot = unreadState === "visible" || unreadState === "fading"; + const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined; return ( - {mobileLeading ?? } + {mobileLeading ?? } @@ -66,7 +72,7 @@ export function IssueRow({ {desktopMetaLeading ?? ( <> - + {identifier} @@ -108,12 +114,16 @@ export function IssueRow({ onMarkRead?.(); } }} - className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" + className={cn( + "inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors", + selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20", + )} aria-label="Mark as read" > diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 68469761..e04ceda2 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -564,8 +564,11 @@ export const MarkdownEditor = forwardRef {mentionActive && filteredMentions.length > 0 && createPortal(
{filteredMentions.map((option, i) => ( + , + ); + }); + + const wrapper = container.firstElementChild as HTMLDivElement; + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + Object.defineProperty(wrapper, "offsetWidth", { configurable: true, value: 200 }); + Object.defineProperty(wrapper, "offsetHeight", { configurable: true, value: 48 }); + + act(() => { + dispatchTouchEvent(wrapper, "touchstart", { x: 180, y: 20 }); + }); + act(() => { + dispatchTouchEvent(wrapper, "touchmove", { x: 80, y: 22 }); + }); + act(() => { + dispatchTouchEvent(wrapper, "touchend", { x: 80, y: 22 }); + }); + + act(() => { + button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onClick).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(140); + }); + + expect(onArchive).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); + + it("does not suppress a normal tap click", () => { + const onArchive = vi.fn(); + const onClick = vi.fn(); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + act(() => { + button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onArchive).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); + + it("renders the selected inbox treatment on the swipe surface", () => { + const root = createRoot(container); + + act(() => { + root.render( + {}} selected> + + , + ); + }); + + const surface = container.querySelector("[data-inbox-row-surface]") as HTMLDivElement | null; + expect(surface).not.toBeNull(); + expect(surface?.style.backgroundColor).toBe("hsl(var(--muted))"); + expect(surface?.style.boxShadow).toBe(""); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/SwipeToArchive.tsx b/ui/src/components/SwipeToArchive.tsx index 17ea3707..acd1d467 100644 --- a/ui/src/components/SwipeToArchive.tsx +++ b/ui/src/components/SwipeToArchive.tsx @@ -6,23 +6,27 @@ interface SwipeToArchiveProps { children: ReactNode; onArchive: () => void; disabled?: boolean; + selected?: boolean; className?: string; } -const COMMIT_THRESHOLD = 0.4; -const MAX_SWIPE = 0.92; -const COMMIT_DELAY_MS = 210; +const COMMIT_THRESHOLD = 0.32; +const MAX_SWIPE = 0.88; +const COMMIT_DELAY_MS = 140; +const SELECTED_ROW_BACKGROUND = "hsl(var(--muted))"; export function SwipeToArchive({ children, onArchive, disabled = false, + selected = false, className, }: SwipeToArchiveProps) { const containerRef = useRef(null); const startPointRef = useRef<{ x: number; y: number } | null>(null); const widthRef = useRef(0); const timeoutRef = useRef(null); + const suppressClickRef = useRef(false); const [offsetX, setOffsetX] = useState(0); const [isDragging, setIsDragging] = useState(false); const [isCollapsing, setIsCollapsing] = useState(false); @@ -68,6 +72,7 @@ export function SwipeToArchive({ widthRef.current = node?.offsetWidth ?? 0; setLockedHeight(node?.offsetHeight ?? null); setIsCollapsing(false); + suppressClickRef.current = false; startPointRef.current = { x: touch.clientX, y: touch.clientY }; }; @@ -86,6 +91,7 @@ export function SwipeToArchive({ startPointRef.current = null; return; } + suppressClickRef.current = true; } if (deltaX >= 0) { @@ -127,6 +133,12 @@ export function SwipeToArchive({ onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} onTouchCancel={handleTouchEnd} + onClickCapture={(event) => { + if (!suppressClickRef.current) return; + event.preventDefault(); + event.stopPropagation(); + suppressClickRef.current = false; + }} >
{children} diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index c24faf70..8aaa7581 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider"; import { queryKeys } from "../lib/queryKeys"; @@ -191,3 +191,55 @@ describe("LiveUpdatesProvider run lifecycle toasts", () => { }); }); }); + +describe("LiveUpdatesProvider socket helpers", () => { + it("waits for the selected company object to catch up before connecting", () => { + expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", null)).toBeNull(); + expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-2")).toBeNull(); + expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-1")).toBe("company-1"); + }); + + it("defers close until onopen for sockets that are still connecting", () => { + const socket = { + readyState: 0, + onopen: (() => undefined) as (() => void) | null, + onmessage: (() => undefined) as (() => void) | null, + onerror: (() => undefined) as (() => void) | null, + onclose: (() => undefined) as (() => void) | null, + close: vi.fn(), + }; + + __liveUpdatesTestUtils.closeSocketQuietly(socket as never, "provider_unmount"); + + expect(socket.close).not.toHaveBeenCalled(); + expect(socket.onmessage).toBeNull(); + expect(socket.onclose).toBeNull(); + expect(socket.onopen).toBeTypeOf("function"); + expect(socket.onerror).toBeTypeOf("function"); + + socket.onopen?.(); + + expect(socket.close).toHaveBeenCalledWith(1000, "provider_unmount"); + expect(socket.onopen).toBeNull(); + expect(socket.onerror).toBeNull(); + }); + + it("closes open sockets immediately without leaving handlers behind", () => { + const socket = { + readyState: 1, + onopen: (() => undefined) as (() => void) | null, + onmessage: (() => undefined) as (() => void) | null, + onerror: (() => undefined) as (() => void) | null, + onclose: (() => undefined) as (() => void) | null, + close: vi.fn(), + }; + + __liveUpdatesTestUtils.closeSocketQuietly(socket as never, "stale_connection"); + + expect(socket.close).toHaveBeenCalledWith(1000, "stale_connection"); + expect(socket.onopen).toBeNull(); + expect(socket.onmessage).toBeNull(); + expect(socket.onerror).toBeNull(); + expect(socket.onclose).toBeNull(); + }); +}); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 20132721..71e6fbc8 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -14,6 +14,17 @@ import { useLocation } from "../lib/router"; const TOAST_COOLDOWN_WINDOW_MS = 10_000; const TOAST_COOLDOWN_MAX = 3; const RECONNECT_SUPPRESS_MS = 2000; +const SOCKET_CONNECTING = 0; +const SOCKET_OPEN = 1; + +type LiveUpdatesSocketLike = { + readyState: number; + onopen: ((this: WebSocket, ev: Event) => unknown) | null; + onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null; + onerror: ((this: WebSocket, ev: Event) => unknown) | null; + onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null; + close: (code?: number, reason?: string) => void; +}; function readString(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; @@ -652,35 +663,90 @@ function handleLiveEvent( } } +function resolveLiveCompanyId( + selectedCompanyId: string | null, + selectedCompanyLiveId: string | null, +): string | null { + return selectedCompanyId && selectedCompanyId === selectedCompanyLiveId + ? selectedCompanyId + : null; +} + +function resetSocketHandlers(target: LiveUpdatesSocketLike) { + target.onopen = null; + target.onmessage = null; + target.onerror = null; + target.onclose = null; +} + +function closeSocketQuietly(target: LiveUpdatesSocketLike | null, reason: string) { + if (!target) return; + + if (target.readyState === SOCKET_CONNECTING) { + // Let the handshake complete and then close. Calling close() while the + // socket is still CONNECTING is what triggers the noisy browser error. + target.onopen = () => { + resetSocketHandlers(target); + target.close(1000, reason); + }; + target.onmessage = null; + target.onerror = () => undefined; + target.onclose = null; + return; + } + + resetSocketHandlers(target); + + if (target.readyState === SOCKET_OPEN) { + target.close(1000, reason); + } +} + export const __liveUpdatesTestUtils = { buildAgentStatusToast, buildRunStatusToast, + closeSocketQuietly, invalidateActivityQueries, + resolveLiveCompanyId, shouldSuppressActivityToastForVisibleIssue, shouldSuppressRunStatusToastForVisibleIssue, shouldSuppressAgentStatusToastForVisibleIssue, }; export function LiveUpdatesProvider({ children }: { children: ReactNode }) { - const { selectedCompanyId } = useCompany(); + const { selectedCompanyId, selectedCompany } = useCompany(); const queryClient = useQueryClient(); const { pushToast } = useToast(); const location = useLocation(); const gateRef = useRef({ cooldownHits: new Map(), suppressUntil: 0 }); const pathnameRef = useRef(location.pathname); - const { data: session } = useQuery({ + const { data: session, status: sessionStatus } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), retry: false, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const socketAuthKey = session?.session?.id ?? currentUserId ?? "signed_out"; + const liveCompanyId = resolveLiveCompanyId(selectedCompanyId, selectedCompany?.id ?? null); + const canConnectSocket = sessionStatus === "success" && session !== null && liveCompanyId !== null; + const currentActorRef = useRef<{ userId: string | null; agentId: string | null }>({ + userId: currentUserId, + agentId: null, + }); useEffect(() => { pathnameRef.current = location.pathname; }, [location.pathname]); useEffect(() => { - if (!selectedCompanyId) return; + currentActorRef.current = { + userId: currentUserId, + agentId: null, + }; + }, [currentUserId]); + + useEffect(() => { + if (!canConnectSocket || !liveCompanyId) return; let closed = false; let reconnectAttempt = 0; @@ -707,55 +773,63 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const connect = () => { if (closed) return; const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(selectedCompanyId)}/events/ws`; - socket = new WebSocket(url); + const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`; + const nextSocket = new WebSocket(url); + socket = nextSocket; - socket.onopen = () => { + nextSocket.onopen = () => { + if (closed || socket !== nextSocket) { + closeSocketQuietly(nextSocket, "stale_connection"); + return; + } if (reconnectAttempt > 0) { gateRef.current.suppressUntil = Date.now() + RECONNECT_SUPPRESS_MS; } reconnectAttempt = 0; }; - socket.onmessage = (message) => { + nextSocket.onmessage = (message) => { const raw = typeof message.data === "string" ? message.data : ""; if (!raw) return; try { const parsed = JSON.parse(raw) as LiveEvent; - handleLiveEvent(queryClient, selectedCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, { - userId: currentUserId, - agentId: null, + handleLiveEvent(queryClient, liveCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, { + userId: currentActorRef.current.userId, + agentId: currentActorRef.current.agentId, }); } catch { // Ignore non-JSON payloads. } }; - socket.onerror = () => { - socket?.close(); + nextSocket.onerror = () => { + // Wait for onclose to drive the reconnect. Self-closing here is what + // produces the "closed before connection established" browser noise. }; - socket.onclose = () => { + nextSocket.onclose = () => { + if (socket !== nextSocket) return; + socket = null; if (closed) return; scheduleReconnect(); }; }; - connect(); + // Delay initial connect slightly so React StrictMode's double-invoke + // cleanup fires before the WebSocket is created, avoiding the + // "WebSocket closed before connection established" dev-mode error. + const connectTimer = window.setTimeout(connect, 0); return () => { closed = true; + window.clearTimeout(connectTimer); clearReconnect(); - if (socket) { - socket.onopen = null; - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - socket.close(1000, "provider_unmount"); - } + const activeSocket = socket; + socket = null; + closeSocketQuietly(activeSocket, "provider_unmount"); }; - }, [queryClient, selectedCompanyId, pushToast, currentUserId]); + }, [queryClient, liveCompanyId, pushToast, canConnectSocket, socketAuthKey]); return <>{children}; } diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts index 50f4323d..6b7daa2b 100644 --- a/ui/src/hooks/useInboxBadge.ts +++ b/ui/src/hooks/useInboxBadge.ts @@ -64,7 +64,16 @@ export function useReadInboxItems() { }); }; - return { readItems, markRead }; + const markUnread = (id: string) => { + setReadItems((prev) => { + const next = new Set(prev); + next.delete(id); + saveReadInboxItems(next); + return next; + }); + }; + + return { readItems, markRead, markUnread }; } export function useInboxBadge(companyId: string | null | undefined) { diff --git a/ui/src/index.css b/ui/src/index.css index b0f839ec..c220e8cd 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -343,6 +343,17 @@ margin-top: 1.1em; } +.paperclip-mdxeditor-content a:not(.paperclip-mention-chip):not(.paperclip-project-mention-chip) { + color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%); + text-decoration: underline; + text-underline-offset: 0.15em; + cursor: pointer; +} + +.dark .paperclip-mdxeditor-content a:not(.paperclip-mention-chip):not(.paperclip-project-mention-chip) { + color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%); +} + .paperclip-mdxeditor-content a.paperclip-mention-chip, .paperclip-mdxeditor-content a.paperclip-project-mention-chip { display: inline-flex; @@ -661,12 +672,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { .paperclip-markdown a { color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%); - text-decoration: none; -} - -.paperclip-markdown a:hover { text-decoration: underline; text-underline-offset: 0.15em; + cursor: pointer; +} + +.paperclip-markdown a.paperclip-mention-chip { + text-decoration: none; } .dark .paperclip-markdown a { diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index af7c1d35..a67a7451 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -6,10 +6,13 @@ import { computeInboxBadgeData, getApprovalsForTab, getInboxWorkItems, + getInboxKeyboardSelectionIndex, getRecentTouchedIssues, getUnreadTouchedIssues, + isMineInboxTab, loadLastInboxTab, RECENT_ISSUES_LIMIT, + resolveInboxSelectionIndex, saveLastInboxTab, shouldShowInboxSection, } from "./inbox"; @@ -400,4 +403,24 @@ describe("inbox helpers", () => { localStorage.setItem("paperclip:inbox:last-tab", "new"); expect(loadLastInboxTab()).toBe("mine"); }); + + it("enables swipe archive only on the mine tab", () => { + expect(isMineInboxTab("mine")).toBe(true); + expect(isMineInboxTab("recent")).toBe(false); + expect(isMineInboxTab("unread")).toBe(false); + expect(isMineInboxTab("all")).toBe(false); + }); + + it("anchors Mine selection to the first available inbox row", () => { + expect(resolveInboxSelectionIndex(-1, 3)).toBe(-1); + expect(resolveInboxSelectionIndex(5, 3)).toBe(2); + expect(resolveInboxSelectionIndex(1, 0)).toBe(-1); + }); + + it("selects the first row only after keyboard navigation starts", () => { + expect(getInboxKeyboardSelectionIndex(-1, 3, "next")).toBe(0); + expect(getInboxKeyboardSelectionIndex(-1, 3, "previous")).toBe(0); + expect(getInboxKeyboardSelectionIndex(0, 3, "next")).toBe(1); + expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0); + }); }); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 3b4297b8..e86a02ee 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -98,6 +98,31 @@ export function saveLastInboxTab(tab: InboxTab) { } } +export function isMineInboxTab(tab: InboxTab): boolean { + return tab === "mine"; +} + +export function resolveInboxSelectionIndex( + previousIndex: number, + itemCount: number, +): number { + if (itemCount === 0) return -1; + if (previousIndex < 0) return -1; + return Math.min(previousIndex, itemCount - 1); +} + +export function getInboxKeyboardSelectionIndex( + previousIndex: number, + itemCount: number, + direction: "next" | "previous", +): number { + if (itemCount === 0) return -1; + if (previousIndex < 0) return 0; + return direction === "next" + ? Math.min(previousIndex + 1, itemCount - 1) + : Math.max(previousIndex - 1, 0); +} + export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] { const sorted = [...runs].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), diff --git a/ui/src/lib/issueDetailBreadcrumb.test.ts b/ui/src/lib/issueDetailBreadcrumb.test.ts new file mode 100644 index 00000000..dcb18479 --- /dev/null +++ b/ui/src/lib/issueDetailBreadcrumb.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + createIssueDetailLocationState, + createIssueDetailPath, + readIssueDetailBreadcrumb, +} from "./issueDetailBreadcrumb"; + +describe("issueDetailBreadcrumb", () => { + it("prefers the full breadcrumb from route state", () => { + const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"); + + expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({ + label: "Inbox", + href: "/inbox/mine", + }); + }); + + it("falls back to the source query param when route state is unavailable", () => { + expect(readIssueDetailBreadcrumb(null, "?from=inbox")).toEqual({ + label: "Inbox", + href: "/inbox", + }); + }); + + it("adds the source query param when building an issue detail path", () => { + const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"); + + expect(createIssueDetailPath("PAP-465", state)).toBe("/issues/PAP-465?from=inbox"); + }); + + it("reuses the current source query param when state has been dropped", () => { + expect(createIssueDetailPath("PAP-465", null, "?from=issues")).toBe("/issues/PAP-465?from=issues"); + }); +}); diff --git a/ui/src/lib/issueDetailBreadcrumb.ts b/ui/src/lib/issueDetailBreadcrumb.ts index ba330eb3..1f940ef8 100644 --- a/ui/src/lib/issueDetailBreadcrumb.ts +++ b/ui/src/lib/issueDetailBreadcrumb.ts @@ -1,3 +1,5 @@ +type IssueDetailSource = "issues" | "inbox"; + type IssueDetailBreadcrumb = { label: string; href: string; @@ -5,20 +7,64 @@ type IssueDetailBreadcrumb = { type IssueDetailLocationState = { issueDetailBreadcrumb?: IssueDetailBreadcrumb; + issueDetailSource?: IssueDetailSource; }; +const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from"; + function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb { if (typeof value !== "object" || value === null) return false; const candidate = value as Partial; return typeof candidate.label === "string" && typeof candidate.href === "string"; } -export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState { - return { issueDetailBreadcrumb: { label, href } }; +function isIssueDetailSource(value: unknown): value is IssueDetailSource { + return value === "issues" || value === "inbox"; } -export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null { +function readIssueDetailSource(state: unknown): IssueDetailSource | null { if (typeof state !== "object" || state === null) return null; - const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb; - return isIssueDetailBreadcrumb(candidate) ? candidate : null; + const source = (state as IssueDetailLocationState).issueDetailSource; + return isIssueDetailSource(source) ? source : null; +} + +function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | null { + if (!search) return null; + const params = new URLSearchParams(search); + const source = params.get(ISSUE_DETAIL_SOURCE_QUERY_PARAM); + return isIssueDetailSource(source) ? source : null; +} + +function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb { + if (source === "inbox") return { label: "Inbox", href: "/inbox" }; + return { label: "Issues", href: "/issues" }; +} + +export function createIssueDetailLocationState( + label: string, + href: string, + source?: IssueDetailSource, +): IssueDetailLocationState { + return { + issueDetailBreadcrumb: { label, href }, + issueDetailSource: source, + }; +} + +export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string { + const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search); + if (!source) return `/issues/${issuePathId}`; + const params = new URLSearchParams(); + params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source); + return `/issues/${issuePathId}?${params.toString()}`; +} + +export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null { + if (typeof state === "object" && state !== null) { + const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb; + if (isIssueDetailBreadcrumb(candidate)) return candidate; + } + + const source = readIssueDetailSourceFromSearch(search); + return source ? breadcrumbForSource(source) : null; } diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts new file mode 100644 index 00000000..bb4ae9ae --- /dev/null +++ b/ui/src/lib/optimistic-issue-comments.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + applyOptimisticIssueCommentUpdate, + createOptimisticIssueComment, + isQueuedIssueComment, + mergeIssueComments, + upsertIssueComment, +} from "./optimistic-issue-comments"; + +describe("optimistic issue comments", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("creates a pending optimistic comment for the current user", () => { + const comment = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "Working on it", + authorUserId: "board-1", + }); + + expect(comment.id).toMatch(/^optimistic-/); + expect(comment.clientId).toBe(comment.id); + expect(comment.clientStatus).toBe("pending"); + expect(comment.authorUserId).toBe("board-1"); + expect(comment.authorAgentId).toBeNull(); + }); + + it("falls back when crypto.randomUUID is unavailable", () => { + vi.stubGlobal("crypto", {}); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_746_000_000_000); + const mathSpy = vi.spyOn(Math, "random").mockReturnValue(0.123456789); + + const comment = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "Working on it", + authorUserId: "board-1", + }); + + expect(comment.id).toBe("optimistic-1746000000000-4fzzzxjy"); + expect(comment.clientId).toBe(comment.id); + + nowSpy.mockRestore(); + mathSpy.mockRestore(); + }); + + it("supports queued optimistic comments for active-run follow-ups", () => { + const comment = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "Queue this", + authorUserId: "board-1", + clientStatus: "queued", + queueTargetRunId: "run-1", + }); + + expect(comment.clientStatus).toBe("queued"); + expect(comment.queueTargetRunId).toBe("run-1"); + }); + + it("merges optimistic comments into the server thread in chronological order", () => { + const merged = mergeIssueComments( + [ + { + id: "comment-2", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Second", + createdAt: new Date("2026-03-28T14:00:02.000Z"), + updatedAt: new Date("2026-03-28T14:00:02.000Z"), + }, + ], + [ + { + id: "optimistic-1", + clientId: "optimistic-1", + clientStatus: "pending", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "First", + createdAt: new Date("2026-03-28T14:00:01.000Z"), + updatedAt: new Date("2026-03-28T14:00:01.000Z"), + }, + ], + ); + + expect(merged.map((comment) => comment.id)).toEqual(["optimistic-1", "comment-2"]); + }); + + it("upserts confirmed comments without creating duplicates", () => { + const next = upsertIssueComment( + [ + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Original", + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + ], + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Updated", + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:05.000Z"), + }, + ); + + expect(next).toHaveLength(1); + expect(next[0]?.body).toBe("Updated"); + }); + + it("applies optimistic reopen and reassignment updates to the issue cache", () => { + const next = applyOptimisticIssueCommentUpdate( + { + id: "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Fix comment flow", + description: null, + status: "done", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: "board-1", + issueNumber: 1, + identifier: "PAP-1", + originKind: "manual", + originId: null, + originRunId: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + { + reopen: true, + reassignment: { + assigneeAgentId: null, + assigneeUserId: "board-2", + }, + }, + ); + + expect(next?.status).toBe("todo"); + expect(next?.assigneeAgentId).toBeNull(); + expect(next?.assigneeUserId).toBe("board-2"); + }); + + it("treats comments without a run id as queued when they arrive during an active run", () => { + expect( + isQueuedIssueComment({ + comment: { + createdAt: new Date("2026-03-28T16:20:05.000Z"), + }, + activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"), + runId: null, + }), + ).toBe(true); + }); + + it("does not mark comments with an associated run as queued", () => { + expect( + isQueuedIssueComment({ + comment: { + createdAt: new Date("2026-03-28T16:20:05.000Z"), + }, + activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"), + runId: "run-1", + }), + ).toBe(false); + }); + + it("does not mark interrupt comments as queued", () => { + expect( + isQueuedIssueComment({ + comment: { + createdAt: new Date("2026-03-28T16:20:05.000Z"), + }, + activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"), + interruptedRunId: "run-1", + }), + ).toBe(false); + }); +}); diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts new file mode 100644 index 00000000..44d85332 --- /dev/null +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -0,0 +1,123 @@ +import type { Issue, IssueComment } from "@paperclipai/shared"; + +export interface IssueCommentReassignment { + assigneeAgentId: string | null; + assigneeUserId: string | null; +} + +export interface OptimisticIssueComment extends IssueComment { + clientId: string; + clientStatus: "pending" | "queued"; + queueTargetRunId?: string | null; +} + +export type IssueTimelineComment = IssueComment | OptimisticIssueComment; + +function toTimestamp(value: Date | string) { + return new Date(value).getTime(); +} + +function createOptimisticCommentId() { + const randomUuid = globalThis.crypto?.randomUUID?.(); + if (randomUuid) { + return `optimistic-${randomUuid}`; + } + return `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +export function sortIssueComments(comments: T[]) { + return [...comments].sort((a, b) => { + const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt); + if (createdAtDiff !== 0) return createdAtDiff; + return a.id.localeCompare(b.id); + }); +} + +export function createOptimisticIssueComment(params: { + companyId: string; + issueId: string; + body: string; + authorUserId: string | null; + clientStatus?: OptimisticIssueComment["clientStatus"]; + queueTargetRunId?: string | null; +}): OptimisticIssueComment { + const now = new Date(); + const clientId = createOptimisticCommentId(); + return { + id: clientId, + clientId, + companyId: params.companyId, + issueId: params.issueId, + authorAgentId: null, + authorUserId: params.authorUserId, + body: params.body, + clientStatus: params.clientStatus ?? "pending", + queueTargetRunId: params.queueTargetRunId ?? null, + createdAt: now, + updatedAt: now, + }; +} + +export function isQueuedIssueComment(params: { + comment: Pick & Partial>; + activeRunStartedAt?: Date | string | null; + runId?: string | null; + interruptedRunId?: string | null; +}) { + if (params.runId) return false; + if (params.interruptedRunId) return false; + if (params.comment.clientStatus === "queued") return true; + if (!params.activeRunStartedAt) return false; + return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt); +} + +export function mergeIssueComments( + comments: IssueComment[] | undefined, + optimisticComments: OptimisticIssueComment[], +): IssueTimelineComment[] { + const merged = [...(comments ?? [])]; + const existingIds = new Set(merged.map((comment) => comment.id)); + for (const comment of optimisticComments) { + if (!existingIds.has(comment.id)) { + merged.push(comment); + } + } + return sortIssueComments(merged); +} + +export function upsertIssueComment( + comments: IssueComment[] | undefined, + nextComment: IssueComment, +): IssueComment[] { + const current = comments ?? []; + const existingIndex = current.findIndex((comment) => comment.id === nextComment.id); + if (existingIndex === -1) { + return sortIssueComments([...current, nextComment]); + } + + const updated = [...current]; + updated[existingIndex] = nextComment; + return sortIssueComments(updated); +} + +export function applyOptimisticIssueCommentUpdate( + issue: Issue | undefined, + params: { + reopen?: boolean; + reassignment?: IssueCommentReassignment; + }, +) { + if (!issue) return issue; + const nextIssue: Issue = { ...issue }; + + if (params.reopen === true && (issue.status === "done" || issue.status === "cancelled")) { + nextIssue.status = "todo"; + } + + if (params.reassignment) { + nextIssue.assigneeAgentId = params.reassignment.assigneeAgentId; + nextIssue.assigneeUserId = params.reassignment.assigneeUserId; + } + + return nextIssue; +} diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx new file mode 100644 index 00000000..c103a523 --- /dev/null +++ b/ui/src/pages/Inbox.test.tsx @@ -0,0 +1,181 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ComponentProps } from "react"; +import { createRoot } from "react-dom/client"; +import type { Issue } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FailedRunInboxRow, InboxIssueMetaLeading } from "./Inbox"; + +vi.mock("@/lib/router", () => ({ + Link: ({ children, className, ...props }: ComponentProps<"a">) => ( + {children} + ), + useLocation: () => ({ pathname: "/", search: "", hash: "" }), + useNavigate: () => () => {}, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-904", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Inbox item", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 904, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + isUnreadForMe: false, + ...overrides, + }; +} + +describe("FailedRunInboxRow", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("suppresses accent hover styling when selected", () => { + const root = createRoot(container); + const run = { + id: "run-1", + companyId: "company-1", + agentId: "agent-1", + invocationSource: "assignment", + triggerDetail: null, + status: "failed", + error: "boom", + wakeupRequestId: null, + exitCode: null, + signal: null, + usageJson: null, + resultJson: null, + sessionIdBefore: null, + sessionIdAfter: null, + logStore: null, + logRef: null, + logBytes: null, + logSha256: null, + logCompressed: false, + errorCode: null, + externalRunId: null, + processPid: null, + processStartedAt: null, + retryOfRunId: null, + processLossRetryCount: 0, + stdoutExcerpt: null, + stderrExcerpt: null, + contextSnapshot: null, + startedAt: new Date("2026-03-11T00:00:00.000Z"), + finishedAt: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + } as const; + + act(() => { + root.render( + {}} + onRetry={() => {}} + isRetrying={false} + selected + />, + ); + }); + + const link = container.querySelector("a"); + expect(link).not.toBeNull(); + expect(link?.className).toContain("hover:bg-transparent"); + expect(link?.className).not.toContain("hover:bg-accent/50"); + + act(() => { + root.unmount(); + }); + }); +}); + +describe("InboxIssueMetaLeading", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("neutralizes selected status and live accents", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const statusIcon = container.querySelector('span[class*="border-muted-foreground"]'); + const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-muted"]'); + const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find( + (node) => node.textContent === "Live" && node.className.includes("text-"), + ); + const liveDot = container.querySelector('span[class*="bg-muted-foreground/70"]'); + const pulseRing = container.querySelector('span[class*="animate-pulse"]'); + + expect(statusIcon).not.toBeNull(); + expect(statusIcon?.className).toContain("!border-muted-foreground"); + expect(statusIcon?.className).toContain("!text-muted-foreground"); + expect(liveBadge).not.toBeNull(); + expect(liveBadge?.className).toContain("bg-muted"); + expect(liveBadgeLabel).not.toBeNull(); + expect(liveBadgeLabel?.className).toContain("text-muted-foreground"); + expect(liveBadgeLabel?.className).not.toContain("text-blue-600"); + expect(liveDot).not.toBeNull(); + expect(pulseRing).toBeNull(); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 149bd292..9c86b04b 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1,6 +1,7 @@ -import { useEffect, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; import { approvalsApi } from "../api/approvals"; import { accessApi } from "../api/access"; import { ApiError } from "../api/client"; @@ -11,7 +12,7 @@ import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; -import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; +import { createIssueDetailLocationState, createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { IssueRow } from "../components/IssueRow"; @@ -46,12 +47,16 @@ import { ACTIONABLE_APPROVAL_STATUSES, getApprovalsForTab, getInboxWorkItems, + getInboxKeyboardSelectionIndex, getLatestFailedRunsByAgent, getRecentTouchedIssues, + isMineInboxTab, + resolveInboxSelectionIndex, InboxApprovalFilter, saveLastInboxTab, shouldShowInboxSection, type InboxTab, + type InboxWorkItem, } from "../lib/inbox"; import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge"; @@ -66,8 +71,6 @@ type SectionKey = | "work_items" | "alerts"; -const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done"; - function firstNonEmptyLine(value: string | null | undefined): string | null { if (!value) return null; const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean); @@ -97,8 +100,69 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null { type NonIssueUnreadState = "visible" | "fading" | "hidden" | null; +const selectedInboxAccentClass = "!text-muted-foreground !border-muted-foreground"; -function FailedRunInboxRow({ +function getSelectedUnreadButtonClass(selected: boolean): string { + return selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20"; +} + +function getSelectedUnreadDotClass(selected: boolean): string { + return selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400"; +} + +export function InboxIssueMetaLeading({ + issue, + selected, + isLive, +}: { + issue: Issue; + selected: boolean; + isLive: boolean; +}) { + return ( + <> + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {isLive && ( + + + {!selected ? ( + + ) : null} + + + + Live + + + )} + + ); +} + +export function FailedRunInboxRow({ run, issueById, agentName: linkedAgentName, @@ -110,6 +174,7 @@ function FailedRunInboxRow({ onMarkRead, onArchive, archiveDisabled, + selected = false, className, }: { run: HeartbeatRun; @@ -123,6 +188,7 @@ function FailedRunInboxRow({ onMarkRead?: () => void; onArchive?: () => void; archiveDisabled?: boolean; + selected?: boolean; className?: string; }) { const issueId = readIssueIdFromRun(run); @@ -143,11 +209,15 @@ function FailedRunInboxRow({ @@ -168,7 +238,10 @@ function FailedRunInboxRow({ ) : null} {!showUnreadSlot &&