Merge public-gh/master into pap-979-runtime-workspaces
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
4d61dbfd34
46 changed files with 3635 additions and 297 deletions
|
|
@ -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
292
cli/README.md
Normal 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> ·
|
||||
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> ·
|
||||
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> ·
|
||||
<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 © 2026 Paperclip
|
||||
|
||||
## Star History
|
||||
|
||||
[](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>
|
||||
105
cli/src/__tests__/onboard.test.ts
Normal file
105
cli/src/__tests__/onboard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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."));
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
883
scripts/paperclip-commit-metrics.ts
Normal file
883
scripts/paperclip-commit-metrics.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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`, {
|
||||
|
|
|
|||
|
|
@ -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 & Runs ({timeline.length})</h3>
|
||||
<h3 className="text-sm font-semibold">Comments & 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}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
);
|
||||
|
|
|
|||
116
ui/src/components/IssueRow.test.tsx
Normal file
116
ui/src/components/IssueRow.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
146
ui/src/components/SwipeToArchive.test.tsx
Normal file
146
ui/src/components/SwipeToArchive.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
34
ui/src/lib/issueDetailBreadcrumb.test.ts
Normal file
34
ui/src/lib/issueDetailBreadcrumb.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
215
ui/src/lib/optimistic-issue-comments.test.ts
Normal file
215
ui/src/lib/optimistic-issue-comments.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
123
ui/src/lib/optimistic-issue-comments.ts
Normal file
123
ui/src/lib/optimistic-issue-comments.ts
Normal 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
181
ui/src/pages/Inbox.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export function Issues() {
|
|||
createIssueDetailLocationState(
|
||||
"Issues",
|
||||
`${location.pathname}${location.search}${location.hash}`,
|
||||
"issues",
|
||||
),
|
||||
[location.pathname, location.search, location.hash],
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue