Merge public-gh/master into pap-979-runtime-workspaces

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-30 08:35:30 -05:00
commit 4d61dbfd34
46 changed files with 3635 additions and 297 deletions

View file

@ -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

292
cli/README.md Normal file
View file

@ -0,0 +1,292 @@
<p align="center">
<img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/header.png" alt="Paperclip — runs your business" width="720" />
</p>
<p align="center">
<a href="#quickstart"><strong>Quickstart</strong></a> &middot;
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> &middot;
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> &middot;
<a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a>
</p>
<p align="center">
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/badge/discord-join%20chat-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<br/>
<div align="center">
<video src="https://github.com/user-attachments/assets/773bdfb2-6d1e-4e30-8c5f-3487d5b70c8f" width="600" controls></video>
</div>
<br/>
## 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. |
<br/>
> **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.
<br/>
<div align="center">
<table>
<tr>
<td align="center"><strong>Works<br/>with</strong></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/openclaw.svg" width="32" alt="OpenClaw" /><br/><sub>OpenClaw</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/claude.svg" width="32" alt="Claude" /><br/><sub>Claude Code</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/codex.svg" width="32" alt="Codex" /><br/><sub>Codex</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/cursor.svg" width="32" alt="Cursor" /><br/><sub>Cursor</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/bash.svg" width="32" alt="Bash" /><br/><sub>Bash</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/http.svg" width="32" alt="HTTP" /><br/><sub>HTTP</sub></td>
</tr>
</table>
<em>If it can receive a heartbeat, it's hired.</em>
</div>
<br/>
## 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**
<br/>
## Features
<table>
<tr>
<td align="center" width="33%">
<h3>🔌 Bring Your Own Agent</h3>
Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired.
</td>
<td align="center" width="33%">
<h3>🎯 Goal Alignment</h3>
Every task traces back to the company mission. Agents know <em>what</em> to do and <em>why</em>.
</td>
<td align="center" width="33%">
<h3>💓 Heartbeats</h3>
Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart.
</td>
</tr>
<tr>
<td align="center">
<h3>💰 Cost Control</h3>
Monthly budgets per agent. When they hit the limit, they stop. No runaway costs.
</td>
<td align="center">
<h3>🏢 Multi-Company</h3>
One deployment, many companies. Complete data isolation. One control plane for your portfolio.
</td>
<td align="center">
<h3>🎫 Ticket System</h3>
Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log.
</td>
</tr>
<tr>
<td align="center">
<h3>🛡️ Governance</h3>
You're the board. Approve hires, override strategy, pause or terminate any agent — at any time.
</td>
<td align="center">
<h3>📊 Org Chart</h3>
Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description.
</td>
<td align="center">
<h3>📱 Mobile Ready</h3>
Monitor and manage your autonomous businesses from anywhere.
</td>
</tr>
</table>
<br/>
## 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. |
<br/>
## 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. |
<br/>
## 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. |
<br/>
## 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+
<br/>
## 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.
<br/>
## 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.
<br/>
## 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
<br/>
## 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.
<br/>
## 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
<br/>
## License
MIT &copy; 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)
<br/>
---
<p align="center">
<img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/footer.jpg" alt="" width="720" />
</p>
<p align="center">
<sub>Open source under MIT. Built for people who want to run companies, not babysit agents.</sub>
</p>

View file

@ -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);
});
});

View file

@ -244,11 +244,12 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
),
);
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<void> {
}
}
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."));

View file

@ -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:

View file

@ -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

View file

@ -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",

View file

@ -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];

View file

@ -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,

View file

@ -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<typeof createAgentKeySchema>;
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<typeof agentMineInboxQuerySchema>;
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(),

View file

@ -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,

View file

@ -66,6 +66,7 @@ export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
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(),
});

View file

@ -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 <noreply@paperclip.ing>\"";
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<string, CachedCommit>;
queryKey: string;
searchField: CliOptions["searchField"];
stats: Record<string, CachedCommitStats>;
updatedAt: string | null;
version: number;
windows: Record<string, WindowCacheEntry>;
}
interface SearchResponse {
incomplete_results: boolean;
items: SearchCommitItem[];
total_count: number;
}
interface SearchWindowResult {
shas: Set<string>;
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 <date> ISO date/time lower bound (default: ${DEFAULT_SEARCH_START})
--end <date> ISO date/time upper bound (default: now)
--query <search> Commit search string (default: ${DEFAULT_QUERY})
--search-field <field> author-date | committer-date (default: ${DEFAULT_SEARCH_FIELD})
--include-private Include repos visible to the current token
--exclude-owner <owner> Exclude repositories owned by this GitHub owner/org (repeatable)
--cache-file <path> Cache path (default: ${DEFAULT_CACHE_FILE})
--skip-stats Skip additions/deletions enrichment
--stats-fetch-limit <n> Max uncached commit stats to fetch this run (default: ${DEFAULT_STATS_FETCH_LIMIT})
--stats-concurrency <n> Parallel commit stat requests (default: ${DEFAULT_STATS_CONCURRENCY})
--output <path> Write the full filtered result set to a file
--export-format <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<string> {
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<CacheFile> {
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<void> {
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<SearchWindowResult> {
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<string>();
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<SearchResponse> {
const searchQuery = buildSearchQuery(options, start, end);
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
q: searchQuery,
});
return client.getJson<SearchResponse>(`/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<string>) {
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<string, ContributorRecord>();
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<number> {
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<CommitStats> {
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<string>();
const contributors = new Map<string, ContributorRecord>();
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<void> {
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<T>(pathname: string): Promise<T> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});

View file

@ -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",
},
]);
});
});

View file

@ -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 = {

View file

@ -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<string, unknown>) => ({
...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",
}),
}),
);
});
});

View file

@ -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());

View file

@ -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);

View file

@ -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<string, unknown>).issueId === "string"
? ((activeRun.contextSnapshot as Record<string, unknown>).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<string> {
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<string, unknown>).issueId === "string"
? ((activeRun.contextSnapshot as Record<string, unknown>).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;

View file

@ -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

View file

@ -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` |

View file

@ -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 |

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#18181b" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Paperclip" />

View file

@ -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<IssueLabel>(`/labels/${id}`),
get: (id: string) => api.get<Issue>(`/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<string, unknown>) =>
api.post<Issue>(`/companies/${companyId}/issues`, data),
update: (id: string, data: Record<string, unknown>) => api.patch<Issue>(`/issues/${id}`, data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<IssueUpdateResponse>(`/issues/${id}`, data),
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
checkout: (id: string, agentId: string) =>
api.post<Issue>(`/issues/${id}/checkout`, {

View file

@ -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<void>;
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<string, Agent>;
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 (
<div
key={comment.id}
id={`comment-${comment.id}`}
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${
isQueued
? "border-amber-300/70 bg-amber-50/70 dark:border-amber-500/40 dark:bg-amber-500/10"
: isHighlighted
? "border-primary/50 bg-primary/5"
: "border-border"
} ${isPending ? "opacity-80" : ""}`}
>
<div className="flex items-center justify-between mb-1">
{comment.authorAgentId ? (
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
<Identity
name={agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8)}
size="sm"
/>
</Link>
) : (
<Identity name="You" size="sm" />
)}
<span className="flex items-center gap-1.5">
{isQueued ? (
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
Queued
</span>
) : null}
{companyId && !isPending ? (
<PluginSlotOutlet
slotTypes={["commentContextMenuItem"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="flex flex-wrap items-center gap-1.5"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
) : null}
{isPending ? (
<span className="text-xs text-muted-foreground">{isQueued ? "Queueing..." : "Sending..."}</span>
) : (
<a
href={`#comment-${comment.id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
{formatDateTime(comment.createdAt)}
</a>
)}
<CopyMarkdownButton text={comment.body} />
</span>
</div>
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
{companyId && !isPending ? (
<div className="mt-2 space-y-2">
<PluginSlotOutlet
slotTypes={["commentAnnotation"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="space-y-2"
itemClassName="rounded-md"
missingBehavior="placeholder"
/>
</div>
) : null}
{comment.runId && !isPending ? (
<div className="mt-2 pt-2 border-t border-border/60">
{comment.runAgentId ? (
<Link
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
run {comment.runId.slice(0, 8)}
</Link>
) : (
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
run {comment.runId.slice(0, 8)}
</span>
)}
</div>
) : null}
</div>
);
}
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 (
<div
<CommentCard
key={comment.id}
id={`comment-${comment.id}`}
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${isHighlighted ? "border-primary/50 bg-primary/5" : "border-border"}`}
>
<div className="flex items-center justify-between mb-1">
{comment.authorAgentId ? (
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
<Identity
name={agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8)}
size="sm"
/>
</Link>
) : (
<Identity name="You" size="sm" />
)}
<span className="flex items-center gap-1.5">
{companyId ? (
<PluginSlotOutlet
slotTypes={["commentContextMenuItem"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="flex flex-wrap items-center gap-1.5"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
) : null}
<a
href={`#comment-${comment.id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
{formatDateTime(comment.createdAt)}
</a>
<CopyMarkdownButton text={comment.body} />
</span>
</div>
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
{companyId ? (
<div className="mt-2 space-y-2">
<PluginSlotOutlet
slotTypes={["commentAnnotation"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="space-y-2"
itemClassName="rounded-md"
missingBehavior="placeholder"
/>
</div>
) : null}
{comment.runId && (
<div className="mt-2 pt-2 border-t border-border/60">
{comment.runAgentId ? (
<Link
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
run {comment.runId.slice(0, 8)}
</Link>
) : (
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
run {comment.runId.slice(0, 8)}
</span>
)}
</div>
)}
</div>
comment={comment}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
/>
);
})}
</div>
@ -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 (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length})</h3>
<h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length + queuedComments.length})</h3>
<TimelineList
timeline={timeline}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
/>
{timeline.length > 0 ? (
<TimelineList
timeline={timeline}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
/>
) : null}
{liveRunSlot}
{queuedComments.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.14em] text-amber-700 dark:text-amber-300">
Queued Comments ({queuedComments.length})
</h4>
{onInterruptQueued && queuedComments[0]?.queueTargetRunId ? (
<Button
size="sm"
variant="outline"
className="border-red-300 text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
disabled={interruptingQueuedRunId === queuedComments[0].queueTargetRunId}
onClick={() => void onInterruptQueued(queuedComments[0]!.queueTargetRunId!)}
>
{interruptingQueuedRunId === queuedComments[0].queueTargetRunId ? "Interrupting..." : "Interrupt"}
</Button>
) : null}
</div>
<div className="space-y-3">
{queuedComments.map((comment) => (
<CommentCard
key={comment.id}
comment={comment}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
queued
/>
))}
</div>
</div>
)}
<div className="space-y-2">
<MarkdownEditor
ref={editorRef}

View file

@ -4,7 +4,7 @@ import { useQueries } from "@tanstack/react-query";
import {
DndContext,
closestCenter,
PointerSensor,
MouseSensor,
useSensor,
useSensors,
type DragEndEvent,
@ -244,7 +244,8 @@ export function CompanyRail() {
// Require 8px of movement before starting a drag to avoid interfering with clicks
const sensors = useSensors(
useSensor(PointerSensor, {
// Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
})
);

View file

@ -0,0 +1,116 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueRow } from "./IssueRow";
vi.mock("@/lib/router", () => ({
Link: ({ children, className, ...props }: React.ComponentProps<"a">) => (
<a className={className} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createIssue(overrides: Partial<Issue> = {}): 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(<IssueRow issue={issue} selected />);
});
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(<IssueRow issue={createIssue()} selected unreadState="visible" />);
});
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();
});
});
});

View file

@ -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 (
<Link
to={`/issues/${issuePathId}`}
to={createIssueDetailPath(issuePathId, issueLinkState)}
state={issueLinkState}
data-inbox-issue-link
className={cn(
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors hover:bg-accent/50 last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
className,
)}
>
<span className="shrink-0 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} />}
{mobileLeading ?? <StatusIcon status={issue.status} className={selectedStatusClass} />}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
@ -66,7 +72,7 @@ export function IssueRow({
{desktopMetaLeading ?? (
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
<StatusIcon status={issue.status} className={selectedStatusClass} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{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"
>
<span
className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
"block h-2 w-2 rounded-full transition-opacity duration-300",
selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)}
/>

View file

@ -564,8 +564,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
{mentionActive && filteredMentions.length > 0 &&
createPortal(
<div
className="fixed z-[9999] min-w-[180px] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
style={{ top: mentionState.viewportTop + 4, left: mentionState.viewportLeft }}
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
style={{
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
}}
>
{filteredMentions.map((option, i) => (
<button

View file

@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Plus } from "lucide-react";
import {
DndContext,
PointerSensor,
MouseSensor,
closestCenter,
type DragEndEvent,
useSensor,
@ -153,7 +153,8 @@ export function SidebarProjects() {
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
const activeProjectRef = projectMatch?.[1] ?? null;
const sensors = useSensors(
useSensor(PointerSensor, {
// Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior.
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
}),
);

View file

@ -0,0 +1,146 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SwipeToArchive } from "./SwipeToArchive";
// Tell React this environment uses act() for event flushing.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function dispatchTouchEvent(
node: Element,
type: "touchstart" | "touchmove" | "touchend",
coords: { x: number; y: number },
) {
const event = new Event(type, { bubbles: true, cancelable: true });
const touchPoint = { clientX: coords.x, clientY: coords.y };
Object.defineProperty(event, "touches", {
configurable: true,
value: type === "touchend" ? [] : [touchPoint],
});
Object.defineProperty(event, "changedTouches", {
configurable: true,
value: [touchPoint],
});
node.dispatchEvent(event);
}
describe("SwipeToArchive", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
container.remove();
});
it("suppresses descendant clicks after a horizontal swipe and archives the row", () => {
const onArchive = vi.fn();
const onClick = vi.fn();
const root = createRoot(container);
act(() => {
root.render(
<SwipeToArchive onArchive={onArchive}>
<button type="button" onClick={onClick}>
Open issue
</button>
</SwipeToArchive>,
);
});
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(
<SwipeToArchive onArchive={onArchive}>
<button type="button" onClick={onClick}>
Open issue
</button>
</SwipeToArchive>,
);
});
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(
<SwipeToArchive onArchive={() => {}} selected>
<button type="button">Open issue</button>
</SwipeToArchive>,
);
});
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();
});
});
});

View file

@ -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<HTMLDivElement | null>(null);
const startPointRef = useRef<{ x: number; y: number } | null>(null);
const widthRef = useRef(0);
const timeoutRef = useRef<number | null>(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;
}}
>
<div
aria-hidden="true"
@ -139,10 +151,12 @@ export function SwipeToArchive({
</span>
</div>
<div
data-inbox-row-surface
className="relative bg-card will-change-transform"
style={{
transform: `translate3d(${offsetX}px, 0, 0)`,
transition: isDragging ? "none" : "transform 180ms ease-out",
backgroundColor: selected ? SELECTED_ROW_BACKGROUND : undefined,
}}
>
{children}

View file

@ -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();
});
});

View file

@ -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<ToastGate>({ 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}</>;
}

View file

@ -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) {

View file

@ -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 {

View file

@ -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);
});
});

View file

@ -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(),

View file

@ -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");
});
});

View file

@ -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<IssueDetailBreadcrumb>;
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;
}

View file

@ -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);
});
});

View file

@ -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<T extends { createdAt: Date | string; id: string }>(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<IssueTimelineComment, "createdAt"> & Partial<Pick<OptimisticIssueComment, "clientStatus">>;
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;
}

181
ui/src/pages/Inbox.test.tsx Normal file
View file

@ -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">) => (
<a className={className} {...props}>{children}</a>
),
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> = {}): 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(
<FailedRunInboxRow
run={run}
issueById={new Map()}
agentName="Agent"
issueLinkState={null}
onDismiss={() => {}}
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(<InboxIssueMetaLeading issue={createIssue()} selected isLive />);
});
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();
});
});
});

View file

@ -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 (
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon
status={issue.status}
className={selected ? selectedInboxAccentClass : undefined}
/>
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{isLive && (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
selected ? "bg-muted" : "bg-blue-500/10",
)}
>
<span className="relative flex h-2 w-2">
{!selected ? (
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
) : null}
<span
className={cn(
"relative inline-flex h-2 w-2 rounded-full",
selected ? "bg-muted-foreground/70" : "bg-blue-500",
)}
/>
</span>
<span
className={cn(
"hidden text-[11px] font-medium sm:inline",
selected ? "text-muted-foreground" : "text-blue-600 dark:text-blue-400",
)}
>
Live
</span>
</span>
)}
</>
);
}
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({
<button
type="button"
onClick={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",
getSelectedUnreadButtonClass(selected),
)}
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
"block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
@ -168,7 +238,10 @@ function FailedRunInboxRow({
) : null}
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
className={cn(
"flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
)}
>
{!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />}
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
@ -257,6 +330,7 @@ function ApprovalInboxRow({
onMarkRead,
onArchive,
archiveDisabled,
selected = false,
className,
}: {
approval: Approval;
@ -268,6 +342,7 @@ function ApprovalInboxRow({
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
selected?: boolean;
className?: string;
}) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
@ -290,11 +365,15 @@ function ApprovalInboxRow({
<button
type="button"
onClick={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",
getSelectedUnreadButtonClass(selected),
)}
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
"block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
@ -315,7 +394,10 @@ function ApprovalInboxRow({
) : null}
<Link
to={`/approvals/${approval.id}`}
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
className={cn(
"flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
)}
>
{!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />}
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
@ -389,6 +471,7 @@ function JoinRequestInboxRow({
onMarkRead,
onArchive,
archiveDisabled,
selected = false,
className,
}: {
joinRequest: JoinRequest;
@ -399,6 +482,7 @@ function JoinRequestInboxRow({
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
selected?: boolean;
className?: string;
}) {
const label =
@ -420,11 +504,15 @@ function JoinRequestInboxRow({
<button
type="button"
onClick={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",
getSelectedUnreadButtonClass(selected),
)}
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
"block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
@ -512,18 +600,20 @@ export function Inbox() {
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const { dismissed, dismiss } = useDismissedInboxItems();
const { readItems, markRead: markItemRead } = useReadInboxItems();
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
const pathSegment = location.pathname.split("/").pop() ?? "mine";
const tab: InboxTab =
pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread"
? pathSegment
: "mine";
const canArchiveFromTab = isMineInboxTab(tab);
const issueLinkState = useMemo(
() =>
createIssueDetailLocationState(
"Inbox",
`${location.pathname}${location.search}${location.hash}`,
"inbox",
),
[location.pathname, location.search, location.hash],
);
@ -540,6 +630,7 @@ export function Inbox() {
useEffect(() => {
saveLastInboxTab(tab);
setSelectedIndex(-1);
}, [tab]);
const {
@ -591,7 +682,7 @@ export function Inbox() {
issuesApi.list(selectedCompanyId!, {
touchedByUserId: "me",
inboxArchivedByUserId: "me",
status: INBOX_ISSUE_STATUSES,
status: INBOX_MINE_ISSUE_STATUS_FILTER,
}),
enabled: !!selectedCompanyId,
});
@ -603,7 +694,7 @@ export function Inbox() {
queryFn: () =>
issuesApi.list(selectedCompanyId!, {
touchedByUserId: "me",
status: INBOX_ISSUE_STATUSES,
status: INBOX_MINE_ISSUE_STATUS_FILTER,
}),
enabled: !!selectedCompanyId,
});
@ -793,6 +884,8 @@ export function Inbox() {
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const listRef = useRef<HTMLDivElement>(null);
const invalidateInboxIssueQueries = () => {
if (!selectedCompanyId) return;
@ -875,7 +968,14 @@ export function Inbox() {
},
});
const handleMarkNonIssueRead = (key: string) => {
const markUnreadMutation = useMutation({
mutationFn: (id: string) => issuesApi.markUnread(id),
onSuccess: () => {
invalidateInboxIssueQueries();
},
});
const handleMarkNonIssueRead = useCallback((key: string) => {
setFadingNonIssueItems((prev) => new Set(prev).add(key));
markItemRead(key);
setTimeout(() => {
@ -885,9 +985,9 @@ export function Inbox() {
return next;
});
}, 300);
};
}, [markItemRead]);
const handleArchiveNonIssue = (key: string) => {
const handleArchiveNonIssue = useCallback((key: string) => {
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
setTimeout(() => {
dismiss(key);
@ -897,10 +997,10 @@ export function Inbox() {
return next;
});
}, 200);
};
}, [dismiss]);
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
if (tab !== "mine") return null;
if (!canArchiveFromTab) return null;
const isRead = readItems.has(key);
const isFading = fadingNonIssueItems.has(key);
if (isFading) return "fading";
@ -908,6 +1008,170 @@ export function Inbox() {
return "hidden";
};
const getWorkItemKey = useCallback((item: InboxWorkItem): string => {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
if (item.kind === "failed_run") return `run:${item.run.id}`;
return `join:${item.joinRequest.id}`;
}, []);
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
useEffect(() => {
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length));
}, [workItemsToRender.length]);
// Use refs for keyboard handler to avoid stale closures
const kbStateRef = useRef({
workItems: workItemsToRender,
selectedIndex,
canArchive: canArchiveFromTab,
archivingIssueIds,
archivingNonIssueIds,
fadingOutIssues,
readItems,
});
kbStateRef.current = {
workItems: workItemsToRender,
selectedIndex,
canArchive: canArchiveFromTab,
archivingIssueIds,
archivingNonIssueIds,
fadingOutIssues,
readItems,
};
const kbActionsRef = useRef({
archiveIssue: (id: string) => archiveIssueMutation.mutate(id),
archiveNonIssue: handleArchiveNonIssue,
markRead: (id: string) => markReadMutation.mutate(id),
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
markNonIssueRead: handleMarkNonIssueRead,
markNonIssueUnread: markItemUnread,
navigate,
});
kbActionsRef.current = {
archiveIssue: (id: string) => archiveIssueMutation.mutate(id),
archiveNonIssue: handleArchiveNonIssue,
markRead: (id: string) => markReadMutation.mutate(id),
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
markNonIssueRead: handleMarkNonIssueRead,
markNonIssueUnread: markItemUnread,
navigate,
};
// Keyboard shortcuts (mail-client style) — single stable listener using refs
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
// Don't capture when typing in inputs/textareas or with modifier keys
const target = e.target;
if (
!(target instanceof HTMLElement) ||
target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], [role='combobox']") ||
target.isContentEditable ||
document.querySelector("[role='dialog'], [aria-modal='true']") ||
e.metaKey ||
e.ctrlKey ||
e.altKey
) {
return;
}
const st = kbStateRef.current;
const act = kbActionsRef.current;
// Keyboard shortcuts are only active on the "mine" tab
if (!st.canArchive) return;
const itemCount = st.workItems.length;
if (itemCount === 0) return;
switch (e.key) {
case "j": {
e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
break;
}
case "k": {
e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous"));
break;
}
case "a":
case "y": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
if (!st.archivingIssueIds.has(item.issue.id)) {
act.archiveIssue(item.issue.id);
}
} else {
const key = getWorkItemKey(item);
if (!st.archivingNonIssueIds.has(key)) {
act.archiveNonIssue(key);
}
}
break;
}
case "U": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
act.markUnreadIssue(item.issue.id);
} else {
act.markNonIssueUnread(getWorkItemKey(item));
}
break;
}
case "r": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) {
act.markRead(item.issue.id);
}
} else {
const key = getWorkItemKey(item);
if (!st.readItems.has(key)) {
act.markNonIssueRead(key);
}
}
break;
}
case "Enter": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
const pathId = item.issue.identifier ?? item.issue.id;
act.navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState });
} else if (item.kind === "approval") {
act.navigate(`/approvals/${item.approval.id}`);
} else if (item.kind === "failed_run") {
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
}
break;
}
default:
return;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [getWorkItemKey, issueLinkState]);
// Scroll selected item into view
useEffect(() => {
if (selectedIndex < 0 || !listRef.current) return;
const rows = listRef.current.querySelectorAll("[data-inbox-item]");
const row = rows[selectedIndex];
if (row) row.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
if (!selectedCompanyId) {
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
}
@ -953,25 +1217,25 @@ export function Inbox() {
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
{
value: "mine",
label: "Mine",
},
{
value: "recent",
label: "Recent",
},
{ value: "unread", label: "Unread" },
{ value: "all", label: "All" },
]}
/>
</Tabs>
<div className="flex items-center justify-between gap-2">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
{
value: "mine",
label: "Mine",
},
{
value: "recent",
label: "Recent",
},
{ value: "unread", label: "Unread" },
{ value: "all", label: "All" },
]}
/>
</Tabs>
<div className="flex items-center gap-2">
{canMarkAllRead && (
<Button
type="button"
@ -985,44 +1249,44 @@ export function Inbox() {
</Button>
)}
</div>
</div>
{tab === "all" && (
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
{tab === "all" && (
<div className="flex flex-wrap items-center gap-2">
<Select
value={allCategoryFilter}
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
>
<SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="everything">All categories</SelectItem>
<SelectItem value="issues_i_touched">My recent issues</SelectItem>
<SelectItem value="join_requests">Join requests</SelectItem>
<SelectItem value="approvals">Approvals</SelectItem>
<SelectItem value="failed_runs">Failed runs</SelectItem>
<SelectItem value="alerts">Alerts</SelectItem>
</SelectContent>
</Select>
{showApprovalsCategory && (
<Select
value={allCategoryFilter}
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
value={allApprovalFilter}
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
>
<SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Category" />
<SelectValue placeholder="Approval status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="everything">All categories</SelectItem>
<SelectItem value="issues_i_touched">My recent issues</SelectItem>
<SelectItem value="join_requests">Join requests</SelectItem>
<SelectItem value="approvals">Approvals</SelectItem>
<SelectItem value="failed_runs">Failed runs</SelectItem>
<SelectItem value="alerts">Alerts</SelectItem>
<SelectItem value="all">All approval statuses</SelectItem>
<SelectItem value="actionable">Needs action</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
</SelectContent>
</Select>
{showApprovalsCategory && (
<Select
value={allApprovalFilter}
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
>
<SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Approval status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All approval statuses</SelectItem>
<SelectItem value="actionable">Needs action</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
</SelectContent>
</Select>
)}
</div>
)}
</div>
)}
</div>
)}
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
@ -1050,9 +1314,36 @@ export function Inbox() {
<>
{showSeparatorBefore("work_items") && <Separator />}
<div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
{workItemsToRender.map((item) => {
const isMineTab = tab === "mine";
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
{workItemsToRender.flatMap((item, index) => {
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
<div
key={`sel-${key}`}
data-inbox-item
className="relative"
onClick={() => setSelectedIndex(index)}
>
{child}
</div>
);
const todayCutoff = Date.now() - 24 * 60 * 60 * 1000;
const showTodayDivider =
index > 0 &&
item.timestamp > 0 &&
item.timestamp < todayCutoff &&
workItemsToRender[index - 1].timestamp >= todayCutoff;
const elements: ReactNode[] = [];
if (showTodayDivider) {
elements.push(
<div key="today-divider" className="flex items-center gap-3 px-4 my-2">
<div className="flex-1 border-t border-border" />
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Earlier
</span>
</div>,
);
}
const isSelected = selectedIndex === index;
if (item.kind === "approval") {
const approvalKey = `approval:${item.approval.id}`;
@ -1061,13 +1352,14 @@ export function Inbox() {
<ApprovalInboxRow
key={approvalKey}
approval={item.approval}
selected={isSelected}
requesterName={agentName(item.approval.requestedByAgentId)}
onApprove={() => approveMutation.mutate(item.approval.id)}
onReject={() => rejectMutation.mutate(item.approval.id)}
isPending={approveMutation.isPending || rejectMutation.isPending}
unreadState={nonIssueUnreadState(approvalKey)}
onMarkRead={() => handleMarkNonIssueRead(approvalKey)}
onArchive={isMineTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
archiveDisabled={isArchiving}
className={
isArchiving
@ -1076,15 +1368,17 @@ export function Inbox() {
}
/>
);
return isMineTab ? (
elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
<SwipeToArchive
key={approvalKey}
selected={isSelected}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(approvalKey)}
>
{row}
</SwipeToArchive>
) : row;
) : row));
return elements;
}
if (item.kind === "failed_run") {
@ -1094,6 +1388,7 @@ export function Inbox() {
<FailedRunInboxRow
key={runKey}
run={item.run}
selected={isSelected}
issueById={issueById}
agentName={agentName(item.run.agentId)}
issueLinkState={issueLinkState}
@ -1102,7 +1397,7 @@ export function Inbox() {
isRetrying={retryingRunIds.has(item.run.id)}
unreadState={nonIssueUnreadState(runKey)}
onMarkRead={() => handleMarkNonIssueRead(runKey)}
onArchive={isMineTab ? () => handleArchiveNonIssue(runKey) : undefined}
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined}
archiveDisabled={isArchiving}
className={
isArchiving
@ -1111,15 +1406,17 @@ export function Inbox() {
}
/>
);
return isMineTab ? (
elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
<SwipeToArchive
key={runKey}
selected={isSelected}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(runKey)}
>
{row}
</SwipeToArchive>
) : row;
) : row));
return elements;
}
if (item.kind === "join_request") {
@ -1129,12 +1426,13 @@ export function Inbox() {
<JoinRequestInboxRow
key={joinKey}
joinRequest={item.joinRequest}
selected={isSelected}
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
unreadState={nonIssueUnreadState(joinKey)}
onMarkRead={() => handleMarkNonIssueRead(joinKey)}
onArchive={isMineTab ? () => handleArchiveNonIssue(joinKey) : undefined}
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined}
archiveDisabled={isArchiving}
className={
isArchiving
@ -1143,15 +1441,17 @@ export function Inbox() {
}
/>
);
return isMineTab ? (
elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
<SwipeToArchive
key={joinKey}
selected={isSelected}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(joinKey)}
>
{row}
</SwipeToArchive>
) : row;
) : row));
return elements;
}
const issue = item.issue;
@ -1163,32 +1463,19 @@ export function Inbox() {
key={`issue:${issue.id}`}
issue={issue}
issueLinkState={issueLinkState}
selected={isSelected}
className={
isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out"
}
desktopMetaLeading={(
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
desktopMetaLeading={
<InboxIssueMetaLeading
issue={issue}
selected={isSelected}
isLive={liveIssueIds.has(issue.id)}
/>
}
mobileMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
@ -1199,7 +1486,7 @@ export function Inbox() {
}
onMarkRead={() => markReadMutation.mutate(issue.id)}
onArchive={
isMineTab
canArchiveFromTab
? () => archiveIssueMutation.mutate(issue.id)
: undefined
}
@ -1212,15 +1499,17 @@ export function Inbox() {
/>
);
return isMineTab ? (
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
<SwipeToArchive
key={`issue:${issue.id}`}
selected={isSelected}
disabled={isArchiving || archiveIssueMutation.isPending}
onArchive={() => archiveIssueMutation.mutate(issue.id)}
>
{row}
</SwipeToArchive>
) : row;
) : row));
return elements;
})}
</div>
</div>

View file

@ -14,7 +14,16 @@ import { useToast } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
import { queryKeys } from "../lib/queryKeys";
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
import {
applyOptimisticIssueCommentUpdate,
createOptimisticIssueComment,
isQueuedIssueComment,
mergeIssueComments,
upsertIssueComment,
type IssueCommentReassignment,
type OptimisticIssueComment,
} from "../lib/optimistic-issue-comments";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { InlineEditor } from "../components/InlineEditor";
@ -55,11 +64,15 @@ import {
Trash2,
} from "lucide-react";
import type { ActivityEvent } from "@paperclipai/shared";
import type { Agent, IssueAttachment } from "@paperclipai/shared";
import type { Agent, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
type CommentReassignment = {
assigneeAgentId: string | null;
assigneeUserId: string | null;
type CommentReassignment = IssueCommentReassignment;
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
runId?: string | null;
runAgentId?: string | null;
interruptedRunId?: string | null;
queueState?: "queued";
queueTargetRunId?: string | null;
};
const ACTION_LABELS: Record<string, string> = {
@ -213,6 +226,7 @@ export function IssueDetail() {
});
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
@ -269,9 +283,17 @@ export function IssueDetail() {
});
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
const runningIssueRun = useMemo(
() => (
activeRun?.status === "running"
? activeRun
: (liveRuns ?? []).find((run) => run.status === "running") ?? null
),
[activeRun, liveRuns],
);
const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" },
[location.state],
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
[location.state, location.search],
);
// Filter out runs already shown by the live widget to avoid duplication
@ -386,12 +408,23 @@ export function IssueDetail() {
);
const suggestedAssigneeValue = useMemo(
() => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId),
[issue, comments, currentUserId],
() =>
suggestedCommentAssigneeValue(
issue ?? {},
mergeIssueComments(comments ?? [], optimisticComments),
currentUserId,
),
[issue, comments, optimisticComments, currentUserId],
);
const commentsWithRunMeta = useMemo(() => {
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
const threadComments = useMemo(
() => mergeIssueComments(comments ?? [], optimisticComments),
[comments, optimisticComments],
);
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
const agentIdByRunId = new Map<string, string>();
for (const run of linkedRuns ?? []) {
agentIdByRunId.set(run.runId, run.agentId);
@ -401,16 +434,44 @@ export function IssueDetail() {
const details = evt.details ?? {};
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
if (!commentId || runMetaByCommentId.has(commentId)) continue;
const interruptedRunId =
typeof details["interruptedRunId"] === "string" ? details["interruptedRunId"] : null;
runMetaByCommentId.set(commentId, {
runId: evt.runId,
runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null,
interruptedRunId,
});
}
return (comments ?? []).map((comment) => {
return threadComments.map((comment) => {
const meta = runMetaByCommentId.get(comment.id);
return meta ? { ...comment, ...meta } : comment;
const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment };
if (
isQueuedIssueComment({
comment: nextComment,
activeRunStartedAt,
runId: meta?.runId ?? nextComment.runId ?? null,
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
})
) {
return {
...nextComment,
queueState: "queued" as const,
queueTargetRunId: runningIssueRun?.id ?? nextComment.queueTargetRunId ?? null,
};
}
return nextComment;
});
}, [activity, comments, linkedRuns]);
}, [activity, threadComments, linkedRuns, runningIssueRun]);
const queuedComments = useMemo(
() => commentsWithRunMeta.filter((comment) => comment.queueState === "queued"),
[commentsWithRunMeta],
);
const timelineComments = useMemo(
() => commentsWithRunMeta.filter((comment) => comment.queueState !== "queued"),
[commentsWithRunMeta],
);
const issueCostSummary = useMemo(() => {
let input = 0;
@ -489,9 +550,67 @@ export function IssueDetail() {
});
const addComment = useMutation({
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
issuesApi.addComment(issueId!, body, reopen),
onSuccess: () => {
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
issuesApi.addComment(issueId!, body, reopen, interrupt),
onMutate: async ({ body, reopen, interrupt }) => {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
const queuedComment = !interrupt && runningIssueRun;
const optimisticComment = issue
? createOptimisticIssueComment({
companyId: issue.companyId,
issueId: issue.id,
body,
authorUserId: currentUserId,
clientStatus: queuedComment ? "queued" : "pending",
queueTargetRunId: queuedComment ? runningIssueRun.id : null,
})
: null;
if (optimisticComment) {
setOptimisticComments((current) => [...current, optimisticComment]);
}
if (previousIssue) {
queryClient.setQueryData(
queryKeys.issues.detail(issueId!),
applyOptimisticIssueCommentUpdate(previousIssue, { reopen }),
);
}
return {
optimisticCommentId: optimisticComment?.clientId ?? null,
previousIssue,
};
},
onSuccess: (comment, _variables, context) => {
if (context?.optimisticCommentId) {
setOptimisticComments((current) =>
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
);
}
queryClient.setQueryData<IssueComment[]>(
queryKeys.issues.comments(issueId!),
(current) => upsertIssueComment(current, comment),
);
},
onError: (err, _variables, context) => {
if (context?.optimisticCommentId) {
setOptimisticComments((current) =>
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
);
}
if (context?.previousIssue) {
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
}
pushToast({
title: "Comment failed",
body: err instanceof Error ? err.message : "Unable to post comment",
tone: "error",
});
},
onSettled: () => {
invalidateIssue();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
},
@ -501,10 +620,12 @@ export function IssueDetail() {
mutationFn: ({
body,
reopen,
interrupt,
reassignment,
}: {
body: string;
reopen?: boolean;
interrupt?: boolean;
reassignment: CommentReassignment;
}) =>
issuesApi.update(issueId!, {
@ -512,13 +633,96 @@ export function IssueDetail() {
assigneeAgentId: reassignment.assigneeAgentId,
assigneeUserId: reassignment.assigneeUserId,
...(reopen ? { status: "todo" } : {}),
...(interrupt ? { interrupt } : {}),
}),
onSuccess: () => {
onMutate: async ({ body, reopen, reassignment, interrupt }) => {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
const queuedComment = !interrupt && runningIssueRun;
const optimisticComment = issue
? createOptimisticIssueComment({
companyId: issue.companyId,
issueId: issue.id,
body,
authorUserId: currentUserId,
clientStatus: queuedComment ? "queued" : "pending",
queueTargetRunId: queuedComment ? runningIssueRun.id : null,
})
: null;
if (optimisticComment) {
setOptimisticComments((current) => [...current, optimisticComment]);
}
if (previousIssue) {
queryClient.setQueryData(
queryKeys.issues.detail(issueId!),
applyOptimisticIssueCommentUpdate(previousIssue, { reopen, reassignment }),
);
}
return {
optimisticCommentId: optimisticComment?.clientId ?? null,
previousIssue,
};
},
onSuccess: (result, _variables, context) => {
if (context?.optimisticCommentId) {
setOptimisticComments((current) =>
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
);
}
const { comment, ...nextIssue } = result;
queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue);
if (comment) {
queryClient.setQueryData<IssueComment[]>(
queryKeys.issues.comments(issueId!),
(current) => upsertIssueComment(current, comment),
);
}
},
onError: (err, _variables, context) => {
if (context?.optimisticCommentId) {
setOptimisticComments((current) =>
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
);
}
if (context?.previousIssue) {
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
}
pushToast({
title: "Comment failed",
body: err instanceof Error ? err.message : "Unable to post comment",
tone: "error",
});
},
onSettled: () => {
invalidateIssue();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
},
});
const interruptQueuedComment = useMutation({
mutationFn: (runId: string) => heartbeatsApi.cancel(runId),
onSuccess: () => {
invalidateIssue();
pushToast({
title: "Interrupt requested",
body: "The active run is stopping so queued comments can continue next.",
tone: "success",
});
},
onError: (err) => {
pushToast({
title: "Interrupt failed",
body: err instanceof Error ? err.message : "Unable to interrupt the active run",
tone: "error",
});
},
});
const uploadAttachment = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected");
@ -581,9 +785,12 @@ export function IssueDetail() {
// Redirect to identifier-based URL if navigated via UUID
useEffect(() => {
if (issue?.identifier && issueId !== issue.identifier) {
navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state });
navigate(createIssueDetailPath(issue.identifier, location.state, location.search), {
replace: true,
state: location.state,
});
}
}, [issue, issueId, navigate, location.state]);
}, [issue, issueId, navigate, location.state, location.search]);
useEffect(() => {
if (!issue?.id) return;
@ -695,7 +902,7 @@ export function IssueDetail() {
<span key={ancestor.id} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
<Link
to={`/issues/${ancestor.identifier ?? ancestor.id}`}
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id, location.state, location.search)}
state={location.state}
className="hover:text-foreground transition-colors truncate max-w-[200px]"
title={ancestor.title}
@ -1025,7 +1232,8 @@ export function IssueDetail() {
<TabsContent value="comments">
<CommentThread
comments={commentsWithRunMeta}
comments={timelineComments}
queuedComments={queuedComments}
linkedRuns={timelineRuns}
companyId={issue.companyId}
projectId={issue.projectId}
@ -1037,6 +1245,10 @@ export function IssueDetail() {
currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions}
onInterruptQueued={async (runId) => {
await interruptQueuedComment.mutateAsync(runId);
}}
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
onAdd={async (body, reopen, reassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
@ -1063,7 +1275,7 @@ export function IssueDetail() {
{childIssues.map((child) => (
<Link
key={child.id}
to={`/issues/${child.identifier ?? child.id}`}
to={createIssueDetailPath(child.identifier ?? child.id, location.state, location.search)}
state={location.state}
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
>

View file

@ -70,6 +70,7 @@ export function Issues() {
createIssueDetailLocationState(
"Issues",
`${location.pathname}${location.search}${location.hash}`,
"issues",
),
[location.pathname, location.search, location.hash],
);