Compare commits

..

36 Commits

Author SHA1 Message Date
Dotta
472322de24 Install dependencies after creating worktree
Run pnpm install in the new worktree directory before initializing the
Paperclip instance so that node_modules are ready when the init step runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:31:41 -05:00
Dotta
3770e94d56 Merge pull request #613 from paperclipai/public/inbox-runs-worktree-history
Polish inbox workflows, agent runs, and worktree setup
2026-03-11 09:21:51 -05:00
Dotta
d9492f02d6 Add worktree start-point support 2026-03-11 09:15:27 -05:00
Dotta
57d8d01079 Align inbox badge with visible unread items 2026-03-11 09:02:23 -05:00
Dotta
345c7f4a88 Remove inbox recent issues label 2026-03-11 08:51:30 -05:00
Dotta
521b24da3d Tighten recent inbox tab behavior 2026-03-11 08:42:41 -05:00
Dotta
96e03b45b9 Refine inbox tabs and layout 2026-03-11 08:26:41 -05:00
Dotta
57dcdb51af ui: apply interface polish from design article review
- Add global font smoothing (antialiased) to body
- Add tabular-nums to all numeric displays: MetricCard values, Costs page,
  AgentDetail token/cost grids and tables, IssueDetail cost summary,
  Companies page budget display
- Replace markdown image hard border with subtle inset box-shadow overlay
- Replace all animate-ping status dots with calmer animate-pulse across
  AgentDetail, IssueDetail, Agents, sidebar, kanban, issues list, and
  active agents panel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:20:24 -05:00
Dotta
a503d2c12c Adjust inbox tab memory and badge counts 2026-03-11 07:42:19 -05:00
Dotta
21d2b075e7 Fix inbox badge logic and landing view 2026-03-10 22:55:45 -05:00
Dotta
92aef9bae8 Slim heartbeat run list payloads 2026-03-10 21:16:33 -05:00
Dotta
5f76d03913 ui: smooth new issue submit state 2026-03-10 21:06:16 -05:00
Dotta
d3ac8722be Add agent runs tab to detail page 2026-03-10 21:06:15 -05:00
Dotta
183d71eb7c Restore native mobile page scrolling 2026-03-10 21:06:10 -05:00
Dotta
3273692944 Fix markdown link dialog positioning 2026-03-10 21:01:47 -05:00
Dotta
b5935349ed Preserve issue breadcrumb source 2026-03-10 20:59:55 -05:00
Dotta
4b49efa02e Smooth agent config save button state 2026-03-10 20:58:18 -05:00
Dotta
c2c63868e9 Refine issue markdown typography 2026-03-10 20:55:41 -05:00
Dotta
3a003e11cc Merge pull request #545 from paperclipai/feat/worktree-and-routing-polish
Add worktree:make and routing polish
2026-03-10 17:38:17 -05:00
Dotta
d388255e66 Remove inbox New tab badge count 2026-03-10 17:05:47 -05:00
Dotta
80d87d3b4e Add paperclipai worktree:make command 2026-03-10 16:52:26 -05:00
Dotta
21eb904a4d fix(server): keep pretty logger metadata on one line 2026-03-10 16:42:36 -05:00
Dotta
d62b89cadd ui: add company-aware not found handling 2026-03-10 16:38:46 -05:00
Dotta
78207304d4 Merge pull request #540 from paperclipai/nm/worktree-favicon
Add worktree-specific favicon branding
2026-03-10 16:33:20 -05:00
Dotta
c799fca313 Add worktree-specific favicon branding 2026-03-10 16:15:11 -05:00
Dotta
50db379db2 Merge pull request #536 from paperclipai/fix/dev-migration-flow
Fix dev migration prompt and embedded db:migrate
2026-03-10 15:32:10 -05:00
Dotta
56aeddfa1c Fix dev migration prompt and embedded db:migrate 2026-03-10 15:31:05 -05:00
Dotta
42c8aca5c0 Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  Fix approvals service idempotency test
  Add workspace strategy plan doc
  Copy git hooks during worktree init
2026-03-10 15:09:00 -05:00
Dotta
00495d3d89 Merge pull request #534 from paperclipai/fix/approval-service-idempotency
Fix approvals service idempotency test
2026-03-10 15:07:45 -05:00
Dotta
a613435249 Fix approvals service idempotency test 2026-03-10 15:05:19 -05:00
Dotta
576b408682 Merge pull request #532 from paperclipai/feature/worktree-init-copy-hooks
Copy git hooks during worktree init
2026-03-10 14:58:18 -05:00
Dotta
24a553c255 doc for workspace strategy 2026-03-10 14:52:43 -05:00
Dotta
2332a79e0b Merge branch 'master' of github.com-dotta:paperclipai/paperclip
* 'master' of github.com-dotta:paperclipai/paperclip:
  updating paths
  Rebind seeded project workspaces to the current worktree
  Add command-based worktree provisioning
  Refine project and agent configuration UI
  Add configuration tabs to project and agent pages
  Add project-first execution workspace policies
  Add worktree-aware workspace runtime support
2026-03-10 14:47:50 -05:00
Dotta
0c525febf2 Merge remote-tracking branch 'public-gh/master'
* public-gh/master:
  Copy seeded secrets key into worktree instances
  server: make approval retries idempotent (#499)
  Fix doctor summary after repairs
  Fix worktree minimal clone startup
  Add minimal worktree seed mode
  Add worktree init CLI for isolated development instances
  fix: address review feedback — stale error message and * wildcard
  Update server/src/routes/assets.ts
  feat: make attachment content types configurable via env var
  fix: wire parentId query filter into issues list endpoint
  Apply suggestions from code review
  fix(adapter-utils): strip Claude Code env vars from child processes
2026-03-10 13:41:47 -05:00
Dotta
b0fe48b730 Revert "ui: move settings to footer icon beside theme toggle"
This reverts commit f3a9b6de21.
2026-03-10 13:41:37 -05:00
Dotta
f3a9b6de21 ui: move settings to footer icon beside theme toggle 2026-03-10 13:23:35 -05:00
65 changed files with 2357 additions and 520 deletions

View File

@@ -3,7 +3,14 @@ import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, rebindWorkspaceCwd } from "../commands/worktree.js";
import {
copyGitHooksToWorktreeGitDir,
copySeededSecretsKey,
rebindWorkspaceCwd,
resolveGitWorktreeAddArgs,
resolveWorktreeMakeTargetPath,
worktreeMakeCommand,
} from "../commands/worktree.js";
import {
buildWorktreeConfig,
buildWorktreeEnvEntries,
@@ -78,6 +85,58 @@ describe("worktree helpers", () => {
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
});
it("resolves worktree:make target paths under the user home directory", () => {
expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe(
path.resolve(os.homedir(), "paperclip-pr-432"),
);
});
it("rejects worktree:make names that are not safe directory/branch names", () => {
expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow(
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
);
});
it("builds git worktree add args for new and existing branches", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "feature-branch",
targetPath: "/tmp/feature-branch",
branchExists: false,
}),
).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]);
expect(
resolveGitWorktreeAddArgs({
branchName: "feature-branch",
targetPath: "/tmp/feature-branch",
branchExists: true,
}),
).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]);
});
it("builds git worktree add args with a start point", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "my-worktree",
targetPath: "/tmp/my-worktree",
branchExists: false,
startPoint: "public-gh/master",
}),
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]);
});
it("uses start point even when a local branch with the same name exists", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "my-worktree",
targetPath: "/tmp/my-worktree",
branchExists: true,
startPoint: "origin/main",
}),
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
});
it("rewrites loopback auth URLs to the new port only", () => {
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
@@ -110,6 +169,7 @@ describe("worktree helpers", () => {
const env = buildWorktreeEnvEntries(paths);
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
});
@@ -248,4 +308,44 @@ describe("worktree helpers", () => {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("creates and initializes a worktree from the top-level worktree:make command", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-"));
const repoRoot = path.join(tempRoot, "repo");
const fakeHome = path.join(tempRoot, "home");
const worktreePath = path.join(fakeHome, "paperclip-make-test");
const originalCwd = process.cwd();
const originalHome = process.env.HOME;
try {
fs.mkdirSync(repoRoot, { recursive: true });
fs.mkdirSync(fakeHome, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
process.env.HOME = fakeHome;
process.chdir(repoRoot);
await worktreeMakeCommand("paperclip-make-test", {
seed: false,
home: path.join(tempRoot, ".paperclip-worktrees"),
});
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
} finally {
process.chdir(originalCwd);
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
});

View File

@@ -202,6 +202,7 @@ export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record<strin
PAPERCLIP_INSTANCE_ID: paths.instanceId,
PAPERCLIP_CONFIG: paths.configPath,
PAPERCLIP_CONTEXT: paths.contextPath,
PAPERCLIP_IN_WORKTREE: "true",
};
}

View File

@@ -62,6 +62,10 @@ type WorktreeInitOptions = {
force?: boolean;
};
type WorktreeMakeOptions = WorktreeInitOptions & {
startPoint?: string;
};
type WorktreeEnvOptions = {
config?: string;
json?: boolean;
@@ -115,6 +119,64 @@ function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function resolveWorktreeMakeName(name: string): string {
const value = nonEmpty(name);
if (!value) {
throw new Error("Worktree name is required.");
}
if (!/^[A-Za-z0-9._-]+$/.test(value)) {
throw new Error(
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
);
}
return value;
}
export function resolveWorktreeMakeTargetPath(name: string): string {
return path.resolve(os.homedir(), resolveWorktreeMakeName(name));
}
function extractExecSyncErrorMessage(error: unknown): string | null {
if (!error || typeof error !== "object") {
return error instanceof Error ? error.message : null;
}
const stderr = "stderr" in error ? error.stderr : null;
if (typeof stderr === "string") {
return nonEmpty(stderr);
}
if (stderr instanceof Buffer) {
return nonEmpty(stderr.toString("utf8"));
}
return error instanceof Error ? nonEmpty(error.message) : null;
}
function localBranchExists(cwd: string, branchName: string): boolean {
try {
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
cwd,
stdio: "ignore",
});
return true;
} catch {
return false;
}
}
export function resolveGitWorktreeAddArgs(input: {
branchName: string;
targetPath: string;
branchExists: boolean;
startPoint?: string;
}): string[] {
if (input.branchExists && !input.startPoint) {
return ["worktree", "add", input.targetPath, input.branchName];
}
const commitish = input.startPoint ?? "HEAD";
return ["worktree", "add", "-b", input.branchName, input.targetPath, commitish];
}
function readPidFilePort(postmasterPidFile: string): number | null {
if (!existsSync(postmasterPidFile)) return null;
try {
@@ -538,10 +600,7 @@ async function seedWorktreeDatabase(input: {
}
}
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
const cwd = process.cwd();
const name = resolveSuggestedWorktreeName(
cwd,
@@ -642,6 +701,85 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
);
}
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
await runWorktreeInit(opts);
}
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
const name = resolveWorktreeMakeName(nameArg);
const sourceCwd = process.cwd();
const targetPath = resolveWorktreeMakeTargetPath(name);
if (existsSync(targetPath)) {
throw new Error(`Target path already exists: ${targetPath}`);
}
mkdirSync(path.dirname(targetPath), { recursive: true });
if (opts.startPoint) {
const [remote] = opts.startPoint.split("/", 1);
try {
execFileSync("git", ["fetch", remote], {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
throw new Error(
`Failed to fetch from remote "${remote}": ${extractExecSyncErrorMessage(error) ?? String(error)}`,
);
}
}
const worktreeArgs = resolveGitWorktreeAddArgs({
branchName: name,
targetPath,
branchExists: !opts.startPoint && localBranchExists(sourceCwd, name),
startPoint: opts.startPoint,
});
const spinner = p.spinner();
spinner.start(`Creating git worktree at ${targetPath}...`);
try {
execFileSync("git", worktreeArgs, {
cwd: sourceCwd,
stdio: ["ignore", "pipe", "pipe"],
});
spinner.stop(`Created git worktree at ${targetPath}.`);
} catch (error) {
spinner.stop(pc.red("Failed to create git worktree."));
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
}
const installSpinner = p.spinner();
installSpinner.start("Installing dependencies...");
try {
execFileSync("pnpm", ["install"], {
cwd: targetPath,
stdio: ["ignore", "pipe", "pipe"],
});
installSpinner.stop("Installed dependencies.");
} catch (error) {
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
}
const originalCwd = process.cwd();
try {
process.chdir(targetPath);
await runWorktreeInit({
...opts,
name,
});
} catch (error) {
throw error;
} finally {
process.chdir(originalCwd);
}
}
export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
const envPath = resolvePaperclipEnvFile(configPath);
@@ -665,6 +803,23 @@ export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void
export function registerWorktreeCommands(program: Command): void {
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
program
.command("worktree:make")
.description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it")
.argument("<name>", "Worktree directory and branch name (created at ~/NAME)")
.option("--start-point <ref>", "Remote ref to base the new branch on (e.g. origin/main)")
.option("--instance <id>", "Explicit isolated instance id")
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
.option("--from-config <path>", "Source config.json to seed from")
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--no-seed", "Skip database seeding from the source instance")
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeMakeCommand);
worktree
.command("init")
.description("Create repo-local config/env and an isolated instance for this worktree")

View File

@@ -19,6 +19,14 @@ That's it. On first start the server:
Data persists across restarts in `~/.paperclip/instances/default/db/`. To reset local dev data, delete that directory.
If you need to apply pending migrations manually, run:
```sh
pnpm db:migrate
```
When `DATABASE_URL` is unset, this command targets the current embedded PostgreSQL instance for your active Paperclip config/instance.
This mode is ideal for local development and one-command installs.
Docker note: the Docker quickstart image also uses embedded PostgreSQL by default. Persist `/paperclip` to keep DB state across container restarts (see `doc/DOCKER.md`).

View File

@@ -132,6 +132,8 @@ Instead, create a repo-local Paperclip config plus an isolated instance for the
```sh
paperclipai worktree init
# or create the git worktree and initialize it in one step:
pnpm paperclipai worktree:make paperclip-pr-432
```
This command:
@@ -150,6 +152,8 @@ Seed modes:
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
That repo-local env also sets `PAPERCLIP_IN_WORKTREE=true`, which the server can use for worktree-specific UI behavior such as an alternate favicon.
Print shell exports explicitly when needed:
```sh

View File

@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
| Visibility | Full visibility to board and all agents in same company |
| Communication | Tasks + comments only (no separate chat system) |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
| Recovery | No automatic reassignment; stale work is surfaced, not silently fixed |
| Recovery | No automatic reassignment; work recovery stays manual/explicit |
| Agent adapters | Built-in `process` and `http` adapters |
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
| Budget period | Monthly UTC calendar window |
@@ -106,7 +106,6 @@ A lightweight scheduler/worker in the server process handles:
- heartbeat trigger checks
- stuck run detection
- budget threshold checks
- stale task reporting generation
Separate queue infrastructure is not required for V1.
@@ -502,7 +501,6 @@ Dashboard payload must include:
- open/in-progress/blocked/done issue counts
- month-to-date spend and budget utilization
- pending approvals count
- stale task count
## 10.9 Error Semantics
@@ -681,7 +679,6 @@ Required UX behaviors:
- global company selector
- quick actions: pause/resume agent, create task, approve/reject request
- conflict toasts on atomic checkout failure
- clear stale-task indicators
- no silent background failures; every failed run visible in UI
## 15. Operational Requirements
@@ -780,7 +777,6 @@ A release candidate is blocked unless these pass:
- add company selector and org chart view
- add approvals and cost pages
- add operational dashboard and stale-task surfacing
## Milestone 6: Hardening and Release

View File

@@ -114,7 +114,7 @@ No section header — these are always at the top, below the company header.
My Issues
```
- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, stale tasks, budget alerts, failed heartbeats. The number is the total unread/unresolved count.
- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, budget alerts, failed heartbeats. The number is the total unread/unresolved count.
- **My Issues** — issues created by or assigned to the board operator.
### 3.3 Work Section

View File

@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"dev": "node scripts/dev-runner.mjs watch",
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch",
"dev:watch": "node scripts/dev-runner.mjs watch",
"dev:once": "node scripts/dev-runner.mjs dev",
"dev:server": "pnpm --filter @paperclipai/server dev",
"dev:ui": "pnpm --filter @paperclipai/ui dev",

View File

@@ -1,21 +1,29 @@
import { applyPendingMigrations, inspectMigrations } from "./client.js";
import { resolveMigrationConnection } from "./migration-runtime.js";
const url = process.env.DATABASE_URL;
async function main(): Promise<void> {
const resolved = await resolveMigrationConnection();
if (!url) {
throw new Error("DATABASE_URL is required for db:migrate");
}
console.log(`Migrating database via ${resolved.source}`);
const before = await inspectMigrations(url);
if (before.status === "upToDate") {
console.log("No pending migrations");
} else {
console.log(`Applying ${before.pendingMigrations.length} pending migration(s)...`);
await applyPendingMigrations(url);
try {
const before = await inspectMigrations(resolved.connectionString);
if (before.status === "upToDate") {
console.log("No pending migrations");
return;
}
const after = await inspectMigrations(url);
if (after.status !== "upToDate") {
throw new Error(`Migrations incomplete: ${after.pendingMigrations.join(", ")}`);
console.log(`Applying ${before.pendingMigrations.length} pending migration(s)...`);
await applyPendingMigrations(resolved.connectionString);
const after = await inspectMigrations(resolved.connectionString);
if (after.status !== "upToDate") {
throw new Error(`Migrations incomplete: ${after.pendingMigrations.join(", ")}`);
}
console.log("Migrations complete");
} finally {
await resolved.stop();
}
console.log("Migrations complete");
}
await main();

View File

@@ -0,0 +1,134 @@
import { existsSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { ensurePostgresDatabase } from "./client.js";
import { resolveDatabaseTarget } from "./runtime-config.js";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
export type MigrationConnection = {
connectionString: string;
source: string;
stop: () => Promise<void>;
};
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
if (!existsSync(postmasterPidFile)) return null;
try {
const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
if (!Number.isInteger(pid) || pid <= 0) return null;
process.kill(pid, 0);
return pid;
} catch {
return null;
}
}
function readPidFilePort(postmasterPidFile: string): number | null {
if (!existsSync(postmasterPidFile)) return null;
try {
const lines = readFileSync(postmasterPidFile, "utf8").split("\n");
const port = Number(lines[3]?.trim());
return Number.isInteger(port) && port > 0 ? port : null;
} catch {
return null;
}
}
async function loadEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const require = createRequire(import.meta.url);
const resolveCandidates = [
path.resolve(fileURLToPath(new URL("../..", import.meta.url))),
path.resolve(fileURLToPath(new URL("../../server", import.meta.url))),
path.resolve(fileURLToPath(new URL("../../cli", import.meta.url))),
process.cwd(),
];
try {
const resolvedModulePath = require.resolve("embedded-postgres", { paths: resolveCandidates });
const mod = await import(pathToFileURL(resolvedModulePath).href);
return mod.default as EmbeddedPostgresCtor;
} catch {
throw new Error(
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
);
}
}
async function ensureEmbeddedPostgresConnection(
dataDir: string,
preferredPort: number,
): Promise<MigrationConnection> {
const EmbeddedPostgres = await loadEmbeddedPostgresCtor();
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
const runningPid = readRunningPostmasterPid(postmasterPidFile);
const runningPort = readPidFilePort(postmasterPidFile);
if (runningPid) {
const port = runningPort ?? preferredPort;
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
return {
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`,
source: `embedded-postgres@${port}`,
stop: async () => {},
};
}
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port: preferredPort,
persistent: true,
onLog: () => {},
onError: () => {},
});
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
await instance.initialise();
}
if (existsSync(postmasterPidFile)) {
rmSync(postmasterPidFile, { force: true });
}
await instance.start();
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
return {
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/paperclip`,
source: `embedded-postgres@${preferredPort}`,
stop: async () => {
await instance.stop();
},
};
}
export async function resolveMigrationConnection(): Promise<MigrationConnection> {
const target = resolveDatabaseTarget();
if (target.mode === "postgres") {
return {
connectionString: target.connectionString,
source: target.source,
stop: async () => {},
};
}
return ensureEmbeddedPostgresConnection(target.dataDir, target.port);
}

View File

@@ -0,0 +1,45 @@
import { inspectMigrations } from "./client.js";
import { resolveMigrationConnection } from "./migration-runtime.js";
const jsonMode = process.argv.includes("--json");
async function main(): Promise<void> {
const connection = await resolveMigrationConnection();
try {
const state = await inspectMigrations(connection.connectionString);
const payload =
state.status === "upToDate"
? {
source: connection.source,
status: "upToDate" as const,
tableCount: state.tableCount,
pendingMigrations: [] as string[],
}
: {
source: connection.source,
status: "needsMigrations" as const,
tableCount: state.tableCount,
pendingMigrations: state.pendingMigrations,
reason: state.reason,
};
if (jsonMode) {
console.log(JSON.stringify(payload));
return;
}
if (payload.status === "upToDate") {
console.log(`Database is up to date via ${payload.source}`);
return;
}
console.log(
`Pending migrations via ${payload.source}: ${payload.pendingMigrations.join(", ")}`,
);
} finally {
await connection.stop();
}
}
await main();

View File

@@ -0,0 +1,107 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveDatabaseTarget } from "./runtime-config.js";
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_ENV = { ...process.env };
function writeJson(filePath: string, value: unknown) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
}
function writeText(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value);
}
afterEach(() => {
process.chdir(ORIGINAL_CWD);
for (const key of Object.keys(process.env)) {
if (!(key in ORIGINAL_ENV)) delete process.env[key];
}
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
describe("resolveDatabaseTarget", () => {
it("uses DATABASE_URL from process env first", () => {
process.env.DATABASE_URL = "postgres://env-user:env-pass@db.example.com:5432/paperclip";
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "postgres",
connectionString: "postgres://env-user:env-pass@db.example.com:5432/paperclip",
source: "DATABASE_URL",
});
});
it("uses DATABASE_URL from repo-local .paperclip/.env", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
const projectDir = path.join(tempDir, "repo");
fs.mkdirSync(projectDir, { recursive: true });
process.chdir(projectDir);
writeJson(path.join(projectDir, ".paperclip", "config.json"), {
database: { mode: "embedded-postgres", embeddedPostgresPort: 54329 },
});
writeText(
path.join(projectDir, ".paperclip", ".env"),
'DATABASE_URL="postgres://file-user:file-pass@db.example.com:6543/paperclip"\n',
);
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "postgres",
connectionString: "postgres://file-user:file-pass@db.example.com:6543/paperclip",
source: "paperclip-env",
});
});
it("uses config postgres connection string when configured", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
const configPath = path.join(tempDir, "instance", "config.json");
process.env.PAPERCLIP_CONFIG = configPath;
writeJson(configPath, {
database: {
mode: "postgres",
connectionString: "postgres://cfg-user:cfg-pass@db.example.com:5432/paperclip",
},
});
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "postgres",
connectionString: "postgres://cfg-user:cfg-pass@db.example.com:5432/paperclip",
source: "config.database.connectionString",
});
});
it("falls back to embedded postgres settings from config", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-runtime-"));
const configPath = path.join(tempDir, "instance", "config.json");
process.env.PAPERCLIP_CONFIG = configPath;
writeJson(configPath, {
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "~/paperclip-test-db",
embeddedPostgresPort: 55444,
},
});
const target = resolveDatabaseTarget();
expect(target).toMatchObject({
mode: "embedded-postgres",
dataDir: path.resolve(os.homedir(), "paperclip-test-db"),
port: 55444,
source: "embedded-postgres@55444",
});
});
});

View File

@@ -0,0 +1,267 @@
import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
const DEFAULT_INSTANCE_ID = "default";
const CONFIG_BASENAME = "config.json";
const ENV_BASENAME = ".env";
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
type PartialConfig = {
database?: {
mode?: "embedded-postgres" | "postgres";
connectionString?: string;
embeddedPostgresDataDir?: string;
embeddedPostgresPort?: number;
pgliteDataDir?: string;
pglitePort?: number;
};
};
export type ResolvedDatabaseTarget =
| {
mode: "postgres";
connectionString: string;
source: "DATABASE_URL" | "paperclip-env" | "config.database.connectionString";
configPath: string;
envPath: string;
}
| {
mode: "embedded-postgres";
dataDir: string;
port: number;
source: `embedded-postgres@${number}`;
configPath: string;
envPath: string;
};
function expandHomePrefix(value: string): string {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
return value;
}
function resolvePaperclipHomeDir(): string {
const envHome = process.env.PAPERCLIP_HOME?.trim();
if (envHome) return path.resolve(expandHomePrefix(envHome));
return path.resolve(os.homedir(), ".paperclip");
}
function resolvePaperclipInstanceId(): string {
const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
if (!INSTANCE_ID_RE.test(raw)) {
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
}
return raw;
}
function resolveDefaultConfigPath(): string {
return path.resolve(
resolvePaperclipHomeDir(),
"instances",
resolvePaperclipInstanceId(),
CONFIG_BASENAME,
);
}
function resolveDefaultEmbeddedPostgresDir(): string {
return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "db");
}
function resolveHomeAwarePath(value: string): string {
return path.resolve(expandHomePrefix(value));
}
function findConfigFileFromAncestors(startDir: string): string | null {
let currentDir = path.resolve(startDir);
while (true) {
const candidate = path.resolve(currentDir, ".paperclip", CONFIG_BASENAME);
if (existsSync(candidate)) return candidate;
const nextDir = path.resolve(currentDir, "..");
if (nextDir === currentDir) return null;
currentDir = nextDir;
}
}
function resolvePaperclipConfigPath(): string {
if (process.env.PAPERCLIP_CONFIG?.trim()) {
return path.resolve(process.env.PAPERCLIP_CONFIG.trim());
}
return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath();
}
function resolvePaperclipEnvPath(configPath: string): string {
return path.resolve(path.dirname(configPath), ENV_BASENAME);
}
function parseEnvFile(contents: string): Record<string, string> {
const entries: Record<string, string> = {};
for (const rawLine of contents.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
if (!match) continue;
const [, key, rawValue] = match;
const value = rawValue.trim();
if (!value) {
entries[key] = "";
continue;
}
if (
(value.startsWith("\"") && value.endsWith("\"")) ||
(value.startsWith("'") && value.endsWith("'"))
) {
entries[key] = value.slice(1, -1);
continue;
}
entries[key] = value.replace(/\s+#.*$/, "").trim();
}
return entries;
}
function readEnvEntries(envPath: string): Record<string, string> {
if (!existsSync(envPath)) return {};
return parseEnvFile(readFileSync(envPath, "utf8"));
}
function migrateLegacyConfig(raw: unknown): PartialConfig | null {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const config = { ...(raw as Record<string, unknown>) };
const databaseRaw = config.database;
if (typeof databaseRaw !== "object" || databaseRaw === null || Array.isArray(databaseRaw)) {
return config;
}
const database = { ...(databaseRaw as Record<string, unknown>) };
if (database.mode === "pglite") {
database.mode = "embedded-postgres";
if (
typeof database.embeddedPostgresDataDir !== "string" &&
typeof database.pgliteDataDir === "string"
) {
database.embeddedPostgresDataDir = database.pgliteDataDir;
}
if (
typeof database.embeddedPostgresPort !== "number" &&
typeof database.pglitePort === "number" &&
Number.isFinite(database.pglitePort)
) {
database.embeddedPostgresPort = database.pglitePort;
}
}
config.database = database;
return config as PartialConfig;
}
function asPositiveInt(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
const rounded = Math.trunc(value);
return rounded > 0 ? rounded : null;
}
function readConfig(configPath: string): PartialConfig | null {
if (!existsSync(configPath)) return null;
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(configPath, "utf8"));
} catch (err) {
throw new Error(
`Failed to parse config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
const migrated = migrateLegacyConfig(parsed);
if (migrated === null || typeof migrated !== "object" || Array.isArray(migrated)) {
throw new Error(`Invalid config at ${configPath}: expected a JSON object`);
}
const database =
typeof migrated.database === "object" &&
migrated.database !== null &&
!Array.isArray(migrated.database)
? migrated.database
: undefined;
return {
database: database
? {
mode: database.mode === "postgres" ? "postgres" : "embedded-postgres",
connectionString:
typeof database.connectionString === "string" ? database.connectionString : undefined,
embeddedPostgresDataDir:
typeof database.embeddedPostgresDataDir === "string"
? database.embeddedPostgresDataDir
: undefined,
embeddedPostgresPort: asPositiveInt(database.embeddedPostgresPort) ?? undefined,
pgliteDataDir: typeof database.pgliteDataDir === "string" ? database.pgliteDataDir : undefined,
pglitePort: asPositiveInt(database.pglitePort) ?? undefined,
}
: undefined,
};
}
export function resolveDatabaseTarget(): ResolvedDatabaseTarget {
const configPath = resolvePaperclipConfigPath();
const envPath = resolvePaperclipEnvPath(configPath);
const envEntries = readEnvEntries(envPath);
const envUrl = process.env.DATABASE_URL?.trim();
if (envUrl) {
return {
mode: "postgres",
connectionString: envUrl,
source: "DATABASE_URL",
configPath,
envPath,
};
}
const fileEnvUrl = envEntries.DATABASE_URL?.trim();
if (fileEnvUrl) {
return {
mode: "postgres",
connectionString: fileEnvUrl,
source: "paperclip-env",
configPath,
envPath,
};
}
const config = readConfig(configPath);
const connectionString = config?.database?.connectionString?.trim();
if (config?.database?.mode === "postgres" && connectionString) {
return {
mode: "postgres",
connectionString,
source: "config.database.connectionString",
configPath,
envPath,
};
}
const port = config?.database?.embeddedPostgresPort ?? 54329;
const dataDir = resolveHomeAwarePath(
config?.database?.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(),
);
return {
mode: "embedded-postgres",
dataDir,
port,
source: `embedded-postgres@${port}`,
configPath,
envPath,
};
}

View File

@@ -18,5 +18,4 @@ export interface DashboardSummary {
monthUtilizationPercent: number;
};
pendingApprovals: number;
staleTasks: number;
}

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
const mode = process.argv[2] === "watch" ? "watch" : "dev";
const cliArgs = process.argv.slice(3);
@@ -43,6 +45,121 @@ if (tailscaleAuth) {
}
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
function formatPendingMigrationSummary(migrations) {
if (migrations.length === 0) return "none";
return migrations.length > 3
? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
: migrations.join(", ");
}
async function runPnpm(args, options = {}) {
return await new Promise((resolve, reject) => {
const child = spawn(pnpmBin, args, {
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
env: options.env ?? process.env,
shell: process.platform === "win32",
});
let stdoutBuffer = "";
let stderrBuffer = "";
if (child.stdout) {
child.stdout.on("data", (chunk) => {
stdoutBuffer += String(chunk);
});
}
if (child.stderr) {
child.stderr.on("data", (chunk) => {
stderrBuffer += String(chunk);
});
}
child.on("error", reject);
child.on("exit", (code, signal) => {
resolve({
code: code ?? 0,
signal,
stdout: stdoutBuffer,
stderr: stderrBuffer,
});
});
});
}
async function maybePreflightMigrations() {
if (mode !== "watch") return;
if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return;
const status = await runPnpm(
["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"],
{ env },
);
if (status.code !== 0) {
process.stderr.write(status.stderr || status.stdout);
process.exit(status.code);
}
let payload;
try {
payload = JSON.parse(status.stdout.trim());
} catch (error) {
process.stderr.write(status.stderr || status.stdout);
throw error;
}
if (payload.status !== "needsMigrations" || payload.pendingMigrations.length === 0) {
return;
}
const autoApply = process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true";
let shouldApply = autoApply;
if (!autoApply) {
if (!stdin.isTTY || !stdout.isTTY) {
shouldApply = true;
} else {
const prompt = createInterface({ input: stdin, output: stdout });
try {
const answer = (
await prompt.question(
`Apply pending migrations (${formatPendingMigrationSummary(payload.pendingMigrations)}) now? (y/N): `,
)
)
.trim()
.toLowerCase();
shouldApply = answer === "y" || answer === "yes";
} finally {
prompt.close();
}
}
}
if (!shouldApply) return;
const migrate = spawn(pnpmBin, ["db:migrate"], {
stdio: "inherit",
env,
shell: process.platform === "win32",
});
const exit = await new Promise((resolve) => {
migrate.on("exit", (code, signal) => resolve({ code: code ?? 0, signal }));
});
if (exit.signal) {
process.kill(process.pid, exit.signal);
return;
}
if (exit.code !== 0) {
process.exit(exit.code);
}
}
await maybePreflightMigrations();
if (mode === "watch") {
env.PAPERCLIP_MIGRATION_PROMPT = "never";
}
const serverScript = mode === "watch" ? "dev:watch" : "dev";
const child = spawn(
pnpmBin,
@@ -57,4 +174,3 @@ child.on("exit", (code, signal) => {
}
process.exit(code ?? 0);
});

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { approvalService } from "../services/approvals.js";
import { approvalService } from "../services/approvals.ts";
const mockAgentService = vi.hoisted(() => ({
activatePendingApproval: vi.fn(),
@@ -38,15 +38,12 @@ function createApproval(status: string): ApprovalRecord {
}
function createDbStub(selectResults: ApprovalRecord[][], updateResults: ApprovalRecord[]) {
const selectWhere = vi.fn();
for (const result of selectResults) {
selectWhere.mockResolvedValueOnce(result);
}
const pendingSelectResults = [...selectResults];
const selectWhere = vi.fn(async () => pendingSelectResults.shift() ?? []);
const from = vi.fn(() => ({ where: selectWhere }));
const select = vi.fn(() => ({ from }));
const returning = vi.fn().mockResolvedValue(updateResults);
const returning = vi.fn(async () => updateResults);
const updateWhere = vi.fn(() => ({ returning }));
const set = vi.fn(() => ({ where: updateWhere }));
const update = vi.fn(() => ({ set }));

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { applyUiBranding, isWorktreeUiBrandingEnabled, renderFaviconLinks } from "../ui-branding.js";
const TEMPLATE = `<!doctype html>
<head>
<!-- PAPERCLIP_FAVICON_START -->
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<!-- PAPERCLIP_FAVICON_END -->
</head>`;
describe("ui branding", () => {
it("detects worktree mode from PAPERCLIP_IN_WORKTREE", () => {
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "true" })).toBe(true);
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "1" })).toBe(true);
expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "false" })).toBe(false);
});
it("renders the worktree favicon asset set when enabled", () => {
const links = renderFaviconLinks(true);
expect(links).toContain("/worktree-favicon.ico");
expect(links).toContain("/worktree-favicon.svg");
expect(links).toContain("/worktree-favicon-32x32.png");
expect(links).toContain("/worktree-favicon-16x16.png");
});
it("rewrites the favicon block for worktree instances only", () => {
const branded = applyUiBranding(TEMPLATE, { PAPERCLIP_IN_WORKTREE: "true" });
expect(branded).toContain("/worktree-favicon.svg");
expect(branded).not.toContain('href="/favicon.svg"');
const defaultHtml = applyUiBranding(TEMPLATE, {});
expect(defaultHtml).toContain('href="/favicon.svg"');
expect(defaultHtml).not.toContain("/worktree-favicon.svg");
});
});

View File

@@ -24,6 +24,7 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { llmRoutes } from "./routes/llms.js";
import { assetRoutes } from "./routes/assets.js";
import { accessRoutes } from "./routes/access.js";
import { applyUiBranding } from "./ui-branding.js";
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
type UiMode = "none" | "static" | "vite-dev";
@@ -135,7 +136,7 @@ export async function createApp(
];
const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
if (uiDist) {
const indexHtml = fs.readFileSync(path.join(uiDist, "index.html"), "utf-8");
const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"));
app.use(express.static(uiDist));
app.get(/.*/, (_req, res) => {
res.status(200).set("Content-Type", "text/html").end(indexHtml);
@@ -168,7 +169,7 @@ export async function createApp(
try {
const templatePath = path.resolve(uiRoot, "index.html");
const template = fs.readFileSync(templatePath, "utf-8");
const html = await vite.transformIndexHtml(req.originalUrl, template);
const html = applyUiBranding(await vite.transformIndexHtml(req.originalUrl, template));
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (err) {
next(err);

View File

@@ -23,6 +23,7 @@ const logFile = path.join(logDir, "server.log");
const sharedOpts = {
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
singleLine: true,
};
export const logger = pino({

View File

@@ -1346,6 +1346,17 @@ export function agentRoutes(db: Db) {
res.json(liveRuns);
});
router.get("/heartbeat-runs/:runId", async (req, res) => {
const runId = req.params.runId as string;
const run = await heartbeat.getRun(runId);
if (!run) {
res.status(404).json({ error: "Heartbeat run not found" });
return;
}
assertCompanyAccess(req, run.companyId);
res.json(run);
});
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
assertBoard(req);
const runId = req.params.runId as string;

View File

@@ -3,7 +3,6 @@ import type { Db } from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
import { joinRequests } from "@paperclipai/db";
import { sidebarBadgeService } from "../services/sidebar-badges.js";
import { issueService } from "../services/issues.js";
import { accessService } from "../services/access.js";
import { dashboardService } from "../services/dashboard.js";
import { assertCompanyAccess } from "./authz.js";
@@ -11,7 +10,6 @@ import { assertCompanyAccess } from "./authz.js";
export function sidebarBadgeRoutes(db: Db) {
const router = Router();
const svc = sidebarBadgeService(db);
const issueSvc = issueService(db);
const access = accessService(db);
const dashboard = dashboardService(db);
@@ -40,12 +38,11 @@ export function sidebarBadgeRoutes(db: Db) {
joinRequests: joinRequestCount,
});
const summary = await dashboard.summary(companyId);
const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60);
const hasFailedRuns = badges.failedRuns > 0;
const alertsCount =
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals;
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
res.json(badges);
});

View File

@@ -32,19 +32,6 @@ export function dashboardService(db: Db) {
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
.then((rows) => Number(rows[0]?.count ?? 0));
const staleCutoff = new Date(Date.now() - 60 * 60 * 1000);
const staleTasks = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.status, "in_progress"),
sql`${issues.startedAt} < ${staleCutoff.toISOString()}`,
),
)
.then((rows) => Number(rows[0]?.count ?? 0));
const agentCounts: Record<string, number> = {
active: 0,
running: 0,
@@ -107,7 +94,6 @@ export function dashboardService(db: Db) {
monthUtilizationPercent: Number(utilization.toFixed(2)),
},
pendingApprovals,
staleTasks,
};
},
};

View File

@@ -46,6 +46,69 @@ const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const startLocksByAgent = new Map<string, Promise<void>>();
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
const summarizedHeartbeatRunResultJson = sql<Record<string, unknown> | null>`
CASE
WHEN ${heartbeatRuns.resultJson} IS NULL THEN NULL
ELSE NULLIF(
jsonb_strip_nulls(
jsonb_build_object(
'summary', CASE
WHEN ${heartbeatRuns.resultJson} ->> 'summary' IS NULL THEN NULL
ELSE left(${heartbeatRuns.resultJson} ->> 'summary', 500)
END,
'result', CASE
WHEN ${heartbeatRuns.resultJson} ->> 'result' IS NULL THEN NULL
ELSE left(${heartbeatRuns.resultJson} ->> 'result', 500)
END,
'message', CASE
WHEN ${heartbeatRuns.resultJson} ->> 'message' IS NULL THEN NULL
ELSE left(${heartbeatRuns.resultJson} ->> 'message', 500)
END,
'error', CASE
WHEN ${heartbeatRuns.resultJson} ->> 'error' IS NULL THEN NULL
ELSE left(${heartbeatRuns.resultJson} ->> 'error', 500)
END,
'total_cost_usd', ${heartbeatRuns.resultJson} -> 'total_cost_usd',
'cost_usd', ${heartbeatRuns.resultJson} -> 'cost_usd',
'costUsd', ${heartbeatRuns.resultJson} -> 'costUsd'
)
),
'{}'::jsonb
)
END
`;
const heartbeatRunListColumns = {
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
invocationSource: heartbeatRuns.invocationSource,
triggerDetail: heartbeatRuns.triggerDetail,
status: heartbeatRuns.status,
startedAt: heartbeatRuns.startedAt,
finishedAt: heartbeatRuns.finishedAt,
error: heartbeatRuns.error,
wakeupRequestId: heartbeatRuns.wakeupRequestId,
exitCode: heartbeatRuns.exitCode,
signal: heartbeatRuns.signal,
usageJson: heartbeatRuns.usageJson,
resultJson: summarizedHeartbeatRunResultJson.as("resultJson"),
sessionIdBefore: heartbeatRuns.sessionIdBefore,
sessionIdAfter: heartbeatRuns.sessionIdAfter,
logStore: heartbeatRuns.logStore,
logRef: heartbeatRuns.logRef,
logBytes: heartbeatRuns.logBytes,
logSha256: heartbeatRuns.logSha256,
logCompressed: heartbeatRuns.logCompressed,
stdoutExcerpt: sql<string | null>`NULL`.as("stdoutExcerpt"),
stderrExcerpt: sql<string | null>`NULL`.as("stderrExcerpt"),
errorCode: heartbeatRuns.errorCode,
externalRunId: heartbeatRuns.externalRunId,
contextSnapshot: heartbeatRuns.contextSnapshot,
createdAt: heartbeatRuns.createdAt,
updatedAt: heartbeatRuns.updatedAt,
} as const;
function appendExcerpt(prev: string, chunk: string) {
return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES);
}
@@ -2260,9 +2323,9 @@ export function heartbeatService(db: Db) {
}
return {
list: (companyId: string, agentId?: string, limit?: number) => {
list: async (companyId: string, agentId?: string, limit?: number) => {
const query = db
.select()
.select(heartbeatRunListColumns)
.from(heartbeatRuns)
.where(
agentId

View File

@@ -1411,23 +1411,5 @@ export function issueService(db: Db) {
goal: a.goalId ? goalMap.get(a.goalId) ?? null : null,
}));
},
staleCount: async (companyId: string, minutes = 60) => {
const cutoff = new Date(Date.now() - minutes * 60 * 1000);
const result = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.status, "in_progress"),
isNull(issues.hiddenAt),
sql`${issues.startedAt} < ${cutoff.toISOString()}`,
),
)
.then((rows) => rows[0]);
return Number(result?.count ?? 0);
},
};
}

41
server/src/ui-branding.ts Normal file
View File

@@ -0,0 +1,41 @@
const FAVICON_BLOCK_START = "<!-- PAPERCLIP_FAVICON_START -->";
const FAVICON_BLOCK_END = "<!-- PAPERCLIP_FAVICON_END -->";
const DEFAULT_FAVICON_LINKS = [
'<link rel="icon" href="/favicon.ico" sizes="48x48" />',
'<link rel="icon" href="/favicon.svg" type="image/svg+xml" />',
'<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />',
'<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />',
].join("\n");
const WORKTREE_FAVICON_LINKS = [
'<link rel="icon" href="/worktree-favicon.ico" sizes="48x48" />',
'<link rel="icon" href="/worktree-favicon.svg" type="image/svg+xml" />',
'<link rel="icon" type="image/png" sizes="32x32" href="/worktree-favicon-32x32.png" />',
'<link rel="icon" type="image/png" sizes="16x16" href="/worktree-favicon-16x16.png" />',
].join("\n");
function isTruthyEnvValue(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
export function isWorktreeUiBrandingEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return isTruthyEnvValue(env.PAPERCLIP_IN_WORKTREE);
}
export function renderFaviconLinks(worktree: boolean): string {
return worktree ? WORKTREE_FAVICON_LINKS : DEFAULT_FAVICON_LINKS;
}
export function applyUiBranding(html: string, env: NodeJS.ProcessEnv = process.env): string {
const start = html.indexOf(FAVICON_BLOCK_START);
const end = html.indexOf(FAVICON_BLOCK_END);
if (start === -1 || end === -1 || end < start) return html;
const before = html.slice(0, start + FAVICON_BLOCK_START.length);
const after = html.slice(end);
const links = renderFaviconLinks(isWorktreeUiBrandingEnabled(env));
return `${before}\n${links}\n ${after}`;
}

View File

@@ -8,10 +8,12 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Paperclip" />
<title>Paperclip</title>
<!-- PAPERCLIP_FAVICON_START -->
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<!-- PAPERCLIP_FAVICON_END -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round">
<style>
path { stroke: #db2777; }
@media (prefers-color-scheme: dark) {
path { stroke: #f472b6; }
}
</style>
<path stroke-width="2" d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

View File

@@ -28,9 +28,11 @@ import { NewAgent } from "./pages/NewAgent";
import { AuthPage } from "./pages/Auth";
import { BoardClaimPage } from "./pages/BoardClaim";
import { InviteLandingPage } from "./pages/InviteLanding";
import { NotFoundPage } from "./pages/NotFound";
import { queryKeys } from "./lib/queryKeys";
import { useCompany } from "./context/CompanyContext";
import { useDialog } from "./context/DialogContext";
import { loadLastInboxTab } from "./lib/inbox";
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
return (
@@ -137,14 +139,21 @@ function boardRoutes() {
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<Activity />} />
<Route path="inbox" element={<Navigate to="/inbox/new" replace />} />
<Route path="inbox/new" element={<Inbox />} />
<Route path="inbox" element={<InboxRootRedirect />} />
<Route path="inbox/recent" element={<Inbox />} />
<Route path="inbox/unread" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} />
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="*" element={<NotFoundPage scope="board" />} />
</>
);
}
function InboxRootRedirect() {
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
}
function CompanyRootRedirect() {
const { companies, selectedCompany, loading } = useCompany();
const { onboardingOpen } = useDialog();
@@ -240,6 +249,7 @@ export function App() {
<Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()}
</Route>
<Route path="*" element={<NotFoundPage scope="global" />} />
</Route>
</Routes>
<OnboardingWizard />

View File

@@ -29,6 +29,7 @@ export const heartbeatsApi = {
const qs = searchParams.toString();
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`);
},
get: (runId: string) => api.get<HeartbeatRun>(`/heartbeat-runs/${runId}`),
events: (runId: string, afterSeq = 0, limit = 200) =>
api.get<HeartbeatRunEvent[]>(
`/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,

View File

@@ -434,7 +434,7 @@ function AgentRunCard({
<div className="flex items-center gap-2 min-w-0">
{isActive ? (
<span className="relative flex h-2 w-2 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
) : (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import type {
@@ -221,7 +221,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
/** Build accumulated patch and send to parent */
function handleSave() {
const handleCancel = useCallback(() => {
setOverlay({ ...emptyOverlay });
}, []);
const handleSave = useCallback(() => {
if (isCreate || !isDirty) return;
const agent = props.agent;
const patch: Record<string, unknown> = {};
@@ -248,21 +252,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
props.onSave(patch);
}
}, [isCreate, isDirty, overlay, props]);
useEffect(() => {
if (!isCreate) {
props.onDirtyChange?.(isDirty);
props.onSaveActionChange?.(() => handleSave());
props.onCancelActionChange?.(() => setOverlay({ ...emptyOverlay }));
return () => {
props.onSaveActionChange?.(null);
props.onCancelActionChange?.(null);
props.onDirtyChange?.(false);
};
props.onSaveActionChange?.(handleSave);
props.onCancelActionChange?.(handleCancel);
}
return;
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, overlay]); // eslint-disable-line react-hooks/exhaustive-deps
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]);
useEffect(() => {
if (isCreate) return;
return () => {
props.onSaveActionChange?.(null);
props.onCancelActionChange?.(null);
props.onDirtyChange?.(false);
};
}, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]);
// ---- Resolve values ----
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};

View File

@@ -132,7 +132,7 @@ function SortableCompanyItem({
{hasLiveAgents && (
<span className="pointer-events-none absolute -right-0.5 -top-0.5 z-10">
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-80" />
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-80" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500 ring-2 ring-background" />
</span>
</span>

View File

@@ -142,6 +142,7 @@ interface IssuesListProps {
liveIssueIds?: Set<string>;
projectId?: string;
viewStateKey: string;
issueLinkState?: unknown;
initialAssignees?: string[];
initialSearch?: string;
onSearchChange?: (search: string) => void;
@@ -156,6 +157,7 @@ export function IssuesList({
liveIssueIds,
projectId,
viewStateKey,
issueLinkState,
initialAssignees,
initialSearch,
onSearchChange,
@@ -591,6 +593,7 @@ export function IssuesList({
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
state={issueLinkState}
className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1"
>
{/* Status icon - left column on mobile, inline on desktop */}
@@ -625,7 +628,7 @@ export function IssuesList({
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 rounded-full bg-blue-500/10">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>

View File

@@ -154,7 +154,7 @@ function KanbanCard({
</span>
{isLive && (
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
)}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type UIEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, Moon, Sun } from "lucide-react";
import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
@@ -24,13 +24,20 @@ import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { Button } from "@/components/ui/button";
export function Layout() {
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
const { openNewIssue, openOnboarding } = useDialog();
const { togglePanelVisible } = usePanel();
const { companies, loading: companiesLoading, selectedCompanyId, setSelectedCompanyId } = useCompany();
const {
companies,
loading: companiesLoading,
selectedCompany,
selectedCompanyId,
setSelectedCompanyId,
} = useCompany();
const { theme, toggleTheme } = useTheme();
const { companyPrefix } = useParams<{ companyPrefix: string }>();
const navigate = useNavigate();
@@ -39,6 +46,13 @@ export function Layout() {
const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const nextTheme = theme === "dark" ? "light" : "dark";
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
const requestedPrefix = companyPrefix.toUpperCase();
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix) ?? null;
}, [companies, companyPrefix]);
const hasUnknownCompanyPrefix =
Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;
const { data: health } = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
@@ -57,30 +71,30 @@ export function Layout() {
useEffect(() => {
if (!companyPrefix || companiesLoading || companies.length === 0) return;
const requestedPrefix = companyPrefix.toUpperCase();
const matched = companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix);
if (!matched) {
const fallback =
(selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null)
?? companies[0]!;
navigate(`/${fallback.issuePrefix}/dashboard`, { replace: true });
if (!matchedCompany) {
const fallback = (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null)
?? companies[0]
?? null;
if (fallback && selectedCompanyId !== fallback.id) {
setSelectedCompanyId(fallback.id, { source: "route_sync" });
}
return;
}
if (companyPrefix !== matched.issuePrefix) {
if (companyPrefix !== matchedCompany.issuePrefix) {
const suffix = location.pathname.replace(/^\/[^/]+/, "");
navigate(`/${matched.issuePrefix}${suffix}${location.search}`, { replace: true });
navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, { replace: true });
return;
}
if (selectedCompanyId !== matched.id) {
setSelectedCompanyId(matched.id, { source: "route_sync" });
if (selectedCompanyId !== matchedCompany.id) {
setSelectedCompanyId(matchedCompany.id, { source: "route_sync" });
}
}, [
companyPrefix,
companies,
companiesLoading,
matchedCompany,
location.pathname,
location.search,
navigate,
@@ -163,28 +177,56 @@ export function Layout() {
};
}, [isMobile, sidebarOpen, setSidebarOpen]);
const handleMainScroll = useCallback(
(event: UIEvent<HTMLElement>) => {
if (!isMobile) return;
const updateMobileNavVisibility = useCallback((currentTop: number) => {
const delta = currentTop - lastMainScrollTop.current;
const currentTop = event.currentTarget.scrollTop;
const delta = currentTop - lastMainScrollTop.current;
if (currentTop <= 24) {
setMobileNavVisible(true);
} else if (delta > 8) {
setMobileNavVisible(false);
} else if (delta < -8) {
setMobileNavVisible(true);
}
if (currentTop <= 24) {
setMobileNavVisible(true);
} else if (delta > 8) {
setMobileNavVisible(false);
} else if (delta < -8) {
setMobileNavVisible(true);
}
lastMainScrollTop.current = currentTop;
}, []);
lastMainScrollTop.current = currentTop;
},
[isMobile],
);
useEffect(() => {
if (!isMobile) {
setMobileNavVisible(true);
lastMainScrollTop.current = 0;
return;
}
const onScroll = () => {
updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
};
}, [isMobile, updateMobileNavVisibility]);
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = isMobile ? "visible" : "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isMobile]);
return (
<div className="flex h-dvh bg-background text-foreground overflow-hidden pt-[env(safe-area-inset-top)]">
<div
className={cn(
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
isMobile ? "min-h-dvh" : "flex h-dvh overflow-hidden",
)}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -273,16 +315,31 @@ export function Layout() {
)}
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 h-full">
<BreadcrumbBar />
<div className="flex flex-1 min-h-0">
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
>
<BreadcrumbBar />
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
id="main-content"
tabIndex={-1}
className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-[calc(5rem+env(safe-area-inset-bottom))]")}
onScroll={handleMainScroll}
className={cn(
"flex-1 p-4 md:p-6",
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
)}
>
<Outlet />
{hasUnknownCompanyPrefix ? (
<NotFoundPage
scope="invalid_company_prefix"
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
/>
) : (
<Outlet />
)}
</main>
<PropertiesPanel />
</div>

View File

@@ -117,7 +117,7 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
return (
<div
className={cn(
"prose prose-sm max-w-none prose-p:my-2 prose-p:leading-[1.4] prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5 prose-li:leading-[1.4] prose-pre:my-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-headings:my-2 prose-headings:text-sm prose-blockquote:leading-[1.4] prose-table:my-2 prose-th:px-3 prose-th:py-1.5 prose-td:px-3 prose-td:py-1.5 prose-code:break-all [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
"paperclip-markdown prose prose-sm max-w-none prose-pre:whitespace-pre-wrap prose-pre:break-words prose-code:break-all",
theme === "dark" && "prose-invert",
className,
)}

View File

@@ -566,7 +566,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
contentClassName,
)}
overlayContainer={containerRef.current}
plugins={plugins}
/>

View File

@@ -18,7 +18,7 @@ export function MetricCard({ icon: Icon, value, label, description, to, onClick
<div className={`h-full px-4 py-4 sm:px-5 sm:py-5 rounded-lg transition-colors${isClickable ? " hover:bg-accent/50 cursor-pointer" : ""}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-2xl sm:text-3xl font-semibold tracking-tight">
<p className="text-2xl sm:text-3xl font-semibold tracking-tight tabular-nums">
{value}
</p>
<p className="text-xs sm:text-sm font-medium text-muted-foreground mt-1">

View File

@@ -1,6 +1,5 @@
import { useMemo } from "react";
import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import {
House,
CircleDot,
@@ -8,11 +7,10 @@ import {
Users,
Inbox,
} from "lucide-react";
import { sidebarBadgesApi } from "../api/sidebarBadges";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { useInboxBadge } from "../hooks/useInboxBadge";
interface MobileBottomNavProps {
visible: boolean;
@@ -39,12 +37,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
const location = useLocation();
const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialog();
const { data: sidebarBadges } = useQuery({
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const inboxBadge = useInboxBadge(selectedCompanyId);
const items = useMemo<MobileNavItem[]>(
() => [
@@ -57,10 +50,10 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
to: "/inbox",
label: "Inbox",
icon: Inbox,
badge: sidebarBadges?.inbox,
badge: inboxBadge.inbox,
},
],
[openNewIssue, sidebarBadges?.inbox],
[openNewIssue, inboxBadge.inbox],
);
return (

View File

@@ -34,6 +34,7 @@ import {
Tag,
Calendar,
Paperclip,
Loader2,
} from "lucide-react";
import { cn } from "../lib/utils";
import { extractProviderIdWithFallback } from "../lib/model-utils";
@@ -420,7 +421,7 @@ export function NewIssueDialog() {
}
function handleSubmit() {
if (!effectiveCompanyId || !title.trim()) return;
if (!effectiveCompanyId || !title.trim() || createIssue.isPending) return;
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
adapterType: assigneeAdapterType,
modelOverride: assigneeModelOverride,
@@ -516,6 +517,11 @@ export function NewIssueDialog() {
})),
[orderedProjects],
);
const savedDraft = loadDraft();
const hasSavedDraft = Boolean(savedDraft?.title.trim() || savedDraft?.description.trim());
const canDiscardDraft = hasDraft || hasSavedDraft;
const createIssueErrorMessage =
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create issue. Try again.";
const handleProjectChange = useCallback((nextProjectId: string) => {
setProjectId(nextProjectId);
@@ -563,7 +569,7 @@ export function NewIssueDialog() {
<Dialog
open={newIssueOpen}
onOpenChange={(open) => {
if (!open) closeNewIssue();
if (!open && !createIssue.isPending) closeNewIssue();
}}
>
<DialogContent
@@ -576,7 +582,16 @@ export function NewIssueDialog() {
: "sm:max-w-lg"
)}
onKeyDown={handleKeyDown}
onEscapeKeyDown={(event) => {
if (createIssue.isPending) {
event.preventDefault();
}
}}
onPointerDownOutside={(event) => {
if (createIssue.isPending) {
event.preventDefault();
return;
}
// Radix Dialog's modal DismissableLayer calls preventDefault() on
// pointerdown events that originate outside the Dialog DOM tree.
// Popover portals render at the body level (outside the Dialog), so
@@ -654,6 +669,7 @@ export function NewIssueDialog() {
size="icon-xs"
className="text-muted-foreground"
onClick={() => setExpanded(!expanded)}
disabled={createIssue.isPending}
>
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</Button>
@@ -662,6 +678,7 @@ export function NewIssueDialog() {
size="icon-xs"
className="text-muted-foreground"
onClick={() => closeNewIssue()}
disabled={createIssue.isPending}
>
<span className="text-lg leading-none">&times;</span>
</Button>
@@ -680,6 +697,7 @@ export function NewIssueDialog() {
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
readOnly={createIssue.isPending}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
@@ -998,17 +1016,36 @@ export function NewIssueDialog() {
size="sm"
className="text-muted-foreground"
onClick={discardDraft}
disabled={!hasDraft && !loadDraft()}
disabled={createIssue.isPending || !canDiscardDraft}
>
Discard Draft
</Button>
<Button
size="sm"
disabled={!title.trim() || createIssue.isPending}
onClick={handleSubmit}
>
{createIssue.isPending ? "Creating..." : "Create Issue"}
</Button>
<div className="flex items-center gap-3">
<div className="min-h-5 text-right">
{createIssue.isPending ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Creating issue...
</span>
) : createIssue.isError ? (
<span className="text-xs text-destructive">{createIssueErrorMessage}</span>
) : canDiscardDraft ? (
<span className="text-xs text-muted-foreground">Draft autosaves locally</span>
) : null}
</div>
<Button
size="sm"
className="min-w-[8.5rem] disabled:opacity-100"
disabled={!title.trim() || createIssue.isPending}
onClick={handleSubmit}
aria-busy={createIssue.isPending}
>
<span className="inline-flex items-center justify-center gap-1.5">
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
<span>{createIssue.isPending ? "Creating..." : "Create Issue"}</span>
</span>
</Button>
</div>
</div>
</DialogContent>
</Dialog>

View File

@@ -1,29 +1,68 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDown } from "lucide-react";
function resolveScrollTarget() {
const mainContent = document.getElementById("main-content");
if (mainContent instanceof HTMLElement) {
const overflowY = window.getComputedStyle(mainContent).overflowY;
const usesOwnScroll =
(overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay")
&& mainContent.scrollHeight > mainContent.clientHeight + 1;
if (usesOwnScroll) {
return { type: "element" as const, element: mainContent };
}
}
return { type: "window" as const };
}
function distanceFromBottom(target: ReturnType<typeof resolveScrollTarget>) {
if (target.type === "element") {
return target.element.scrollHeight - target.element.scrollTop - target.element.clientHeight;
}
const scroller = document.scrollingElement ?? document.documentElement;
return scroller.scrollHeight - window.scrollY - window.innerHeight;
}
/**
* Floating scroll-to-bottom button that appears when the user is far from the
* bottom of the `#main-content` scroll container. Hides when within 300px of
* the bottom. Positioned to avoid the mobile bottom nav.
* Floating scroll-to-bottom button that follows the active page scroller.
* On desktop that is `#main-content`; on mobile it falls back to window/page scroll.
*/
export function ScrollToBottom() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = document.getElementById("main-content");
if (!el) return;
const check = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setVisible(distance > 300);
setVisible(distanceFromBottom(resolveScrollTarget()) > 300);
};
const mainContent = document.getElementById("main-content");
check();
el.addEventListener("scroll", check, { passive: true });
return () => el.removeEventListener("scroll", check);
mainContent?.addEventListener("scroll", check, { passive: true });
window.addEventListener("scroll", check, { passive: true });
window.addEventListener("resize", check);
return () => {
mainContent?.removeEventListener("scroll", check);
window.removeEventListener("scroll", check);
window.removeEventListener("resize", check);
};
}, []);
const scroll = useCallback(() => {
const el = document.getElementById("main-content");
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
const target = resolveScrollTarget();
if (target.type === "element") {
target.element.scrollTo({ top: target.element.scrollHeight, behavior: "smooth" });
return;
}
const scroller = document.scrollingElement ?? document.documentElement;
window.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
}, []);
if (!visible) return null;

View File

@@ -17,19 +17,15 @@ import { SidebarProjects } from "./SidebarProjects";
import { SidebarAgents } from "./SidebarAgents";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { sidebarBadgesApi } from "../api/sidebarBadges";
import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { Button } from "@/components/ui/button";
export function Sidebar() {
const { openNewIssue } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
const { data: sidebarBadges } = useQuery({
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const inboxBadge = useInboxBadge(selectedCompanyId);
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
@@ -80,9 +76,9 @@ export function Sidebar() {
to="/inbox"
label="Inbox"
icon={Inbox}
badge={sidebarBadges?.inbox}
badgeTone={sidebarBadges?.failedRuns ? "danger" : "default"}
alert={(sidebarBadges?.failedRuns ?? 0) > 0}
badge={inboxBadge.inbox}
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0}
/>
</div>

View File

@@ -127,7 +127,7 @@ export function SidebarAgents() {
{runCount > 0 && (
<span className="ml-auto flex items-center gap-1.5 shrink-0">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">

View File

@@ -53,7 +53,7 @@ export function SidebarNavItem({
{liveCount != null && liveCount > 0 && (
<span className="ml-auto flex items-center gap-1.5">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>

View File

@@ -256,7 +256,7 @@ function buildJoinRequestToast(
title: `${label} wants to join`,
body: "A new join request is waiting for approval.",
tone: "info",
action: { label: "View inbox", href: "/inbox/new" },
action: { label: "View inbox", href: "/inbox/unread" },
dedupeKey: `join-request:${entityId}`,
};
}

View File

@@ -0,0 +1,108 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { accessApi } from "../api/access";
import { ApiError } from "../api/client";
import { approvalsApi } from "../api/approvals";
import { dashboardApi } from "../api/dashboard";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import {
computeInboxBadgeData,
getRecentTouchedIssues,
loadDismissedInboxItems,
saveDismissedInboxItems,
getUnreadTouchedIssues,
} from "../lib/inbox";
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
export function useDismissedInboxItems() {
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== "paperclip:inbox:dismissed") return;
setDismissed(loadDismissedInboxItems());
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const dismiss = (id: string) => {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
saveDismissedInboxItems(next);
return next;
});
};
return { dismissed, dismiss };
}
export function useInboxBadge(companyId: string | null | undefined) {
const { dismissed } = useDismissedInboxItems();
const { data: approvals = [] } = useQuery({
queryKey: queryKeys.approvals.list(companyId!),
queryFn: () => approvalsApi.list(companyId!),
enabled: !!companyId,
});
const { data: joinRequests = [] } = useQuery({
queryKey: queryKeys.access.joinRequests(companyId!),
queryFn: async () => {
try {
return await accessApi.listJoinRequests(companyId!, "pending_approval");
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
return [];
}
throw err;
}
},
enabled: !!companyId,
retry: false,
});
const { data: dashboard } = useQuery({
queryKey: queryKeys.dashboard(companyId!),
queryFn: () => dashboardApi.summary(companyId!),
enabled: !!companyId,
});
const { data: touchedIssues = [] } = useQuery({
queryKey: queryKeys.issues.listTouchedByMe(companyId!),
queryFn: () =>
issuesApi.list(companyId!, {
touchedByUserId: "me",
status: INBOX_ISSUE_STATUSES,
}),
enabled: !!companyId,
});
const unreadIssues = useMemo(
() => getUnreadTouchedIssues(getRecentTouchedIssues(touchedIssues)),
[touchedIssues],
);
const { data: heartbeatRuns = [] } = useQuery({
queryKey: queryKeys.heartbeats(companyId!),
queryFn: () => heartbeatsApi.list(companyId!),
enabled: !!companyId,
});
return useMemo(
() =>
computeInboxBadgeData({
approvals,
joinRequests,
dashboard,
heartbeatRuns,
unreadIssues,
dismissed,
}),
[approvals, joinRequests, dashboard, heartbeatRuns, unreadIssues, dismissed],
);
}

View File

@@ -123,7 +123,7 @@
-webkit-tap-highlight-color: color-mix(in oklab, var(--foreground) 20%, transparent);
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground antialiased;
height: 100%;
overflow: hidden;
}
@@ -426,6 +426,121 @@
font-weight: 500;
}
.paperclip-markdown {
color: var(--foreground);
font-size: 0.9375rem;
line-height: 1.6;
}
.paperclip-markdown > :first-child {
margin-top: 0;
}
.paperclip-markdown > :last-child {
margin-bottom: 0;
}
.paperclip-markdown :where(p, ul, ol, blockquote, pre, table) {
margin-top: 0.7rem;
margin-bottom: 0.7rem;
}
.paperclip-markdown :where(ul, ol) {
padding-left: 1.15rem;
}
.paperclip-markdown ul {
list-style-type: disc;
}
.paperclip-markdown ol {
list-style-type: decimal;
}
.paperclip-markdown li {
margin: 0.14rem 0;
padding-left: 0.2rem;
}
.paperclip-markdown li > :where(p, ul, ol) {
margin-top: 0.3rem;
margin-bottom: 0.3rem;
}
.paperclip-markdown li::marker {
color: var(--muted-foreground);
}
.paperclip-markdown :where(h1, h2, h3, h4) {
margin-top: 1.15rem;
margin-bottom: 0.45rem;
color: var(--foreground);
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.3;
}
.paperclip-markdown h1 {
font-size: 1.5rem;
}
.paperclip-markdown h2 {
font-size: 1.25rem;
}
.paperclip-markdown h3 {
font-size: 1.05rem;
}
.paperclip-markdown h4 {
font-size: 0.95rem;
}
.paperclip-markdown :where(strong, b) {
color: var(--foreground);
font-weight: 600;
}
.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;
}
.dark .paperclip-markdown a {
color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%);
}
.paperclip-markdown blockquote {
margin-left: 0;
padding-left: 0.95rem;
border-left: 0.24rem solid color-mix(in oklab, var(--border) 84%, var(--muted-foreground) 16%);
color: var(--muted-foreground);
}
.paperclip-markdown hr {
margin: 1.25rem 0;
border-color: var(--border);
}
.paperclip-markdown img {
border-radius: calc(var(--radius) + 2px);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--foreground) 10%, transparent);
}
.paperclip-markdown table {
width: 100%;
}
.paperclip-markdown th {
font-weight: 600;
text-align: left;
}
.paperclip-mermaid {
margin: 0.5rem 0;
padding: 0.45rem 0.55rem;
@@ -476,25 +591,21 @@ a.paperclip-project-mention-chip {
white-space: nowrap;
}
/* Keep MDXEditor popups above app dialogs when editor is inside a modal. */
.paperclip-mdxeditor-scope [class*="_dialogOverlay_"],
.paperclip-mdxeditor [class*="_dialogOverlay_"] {
/* Keep MDXEditor popups above app dialogs, even when they portal to <body>. */
[class*="_popupContainer_"] {
z-index: 81 !important;
}
[class*="_dialogOverlay_"] {
z-index: 80;
}
.paperclip-mdxeditor-scope [class*="_dialogContent_"],
.paperclip-mdxeditor-scope [class*="_largeDialogContent_"],
.paperclip-mdxeditor-scope [class*="_popoverContent_"],
.paperclip-mdxeditor-scope [class*="_linkDialogPopoverContent_"],
.paperclip-mdxeditor-scope [class*="_tableColumnEditorPopoverContent_"],
.paperclip-mdxeditor-scope [class*="_toolbarButtonDropdownContainer_"],
.paperclip-mdxeditor-scope [class*="_toolbarNodeKindSelectContainer_"],
.paperclip-mdxeditor [class*="_dialogContent_"],
.paperclip-mdxeditor [class*="_largeDialogContent_"],
.paperclip-mdxeditor [class*="_popoverContent_"],
.paperclip-mdxeditor [class*="_linkDialogPopoverContent_"],
.paperclip-mdxeditor [class*="_tableColumnEditorPopoverContent_"],
.paperclip-mdxeditor [class*="_toolbarButtonDropdownContainer_"],
.paperclip-mdxeditor [class*="_toolbarNodeKindSelectContainer_"] {
[class*="_dialogContent_"],
[class*="_largeDialogContent_"],
[class*="_popoverContent_"],
[class*="_linkDialogPopoverContent_"],
[class*="_tableColumnEditorPopoverContent_"],
[class*="_toolbarButtonDropdownContainer_"],
[class*="_toolbarNodeKindSelectContainer_"] {
z-index: 81 !important;
}

250
ui/src/lib/inbox.test.ts Normal file
View File

@@ -0,0 +1,250 @@
// @vitest-environment node
import { beforeEach, describe, expect, it } from "vitest";
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import {
computeInboxBadgeData,
getRecentTouchedIssues,
getUnreadTouchedIssues,
loadLastInboxTab,
RECENT_ISSUES_LIMIT,
saveLastInboxTab,
} from "./inbox";
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
},
},
configurable: true,
});
function makeApproval(status: Approval["status"]): Approval {
return {
id: `approval-${status}`,
companyId: "company-1",
type: "hire_agent",
requestedByAgentId: null,
requestedByUserId: null,
status,
payload: {},
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
};
}
function makeJoinRequest(id: string): JoinRequest {
return {
id,
inviteId: "invite-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
requestEmailSnapshot: null,
requestIp: "127.0.0.1",
requestingUserId: null,
agentName: null,
adapterType: null,
capabilities: null,
agentDefaultsPayload: null,
claimSecretExpiresAt: null,
claimSecretConsumedAt: null,
createdAgentId: null,
approvedByUserId: null,
approvedAt: null,
rejectedByUserId: null,
rejectedAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
};
}
function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string, agentId = "agent-1"): HeartbeatRun {
return {
id,
companyId: "company-1",
agentId,
invocationSource: "assignment",
triggerDetail: null,
status,
error: null,
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,
stdoutExcerpt: null,
stderrExcerpt: null,
contextSnapshot: null,
startedAt: new Date(createdAt),
finishedAt: null,
createdAt: new Date(createdAt),
updatedAt: new Date(createdAt),
};
}
function makeIssue(id: string, isUnreadForMe: boolean): Issue {
return {
id,
companyId: "company-1",
projectId: null,
goalId: null,
parentId: null,
title: `Issue ${id}`,
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
identifier: `PAP-${id}`,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: 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: new Date("2026-03-11T00:00:00.000Z"),
lastExternalCommentAt: new Date("2026-03-11T01:00:00.000Z"),
isUnreadForMe,
};
}
const dashboard: DashboardSummary = {
companyId: "company-1",
agents: {
active: 1,
running: 0,
paused: 0,
error: 1,
},
tasks: {
open: 1,
inProgress: 0,
blocked: 0,
done: 0,
},
costs: {
monthSpendCents: 900,
monthBudgetCents: 1000,
monthUtilizationPercent: 90,
},
pendingApprovals: 1,
};
describe("inbox helpers", () => {
beforeEach(() => {
storage.clear();
});
it("counts the same inbox sources the badge uses", () => {
const result = computeInboxBadgeData({
approvals: [makeApproval("pending"), makeApproval("approved")],
joinRequests: [makeJoinRequest("join-1")],
dashboard,
heartbeatRuns: [
makeRun("run-old", "failed", "2026-03-11T00:00:00.000Z"),
makeRun("run-latest", "timed_out", "2026-03-11T01:00:00.000Z"),
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
],
unreadIssues: [makeIssue("1", true)],
dismissed: new Set<string>(),
});
expect(result).toEqual({
inbox: 6,
approvals: 1,
failedRuns: 2,
joinRequests: 1,
unreadTouchedIssues: 1,
alerts: 1,
});
});
it("drops dismissed runs and alerts from the computed badge", () => {
const result = computeInboxBadgeData({
approvals: [],
joinRequests: [],
dashboard,
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
unreadIssues: [],
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
});
expect(result).toEqual({
inbox: 0,
approvals: 0,
failedRuns: 0,
joinRequests: 0,
unreadTouchedIssues: 0,
alerts: 0,
});
});
it("keeps read issues in the touched list but excludes them from unread counts", () => {
const issues = [makeIssue("1", true), makeIssue("2", false)];
expect(getUnreadTouchedIssues(issues).map((issue) => issue.id)).toEqual(["1"]);
expect(issues).toHaveLength(2);
});
it("limits recent touched issues before unread badge counting", () => {
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
const issue = makeIssue(String(index + 1), index < 3);
issue.lastExternalCommentAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000);
return issue;
});
const recentIssues = getRecentTouchedIssues(issues);
expect(recentIssues).toHaveLength(RECENT_ISSUES_LIMIT);
expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]);
});
it("defaults the remembered inbox tab to recent and persists all", () => {
localStorage.clear();
expect(loadLastInboxTab()).toBe("recent");
saveLastInboxTab("all");
expect(loadLastInboxTab()).toBe("all");
});
it("maps legacy new-tab storage to recent", () => {
localStorage.setItem("paperclip:inbox:last-tab", "new");
expect(loadLastInboxTab()).toBe("recent");
});
});

150
ui/src/lib/inbox.ts Normal file
View File

@@ -0,0 +1,150 @@
import type {
Approval,
DashboardSummary,
HeartbeatRun,
Issue,
JoinRequest,
} from "@paperclipai/shared";
export const RECENT_ISSUES_LIMIT = 100;
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export type InboxTab = "recent" | "unread" | "all";
export interface InboxBadgeData {
inbox: number;
approvals: number;
failedRuns: number;
joinRequests: number;
unreadTouchedIssues: number;
alerts: number;
}
export function loadDismissedInboxItems(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
export function saveDismissedInboxItems(ids: Set<string>) {
try {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
} catch {
// Ignore localStorage failures.
}
}
export function loadLastInboxTab(): InboxTab {
try {
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
if (raw === "all" || raw === "unread" || raw === "recent") return raw;
if (raw === "new") return "recent";
return "recent";
} catch {
return "recent";
}
}
export function saveLastInboxTab(tab: InboxTab) {
try {
localStorage.setItem(INBOX_LAST_TAB_KEY, tab);
} catch {
// Ignore localStorage failures.
}
}
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
const latestByAgent = new Map<string, HeartbeatRun>();
for (const run of sorted) {
if (!latestByAgent.has(run.agentId)) {
latestByAgent.set(run.agentId, run);
}
}
return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status));
}
export function normalizeTimestamp(value: string | Date | null | undefined): number {
if (!value) return 0;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
export function issueLastActivityTimestamp(issue: Issue): number {
const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt);
if (lastExternalCommentAt > 0) return lastExternalCommentAt;
const updatedAt = normalizeTimestamp(issue.updatedAt);
const myLastTouchAt = normalizeTimestamp(issue.myLastTouchAt);
if (myLastTouchAt > 0 && updatedAt <= myLastTouchAt) return 0;
return updatedAt;
}
export function sortIssuesByMostRecentActivity(a: Issue, b: Issue): number {
const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a);
if (activityDiff !== 0) return activityDiff;
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
}
export function getRecentTouchedIssues(issues: Issue[]): Issue[] {
return [...issues].sort(sortIssuesByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT);
}
export function getUnreadTouchedIssues(issues: Issue[]): Issue[] {
return issues.filter((issue) => issue.isUnreadForMe);
}
export function computeInboxBadgeData({
approvals,
joinRequests,
dashboard,
heartbeatRuns,
unreadIssues,
dismissed,
}: {
approvals: Approval[];
joinRequests: JoinRequest[];
dashboard: DashboardSummary | undefined;
heartbeatRuns: HeartbeatRun[];
unreadIssues: Issue[];
dismissed: Set<string>;
}): InboxBadgeData {
const actionableApprovals = approvals.filter((approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status),
).length;
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !dismissed.has(`run:${run.id}`),
).length;
const unreadTouchedIssues = unreadIssues.length;
const agentErrorCount = dashboard?.agents.error ?? 0;
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0;
const showAggregateAgentError =
agentErrorCount > 0 &&
failedRuns === 0 &&
!dismissed.has("alert:agent-errors");
const showBudgetAlert =
monthBudgetCents > 0 &&
monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return {
inbox: actionableApprovals + joinRequests.length + failedRuns + unreadTouchedIssues + alerts,
approvals: actionableApprovals,
failedRuns,
joinRequests: joinRequests.length,
unreadTouchedIssues,
alerts,
};
}

