Compare commits
14 commits
PAP-878-cr
...
docs/maint
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
712ccc802f | ||
|
|
bfe97e08f7 | ||
|
|
e7883135f3 | ||
|
|
37cce49328 | ||
|
|
a34eb3766f | ||
|
|
e4a114331e | ||
|
|
694c2922d0 | ||
|
|
8093fbf09b | ||
|
|
1e805ef1b0 | ||
|
|
3220941a9a | ||
|
|
bd5c988728 | ||
|
|
52dab938cb | ||
|
|
334e7e61b5 | ||
|
|
eda69fed74 |
47 changed files with 1298 additions and 217 deletions
|
|
@ -63,7 +63,7 @@ async function startTempDatabase() {
|
|||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiRequestError, PaperclipApiClient } from "../client/http.js";
|
||||
import { ApiConnectionError, ApiRequestError, PaperclipApiClient } from "../client/http.js";
|
||||
|
||||
describe("PaperclipApiClient", () => {
|
||||
afterEach(() => {
|
||||
|
|
@ -59,6 +59,29 @@ describe("PaperclipApiClient", () => {
|
|||
} satisfies Partial<ApiRequestError>);
|
||||
});
|
||||
|
||||
it("throws ApiConnectionError with recovery guidance when fetch fails", async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError("fetch failed"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
|
||||
|
||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toBeInstanceOf(ApiConnectionError);
|
||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toMatchObject({
|
||||
url: "http://localhost:3100/api/companies/import/preview",
|
||||
method: "POST",
|
||||
causeMessage: "fetch failed",
|
||||
} satisfies Partial<ApiConnectionError>);
|
||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
||||
/Could not reach the Paperclip API\./,
|
||||
);
|
||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
||||
/curl http:\/\/localhost:3100\/api\/health/,
|
||||
);
|
||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
||||
/pnpm dev|pnpm paperclipai run/,
|
||||
);
|
||||
});
|
||||
|
||||
it("retries once after interactive auth recovery", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,26 @@ export class ApiRequestError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export class ApiConnectionError extends Error {
|
||||
url: string;
|
||||
method: string;
|
||||
causeMessage?: string;
|
||||
|
||||
constructor(input: {
|
||||
apiBase: string;
|
||||
path: string;
|
||||
method: string;
|
||||
cause?: unknown;
|
||||
}) {
|
||||
const url = buildUrl(input.apiBase, input.path);
|
||||
const causeMessage = formatConnectionCause(input.cause);
|
||||
super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage }));
|
||||
this.url = url;
|
||||
this.method = input.method;
|
||||
this.causeMessage = causeMessage;
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
ignoreNotFound?: boolean;
|
||||
}
|
||||
|
|
@ -76,6 +96,7 @@ export class PaperclipApiClient {
|
|||
hasRetriedAuth = false,
|
||||
): Promise<T | null> {
|
||||
const url = buildUrl(this.apiBase, path);
|
||||
const method = String(init.method ?? "GET").toUpperCase();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json",
|
||||
|
|
@ -94,10 +115,20 @@ export class PaperclipApiClient {
|
|||
headers["x-paperclip-run-id"] = this.runId;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ApiConnectionError({
|
||||
apiBase: this.apiBase,
|
||||
path,
|
||||
method,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts?.ignoreNotFound && response.status === 404) {
|
||||
return null;
|
||||
|
|
@ -108,7 +139,7 @@ export class PaperclipApiClient {
|
|||
if (!hasRetriedAuth && this.recoverAuth) {
|
||||
const recoveredToken = await this.recoverAuth({
|
||||
path,
|
||||
method: String(init.method ?? "GET").toUpperCase(),
|
||||
method,
|
||||
error: apiError,
|
||||
});
|
||||
if (recoveredToken) {
|
||||
|
|
@ -166,6 +197,50 @@ async function toApiError(response: Response): Promise<ApiRequestError> {
|
|||
return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed);
|
||||
}
|
||||
|
||||
function buildConnectionErrorMessage(input: {
|
||||
apiBase: string;
|
||||
url: string;
|
||||
method: string;
|
||||
causeMessage?: string;
|
||||
}): string {
|
||||
const healthUrl = buildHealthCheckUrl(input.url);
|
||||
const lines = [
|
||||
"Could not reach the Paperclip API.",
|
||||
"",
|
||||
`Request: ${input.method} ${input.url}`,
|
||||
];
|
||||
if (input.causeMessage) {
|
||||
lines.push(`Cause: ${input.causeMessage}`);
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
"This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
|
||||
"",
|
||||
"Try:",
|
||||
"- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
|
||||
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
|
||||
`- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildHealthCheckUrl(requestUrl: string): string {
|
||||
const url = new URL(requestUrl);
|
||||
url.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/api(?:\/.*)?$/, "")}/api/health`;
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function formatConnectionCause(error: unknown): string | undefined {
|
||||
if (!error) return undefined;
|
||||
if (error instanceof Error) {
|
||||
return error.message.trim() || error.name;
|
||||
}
|
||||
const message = String(error).trim();
|
||||
return message || undefined;
|
||||
}
|
||||
|
||||
function toStringRecord(headers: HeadersInit | undefined): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
if (Array.isArray(headers)) {
|
||||
|
|
|
|||
|
|
@ -756,7 +756,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P
|
|||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,10 +51,9 @@ Public packages are discovered from:
|
|||
|
||||
- `packages/`
|
||||
- `server/`
|
||||
- `ui/`
|
||||
- `cli/`
|
||||
|
||||
`ui/` is ignored because it is private.
|
||||
|
||||
The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which:
|
||||
|
||||
- finds all public packages
|
||||
|
|
@ -65,6 +64,18 @@ The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts
|
|||
|
||||
Those rewrites are temporary. The working tree is restored after publish or dry-run.
|
||||
|
||||
## `@paperclipai/ui` packaging
|
||||
|
||||
The UI package publishes prebuilt static assets, not the source workspace.
|
||||
|
||||
The `ui` package uses [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs) during `prepack` to swap in a lean publish manifest that:
|
||||
|
||||
- keeps the release-managed `name` and `version`
|
||||
- publishes only `dist/`
|
||||
- omits the source-only dependency graph from downstream installs
|
||||
|
||||
After packing or publishing, `postpack` restores the development manifest automatically.
|
||||
|
||||
## Version formats
|
||||
|
||||
Paperclip uses calendar versions:
|
||||
|
|
@ -135,6 +146,7 @@ This is the fastest way to restore the default install path if a stable release
|
|||
|
||||
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
|
||||
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
|
||||
- [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs)
|
||||
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
|
||||
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
|
||||
- [`doc/RELEASING.md`](RELEASING.md)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ At minimum that includes:
|
|||
|
||||
- `paperclipai`
|
||||
- `@paperclipai/server`
|
||||
- `@paperclipai/ui`
|
||||
- public packages under `packages/`
|
||||
|
||||
### 2.1. In npm, open each package settings page
|
||||
|
|
|
|||
31
doc/SPEC.md
31
doc/SPEC.md
|
|
@ -186,17 +186,21 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an
|
|||
|
||||
### Execution Adapters
|
||||
|
||||
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters:
|
||||
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Built-in adapters include:
|
||||
|
||||
| Adapter | Mechanism | Example |
|
||||
| -------------------- | ----------------------- | --------------------------------------------- |
|
||||
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
||||
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
||||
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
||||
| `gemini_local` | Gemini CLI process | Local Gemini CLI with sandbox and approval |
|
||||
| `hermes_local` | Hermes agent process | Local Hermes agent |
|
||||
| Adapter | Mechanism | Example |
|
||||
| ---------------- | -------------------------- | -------------------------------------------------- |
|
||||
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
||||
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
||||
| `claude_local` | Local Claude Code process | Claude Code heartbeat worker |
|
||||
| `codex_local` | Local Codex process | Codex CLI heartbeat worker |
|
||||
| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker |
|
||||
| `pi_local` | Local Pi process | Pi CLI heartbeat worker |
|
||||
| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker |
|
||||
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
||||
| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker |
|
||||
|
||||
The `process` and `http` adapters ship as defaults. Additional adapters have been added for specific agent runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
|
||||
The `process` and `http` adapters ship as generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
|
||||
|
||||
### Adapter Interface
|
||||
|
||||
|
|
@ -376,7 +380,7 @@ Flow:
|
|||
| Layer | Technology |
|
||||
| -------- | ------------------------------------------------------------ |
|
||||
| Frontend | React + Vite |
|
||||
| Backend | TypeScript + Hono (REST API, not tRPC — need non-TS clients) |
|
||||
| Backend | TypeScript + Express (REST API, not tRPC — need non-TS clients) |
|
||||
| Database | PostgreSQL (see [doc/DATABASE.md](./doc/DATABASE.md) for details — PGlite embedded for dev, Docker or hosted Supabase for production) |
|
||||
| Auth | [Better Auth](https://www.better-auth.com/) |
|
||||
|
||||
|
|
@ -406,7 +410,7 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization
|
|||
|
||||
### Work Artifacts
|
||||
|
||||
Paperclip does **not** manage work artifacts (code repos, file systems, deployments, documents). That's entirely the agent's domain. Paperclip tracks tasks and costs. Where and how work gets done is outside scope.
|
||||
Paperclip does **not** manage full delivery infrastructure (code repos, deployments, production runtime). It tracks task-linked artifacts (for example issue documents and attachments), while implementation and deployment remain the agent's domain.
|
||||
|
||||
### Open Questions
|
||||
|
||||
|
|
@ -476,15 +480,14 @@ Each is a distinct page/route:
|
|||
- [ ] **Default agent** — basic Claude Code/Codex loop with Paperclip skill
|
||||
- [ ] **Default CEO** — strategic planning, delegation, board communication
|
||||
- [ ] **Paperclip skill (SKILL.md)** — teaches agents to interact with the API
|
||||
- [ ] **REST API** — full API for agent interaction (Hono)
|
||||
- [ ] **REST API** — full API for agent interaction (Express)
|
||||
- [ ] **Web UI** — React/Vite: org chart, task board, dashboard, cost views
|
||||
- [ ] **Agent auth** — connection string generation with URL + key + instructions
|
||||
- [ ] **One-command dev setup** — embedded PGlite, everything local
|
||||
- [ ] **Multiple Adapter types** (HTTP Adapter, OpenClaw Adapter)
|
||||
- [ ] **Multiple Adapter types** (HTTP, OpenClaw gateway, and local coding adapters)
|
||||
|
||||
### Not V1
|
||||
|
||||
- Template export/import
|
||||
- Knowledge base - a future plugin
|
||||
- Advanced governance models (hiring budgets, multi-member boards)
|
||||
- Revenue/expense tracking beyond token costs - a future plugin
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@
|
|||
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.1.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild": "^0.27.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
|
|
@ -44,5 +44,10 @@
|
|||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.4"
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -352,7 +352,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const combinedPath = path.join(skillsDir, "agent-instructions.md");
|
||||
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
|
||||
effectiveInstructionsFilePath = combinedPath;
|
||||
await onLog("stderr", `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
|
|
|
|||
|
|
@ -415,10 +415,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
instructionsChars = instructionsPrefix.length;
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
|
|
|
|||
|
|
@ -307,10 +307,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
instructionsChars = instructionsPrefix.length;
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
|
|
|
|||
|
|
@ -253,10 +253,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
|
|
|
|||
|
|
@ -221,10 +221,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
|
|
|
|||
|
|
@ -266,10 +266,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
|
||||
`You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`;
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
instructionsReadFailed = true;
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ async function createTempDatabase(): Promise<string> {
|
|||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ async function ensureEmbeddedPostgresConnection(
|
|||
password: "paperclip",
|
||||
port: selectedPort,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
|
|
|||
22
patches/embedded-postgres@18.1.0-beta.16.patch
Normal file
22
patches/embedded-postgres@18.1.0-beta.16.patch
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
diff --git a/dist/index.js b/dist/index.js
|
||||
index ccfe17a82f4879bf20cc345c579a987d9eba5309..dd689f5908f625f49b4785318daea736aa88927f 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -133,7 +133,7 @@ class EmbeddedPostgres {
|
||||
`--pwfile=${passwordFile}`,
|
||||
`--lc-messages=${LC_MESSAGES_LOCALE}`,
|
||||
...this.options.initdbFlags,
|
||||
- ], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
|
||||
+ ], Object.assign(Object.assign({}, permissionIds), { env: Object.assign(Object.assign({}, process.env), { LC_MESSAGES: LC_MESSAGES_LOCALE }) }));
|
||||
// Connect to stderr, as that is where the messages get sent
|
||||
(_a = process.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
|
||||
// Parse the data as a string and log it
|
||||
@@ -177,7 +177,7 @@ class EmbeddedPostgres {
|
||||
'-p',
|
||||
this.options.port.toString(),
|
||||
...this.options.postgresFlags,
|
||||
- ], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
|
||||
+ ], Object.assign(Object.assign({}, permissionIds), { env: Object.assign(Object.assign({}, process.env), { LC_MESSAGES: LC_MESSAGES_LOCALE }) }));
|
||||
// Connect to stderr, as that is where the messages get sent
|
||||
(_a = this.process.stderr) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
|
||||
// Parse the data as a string and log it
|
||||
69
pnpm-lock.yaml
generated
69
pnpm-lock.yaml
generated
|
|
@ -4,6 +4,11 @@ settings:
|
|||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
patchedDependencies:
|
||||
embedded-postgres@18.1.0-beta.16:
|
||||
hash: qmixl47dgryk2bbwt4egonhgem
|
||||
path: patches/embedded-postgres@18.1.0-beta.16.patch
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
|
|
@ -22,7 +27,7 @@ importers:
|
|||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
cli:
|
||||
dependencies:
|
||||
|
|
@ -73,7 +78,7 @@ importers:
|
|||
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
||||
embedded-postgres:
|
||||
specifier: ^18.1.0-beta.16
|
||||
version: 18.1.0-beta.16
|
||||
version: 18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem)
|
||||
picocolors:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
|
|
@ -225,7 +230,7 @@ importers:
|
|||
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
||||
embedded-postgres:
|
||||
specifier: ^18.1.0-beta.16
|
||||
version: 18.1.0-beta.16
|
||||
version: 18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.8
|
||||
|
|
@ -244,7 +249,7 @@ importers:
|
|||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
packages/plugins/create-paperclip-plugin:
|
||||
dependencies:
|
||||
|
|
@ -294,7 +299,7 @@ importers:
|
|||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
packages/plugins/examples/plugin-file-browser-example:
|
||||
dependencies:
|
||||
|
|
@ -494,7 +499,7 @@ importers:
|
|||
version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4)
|
||||
embedded-postgres:
|
||||
specifier: ^18.1.0-beta.16
|
||||
version: 18.1.0-beta.16
|
||||
version: 18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem)
|
||||
express:
|
||||
specifier: ^5.1.0
|
||||
version: 5.2.1
|
||||
|
|
@ -570,7 +575,7 @@ importers:
|
|||
version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vitest:
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
ui:
|
||||
dependencies:
|
||||
|
|
@ -691,7 +696,7 @@ importers:
|
|||
version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vitest:
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
packages:
|
||||
|
||||
|
|
@ -9973,7 +9978,7 @@ snapshots:
|
|||
|
||||
electron-to-chromium@1.5.286: {}
|
||||
|
||||
embedded-postgres@18.1.0-beta.16:
|
||||
embedded-postgres@18.1.0-beta.16(patch_hash=qmixl47dgryk2bbwt4egonhgem):
|
||||
dependencies:
|
||||
async-exit-hook: 2.0.1
|
||||
pg: 8.18.0
|
||||
|
|
@ -12169,8 +12174,52 @@ snapshots:
|
|||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
optional: true
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
'@vitest/spy': 3.2.4
|
||||
'@vitest/utils': 3.2.4
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.15
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vite-node: 3.2.4(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 24.12.0
|
||||
jsdom: 28.1.0(@noble/hashes@2.0.1)
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
|
|
|
|||
77
releases/v2026.325.0.md
Normal file
77
releases/v2026.325.0.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# v2026.325.0
|
||||
|
||||
> Released: 2026-03-25
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Company import/export** — Full company portability with a file-browser UX for importing and exporting agent companies. Includes rich frontmatter preview, nested file picker, merge-history support, GitHub shorthand refs, and CLI `company import`/`company export` commands. Imported companies open automatically after import, and heartbeat timers are disabled for imported agents by default. ([#840](https://github.com/paperclipai/paperclip/pull/840), [#1631](https://github.com/paperclipai/paperclip/pull/1631), [#1632](https://github.com/paperclipai/paperclip/pull/1632), [#1655](https://github.com/paperclipai/paperclip/pull/1655))
|
||||
- **Company skills library** — New company-scoped skills system with a skills UI, agent skill sync across all local adapters (Claude, Codex, Pi, Gemini), pinned GitHub skills with update checks, and built-in skill support. ([#1346](https://github.com/paperclipai/paperclip/pull/1346))
|
||||
- **Routines and recurring tasks** — Full routines engine with triggers, routine runs, coalescing, and recurring task portability. Includes API documentation and routine export support. ([#1351](https://github.com/paperclipai/paperclip/pull/1351), [#1622](https://github.com/paperclipai/paperclip/pull/1622), @aronprins)
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Inline join requests in inbox** — Join requests now render inline in the inbox alongside approvals and other work items.
|
||||
- **Onboarding seeding** — New projects and issues are seeded with goal context during onboarding for a better first-run experience.
|
||||
- **Agent instructions recovery** — Managed agent instructions are recovered from disk on startup; instructions are preserved across adapter switches.
|
||||
- **Heartbeats settings page** — Shows all agents regardless of interval config; added a "Disable All" button for quick bulk control.
|
||||
- **Agent history via participation** — Agent issue history now uses participation records instead of direct assignment lookups.
|
||||
- **Alphabetical agent sorting** — Agents are sorted alphabetically by name across all views.
|
||||
- **Company org chart assets** — Improved generated org chart visuals for companies.
|
||||
- **Improved CLI API connection errors** — Better error messages when the CLI cannot reach the Paperclip API.
|
||||
- **Markdown mention links** — Custom URL schemes are now allowed in Lexical LinkNode, enabling mention pills with proper linking behavior. Atomic deletion of mention pills works correctly.
|
||||
- **Issue workspace reuse** — Workspaces are correctly reused after isolation runs.
|
||||
- **Failed-run session resume** — Explicit failed-run sessions can now be resumed via honor flag.
|
||||
- **Docker image CI** — Added Docker image build and deploy workflow. ([#542](https://github.com/paperclipai/paperclip/pull/542), @albttx)
|
||||
- **Project filter on issues** — Issues list can now be filtered by project. ([#552](https://github.com/paperclipai/paperclip/pull/552), @mvanhorn)
|
||||
- **Inline comment image attachments** — Uploaded images are now embedded inline in comments. ([#551](https://github.com/paperclipai/paperclip/pull/551), @mvanhorn)
|
||||
- **AGENTS.md fallback** — Claude-local adapter gracefully falls back when AGENTS.md is missing. ([#550](https://github.com/paperclipai/paperclip/pull/550), @mvanhorn)
|
||||
- **Company-creator skill** — New skill for scaffolding agent company packages from scratch.
|
||||
- **Reports page rename** — Reports section renamed for clarity. ([#1380](https://github.com/paperclipai/paperclip/pull/1380), @DanielSousa)
|
||||
- **Eval framework bootstrap** — Promptfoo-based evaluation framework with YAML test cases for systematic agent behavior testing. ([#832](https://github.com/paperclipai/paperclip/pull/832), @mvanhorn)
|
||||
- **Board CLI authentication** — Browser-based auth flow for the CLI so board users can authenticate without manually copying API keys. ([#1635](https://github.com/paperclipai/paperclip/pull/1635))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Embedded Postgres initdb in Docker slim** — Fixed initdb failure in slim containers by adding proper initdbFlags types. ([#737](https://github.com/paperclipai/paperclip/pull/737), @alaa-alghazouli)
|
||||
- **OpenClaw gateway crash** — Fixed unhandled rejection when challengePromise fails. ([#743](https://github.com/paperclipai/paperclip/pull/743), @Sigmabrogz)
|
||||
- **Agent mention pill alignment** — Fixed vertical misalignment between agent mention pills and project mention pills.
|
||||
- **Task assignment grants** — Preserved task assignment grants for agents that have already joined.
|
||||
- **Instructions tab state** — Fixed tab state not updating correctly when switching between agents.
|
||||
- **Imported agent bundle frontmatter** — Fixed frontmatter leakage in imported agent bundles.
|
||||
- **Login form 1Password detection** — Fixed login form not being detected by password managers; Enter key now submits correctly. ([#1014](https://github.com/paperclipai/paperclip/pull/1014))
|
||||
- **Pill contrast (WCAG)** — Improved mention pill contrast using WCAG contrast ratios on composited backgrounds.
|
||||
- **Documents horizontal scroll** — Prevented documents row from causing horizontal scroll on mobile.
|
||||
- **Toggle switch sizing** — Fixed oversized toggle switches on mobile; added missing `data-slot` attributes.
|
||||
- **Agent instructions tab responsive** — Made agent instructions tab responsive on mobile.
|
||||
- **Monospace font sizing** — Adjusted inline code font size and added dark mode background.
|
||||
- **Priority icon removal** — Removed priority icon from issue rows for a cleaner list view.
|
||||
- **Same-page issue toasts** — Suppressed redundant toasts when navigating to an issue already on screen.
|
||||
- **Noisy adapter log** — Removed noisy "Loaded agent instructions file" log message from all adapters.
|
||||
- **Pi local adapter** — Fixed Pi adapter missing from `isLocal` check. ([#1382](https://github.com/paperclipai/paperclip/pull/1382), @lucas-stellet)
|
||||
- **CLI auth migration idempotency** — Made migration 0044 idempotent to avoid failures on re-run.
|
||||
- **Dev restart tracking** — `.paperclip` and test-only paths are now ignored in dev restart detection.
|
||||
- **Duplicate CLI auth flag** — Fixed duplicate `--company` flag on `auth login`.
|
||||
- **Gemini local execution** — Fixed Gemini local adapter execution and diagnostics.
|
||||
- **Sidebar ordering** — Preserved sidebar ordering during company portability operations.
|
||||
- **Company skill deduplication** — Fixed duplicate skill inventory refreshes.
|
||||
- **Worktree merge-history migrations** — Fixed migration handling in worktree contexts. ([#1385](https://github.com/paperclipai/paperclip/pull/1385))
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
Seven new database migrations (`0038`–`0044`) will run automatically on startup:
|
||||
|
||||
- **Migration 0038** adds process tracking columns to heartbeat runs (PID, started-at, retry tracking).
|
||||
- **Migration 0039** adds the routines engine tables (routines, triggers, routine runs).
|
||||
- **Migrations 0040–0042** extend company skills, recurring tasks, and portability metadata.
|
||||
- **Migration 0043** adds the Codex managed-home and agent instructions recovery columns.
|
||||
- **Migration 0044** adds board API keys and CLI auth challenge tables for browser-based CLI auth.
|
||||
|
||||
All migrations are additive (new tables and columns) — no existing data is modified. Standard `paperclipai` startup will apply them automatically.
|
||||
|
||||
If you use the company import/export feature, note that imported companies have heartbeat timers disabled by default. Re-enable them manually from the Heartbeats settings page after verifying adapter configuration.
|
||||
|
||||
## Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release!
|
||||
|
||||
@alaa-alghazouli, @albttx, @AOrobator, @aronprins, @cryppadotta, @DanielSousa, @lucas-stellet, @mvanhorn, @richardanaya, @Sigmabrogz
|
||||
31
scripts/generate-ui-package-json.mjs
Normal file
31
scripts/generate-ui-package-json.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..");
|
||||
const uiDir = join(repoRoot, "ui");
|
||||
const packageJsonPath = join(uiDir, "package.json");
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
||||
|
||||
const publishPackageJson = {
|
||||
name: packageJson.name,
|
||||
version: packageJson.version,
|
||||
description: packageJson.description,
|
||||
license: packageJson.license,
|
||||
homepage: packageJson.homepage,
|
||||
bugs: packageJson.bugs,
|
||||
repository: packageJson.repository,
|
||||
type: packageJson.type,
|
||||
files: ["dist"],
|
||||
publishConfig: {
|
||||
access: "public",
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(packageJsonPath, `${JSON.stringify(publishPackageJson, null, 2)}\n`);
|
||||
|
||||
console.log(" ✓ Generated publishable UI package.json");
|
||||
|
|
@ -2108,5 +2108,8 @@ describe("company portability", () => {
|
|||
replaceExisting: true,
|
||||
}),
|
||||
);
|
||||
const materializedFiles = agentInstructionsSvc.materializeManagedBundle.mock.calls[0]?.[1] as Record<string, string>;
|
||||
expect(materializedFiles["AGENTS.md"]).not.toMatch(/^---\n/);
|
||||
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ async function startTempDatabase() {
|
|||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
|
|
|||
57
server/src/__tests__/invite-join-grants.test.ts
Normal file
57
server/src/__tests__/invite-join-grants.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { agentJoinGrantsFromDefaults } from "../routes/access.js";
|
||||
|
||||
describe("agentJoinGrantsFromDefaults", () => {
|
||||
it("adds tasks:assign when invite defaults do not specify agent grants", () => {
|
||||
expect(agentJoinGrantsFromDefaults(null)).toEqual([
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves invite agent grants and appends tasks:assign", () => {
|
||||
expect(
|
||||
agentJoinGrantsFromDefaults({
|
||||
agent: {
|
||||
grants: [
|
||||
{
|
||||
permissionKey: "agents:create",
|
||||
scope: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
permissionKey: "agents:create",
|
||||
scope: null,
|
||||
},
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate tasks:assign when invite defaults already include it", () => {
|
||||
expect(
|
||||
agentJoinGrantsFromDefaults({
|
||||
agent: {
|
||||
grants: [
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: { projectId: "project-1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: { projectId: "project-1" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -20,16 +20,29 @@ describe("issue goal fallback", () => {
|
|||
resolveIssueGoalId({
|
||||
projectId: null,
|
||||
goalId: "goal-2",
|
||||
projectGoalId: "goal-3",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-2");
|
||||
});
|
||||
|
||||
it("does not force a company goal when the issue belongs to a project", () => {
|
||||
it("inherits the project goal when creating a project-linked issue", () => {
|
||||
expect(
|
||||
resolveIssueGoalId({
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
projectGoalId: "goal-2",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-2");
|
||||
});
|
||||
|
||||
it("does not force a company goal when the project has no goal", () => {
|
||||
expect(
|
||||
resolveIssueGoalId({
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
projectGoalId: null,
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBeNull();
|
||||
|
|
@ -40,20 +53,47 @@ describe("issue goal fallback", () => {
|
|||
resolveNextIssueGoalId({
|
||||
currentProjectId: null,
|
||||
currentGoalId: null,
|
||||
currentProjectGoalId: null,
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-1");
|
||||
});
|
||||
|
||||
it("clears the fallback when a project is added later", () => {
|
||||
it("switches from the company fallback to the project goal when a project is added later", () => {
|
||||
expect(
|
||||
resolveNextIssueGoalId({
|
||||
currentProjectId: null,
|
||||
currentGoalId: "goal-1",
|
||||
currentProjectGoalId: null,
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
projectGoalId: "goal-2",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBeNull();
|
||||
).toBe("goal-2");
|
||||
});
|
||||
|
||||
it("backfills the project goal for legacy project-linked issues on update", () => {
|
||||
expect(
|
||||
resolveNextIssueGoalId({
|
||||
currentProjectId: "project-1",
|
||||
currentGoalId: null,
|
||||
currentProjectGoalId: "goal-2",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-2");
|
||||
});
|
||||
|
||||
it("preserves an explicit goal across project fallback changes", () => {
|
||||
expect(
|
||||
resolveNextIssueGoalId({
|
||||
currentProjectId: "project-1",
|
||||
currentGoalId: "goal-explicit",
|
||||
currentProjectGoalId: "goal-2",
|
||||
projectId: "project-2",
|
||||
projectGoalId: "goal-3",
|
||||
defaultGoalId: "goal-1",
|
||||
}),
|
||||
).toBe("goal-explicit");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
187
server/src/__tests__/issues-goal-context-routes.test.ts
Normal file
187
server/src/__tests__/issues-goal-context-routes.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getAncestors: vi.fn(),
|
||||
findMentionedProjectIds: vi.fn(),
|
||||
getCommentCursor: vi.fn(),
|
||||
getComment: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockProjectService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
listByIds: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGoalService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
getDefaultCompanyGoal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
documentService: () => ({
|
||||
getIssueDocumentPayload: vi.fn(async () => ({})),
|
||||
}),
|
||||
executionWorkspaceService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
goalService: () => mockGoalService,
|
||||
heartbeatService: () => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => mockProjectService,
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
const legacyProjectLinkedIssue = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-581",
|
||||
title: "Legacy onboarding task",
|
||||
description: "Seed the first CEO task",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
projectId: "22222222-2222-4222-8222-222222222222",
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
updatedAt: new Date("2026-03-24T12:00:00Z"),
|
||||
executionWorkspaceId: null,
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
};
|
||||
|
||||
const projectGoal = {
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
companyId: "company-1",
|
||||
title: "Launch the company",
|
||||
description: null,
|
||||
level: "company",
|
||||
status: "active",
|
||||
parentId: null,
|
||||
ownerAgentId: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00Z"),
|
||||
};
|
||||
|
||||
describe("issue goal context routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
|
||||
mockIssueService.getAncestors.mockResolvedValue([]);
|
||||
mockIssueService.findMentionedProjectIds.mockResolvedValue([]);
|
||||
mockIssueService.getCommentCursor.mockResolvedValue({
|
||||
totalComments: 0,
|
||||
latestCommentId: null,
|
||||
latestCommentAt: null,
|
||||
});
|
||||
mockIssueService.getComment.mockResolvedValue(null);
|
||||
mockProjectService.getById.mockResolvedValue({
|
||||
id: legacyProjectLinkedIssue.projectId,
|
||||
companyId: "company-1",
|
||||
urlKey: "onboarding",
|
||||
goalId: projectGoal.id,
|
||||
goalIds: [projectGoal.id],
|
||||
goals: [{ id: projectGoal.id, title: projectGoal.title }],
|
||||
name: "Onboarding",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
codebase: {
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
repoName: null,
|
||||
localFolder: null,
|
||||
managedFolder: "/tmp/company-1/project-1",
|
||||
effectiveLocalFolder: "/tmp/company-1/project-1",
|
||||
origin: "managed_checkout",
|
||||
},
|
||||
workspaces: [],
|
||||
primaryWorkspace: null,
|
||||
archivedAt: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00Z"),
|
||||
});
|
||||
mockProjectService.listByIds.mockResolvedValue([]);
|
||||
mockGoalService.getById.mockImplementation(async (id: string) =>
|
||||
id === projectGoal.id ? projectGoal : null,
|
||||
);
|
||||
mockGoalService.getDefaultCompanyGoal.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("surfaces the project goal from GET /issues/:id when the issue has no direct goal", async () => {
|
||||
const res = await request(createApp()).get("/api/issues/11111111-1111-4111-8111-111111111111");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.goalId).toBe(projectGoal.id);
|
||||
expect(res.body.goal).toEqual(
|
||||
expect.objectContaining({
|
||||
id: projectGoal.id,
|
||||
title: projectGoal.title,
|
||||
}),
|
||||
);
|
||||
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces the project goal from GET /issues/:id/heartbeat-context", async () => {
|
||||
const res = await request(createApp()).get(
|
||||
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.issue.goalId).toBe(projectGoal.id);
|
||||
expect(res.body.goal).toEqual(
|
||||
expect.objectContaining({
|
||||
id: projectGoal.id,
|
||||
title: projectGoal.title,
|
||||
}),
|
||||
);
|
||||
expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -68,7 +68,7 @@ async function startTempDatabase() {
|
|||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ async function startTempDatabase() {
|
|||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ async function startTempDatabase() {
|
|||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: appendEmbeddedPostgresLog,
|
||||
onError: appendEmbeddedPostgresLog,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1411,6 +1411,25 @@ function grantsFromDefaults(
|
|||
return result;
|
||||
}
|
||||
|
||||
export function agentJoinGrantsFromDefaults(
|
||||
defaultsPayload: Record<string, unknown> | null | undefined
|
||||
): Array<{
|
||||
permissionKey: (typeof PERMISSION_KEYS)[number];
|
||||
scope: Record<string, unknown> | null;
|
||||
}> {
|
||||
const grants = grantsFromDefaults(defaultsPayload, "agent");
|
||||
if (grants.some((grant) => grant.permissionKey === "tasks:assign")) {
|
||||
return grants;
|
||||
}
|
||||
return [
|
||||
...grants,
|
||||
{
|
||||
permissionKey: "tasks:assign",
|
||||
scope: null
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
type JoinRequestManagerCandidate = {
|
||||
id: string;
|
||||
role: string;
|
||||
|
|
@ -2618,17 +2637,8 @@ export function accessRoutes(
|
|||
"member",
|
||||
"active"
|
||||
);
|
||||
await access.setPrincipalPermission(
|
||||
companyId,
|
||||
"agent",
|
||||
created.id,
|
||||
"tasks:assign",
|
||||
true,
|
||||
req.actor.userId ?? null
|
||||
);
|
||||
const grants = grantsFromDefaults(
|
||||
invite.defaultsPayload as Record<string, unknown> | null,
|
||||
"agent"
|
||||
const grants = agentJoinGrantsFromDefaults(
|
||||
invite.defaultsPayload as Record<string, unknown> | null
|
||||
);
|
||||
await access.setPrincipalGrants(
|
||||
companyId,
|
||||
|
|
|
|||
|
|
@ -171,6 +171,33 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
return rawId;
|
||||
}
|
||||
|
||||
async function resolveIssueProjectAndGoal(issue: {
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
goalId: string | null;
|
||||
}) {
|
||||
const projectPromise = issue.projectId ? projectsSvc.getById(issue.projectId) : Promise.resolve(null);
|
||||
const directGoalPromise = issue.goalId ? goalsSvc.getById(issue.goalId) : Promise.resolve(null);
|
||||
const [project, directGoal] = await Promise.all([projectPromise, directGoalPromise]);
|
||||
|
||||
if (directGoal) {
|
||||
return { project, goal: directGoal };
|
||||
}
|
||||
|
||||
const projectGoalId = project?.goalId ?? project?.goalIds[0] ?? null;
|
||||
if (projectGoalId) {
|
||||
const projectGoal = await goalsSvc.getById(projectGoalId);
|
||||
return { project, goal: projectGoal };
|
||||
}
|
||||
|
||||
if (!issue.projectId) {
|
||||
const defaultGoal = await goalsSvc.getDefaultCompanyGoal(issue.companyId);
|
||||
return { project, goal: defaultGoal };
|
||||
}
|
||||
|
||||
return { project, goal: null };
|
||||
}
|
||||
|
||||
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes
|
||||
router.param("id", async (req, res, next, rawId) => {
|
||||
try {
|
||||
|
|
@ -311,14 +338,9 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const [ancestors, project, goal, mentionedProjectIds, documentPayload] = await Promise.all([
|
||||
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload] = await Promise.all([
|
||||
resolveIssueProjectAndGoal(issue),
|
||||
svc.getAncestors(issue.id),
|
||||
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
||||
issue.goalId
|
||||
? goalsSvc.getById(issue.goalId)
|
||||
: !issue.projectId
|
||||
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
|
||||
: null,
|
||||
svc.findMentionedProjectIds(issue.id),
|
||||
documentsSvc.getIssueDocumentPayload(issue),
|
||||
]);
|
||||
|
|
@ -356,14 +378,9 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
? req.query.wakeCommentId.trim()
|
||||
: null;
|
||||
|
||||
const [ancestors, project, goal, commentCursor, wakeComment] = await Promise.all([
|
||||
const [{ project, goal }, ancestors, commentCursor, wakeComment] = await Promise.all([
|
||||
resolveIssueProjectAndGoal(issue),
|
||||
svc.getAncestors(issue.id),
|
||||
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
||||
issue.goalId
|
||||
? goalsSvc.getById(issue.goalId)
|
||||
: !issue.projectId
|
||||
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
|
||||
: null,
|
||||
svc.getCommentCursor(issue.id),
|
||||
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -3864,6 +3864,16 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
: []),
|
||||
);
|
||||
const markdownRaw = bundleFiles["AGENTS.md"] ?? readPortableTextFile(plan.source.files, manifestAgent.path);
|
||||
const entryRelativePath = normalizePortablePath(manifestAgent.path).startsWith(bundlePrefix)
|
||||
? normalizePortablePath(manifestAgent.path).slice(bundlePrefix.length)
|
||||
: "AGENTS.md";
|
||||
if (typeof markdownRaw === "string") {
|
||||
const importedInstructionsBody = parseFrontmatterMarkdown(markdownRaw).body;
|
||||
bundleFiles[entryRelativePath] = importedInstructionsBody;
|
||||
if (entryRelativePath !== "AGENTS.md" && !bundleFiles["AGENTS.md"]) {
|
||||
bundleFiles["AGENTS.md"] = importedInstructionsBody;
|
||||
}
|
||||
}
|
||||
const fallbackPromptTemplate = asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "";
|
||||
if (!markdownRaw && fallbackPromptTemplate) {
|
||||
bundleFiles["AGENTS.md"] = fallbackPromptTemplate;
|
||||
|
|
|
|||
|
|
@ -3,28 +3,54 @@ type MaybeId = string | null | undefined;
|
|||
export function resolveIssueGoalId(input: {
|
||||
projectId: MaybeId;
|
||||
goalId: MaybeId;
|
||||
projectGoalId?: MaybeId;
|
||||
defaultGoalId: MaybeId;
|
||||
}): string | null {
|
||||
if (!input.projectId && !input.goalId) {
|
||||
return input.defaultGoalId ?? null;
|
||||
}
|
||||
return input.goalId ?? null;
|
||||
if (input.goalId) return input.goalId;
|
||||
if (input.projectId) return input.projectGoalId ?? null;
|
||||
return input.defaultGoalId ?? null;
|
||||
}
|
||||
|
||||
export function resolveNextIssueGoalId(input: {
|
||||
currentProjectId: MaybeId;
|
||||
currentGoalId: MaybeId;
|
||||
currentProjectGoalId?: MaybeId;
|
||||
projectId?: MaybeId;
|
||||
goalId?: MaybeId;
|
||||
projectGoalId?: MaybeId;
|
||||
defaultGoalId: MaybeId;
|
||||
}): string | null {
|
||||
const projectId =
|
||||
input.projectId !== undefined ? input.projectId : input.currentProjectId;
|
||||
const goalId =
|
||||
input.goalId !== undefined ? input.goalId : input.currentGoalId;
|
||||
const projectGoalId =
|
||||
input.projectGoalId !== undefined
|
||||
? input.projectGoalId
|
||||
: projectId
|
||||
? input.currentProjectGoalId
|
||||
: null;
|
||||
|
||||
if (!projectId && !goalId) {
|
||||
const resolveFallbackGoalId = (targetProjectId: MaybeId, targetProjectGoalId: MaybeId) => {
|
||||
if (targetProjectId) return targetProjectGoalId ?? null;
|
||||
return input.defaultGoalId ?? null;
|
||||
};
|
||||
|
||||
if (input.goalId !== undefined) {
|
||||
return input.goalId ?? resolveFallbackGoalId(projectId, projectGoalId);
|
||||
}
|
||||
return goalId ?? null;
|
||||
|
||||
const currentFallbackGoalId = resolveFallbackGoalId(
|
||||
input.currentProjectId,
|
||||
input.currentProjectGoalId,
|
||||
);
|
||||
const nextFallbackGoalId = resolveFallbackGoalId(projectId, projectGoalId);
|
||||
|
||||
if (!input.currentGoalId) {
|
||||
return nextFallbackGoalId;
|
||||
}
|
||||
|
||||
if (input.currentGoalId === currentFallbackGoalId) {
|
||||
return nextFallbackGoalId;
|
||||
}
|
||||
|
||||
return input.currentGoalId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ type IssueUserContextInput = {
|
|||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
};
|
||||
type ProjectGoalReader = Pick<Db, "select">;
|
||||
|
||||
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
||||
if (actorRunId) return checkoutRunId === actorRunId;
|
||||
|
|
@ -113,6 +114,20 @@ function escapeLikePattern(value: string): string {
|
|||
return value.replace(/[\\%_]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function getProjectDefaultGoalId(
|
||||
db: ProjectGoalReader,
|
||||
companyId: string,
|
||||
projectId: string | null | undefined,
|
||||
) {
|
||||
if (!projectId) return null;
|
||||
const row = await db
|
||||
.select({ goalId: projects.goalId })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, projectId), eq(projects.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row?.goalId ?? null;
|
||||
}
|
||||
|
||||
function touchedByUserCondition(companyId: string, userId: string) {
|
||||
return sql<boolean>`
|
||||
(
|
||||
|
|
@ -744,6 +759,7 @@ export function issueService(db: Db) {
|
|||
}
|
||||
return db.transaction(async (tx) => {
|
||||
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
|
||||
const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId);
|
||||
let executionWorkspaceSettings =
|
||||
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
|
||||
if (executionWorkspaceSettings == null && issueData.projectId) {
|
||||
|
|
@ -795,6 +811,7 @@ export function issueService(db: Db) {
|
|||
goalId: resolveIssueGoalId({
|
||||
projectId: issueData.projectId,
|
||||
goalId: issueData.goalId,
|
||||
projectGoalId,
|
||||
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
||||
}),
|
||||
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
|
||||
|
|
@ -895,11 +912,21 @@ export function issueService(db: Db) {
|
|||
|
||||
return db.transaction(async (tx) => {
|
||||
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, existing.companyId);
|
||||
const [currentProjectGoalId, nextProjectGoalId] = await Promise.all([
|
||||
getProjectDefaultGoalId(tx, existing.companyId, existing.projectId),
|
||||
getProjectDefaultGoalId(
|
||||
tx,
|
||||
existing.companyId,
|
||||
issueData.projectId !== undefined ? issueData.projectId : existing.projectId,
|
||||
),
|
||||
]);
|
||||
patch.goalId = resolveNextIssueGoalId({
|
||||
currentProjectId: existing.projectId,
|
||||
currentGoalId: existing.goalId,
|
||||
currentProjectGoalId,
|
||||
projectId: issueData.projectId,
|
||||
goalId: issueData.goalId,
|
||||
projectGoalId: nextProjectGoalId,
|
||||
defaultGoalId: defaultCompanyGoal?.id ?? null,
|
||||
});
|
||||
const updated = await tx
|
||||
|
|
|
|||
11
ui/README.md
Normal file
11
ui/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# @paperclipai/ui
|
||||
|
||||
Published static assets for the Paperclip board UI.
|
||||
|
||||
## What gets published
|
||||
|
||||
The npm package contains the production build under `dist/`. It does not ship the UI source tree or workspace-only dependencies.
|
||||
|
||||
## Typical use
|
||||
|
||||
Install the package, then serve or copy the built files from `node_modules/@paperclipai/ui/dist`.
|
||||
|
|
@ -1,13 +1,29 @@
|
|||
{
|
||||
"name": "@paperclipai/ui",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.3.1",
|
||||
"description": "Prebuilt Paperclip board UI assets.",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "ui"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b"
|
||||
"typecheck": "tsc -b",
|
||||
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
||||
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../scripts/generate-ui-package-json.mjs",
|
||||
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
|
|||
|
|
@ -26,53 +26,13 @@ import {
|
|||
thematicBreakPlugin,
|
||||
type RealmPlugin,
|
||||
} from "@mdxeditor/editor";
|
||||
import { LinkNode, type LinkAttributes } from "@lexical/link";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
||||
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
|
||||
import { mentionDeletionPlugin } from "../lib/mention-deletion";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//;
|
||||
|
||||
class MentionAwareLinkNode extends LinkNode {
|
||||
static clone(node: MentionAwareLinkNode): MentionAwareLinkNode {
|
||||
return new MentionAwareLinkNode(
|
||||
node.getURL(),
|
||||
{
|
||||
rel: node.getRel(),
|
||||
target: node.getTarget(),
|
||||
title: node.getTitle(),
|
||||
},
|
||||
node.getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
constructor(url?: string, attributes?: LinkAttributes, key?: string) {
|
||||
super(url, attributes, key);
|
||||
}
|
||||
|
||||
sanitizeUrl(url: string): string {
|
||||
if (CUSTOM_MENTION_URL_RE.test(url)) return url;
|
||||
return super.sanitizeUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
const mentionAwareLinkNodeReplacement = {
|
||||
replace: LinkNode,
|
||||
with: (node: LinkNode) =>
|
||||
new MentionAwareLinkNode(
|
||||
node.getURL(),
|
||||
{
|
||||
rel: node.getRel(),
|
||||
target: node.getTarget(),
|
||||
title: node.getTitle(),
|
||||
},
|
||||
node.getKey(),
|
||||
),
|
||||
withKlass: MentionAwareLinkNode,
|
||||
} as const;
|
||||
|
||||
/* ---- Mention types ---- */
|
||||
|
||||
export interface MentionOption {
|
||||
|
|
@ -590,7 +550,7 @@ 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,
|
||||
)}
|
||||
additionalLexicalNodes={[mentionAwareLinkNodeReplacement]}
|
||||
additionalLexicalNodes={[MentionAwareLinkNode, mentionAwareLinkNodeReplacement]}
|
||||
plugins={plugins}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { companiesApi } from "../api/companies";
|
|||
import { goalsApi } from "../api/goals";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Dialog, DialogPortal } from "@/components/ui/dialog";
|
||||
import {
|
||||
|
|
@ -24,6 +25,11 @@ import {
|
|||
import { getUIAdapter } from "../adapters";
|
||||
import { defaultCreateValues } from "./agent-config-defaults";
|
||||
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
|
||||
import {
|
||||
buildOnboardingIssuePayload,
|
||||
buildOnboardingProjectPayload,
|
||||
selectDefaultCompanyGoalId
|
||||
} from "../lib/onboarding-launch";
|
||||
import {
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
DEFAULT_CODEX_LOCAL_MODEL
|
||||
|
|
@ -144,7 +150,11 @@ export function OnboardingWizard() {
|
|||
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
|
||||
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
|
||||
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -160,6 +170,10 @@ export function OnboardingWizard() {
|
|||
setStep(effectiveOnboardingOptions.initialStep ?? 1);
|
||||
setCreatedCompanyId(cId);
|
||||
setCreatedCompanyPrefix(null);
|
||||
setCreatedCompanyGoalId(null);
|
||||
setCreatedProjectId(null);
|
||||
setCreatedAgentId(null);
|
||||
setCreatedIssueRef(null);
|
||||
}, [
|
||||
effectiveOnboardingOpen,
|
||||
effectiveOnboardingOptions.companyId,
|
||||
|
|
@ -284,7 +298,9 @@ export function OnboardingWizard() {
|
|||
setTaskDescription(DEFAULT_TASK_DESCRIPTION);
|
||||
setCreatedCompanyId(null);
|
||||
setCreatedCompanyPrefix(null);
|
||||
setCreatedCompanyGoalId(null);
|
||||
setCreatedAgentId(null);
|
||||
setCreatedProjectId(null);
|
||||
setCreatedIssueRef(null);
|
||||
}
|
||||
|
||||
|
|
@ -371,7 +387,7 @@ export function OnboardingWizard() {
|
|||
|
||||
if (companyGoal.trim()) {
|
||||
const parsedGoal = parseOnboardingGoalInput(companyGoal);
|
||||
await goalsApi.create(company.id, {
|
||||
const goal = await goalsApi.create(company.id, {
|
||||
title: parsedGoal.title,
|
||||
...(parsedGoal.description
|
||||
? { description: parsedGoal.description }
|
||||
|
|
@ -379,9 +395,12 @@ export function OnboardingWizard() {
|
|||
level: "company",
|
||||
status: "active"
|
||||
});
|
||||
setCreatedCompanyGoalId(goal.id);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.goals.list(company.id)
|
||||
});
|
||||
} else {
|
||||
setCreatedCompanyGoalId(null);
|
||||
}
|
||||
|
||||
setStep(2);
|
||||
|
|
@ -522,16 +541,38 @@ export function OnboardingWizard() {
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let goalId = createdCompanyGoalId;
|
||||
if (!goalId) {
|
||||
const goals = await goalsApi.list(createdCompanyId);
|
||||
goalId = selectDefaultCompanyGoalId(goals);
|
||||
setCreatedCompanyGoalId(goalId);
|
||||
}
|
||||
|
||||
let projectId = createdProjectId;
|
||||
if (!projectId) {
|
||||
const project = await projectsApi.create(
|
||||
createdCompanyId,
|
||||
buildOnboardingProjectPayload(goalId)
|
||||
);
|
||||
projectId = project.id;
|
||||
setCreatedProjectId(projectId);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.projects.list(createdCompanyId)
|
||||
});
|
||||
}
|
||||
|
||||
let issueRef = createdIssueRef;
|
||||
if (!issueRef) {
|
||||
const issue = await issuesApi.create(createdCompanyId, {
|
||||
title: taskTitle.trim(),
|
||||
...(taskDescription.trim()
|
||||
? { description: taskDescription.trim() }
|
||||
: {}),
|
||||
assigneeAgentId: createdAgentId,
|
||||
status: "todo"
|
||||
});
|
||||
const issue = await issuesApi.create(
|
||||
createdCompanyId,
|
||||
buildOnboardingIssuePayload({
|
||||
title: taskTitle,
|
||||
description: taskDescription,
|
||||
assigneeAgentId: createdAgentId,
|
||||
projectId,
|
||||
goalId
|
||||
})
|
||||
);
|
||||
issueRef = issue.identifier ?? issue.id;
|
||||
setCreatedIssueRef(issueRef);
|
||||
queryClient.invalidateQueries({
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@
|
|||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
text-decoration: none;
|
||||
vertical-align: baseline;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
|
@ -746,7 +746,7 @@ a.paperclip-project-mention-chip {
|
|||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
text-decoration: none;
|
||||
vertical-align: baseline;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -296,6 +296,7 @@ describe("inbox helpers", () => {
|
|||
}).map((item) => {
|
||||
if (item.kind === "issue") return `issue:${item.issue.id}`;
|
||||
if (item.kind === "approval") return `approval:${item.approval.id}`;
|
||||
if (item.kind === "join_request") return `join:${item.joinRequest.id}`;
|
||||
return `run:${item.run.id}`;
|
||||
}),
|
||||
).toEqual([
|
||||
|
|
@ -305,6 +306,37 @@ describe("inbox helpers", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("mixes join requests into the inbox feed by most recent activity", () => {
|
||||
const issue = makeIssue("1", true);
|
||||
issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
|
||||
|
||||
const joinRequest = makeJoinRequest("join-1");
|
||||
joinRequest.createdAt = new Date("2026-03-11T03:00:00.000Z");
|
||||
|
||||
const approval = makeApprovalWithTimestamps(
|
||||
"approval-oldest",
|
||||
"pending",
|
||||
"2026-03-11T02:00:00.000Z",
|
||||
);
|
||||
|
||||
expect(
|
||||
getInboxWorkItems({
|
||||
issues: [issue],
|
||||
approvals: [approval],
|
||||
joinRequests: [joinRequest],
|
||||
}).map((item) => {
|
||||
if (item.kind === "issue") return `issue:${item.issue.id}`;
|
||||
if (item.kind === "approval") return `approval:${item.approval.id}`;
|
||||
if (item.kind === "join_request") return `join:${item.joinRequest.id}`;
|
||||
return `run:${item.run.id}`;
|
||||
}),
|
||||
).toEqual([
|
||||
"issue:1",
|
||||
"join:join-1",
|
||||
"approval:approval-oldest",
|
||||
]);
|
||||
});
|
||||
|
||||
it("can include sections on recent without forcing them to be unread", () => {
|
||||
expect(
|
||||
shouldShowInboxSection({
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ export type InboxWorkItem =
|
|||
kind: "failed_run";
|
||||
timestamp: number;
|
||||
run: HeartbeatRun;
|
||||
}
|
||||
| {
|
||||
kind: "join_request";
|
||||
timestamp: number;
|
||||
joinRequest: JoinRequest;
|
||||
};
|
||||
|
||||
export interface InboxBadgeData {
|
||||
|
|
@ -152,10 +157,12 @@ export function getInboxWorkItems({
|
|||
issues,
|
||||
approvals,
|
||||
failedRuns = [],
|
||||
joinRequests = [],
|
||||
}: {
|
||||
issues: Issue[];
|
||||
approvals: Approval[];
|
||||
failedRuns?: HeartbeatRun[];
|
||||
joinRequests?: JoinRequest[];
|
||||
}): InboxWorkItem[] {
|
||||
return [
|
||||
...issues.map((issue) => ({
|
||||
|
|
@ -173,6 +180,11 @@ export function getInboxWorkItems({
|
|||
timestamp: normalizeTimestamp(run.createdAt),
|
||||
run,
|
||||
})),
|
||||
...joinRequests.map((joinRequest) => ({
|
||||
kind: "join_request" as const,
|
||||
timestamp: normalizeTimestamp(joinRequest.createdAt),
|
||||
joinRequest,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
const timestampDiff = b.timestamp - a.timestamp;
|
||||
if (timestampDiff !== 0) return timestampDiff;
|
||||
|
|
|
|||
50
ui/src/lib/mention-aware-link-node.test.ts
Normal file
50
ui/src/lib/mention-aware-link-node.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { $createLinkNode } from "@lexical/link";
|
||||
import { createEditor } from "lexical";
|
||||
import {
|
||||
MentionAwareLinkNode,
|
||||
getMentionAwareLinkNodeInit,
|
||||
mentionAwareLinkNodeReplacement,
|
||||
} from "./mention-aware-link-node";
|
||||
|
||||
function createTestEditor() {
|
||||
return createEditor({
|
||||
namespace: "mention-aware-link-node-test",
|
||||
nodes: [MentionAwareLinkNode, mentionAwareLinkNodeReplacement],
|
||||
onError(error: Error) {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("getMentionAwareLinkNodeInit", () => {
|
||||
it("copies link attributes without carrying over a node key", () => {
|
||||
const init = getMentionAwareLinkNodeInit({
|
||||
getURL: () => "agent://agent-123",
|
||||
getRel: () => "noreferrer",
|
||||
getTarget: () => "_blank",
|
||||
getTitle: () => "Agent mention",
|
||||
});
|
||||
|
||||
expect(Object.keys(init)).toEqual(["url", "attributes"]);
|
||||
expect(init).toEqual({
|
||||
url: "agent://agent-123",
|
||||
attributes: {
|
||||
rel: "noreferrer",
|
||||
target: "_blank",
|
||||
title: "Agent mention",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces LinkNode creation with MentionAwareLinkNode without throwing", () => {
|
||||
const editor = createTestEditor();
|
||||
let created: unknown;
|
||||
|
||||
editor.update(() => {
|
||||
created = $createLinkNode("agent://agent-123");
|
||||
});
|
||||
|
||||
expect(created).toBeInstanceOf(MentionAwareLinkNode);
|
||||
});
|
||||
});
|
||||
67
ui/src/lib/mention-aware-link-node.ts
Normal file
67
ui/src/lib/mention-aware-link-node.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import {
|
||||
LinkNode,
|
||||
type LinkAttributes,
|
||||
type SerializedLinkNode,
|
||||
} from "@lexical/link";
|
||||
|
||||
const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//;
|
||||
|
||||
export class MentionAwareLinkNode extends LinkNode {
|
||||
static getType(): string {
|
||||
return "mention-aware-link";
|
||||
}
|
||||
|
||||
static clone(node: MentionAwareLinkNode): MentionAwareLinkNode {
|
||||
return new MentionAwareLinkNode(
|
||||
node.getURL(),
|
||||
{
|
||||
rel: node.getRel(),
|
||||
target: node.getTarget(),
|
||||
title: node.getTitle(),
|
||||
},
|
||||
node.getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedLinkNode): MentionAwareLinkNode {
|
||||
return new MentionAwareLinkNode(
|
||||
serializedNode.url ?? "",
|
||||
{
|
||||
rel: serializedNode.rel ?? null,
|
||||
target: serializedNode.target ?? null,
|
||||
title: serializedNode.title ?? null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
constructor(url?: string, attributes?: LinkAttributes, key?: string) {
|
||||
super(url, attributes, key);
|
||||
}
|
||||
|
||||
sanitizeUrl(url: string): string {
|
||||
if (CUSTOM_MENTION_URL_RE.test(url)) return url;
|
||||
return super.sanitizeUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
type MentionAwareLinkSource = Pick<LinkNode, "getURL" | "getRel" | "getTarget" | "getTitle">;
|
||||
|
||||
export function getMentionAwareLinkNodeInit(node: MentionAwareLinkSource) {
|
||||
return {
|
||||
url: node.getURL(),
|
||||
attributes: {
|
||||
rel: node.getRel(),
|
||||
target: node.getTarget(),
|
||||
title: node.getTitle(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const mentionAwareLinkNodeReplacement = {
|
||||
replace: LinkNode,
|
||||
with: (node: LinkNode) => {
|
||||
const { url, attributes } = getMentionAwareLinkNodeInit(node);
|
||||
return new MentionAwareLinkNode(url, attributes);
|
||||
},
|
||||
withKlass: MentionAwareLinkNode,
|
||||
} as const;
|
||||
131
ui/src/lib/onboarding-launch.test.ts
Normal file
131
ui/src/lib/onboarding-launch.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildOnboardingIssuePayload,
|
||||
buildOnboardingProjectPayload,
|
||||
selectDefaultCompanyGoalId,
|
||||
} from "./onboarding-launch";
|
||||
|
||||
describe("selectDefaultCompanyGoalId", () => {
|
||||
it("prefers the earliest active root company goal", () => {
|
||||
expect(
|
||||
selectDefaultCompanyGoalId([
|
||||
{
|
||||
id: "team-goal",
|
||||
companyId: "company-1",
|
||||
title: "Nested",
|
||||
description: null,
|
||||
level: "team",
|
||||
status: "active",
|
||||
parentId: null,
|
||||
ownerAgentId: null,
|
||||
createdAt: new Date("2026-03-04T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-04T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "goal-2",
|
||||
companyId: "company-1",
|
||||
title: "Later active root",
|
||||
description: null,
|
||||
level: "company",
|
||||
status: "active",
|
||||
parentId: null,
|
||||
ownerAgentId: null,
|
||||
createdAt: new Date("2026-03-03T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-03T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "goal-1",
|
||||
companyId: "company-1",
|
||||
title: "Earliest active root",
|
||||
description: null,
|
||||
level: "company",
|
||||
status: "active",
|
||||
parentId: null,
|
||||
ownerAgentId: null,
|
||||
createdAt: new Date("2026-03-02T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-02T00:00:00Z"),
|
||||
},
|
||||
]),
|
||||
).toBe("goal-1");
|
||||
});
|
||||
|
||||
it("falls back to the earliest root company goal when none are active", () => {
|
||||
expect(
|
||||
selectDefaultCompanyGoalId([
|
||||
{
|
||||
id: "goal-2",
|
||||
companyId: "company-1",
|
||||
title: "Cancelled root",
|
||||
description: null,
|
||||
level: "company",
|
||||
status: "cancelled",
|
||||
parentId: null,
|
||||
ownerAgentId: null,
|
||||
createdAt: new Date("2026-03-03T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-03T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "goal-1",
|
||||
companyId: "company-1",
|
||||
title: "Earliest root",
|
||||
description: null,
|
||||
level: "company",
|
||||
status: "planned",
|
||||
parentId: null,
|
||||
ownerAgentId: null,
|
||||
createdAt: new Date("2026-03-02T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-02T00:00:00Z"),
|
||||
},
|
||||
]),
|
||||
).toBe("goal-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onboarding launch payloads", () => {
|
||||
it("links the onboarding project and first issue to the selected goal", () => {
|
||||
expect(buildOnboardingProjectPayload("goal-1")).toEqual({
|
||||
name: "Onboarding",
|
||||
status: "in_progress",
|
||||
goalIds: ["goal-1"],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildOnboardingIssuePayload({
|
||||
title: " Hire your first engineer ",
|
||||
description: " Kick off the hiring plan ",
|
||||
assigneeAgentId: "agent-1",
|
||||
projectId: "project-1",
|
||||
goalId: "goal-1",
|
||||
}),
|
||||
).toEqual({
|
||||
title: "Hire your first engineer",
|
||||
description: "Kick off the hiring plan",
|
||||
assigneeAgentId: "agent-1",
|
||||
projectId: "project-1",
|
||||
goalId: "goal-1",
|
||||
status: "todo",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits goal links when no default company goal exists", () => {
|
||||
expect(buildOnboardingProjectPayload(null)).toEqual({
|
||||
name: "Onboarding",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
expect(
|
||||
buildOnboardingIssuePayload({
|
||||
title: "Task",
|
||||
description: "",
|
||||
assigneeAgentId: "agent-1",
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
}),
|
||||
).toEqual({
|
||||
title: "Task",
|
||||
assigneeAgentId: "agent-1",
|
||||
projectId: "project-1",
|
||||
status: "todo",
|
||||
});
|
||||
});
|
||||
});
|
||||
53
ui/src/lib/onboarding-launch.ts
Normal file
53
ui/src/lib/onboarding-launch.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { Goal } from "@paperclipai/shared";
|
||||
|
||||
export const ONBOARDING_PROJECT_NAME = "Onboarding";
|
||||
|
||||
function goalCreatedAt(goal: Goal) {
|
||||
const createdAt = goal.createdAt instanceof Date ? goal.createdAt : new Date(goal.createdAt);
|
||||
return Number.isNaN(createdAt.getTime()) ? 0 : createdAt.getTime();
|
||||
}
|
||||
|
||||
function pickEarliestGoal(goals: Goal[]) {
|
||||
return [...goals].sort((a, b) => goalCreatedAt(a) - goalCreatedAt(b))[0] ?? null;
|
||||
}
|
||||
|
||||
export function selectDefaultCompanyGoalId(goals: Goal[]): string | null {
|
||||
const companyGoals = goals.filter((goal) => goal.level === "company");
|
||||
const rootGoals = companyGoals.filter((goal) => !goal.parentId);
|
||||
const activeRootGoals = rootGoals.filter((goal) => goal.status === "active");
|
||||
|
||||
return (
|
||||
pickEarliestGoal(activeRootGoals)?.id ??
|
||||
pickEarliestGoal(rootGoals)?.id ??
|
||||
pickEarliestGoal(companyGoals)?.id ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function buildOnboardingProjectPayload(goalId: string | null) {
|
||||
return {
|
||||
name: ONBOARDING_PROJECT_NAME,
|
||||
status: "in_progress" as const,
|
||||
...(goalId ? { goalIds: [goalId] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOnboardingIssuePayload(input: {
|
||||
title: string;
|
||||
description: string;
|
||||
assigneeAgentId: string;
|
||||
projectId: string;
|
||||
goalId: string | null;
|
||||
}) {
|
||||
const title = input.title.trim();
|
||||
const description = input.description.trim();
|
||||
|
||||
return {
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
assigneeAgentId: input.assigneeAgentId,
|
||||
projectId: input.projectId,
|
||||
...(input.goalId ? { goalId: input.goalId } : {}),
|
||||
status: "todo" as const,
|
||||
};
|
||||
}
|
||||
|
|
@ -998,6 +998,7 @@ export function AgentDetail() {
|
|||
|
||||
{activeView === "instructions" && (
|
||||
<PromptsTab
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
companyId={resolvedCompanyId ?? undefined}
|
||||
onDirtyChange={setConfigDirty}
|
||||
|
|
@ -1617,6 +1618,20 @@ function PromptsTab({
|
|||
selectedFile: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedFile("AGENTS.md");
|
||||
setShowFilePanel(false);
|
||||
setDraft(null);
|
||||
setBundleDraft(null);
|
||||
setNewFilePath("");
|
||||
setShowNewFileInput(false);
|
||||
setPendingFiles([]);
|
||||
setExpandedDirs(new Set());
|
||||
setAwaitingRefresh(false);
|
||||
lastFileVersionRef.current = null;
|
||||
externalBundleRef.current = null;
|
||||
}, [agent.id]);
|
||||
|
||||
const isLocal =
|
||||
agent.adapterType === "claude_local" ||
|
||||
agent.adapterType === "codex_local" ||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
XCircle,
|
||||
X,
|
||||
RotateCcw,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
|
|
@ -61,7 +62,6 @@ type InboxCategoryFilter =
|
|||
| "alerts";
|
||||
type SectionKey =
|
||||
| "work_items"
|
||||
| "join_requests"
|
||||
| "alerts";
|
||||
|
||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||
|
|
@ -281,6 +281,84 @@ function ApprovalInboxRow({
|
|||
);
|
||||
}
|
||||
|
||||
function JoinRequestInboxRow({
|
||||
joinRequest,
|
||||
onApprove,
|
||||
onReject,
|
||||
isPending,
|
||||
}: {
|
||||
joinRequest: JoinRequest;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const label =
|
||||
joinRequest.requestType === "human"
|
||||
? "Human join request"
|
||||
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
|
||||
<div className="flex items-start gap-2 sm:items-center">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||
<span className="mt-0.5 shrink-0 rounded-md bg-muted p-1.5 sm:mt-0">
|
||||
<UserPlus className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp}</span>
|
||||
{joinRequest.adapterType && <span>adapter: {joinRequest.adapterType}</span>}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden shrink-0 items-center gap-2 sm:flex">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
|
||||
onClick={onApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={onReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2 sm:hidden">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
|
||||
onClick={onApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={onReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Inbox() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
|
@ -431,14 +509,22 @@ export function Inbox() {
|
|||
return failedRuns;
|
||||
}, [failedRuns, tab, showFailedRunsCategory]);
|
||||
|
||||
const joinRequestsForTab = useMemo(() => {
|
||||
if (tab === "all" && !showJoinRequestsCategory) return [];
|
||||
if (tab === "recent") return joinRequests;
|
||||
if (tab === "unread") return joinRequests;
|
||||
return joinRequests;
|
||||
}, [joinRequests, tab, showJoinRequestsCategory]);
|
||||
|
||||
const workItemsToRender = useMemo(
|
||||
() =>
|
||||
getInboxWorkItems({
|
||||
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
|
||||
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
|
||||
failedRuns: failedRunsForTab,
|
||||
joinRequests: joinRequestsForTab,
|
||||
}),
|
||||
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab],
|
||||
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab, joinRequestsForTab],
|
||||
);
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
|
|
@ -602,10 +688,7 @@ export function Inbox() {
|
|||
dashboard.costs.monthUtilizationPercent >= 80 &&
|
||||
!dismissed.has("alert:budget");
|
||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||
const hasJoinRequests = joinRequests.length > 0;
|
||||
const showWorkItemsSection = workItemsToRender.length > 0;
|
||||
const showJoinRequestsSection =
|
||||
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
|
||||
const showAlertsSection = shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems: hasAlerts,
|
||||
|
|
@ -616,7 +699,6 @@ export function Inbox() {
|
|||
|
||||
const visibleSections = [
|
||||
showAlertsSection ? "alerts" : null,
|
||||
showJoinRequestsSection ? "join_requests" : null,
|
||||
showWorkItemsSection ? "work_items" : null,
|
||||
].filter((key): key is SectionKey => key !== null);
|
||||
|
||||
|
|
@ -757,6 +839,18 @@ export function Inbox() {
|
|||
);
|
||||
}
|
||||
|
||||
if (item.kind === "join_request") {
|
||||
return (
|
||||
<JoinRequestInboxRow
|
||||
key={`join:${item.joinRequest.id}`}
|
||||
joinRequest={item.joinRequest}
|
||||
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
|
||||
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
|
||||
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const issue = item.issue;
|
||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||
const isFading = fadingOutIssues.has(issue.id);
|
||||
|
|
@ -806,61 +900,6 @@ export function Inbox() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{showJoinRequestsSection && (
|
||||
<>
|
||||
{showSeparatorBefore("join_requests") && <Separator />}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Join Requests
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{joinRequests.map((joinRequest) => (
|
||||
<div key={joinRequest.id} className="rounded-xl border border-border bg-card p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{joinRequest.requestType === "human"
|
||||
? "Human join request"
|
||||
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
requested {timeAgo(joinRequest.createdAt)} from IP {joinRequest.requestIp}
|
||||
</p>
|
||||
{joinRequest.requestEmailSnapshot && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
email: {joinRequest.requestEmailSnapshot}
|
||||
</p>
|
||||
)}
|
||||
{joinRequest.adapterType && (
|
||||
<p className="text-xs text-muted-foreground">adapter: {joinRequest.adapterType}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
||||
onClick={() => rejectJoinMutation.mutate(joinRequest)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
||||
onClick={() => approveJoinMutation.mutate(joinRequest)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{showAlertsSection && (
|
||||
<>
|
||||
{showSeparatorBefore("alerts") && <Separator />}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue