mirror of
https://github.com/paperclipai/paperclip
synced 2026-05-14 10:56:25 +02:00
Compare commits
36 Commits
feature/wo
...
public/wor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
472322de24 | ||
|
|
3770e94d56 | ||
|
|
d9492f02d6 | ||
|
|
57d8d01079 | ||
|
|
345c7f4a88 | ||
|
|
521b24da3d | ||
|
|
96e03b45b9 | ||
|
|
57dcdb51af | ||
|
|
a503d2c12c | ||
|
|
21d2b075e7 | ||
|
|
92aef9bae8 | ||
|
|
5f76d03913 | ||
|
|
d3ac8722be | ||
|
|
183d71eb7c | ||
|
|
3273692944 | ||
|
|
b5935349ed | ||
|
|
4b49efa02e | ||
|
|
c2c63868e9 | ||
|
|
3a003e11cc | ||
|
|
d388255e66 | ||
|
|
80d87d3b4e | ||
|
|
21eb904a4d | ||
|
|
d62b89cadd | ||
|
|
78207304d4 | ||
|
|
c799fca313 | ||
|
|
50db379db2 | ||
|
|
56aeddfa1c | ||
|
|
42c8aca5c0 | ||
|
|
00495d3d89 | ||
|
|
a613435249 | ||
|
|
576b408682 | ||
|
|
24a553c255 | ||
|
|
2332a79e0b | ||
|
|
0c525febf2 | ||
|
|
b0fe48b730 | ||
|
|
f3a9b6de21 |
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
134
packages/db/src/migration-runtime.ts
Normal file
134
packages/db/src/migration-runtime.ts
Normal 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);
|
||||
}
|
||||
45
packages/db/src/migration-status.ts
Normal file
45
packages/db/src/migration-status.ts
Normal 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();
|
||||
107
packages/db/src/runtime-config.test.ts
Normal file
107
packages/db/src/runtime-config.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
267
packages/db/src/runtime-config.ts
Normal file
267
packages/db/src/runtime-config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -18,5 +18,4 @@ export interface DashboardSummary {
|
||||
monthUtilizationPercent: number;
|
||||
};
|
||||
pendingApprovals: number;
|
||||
staleTasks: number;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
38
server/src/__tests__/ui-branding.test.ts
Normal file
38
server/src/__tests__/ui-branding.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
41
server/src/ui-branding.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
BIN
ui/public/worktree-favicon-16x16.png
Normal file
BIN
ui/public/worktree-favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 790 B |
BIN
ui/public/worktree-favicon-32x32.png
Normal file
BIN
ui/public/worktree-favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
ui/public/worktree-favicon.ico
Normal file
BIN
ui/public/worktree-favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
9
ui/public/worktree-favicon.svg
Normal file
9
ui/public/worktree-favicon.svg
Normal 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 |
@@ -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 />
|
||||
|
||||
@@ -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))}`,
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>) : {};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">×</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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
108
ui/src/hooks/useInboxBadge.ts
Normal file
108
ui/src/hooks/useInboxBadge.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
147
ui/src/index.css
147
ui/src/index.css
@@ -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
250
ui/src/lib/inbox.test.ts
Normal 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
150
ui/src/lib/inbox.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
24
ui/src/lib/issueDetailBreadcrumb.ts
Normal file
24
ui/src/lib/issueDetailBreadcrumb.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -255,7 +255,7 @@ export function Dashboard() {
|
||||
to="/approvals"
|
||||
description={
|
||||
<span>
|
||||
{data.staleTasks} stale tasks
|
||||
Awaiting board review
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">·</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">
|
||||
·
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
66
ui/src/pages/NotFound.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user