View File

@@ -0,0 +1,24 @@
type IssueDetailBreadcrumb = {
label: string;
href: string;
};
type IssueDetailLocationState = {
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
};
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 } };
}
export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null {
if (typeof state !== "object" || state === null) return null;
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
return isIssueDetailBreadcrumb(candidate) ? candidate : null;
}

View File

@@ -68,6 +68,7 @@ export const queryKeys = {
["costs", companyId, from, to] as const,
heartbeats: (companyId: string, agentId?: string) =>
["heartbeats", companyId, agentId] as const,
runDetail: (runId: string) => ["heartbeat-run", runId] as const,
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
runIssues: (runId: string) => ["run-issues", runId] as const,
org: (companyId: string) => ["org", companyId] as const,

View File

@@ -311,7 +311,12 @@ export function AgentDetail() {
}
return;
}
const canonicalTab = activeView === "configuration" ? "configuration" : "dashboard";
const canonicalTab =
activeView === "configuration"
? "configuration"
: activeView === "runs"
? "runs"
: "dashboard";
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
return;
@@ -437,7 +442,7 @@ export function AgentDetail() {
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
}
const isPendingApproval = agent.status === "pending_approval";
const showConfigActionBar = activeView === "configuration" && configDirty;
const showConfigActionBar = activeView === "configuration" && (configDirty || configSaving);
return (
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
@@ -506,7 +511,7 @@ export function AgentDetail() {
className="sm:hidden flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors no-underline"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
@@ -558,15 +563,16 @@ export function AgentDetail() {
{!urlRunId && (
<Tabs
value={activeView === "configuration" ? "configuration" : "dashboard"}
value={activeView}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
>
<PageTabBar
items={[
{ value: "dashboard", label: "Dashboard" },
{ value: "configuration", label: "Configuration" },
{ value: "runs", label: "Runs" },
]}
value={activeView === "configuration" ? "configuration" : "dashboard"}
value={activeView}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
/>
</Tabs>
@@ -707,7 +713,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
<h3 className="flex items-center gap-2 text-sm font-medium">
{isLive && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
</span>
)}
@@ -851,7 +857,7 @@ function CostsSection({
<div className="space-y-4">
{runtimeState && (
<div className="border border-border rounded-lg p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 tabular-nums">
<div>
<span className="text-xs text-muted-foreground block">Input tokens</span>
<span className="text-lg font-semibold">{formatTokens(runtimeState.totalInputTokens)}</span>
@@ -890,9 +896,9 @@ function CostsSection({
<tr key={run.id} className="border-b border-border last:border-b-0">
<td className="px-3 py-2">{formatDate(run.createdAt)}</td>
<td className="px-3 py-2 font-mono">{run.id.slice(0, 8)}</td>
<td className="px-3 py-2 text-right">{formatTokens(Number(u.input_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right">{formatTokens(Number(u.output_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right">
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.input_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.output_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right tabular-nums">
{(u.cost_usd || u.total_cost_usd)
? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}`
: "-"
@@ -1037,6 +1043,8 @@ function ConfigurationTab({
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
}) {
const queryClient = useQueryClient();
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
const lastAgentRef = useRef(agent);
const { data: adapterModels } = useQuery({
queryKey:
@@ -1049,16 +1057,31 @@ function ConfigurationTab({
const updateAgent = useMutation({
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
onMutate: () => {
setAwaitingRefreshAfterSave(true);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) });
},
onError: () => {
setAwaitingRefreshAfterSave(false);
},
});
useEffect(() => {
onSavingChange(updateAgent.isPending);
}, [onSavingChange, updateAgent.isPending]);
if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) {
setAwaitingRefreshAfterSave(false);
}
lastAgentRef.current = agent;
}, [agent, awaitingRefreshAfterSave]);
const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave;
useEffect(() => {
onSavingChange(isConfigSaving);
}, [onSavingChange, isConfigSaving]);
return (
<div className="space-y-6">
@@ -1066,7 +1089,7 @@ function ConfigurationTab({
mode="edit"
agent={agent}
onSave={(patch) => updateAgent.mutate(patch)}
isSaving={updateAgent.isPending}
isSaving={isConfigSaving}
adapterModels={adapterModels}
onDirtyChange={onDirtyChange}
onSaveActionChange={onSaveActionChange}
@@ -1140,7 +1163,7 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
</span>
)}
{(metrics.totalTokens > 0 || metrics.cost > 0) && (
<div className="flex items-center gap-2 pl-5.5 text-[11px] text-muted-foreground">
<div className="flex items-center gap-2 pl-5.5 text-[11px] text-muted-foreground tabular-nums">
{metrics.totalTokens > 0 && <span>{formatTokens(metrics.totalTokens)} tok</span>}
{metrics.cost > 0 && <span>${metrics.cost.toFixed(3)}</span>}
</div>
@@ -1231,9 +1254,15 @@ function RunsTab({
/* ---- Run Detail (expanded) ---- */
function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) {
function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: hydratedRun } = useQuery({
queryKey: queryKeys.runDetail(initialRun.id),
queryFn: () => heartbeatsApi.get(initialRun.id),
enabled: Boolean(initialRun.id),
});
const run = hydratedRun ?? initialRun;
const metrics = runMetrics(run);
const [sessionOpen, setSessionOpen] = useState(false);
const [claudeLoginResult, setClaudeLoginResult] = useState<ClaudeLoginResult | null>(null);
@@ -1510,7 +1539,7 @@ function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agen
{/* Right column: metrics */}
{hasMetrics && (
<div className="border-t sm:border-t-0 sm:border-l border-border p-4 grid grid-cols-2 gap-x-4 sm:gap-x-8 gap-y-3 content-center">
<div className="border-t sm:border-t-0 sm:border-l border-border p-4 grid grid-cols-2 gap-x-4 sm:gap-x-8 gap-y-3 content-center tabular-nums">
<div>
<div className="text-xs text-muted-foreground">Input</div>
<div className="text-sm font-medium font-mono">{formatTokens(metrics.input)}</div>
@@ -2109,7 +2138,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{isLive && (
<span className="flex items-center gap-1 text-xs text-cyan-400">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
</span>
Live

View File

@@ -398,7 +398,7 @@ function LiveRunIndicator({
onClick={(e) => e.stopPropagation()}
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">

View File

@@ -244,7 +244,7 @@ export function Companies() {
{issueCount} {issueCount === 1 ? "issue" : "issues"}
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 tabular-nums">
<DollarSign className="h-3.5 w-3.5" />
<span>
{formatCents(company.spentMonthlyCents)}

View File

@@ -144,7 +144,7 @@ export function Costs() {
</p>
)}
</div>
<p className="text-2xl font-bold">
<p className="text-2xl font-bold tabular-nums">
{formatCents(data.summary.spendCents)}{" "}
<span className="text-base font-normal text-muted-foreground">
{data.summary.budgetCents > 0
@@ -192,7 +192,7 @@ export function Costs() {
<StatusBadge status="terminated" />
)}
</div>
<div className="text-right shrink-0 ml-2">
<div className="text-right shrink-0 ml-2 tabular-nums">
<span className="font-medium block">{formatCents(row.costCents)}</span>
<span className="text-xs text-muted-foreground block">
in {formatTokens(row.inputTokens)} / out {formatTokens(row.outputTokens)} tok
@@ -229,7 +229,7 @@ export function Costs() {
<span className="truncate">
{row.projectName ?? row.projectId ?? "Unattributed"}
</span>
<span className="font-medium">{formatCents(row.costCents)}</span>
<span className="font-medium tabular-nums">{formatCents(row.costCents)}</span>
</div>
))}
</div>

View File

@@ -255,7 +255,7 @@ export function Dashboard() {
to="/approvals"
description={
<span>
{data.staleTasks} stale tasks
Awaiting board review
</span>
}
/>

View File

@@ -1061,7 +1061,7 @@ export function DesignGuide() {
<div className="text-foreground">[12:00:17] INFO Reconnected successfully</div>
<div className="flex items-center gap-1.5">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-cyan-400 animate-ping" />
<span className="absolute inline-flex h-full w-full rounded-full bg-cyan-400 animate-pulse" />
<span className="inline-flex h-full w-full rounded-full bg-cyan-400" />
</span>
<span className="text-cyan-400">Live</span>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals";
@@ -11,6 +11,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 { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { EmptyState } from "../components/EmptyState";
@@ -31,7 +32,6 @@ import {
import {
Inbox as InboxIcon,
AlertTriangle,
Clock,
ArrowUpRight,
XCircle,
X,
@@ -40,59 +40,29 @@ import {
import { Identity } from "../components/Identity";
import { PageTabBar } from "../components/PageTabBar";
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import {
ACTIONABLE_APPROVAL_STATUSES,
getLatestFailedRunsByAgent,
getRecentTouchedIssues,
type InboxTab,
saveLastInboxTab,
} from "../lib/inbox";
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
const RECENT_ISSUES_LIMIT = 100;
const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
type InboxTab = "new" | "all";
type InboxCategoryFilter =
| "everything"
| "issues_i_touched"
| "join_requests"
| "approvals"
| "failed_runs"
| "alerts"
| "stale_work";
| "alerts";
type InboxApprovalFilter = "all" | "actionable" | "resolved";
type SectionKey =
| "issues_i_touched"
| "join_requests"
| "approvals"
| "failed_runs"
| "alerts"
| "stale_work";
const DISMISSED_KEY = "paperclip:inbox:dismissed";
function loadDismissed(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
function saveDismissed(ids: Set<string>) {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
}
function useDismissedItems() {
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissed);
const dismiss = useCallback((id: string) => {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
saveDismissed(next);
return next;
});
}, []);
return { dismissed, dismiss };
}
| "alerts";
const RUN_SOURCE_LABELS: Record<string, string> = {
timer: "Scheduled",
@@ -101,32 +71,6 @@ const RUN_SOURCE_LABELS: Record<string, string> = {
automation: "Automation",
};
function getStaleIssues(issues: Issue[]): Issue[] {
const now = Date.now();
return issues
.filter(
(i) =>
["in_progress", "todo"].includes(i.status) &&
now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS,
)
.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
}
function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
const latestByAgent = new Map<string, HeartbeatRun>();
for (const run of sorted) {
if (!latestByAgent.has(run.agentId)) {
latestByAgent.set(run.agentId, run);
}
}
return Array.from(latestByAgent.values()).filter((run) => FAILED_RUN_STATUSES.has(run.status));
}
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@@ -137,23 +81,6 @@ function runFailureMessage(run: HeartbeatRun): string {
return firstNonEmptyLine(run.error) ?? firstNonEmptyLine(run.stderrExcerpt) ?? "Run exited with an error.";
}
function normalizeTimestamp(value: string | Date | null | undefined): number {
if (!value) return 0;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
function issueLastActivityTimestamp(issue: Issue): number {
const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt);
if (lastExternalCommentAt > 0) return lastExternalCommentAt;
const updatedAt = normalizeTimestamp(issue.updatedAt);
const myLastTouchAt = normalizeTimestamp(issue.myLastTouchAt);
if (myLastTouchAt > 0 && updatedAt <= myLastTouchAt) return 0;
return updatedAt;
}
function readIssueIdFromRun(run: HeartbeatRun): string | null {
const context = run.contextSnapshot;
if (!context) return null;
@@ -171,11 +98,13 @@ function FailedRunCard({
run,
issueById,
agentName: linkedAgentName,
issueLinkState,
onDismiss,
}: {
run: HeartbeatRun;
issueById: Map<string, Issue>;
agentName: string | null;
issueLinkState: unknown;
onDismiss: () => void;
}) {
const queryClient = useQueryClient();
@@ -227,6 +156,7 @@ function FailedRunCard({
{issue ? (
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
state={issueLinkState}
className="block truncate text-sm font-medium transition-colors hover:text-foreground no-underline text-inherit"
>
<span className="font-mono text-muted-foreground mr-1.5">
@@ -311,10 +241,19 @@ export function Inbox() {
const [actionError, setActionError] = useState<string | null>(null);
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const { dismissed, dismiss } = useDismissedItems();
const { dismissed, dismiss } = useDismissedInboxItems();
const pathSegment = location.pathname.split("/").pop() ?? "new";
const tab: InboxTab = pathSegment === "all" ? "all" : "new";
const pathSegment = location.pathname.split("/").pop() ?? "recent";
const tab: InboxTab =
pathSegment === "all" || pathSegment === "unread" ? pathSegment : "recent";
const issueLinkState = useMemo(
() =>
createIssueDetailLocationState(
"Inbox",
`${location.pathname}${location.search}${location.hash}`,
),
[location.pathname, location.search, location.hash],
);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -326,6 +265,10 @@ export function Inbox() {
setBreadcrumbs([{ label: "Inbox" }]);
}, [setBreadcrumbs]);
useEffect(() => {
saveLastInboxTab(tab);
}, [tab]);
const {
data: approvals,
isLoading: isApprovalsLoading,
@@ -385,22 +328,10 @@ export function Inbox() {
enabled: !!selectedCompanyId,
});
const staleIssues = useMemo(
() => (issues ? getStaleIssues(issues) : []).filter((i) => !dismissed.has(`stale:${i.id}`)),
[issues, dismissed],
);
const sortByMostRecentActivity = useCallback(
(a: Issue, b: Issue) => {
const activityDiff = issueLastActivityTimestamp(b) - issueLastActivityTimestamp(a);
if (activityDiff !== 0) return activityDiff;
return normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt);
},
[],
);
const touchedIssues = useMemo(
() => [...touchedIssuesRaw].sort(sortByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
[sortByMostRecentActivity, touchedIssuesRaw],
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
const unreadTouchedIssues = useMemo(
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
[touchedIssues],
);
const agentById = useMemo(() => {
@@ -500,17 +431,20 @@ export function Inbox() {
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
const invalidateInboxIssueQueries = () => {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
};
const markReadMutation = useMutation({
mutationFn: (id: string) => issuesApi.markRead(id),
onMutate: (id) => {
setFadingOutIssues((prev) => new Set(prev).add(id));
},
onSuccess: () => {
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
}
invalidateInboxIssueQueries();
},
onSettled: (_data, _error, id) => {
setTimeout(() => {
@@ -523,6 +457,31 @@ export function Inbox() {
},
});
const markAllReadMutation = useMutation({
mutationFn: async (issueIds: string[]) => {
await Promise.all(issueIds.map((issueId) => issuesApi.markRead(issueId)));
},
onMutate: (issueIds) => {
setFadingOutIssues((prev) => {
const next = new Set(prev);
for (const issueId of issueIds) next.add(issueId);
return next;
});
},
onSuccess: () => {
invalidateInboxIssueQueries();
},
onSettled: (_data, _error, issueIds) => {
setTimeout(() => {
setFadingOutIssues((prev) => {
const next = new Set(prev);
for (const issueId of issueIds) next.delete(issueId);
return next;
});
}, 300);
},
});
if (!selectedCompanyId) {
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
}
@@ -535,16 +494,9 @@ export function Inbox() {
dashboard.costs.monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
const hasAlerts = showAggregateAgentError || showBudgetAlert;
const hasStale = staleIssues.length > 0;
const hasJoinRequests = joinRequests.length > 0;
const hasTouchedIssues = touchedIssues.length > 0;
const newItemCount =
failedRuns.length +
staleIssues.length +
(showAggregateAgentError ? 1 : 0) +
(showBudgetAlert ? 1 : 0);
const showJoinRequestsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
const showTouchedCategory =
@@ -553,25 +505,26 @@ export function Inbox() {
const showFailedRunsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
const showStaleCategory = allCategoryFilter === "everything" || allCategoryFilter === "stale_work";
const approvalsToRender = tab === "new" ? actionableApprovals : filteredAllApprovals;
const showTouchedSection = tab === "new" ? hasTouchedIssues : showTouchedCategory && hasTouchedIssues;
const approvalsToRender = tab === "all" ? filteredAllApprovals : actionableApprovals;
const showTouchedSection =
tab === "all"
? showTouchedCategory && hasTouchedIssues
: tab === "unread"
? unreadTouchedIssues.length > 0
: hasTouchedIssues;
const showJoinRequestsSection =
tab === "new" ? hasJoinRequests : showJoinRequestsCategory && hasJoinRequests;
const showApprovalsSection =
tab === "new"
? actionableApprovals.length > 0
: showApprovalsCategory && filteredAllApprovals.length > 0;
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
const showApprovalsSection = tab === "all"
? showApprovalsCategory && filteredAllApprovals.length > 0
: actionableApprovals.length > 0;
const showFailedRunsSection =
tab === "new" ? hasRunFailures : showFailedRunsCategory && hasRunFailures;
const showAlertsSection = tab === "new" ? hasAlerts : showAlertsCategory && hasAlerts;
const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale;
tab === "all" ? showFailedRunsCategory && hasRunFailures : tab === "unread" && hasRunFailures;
const showAlertsSection = tab === "all" ? showAlertsCategory && hasAlerts : tab === "unread" && hasAlerts;
const visibleSections = [
showFailedRunsSection ? "failed_runs" : null,
showAlertsSection ? "alerts" : null,
showStaleSection ? "stale_work" : null,
showApprovalsSection ? "approvals" : null,
showJoinRequestsSection ? "join_requests" : null,
showTouchedSection ? "issues_i_touched" : null,
@@ -586,33 +539,43 @@ export function Inbox() {
!isRunsLoading;
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
const unreadIssueIds = unreadTouchedIssues
.filter((issue) => !fadingOutIssues.has(issue.id))
.map((issue) => issue.id);
const canMarkAllRead = unreadIssueIds.length > 0;
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value === "all" ? "all" : "new"}`)}>
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
{
value: "new",
label: (
<>
New
{newItemCount > 0 && (
<span className="ml-1.5 rounded-full bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-medium text-blue-500">
{newItemCount}
</span>
)}
</>
),
value: "recent",
label: "Recent",
},
{ value: "unread", label: "Unread" },
{ value: "all", label: "All" },
]}
/>
</Tabs>
{tab === "all" && (
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{canMarkAllRead && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => markAllReadMutation.mutate(unreadIssueIds)}
disabled={markAllReadMutation.isPending}
>
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
</Button>
)}
{tab === "all" && (
<>
<Select
value={allCategoryFilter}
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
@@ -627,7 +590,6 @@ export function Inbox() {
<SelectItem value="approvals">Approvals</SelectItem>
<SelectItem value="failed_runs">Failed runs</SelectItem>
<SelectItem value="alerts">Alerts</SelectItem>
<SelectItem value="stale_work">Stale work</SelectItem>
</SelectContent>
</Select>
@@ -646,8 +608,9 @@ export function Inbox() {
</SelectContent>
</Select>
)}
</div>
)}
</>
)}
</div>
</div>
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
@@ -661,9 +624,11 @@ export function Inbox() {
<EmptyState
icon={InboxIcon}
message={
tab === "new"
? "No issues you're involved in yet."
: "No inbox items match these filters."
tab === "unread"
? "No new inbox items."
: tab === "recent"
? "No recent inbox items."
: "No inbox items match these filters."
}
/>
)}
@@ -673,7 +638,7 @@ export function Inbox() {
{showSeparatorBefore("approvals") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{tab === "new" ? "Approvals Needing Action" : "Approvals"}
{tab === "unread" ? "Approvals Needing Action" : "Approvals"}
</h3>
<div className="grid gap-3">
{approvalsToRender.map((approval) => (
@@ -764,6 +729,7 @@ export function Inbox() {
run={run}
issueById={issueById}
agentName={agentName(run.agentId)}
issueLinkState={issueLinkState}
onDismiss={() => dismiss(`run:${run.id}`)}
/>
))}
@@ -830,165 +796,67 @@ export function Inbox() {
</>
)}
{showStaleSection && (
<>
{showSeparatorBefore("stale_work") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Stale Work
</h3>
<div className="divide-y divide-border border border-border">
{staleIssues.map((issue) => (
<div
key={issue.id}
className="group/stale relative flex items-start gap-2 overflow-hidden px-3 py-3 transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4"
>
{/* Status icon - left column on mobile; Clock icon on desktop */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
</span>
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground hidden sm:block sm:mt-0" />
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex min-w-0 flex-1 cursor-pointer flex-col gap-1 no-underline text-inherit sm:flex-row sm:items-center sm:gap-3"
>
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="shrink-0 text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{issue.assigneeAgentId &&
(() => {
const name = agentName(issue.assigneeAgentId);
return name ? (
<span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
) : null;
})()}
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>
<span className="shrink-0 text-xs text-muted-foreground sm:order-last">
updated {timeAgo(issue.updatedAt)}
</span>
</span>
</Link>
<button
type="button"
onClick={() => dismiss(`stale:${issue.id}`)}
className="mt-0.5 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/stale:opacity-100 sm:mt-0"
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</div>
</>
)}
{showTouchedSection && (
<>
{showSeparatorBefore("issues_i_touched") && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
My Recent Issues
</h3>
<div className="divide-y divide-border border border-border">
{touchedIssues.map((issue) => {
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => {
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
return (
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
state={issueLinkState}
className="flex min-w-0 cursor-pointer items-start gap-2 px-3 py-3 no-underline text-inherit transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4"
>
{/* Status icon - left column on mobile, inline on desktop */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
</span>
{/* Right column on mobile: title + metadata stacked */}
<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:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
{(isUnread || isFading) ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}
}}
className="hidden sm:inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span
className={`h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
isFading ? "opacity-0" : "opacity-100"
}`}
/>
</span>
) : (
<span className="hidden sm:inline-flex h-4 w-4 shrink-0" />
)}
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="text-xs text-muted-foreground sm:hidden">
&middot;
</span>
<span className="text-xs text-muted-foreground sm:order-last">
{issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`}
</span>
</span>
</span>
{/* Unread dot - right side, vertically centered (mobile only; desktop keeps inline) */}
{(isUnread || isFading) && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{(isUnread || isFading) ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}
}}
className="shrink-0 self-center cursor-pointer sm:hidden"
aria-label="Mark as read"
>
<span
className={`block h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
isFading ? "opacity-0" : "opacity-100"
}`}
/>
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}
}}
className="inline-flex h-4 w-4 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span
className={`block h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
isFading ? "opacity-0" : "opacity-100"
}`}
/>
</span>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
</span>
<span className="inline-flex shrink-0 self-center"><PriorityIcon priority={issue.priority} /></span>
<span className="inline-flex shrink-0 self-center"><StatusIcon status={issue.status} /></span>
<span className="shrink-0 self-center text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="min-w-0 flex-1 text-sm">
<span className="line-clamp-2 min-w-0 sm:line-clamp-1 sm:block sm:truncate">
{issue.title}
</span>
)}
</span>
<span className="hidden shrink-0 self-center text-xs text-muted-foreground sm:block">
{issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`}
</span>
</Link>
);
})}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { activityApi } from "../api/activity";
@@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
import { usePanel } from "../context/PanelContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens } from "../lib/utils";
import { InlineEditor } from "../components/InlineEditor";
@@ -150,6 +151,7 @@ export function IssueDetail() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const [moreOpen, setMoreOpen] = useState(false);
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
const [detailTab, setDetailTab] = useState("comments");
@@ -213,6 +215,10 @@ export function IssueDetail() {
});
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" },
[location.state],
);
// Filter out runs already shown by the live widget to avoid duplication
const timelineRuns = useMemo(() => {
@@ -468,17 +474,17 @@ export function IssueDetail() {
useEffect(() => {
const titleLabel = issue?.title ?? issueId ?? "Issue";
setBreadcrumbs([
{ label: "Issues", href: "/issues" },
sourceBreadcrumb,
{ label: hasLiveRuns ? `🔵 ${titleLabel}` : titleLabel },
]);
}, [setBreadcrumbs, issue, issueId, hasLiveRuns]);
}, [setBreadcrumbs, sourceBreadcrumb, issue, issueId, hasLiveRuns]);
// Redirect to identifier-based URL if navigated via UUID
useEffect(() => {
if (issue?.identifier && issueId !== issue.identifier) {
navigate(`/issues/${issue.identifier}`, { replace: true });
navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state });
}
}, [issue, issueId, navigate]);
}, [issue, issueId, navigate, location.state]);
useEffect(() => {
if (!issue?.id) return;
@@ -524,6 +530,7 @@ export function IssueDetail() {
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
<Link
to={`/issues/${ancestor.identifier ?? ancestor.id}`}
state={location.state}
className="hover:text-foreground transition-colors truncate max-w-[200px]"
title={ancestor.title}
>
@@ -558,7 +565,7 @@ export function IssueDetail() {
{hasLiveRuns && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 px-2 py-0.5 text-[10px] font-medium text-cyan-600 dark:text-cyan-400 shrink-0">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-cyan-400" />
</span>
Live
@@ -661,7 +668,7 @@ export function IssueDetail() {
value={issue.description ?? ""}
onSave={(description) => updateIssue.mutate({ description })}
as="p"
className="text-sm text-muted-foreground"
className="text-[15px] leading-7 text-foreground"
placeholder="Add a description..."
multiline
mentions={mentionOptions}
@@ -800,6 +807,7 @@ export function IssueDetail() {
<Link
key={child.id}
to={`/issues/${child.identifier ?? child.id}`}
state={location.state}
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
@@ -893,7 +901,7 @@ export function IssueDetail() {
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
<div className="text-xs text-muted-foreground">No cost data yet.</div>
) : (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums">
{issueCostSummary.hasCost && (
<span className="font-medium text-foreground">
${issueCostSummary.cost.toFixed(4)}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useCallback, useRef } from "react";
import { useSearchParams } from "@/lib/router";
import { useLocation, useSearchParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
@@ -7,6 +7,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 { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
import { CircleDot } from "lucide-react";
@@ -14,6 +15,7 @@ import { CircleDot } from "lucide-react";
export function Issues() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const location = useLocation();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
@@ -63,6 +65,15 @@ export function Issues() {
return ids;
}, [liveRuns]);
const issueLinkState = useMemo(
() =>
createIssueDetailLocationState(
"Issues",
`${location.pathname}${location.search}${location.hash}`,
),
[location.pathname, location.search, location.hash],
);
useEffect(() => {
setBreadcrumbs([{ label: "Issues" }]);
}, [setBreadcrumbs]);
@@ -93,6 +104,7 @@ export function Issues() {
agents={agents}
liveIssueIds={liveIssueIds}
viewStateKey="paperclip:issues-view"
issueLinkState={issueLinkState}
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
initialSearch={initialSearch}
onSearchChange={handleSearchChange}

66
ui/src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,66 @@
import { useEffect } from "react";
import { Link, useLocation } from "@/lib/router";
import { AlertTriangle, Compass } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
type NotFoundScope = "board" | "invalid_company_prefix" | "global";
interface NotFoundPageProps {
scope?: NotFoundScope;
requestedPrefix?: string;
}
export function NotFoundPage({ scope = "global", requestedPrefix }: NotFoundPageProps) {
const location = useLocation();
const { setBreadcrumbs } = useBreadcrumbs();
const { companies, selectedCompany } = useCompany();
useEffect(() => {
setBreadcrumbs([{ label: "Not Found" }]);
}, [setBreadcrumbs]);
const fallbackCompany = selectedCompany ?? companies[0] ?? null;
const dashboardHref = fallbackCompany ? `/${fallbackCompany.issuePrefix}/dashboard` : "/";
const currentPath = `${location.pathname}${location.search}${location.hash}`;
const normalizedPrefix = requestedPrefix?.toUpperCase();
const title = scope === "invalid_company_prefix" ? "Company not found" : "Page not found";
const description =
scope === "invalid_company_prefix"
? `No company matches prefix "${normalizedPrefix ?? "unknown"}".`
: "This route does not exist.";
return (
<div className="mx-auto max-w-2xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<div className="flex items-center gap-3">
<div className="rounded-md border border-destructive/20 bg-destructive/10 p-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
</div>
<div>
<h1 className="text-xl font-semibold">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
<div className="mt-4 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
Requested path: <code className="font-mono">{currentPath}</code>
</div>
<div className="mt-5 flex flex-wrap gap-2">
<Button asChild>
<Link to={dashboardHref}>
<Compass className="mr-1.5 h-4 w-4" />
Open dashboard
</Link>
</Button>
<Button variant="outline" asChild>
<Link to="/">Go home</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
environment: "node",
},
});