diff --git a/.gitignore b/.gitignore index f2c9b9a7..312c3969 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ tmp/ tests/e2e/test-results/ tests/e2e/playwright-report/ .superset/ +.claude/worktrees/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab420a24..1eba95e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ We really appreciate both small fixes and thoughtful larger changes. ## Two Paths to Get Your Pull Request Accepted ### Path 1: Small, Focused Changes (Fastest way to get merged) + - Pick **one** clear thing to fix/improve - Touch the **smallest possible number of files** - Make sure the change is very targeted and easy to review @@ -16,6 +17,7 @@ We really appreciate both small fixes and thoughtful larger changes. These almost always get merged quickly when they're clean. ### Path 2: Bigger or Impactful Changes + - **First** talk about it in Discord โ†’ #dev channel โ†’ Describe what you're trying to solve โ†’ Share rough ideas / approach @@ -30,12 +32,43 @@ These almost always get merged quickly when they're clean. PRs that follow this path are **much** more likely to be accepted, even when they're large. ## General Rules (both paths) + - Write clear commit messages - Keep PR title + description meaningful - One PR = one logical change (unless it's a small related group) - Run tests locally first - Be kind in discussions ๐Ÿ˜„ +## Writing a Good PR message + +Please include a "thinking path" at the top of your PR message that explains from the top of the project down to what you fixed. E.g.: + +### Thinking Path Example 1: + +> - Paperclip orchestrates ai-agents for zero-human companies +> - There are many types of adapters for each LLM model provider +> - But LLM's have a context limit and not all agents can automatically compact their context +> - So we need to have an adapter-specific configuration for which adapters can and cannot automatically compact their context +> - This pull request adds per-adapter configuration of compaction, either auto or paperclip managed +> - That way we can get optimal performance from any adapter/provider in Paperclip + +### Thinking Path Example 2: + +> - Paperclip orchestrates ai-agents for zero-human companies +> - But humans want to watch the agents and oversee their work +> - Human users also operate in teams and so they need their own logins, profiles, views etc. +> - So we have a multi-user system for humans +> - But humans want to be able to update their own profile picture and avatar +> - But the avatar upload form wasn't saving the avatar to the file storage system +> - So this PR fixes the avatar upload form to use the file storage service +> - The benefit is we don't have a one-off file storage for just one aspect of the system, which would cause confusion and extra configuration + +Then have the rest of your normal PR message after the Thinking Path. + +This should include details about what you did, why you did it, why it matters & the benefits, how we can verify it works, and any risks. + +Please include screenshots if possible if you have a visible change. (use something like the [agent-browser skill](https://github.com/vercel-labs/agent-browser/blob/main/skills/agent-browser/SKILL.md) or similar to take screenshots). Ideally, you include before and after screenshots. + Questions? Just ask in #dev โ€” we're happy to help. Happy hacking! diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 62a24e41..aef48f97 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -29,6 +29,20 @@ export type { CLIAdapterModule, CreateConfigValues, } from "./types.js"; +export type { + SessionCompactionPolicy, + NativeContextManagement, + AdapterSessionManagement, + ResolvedSessionCompactionPolicy, +} from "./session-compaction.js"; +export { + ADAPTER_SESSION_MANAGEMENT, + LEGACY_SESSIONED_ADAPTER_TYPES, + getAdapterSessionManagement, + readSessionCompactionOverride, + resolveSessionCompactionPolicy, + hasSessionCompactionThresholds, +} from "./session-compaction.js"; export { REDACTED_HOME_PATH_USER, redactHomePathUserSegments, diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts new file mode 100644 index 00000000..308b54a3 --- /dev/null +++ b/packages/adapter-utils/src/session-compaction.ts @@ -0,0 +1,175 @@ +export interface SessionCompactionPolicy { + enabled: boolean; + maxSessionRuns: number; + maxRawInputTokens: number; + maxSessionAgeHours: number; +} + +export type NativeContextManagement = "confirmed" | "likely" | "unknown" | "none"; + +export interface AdapterSessionManagement { + supportsSessionResume: boolean; + nativeContextManagement: NativeContextManagement; + defaultSessionCompaction: SessionCompactionPolicy; +} + +export interface ResolvedSessionCompactionPolicy { + policy: SessionCompactionPolicy; + adapterSessionManagement: AdapterSessionManagement | null; + explicitOverride: Partial; + source: "adapter_default" | "agent_override" | "legacy_fallback"; +} + +const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = { + enabled: true, + maxSessionRuns: 200, + maxRawInputTokens: 2_000_000, + maxSessionAgeHours: 72, +}; + +// Adapters with native context management still participate in session resume, +// but Paperclip should not rotate them using threshold-based compaction. +const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = { + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, +}; + +export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ + "claude_local", + "codex_local", + "cursor", + "gemini_local", + "opencode_local", + "pi_local", +]); + +export const ADAPTER_SESSION_MANAGEMENT: Record = { + claude_local: { + supportsSessionResume: true, + nativeContextManagement: "confirmed", + defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY, + }, + codex_local: { + supportsSessionResume: true, + nativeContextManagement: "confirmed", + defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY, + }, + cursor: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, + gemini_local: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, + opencode_local: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, + pi_local: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value === "number") { + if (value === 1) return true; + if (value === 0) return false; + return undefined; + } + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { + return false; + } + return undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, Math.floor(value)); + } + if (typeof value !== "string") return undefined; + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : undefined; +} + +export function getAdapterSessionManagement(adapterType: string | null | undefined): AdapterSessionManagement | null { + if (!adapterType) return null; + return ADAPTER_SESSION_MANAGEMENT[adapterType] ?? null; +} + +export function readSessionCompactionOverride(runtimeConfig: unknown): Partial { + const runtime = isRecord(runtimeConfig) ? runtimeConfig : {}; + const heartbeat = isRecord(runtime.heartbeat) ? runtime.heartbeat : {}; + const compaction = isRecord( + heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction, + ) + ? (heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction) as Record + : {}; + + const explicit: Partial = {}; + const enabled = readBoolean(compaction.enabled); + const maxSessionRuns = readNumber(compaction.maxSessionRuns); + const maxRawInputTokens = readNumber(compaction.maxRawInputTokens); + const maxSessionAgeHours = readNumber(compaction.maxSessionAgeHours); + + if (enabled !== undefined) explicit.enabled = enabled; + if (maxSessionRuns !== undefined) explicit.maxSessionRuns = maxSessionRuns; + if (maxRawInputTokens !== undefined) explicit.maxRawInputTokens = maxRawInputTokens; + if (maxSessionAgeHours !== undefined) explicit.maxSessionAgeHours = maxSessionAgeHours; + + return explicit; +} + +export function resolveSessionCompactionPolicy( + adapterType: string | null | undefined, + runtimeConfig: unknown, +): ResolvedSessionCompactionPolicy { + const adapterSessionManagement = getAdapterSessionManagement(adapterType); + const explicitOverride = readSessionCompactionOverride(runtimeConfig); + const hasExplicitOverride = Object.keys(explicitOverride).length > 0; + const fallbackEnabled = Boolean(adapterType && LEGACY_SESSIONED_ADAPTER_TYPES.has(adapterType)); + const basePolicy = adapterSessionManagement?.defaultSessionCompaction ?? { + ...DEFAULT_SESSION_COMPACTION_POLICY, + enabled: fallbackEnabled, + }; + + return { + policy: { + enabled: explicitOverride.enabled ?? basePolicy.enabled, + maxSessionRuns: explicitOverride.maxSessionRuns ?? basePolicy.maxSessionRuns, + maxRawInputTokens: explicitOverride.maxRawInputTokens ?? basePolicy.maxRawInputTokens, + maxSessionAgeHours: explicitOverride.maxSessionAgeHours ?? basePolicy.maxSessionAgeHours, + }, + adapterSessionManagement, + explicitOverride, + source: hasExplicitOverride + ? "agent_override" + : adapterSessionManagement + ? "adapter_default" + : "legacy_fallback", + }; +} + +export function hasSessionCompactionThresholds(policy: Pick< + SessionCompactionPolicy, + "maxSessionRuns" | "maxRawInputTokens" | "maxSessionAgeHours" +>) { + return policy.maxSessionRuns > 0 || policy.maxRawInputTokens > 0 || policy.maxSessionAgeHours > 0; +} diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 2c43cc39..f3616f5a 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -257,6 +257,7 @@ export interface ServerAdapterModule { listSkills?: (ctx: AdapterSkillContext) => Promise; syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise; sessionCodec?: AdapterSessionCodec; + sessionManagement?: import("./session-compaction.js").AdapterSessionManagement; supportsLocalAgentJwt?: boolean; models?: AdapterModel[]; listModels?: () => Promise; diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index e445cc0b..c976fe8f 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -165,6 +165,14 @@ export interface HostServices { createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise; }; + /** Provides `issues.documents.list`, `issues.documents.get`, `issues.documents.upsert`, `issues.documents.delete`. */ + issueDocuments: { + list(params: WorkerToHostMethods["issues.documents.list"][0]): Promise; + get(params: WorkerToHostMethods["issues.documents.get"][0]): Promise; + upsert(params: WorkerToHostMethods["issues.documents.upsert"][0]): Promise; + delete(params: WorkerToHostMethods["issues.documents.delete"][0]): Promise; + }; + /** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */ agents: { list(params: WorkerToHostMethods["agents.list"][0]): Promise; @@ -298,6 +306,12 @@ const METHOD_CAPABILITY_MAP: Record { + return services.issueDocuments.list(params); + }), + "issues.documents.get": gated("issues.documents.get", async (params) => { + return services.issueDocuments.get(params); + }), + "issues.documents.upsert": gated("issues.documents.upsert", async (params) => { + return services.issueDocuments.upsert(params); + }), + "issues.documents.delete": gated("issues.documents.delete", async (params) => { + return services.issueDocuments.delete(params); + }), + // Agents "agents.list": gated("agents.list", async (params) => { return services.agents.list(params); diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 61228b53..8a99bcba 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -25,6 +25,8 @@ import type { Project, Issue, IssueComment, + IssueDocument, + IssueDocumentSummary, Agent, Goal, } from "@paperclipai/shared"; @@ -601,6 +603,32 @@ export interface WorkerToHostMethods { result: IssueComment, ]; + // Issue Documents + "issues.documents.list": [ + params: { issueId: string; companyId: string }, + result: IssueDocumentSummary[], + ]; + "issues.documents.get": [ + params: { issueId: string; key: string; companyId: string }, + result: IssueDocument | null, + ]; + "issues.documents.upsert": [ + params: { + issueId: string; + key: string; + body: string; + companyId: string; + title?: string; + format?: string; + changeSummary?: string; + }, + result: IssueDocument, + ]; + "issues.documents.delete": [ + params: { issueId: string; key: string; companyId: string }, + result: void, + ]; + // Agents (read) "agents.list": [ params: { companyId: string; status?: string; limit?: number; offset?: number }, diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 48705b4d..5c02bf7c 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -422,6 +422,33 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { issueComments.set(issueId, current); return comment; }, + documents: { + async list(issueId, companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.read"); + if (!isInCompany(issues.get(issueId), companyId)) return []; + return []; + }, + async get(issueId, _key, companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.read"); + if (!isInCompany(issues.get(issueId), companyId)) return null; + return null; + }, + async upsert(input) { + requireCapability(manifest, capabilitySet, "issue.documents.write"); + const parentIssue = issues.get(input.issueId); + if (!isInCompany(parentIssue, input.companyId)) { + throw new Error(`Issue not found: ${input.issueId}`); + } + throw new Error("documents.upsert is not implemented in test context"); + }, + async delete(issueId, _key, companyId) { + requireCapability(manifest, capabilitySet, "issue.documents.write"); + const parentIssue = issues.get(issueId); + if (!isInCompany(parentIssue, companyId)) { + throw new Error(`Issue not found: ${issueId}`); + } + }, + }, }, agents: { async list(input) { diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 0ea2db2e..06046983 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -19,6 +19,8 @@ import type { Project, Issue, IssueComment, + IssueDocument, + IssueDocumentSummary, Agent, Goal, } from "@paperclipai/shared"; @@ -61,6 +63,8 @@ export type { Project, Issue, IssueComment, + IssueDocument, + IssueDocumentSummary, Agent, Goal, } from "@paperclipai/shared"; @@ -774,6 +778,73 @@ export interface PluginCompaniesClient { get(companyId: string): Promise; } +/** + * `ctx.issues.documents` โ€” read and write issue documents. + * + * Requires: + * - `issue.documents.read` for `list` and `get` + * - `issue.documents.write` for `upsert` and `delete` + * + * @see PLUGIN_SPEC.md ยง14 โ€” SDK Surface + */ +export interface PluginIssueDocumentsClient { + /** + * List all documents attached to an issue. + * + * Returns summary metadata (id, key, title, format, timestamps) without + * the full document body. Use `get()` to fetch a specific document's body. + * + * Requires the `issue.documents.read` capability. + */ + list(issueId: string, companyId: string): Promise; + + /** + * Get a single document by key, including its full body content. + * + * Returns `null` if no document exists with the given key. + * + * Requires the `issue.documents.read` capability. + * + * @param issueId - UUID of the issue + * @param key - Document key (e.g. `"plan"`, `"design-spec"`) + * @param companyId - UUID of the company + */ + get(issueId: string, key: string, companyId: string): Promise; + + /** + * Create or update a document on an issue. + * + * If a document with the given key already exists, it is updated and a new + * revision is created. If it does not exist, it is created. + * + * Requires the `issue.documents.write` capability. + * + * @param input - Document data including issueId, key, body, and optional title/format/changeSummary + */ + upsert(input: { + issueId: string; + key: string; + body: string; + companyId: string; + title?: string; + format?: string; + changeSummary?: string; + }): Promise; + + /** + * Delete a document and all its revisions. + * + * No-ops silently if the document does not exist (idempotent). + * + * Requires the `issue.documents.write` capability. + * + * @param issueId - UUID of the issue + * @param key - Document key to delete + * @param companyId - UUID of the company + */ + delete(issueId: string, key: string, companyId: string): Promise; +} + /** * `ctx.issues` โ€” read and mutate issues plus comments. * @@ -783,6 +854,8 @@ export interface PluginCompaniesClient { * - `issues.update` for update * - `issue.comments.read` for `listComments` * - `issue.comments.create` for `createComment` + * - `issue.documents.read` for `documents.list` and `documents.get` + * - `issue.documents.write` for `documents.upsert` and `documents.delete` */ export interface PluginIssuesClient { list(input: { @@ -814,6 +887,8 @@ export interface PluginIssuesClient { ): Promise; listComments(issueId: string, companyId: string): Promise; createComment(issueId: string, body: string, companyId: string): Promise; + /** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */ + documents: PluginIssueDocumentsClient; } /** @@ -1056,7 +1131,7 @@ export interface PluginContext { /** Read company metadata. Requires `companies.read`. */ companies: PluginCompaniesClient; - /** Read and write issues/comments. Requires issue capabilities. */ + /** Read and write issues, comments, and documents. Requires issue capabilities. */ issues: PluginIssuesClient; /** Read and manage agents. Requires `agents.read` for reads; `agents.pause` / `agents.resume` / `agents.invoke` for write ops. */ diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 1e8d5591..df387490 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -612,6 +612,32 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost async createComment(issueId: string, body: string, companyId: string) { return callHost("issues.createComment", { issueId, body, companyId }); }, + + documents: { + async list(issueId: string, companyId: string) { + return callHost("issues.documents.list", { issueId, companyId }); + }, + + async get(issueId: string, key: string, companyId: string) { + return callHost("issues.documents.get", { issueId, key, companyId }); + }, + + async upsert(input) { + return callHost("issues.documents.upsert", { + issueId: input.issueId, + key: input.key, + body: input.body, + companyId: input.companyId, + title: input.title, + format: input.format, + changeSummary: input.changeSummary, + }); + }, + + async delete(issueId: string, key: string, companyId: string) { + return callHost("issues.documents.delete", { issueId, key, companyId }); + }, + }, }, agents: { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 7368bfde..8afa80ea 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -385,6 +385,7 @@ export const PLUGIN_CAPABILITIES = [ "project.workspaces.read", "issues.read", "issue.comments.read", + "issue.documents.read", "agents.read", "goals.read", "goals.create", @@ -395,6 +396,7 @@ export const PLUGIN_CAPABILITIES = [ "issues.create", "issues.update", "issue.comments.create", + "issue.documents.write", "agents.pause", "agents.resume", "agents.invoke", diff --git a/scripts/kill-dev.sh b/scripts/kill-dev.sh new file mode 100755 index 00000000..2cb946e2 --- /dev/null +++ b/scripts/kill-dev.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Kill all local Paperclip dev server processes (across all worktrees). +# +# Usage: +# scripts/kill-dev.sh # kill all paperclip dev processes +# scripts/kill-dev.sh --dry # preview what would be killed +# + +set -euo pipefail + +DRY_RUN=false +if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then + DRY_RUN=true +fi + +# Collect PIDs of node processes running from any paperclip directory. +# Matches paths like /Users/*/paperclip/... or /Users/*/paperclip-*/... +# Excludes postgres-related processes. +pids=() +lines=() + +while IFS= read -r line; do + [[ -z "$line" ]] && continue + # skip postgres processes + [[ "$line" == *postgres* ]] && continue + pid=$(echo "$line" | awk '{print $2}') + pids+=("$pid") + lines+=("$line") +done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true) + +if [[ ${#pids[@]} -eq 0 ]]; then + echo "No Paperclip dev processes found." + exit 0 +fi + +echo "Found ${#pids[@]} Paperclip dev process(es):" +echo "" + +for i in "${!pids[@]}"; do + line="${lines[$i]}" + pid=$(echo "$line" | awk '{print $2}') + start=$(echo "$line" | awk '{print $9}') + cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}') + # Shorten the command for readability + cmd=$(echo "$cmd" | sed "s|$HOME/||g") + printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd" +done + +echo "" + +if [[ "$DRY_RUN" == true ]]; then + echo "Dry run โ€” re-run without --dry to kill these processes." + exit 0 +fi + +echo "Sending SIGTERM..." +for pid in "${pids[@]}"; do + kill "$pid" 2>/dev/null && echo " killed $pid" || echo " $pid already gone" +done + +# Give processes a moment to exit, then SIGKILL any stragglers +sleep 2 +for pid in "${pids[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + echo " $pid still alive, sending SIGKILL..." + kill -9 "$pid" 2>/dev/null || true + fi +done + +echo "Done." diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 5583955f..1511b95e 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import express from "express"; import request from "supertest"; import { healthRoutes } from "../routes/health.js"; +import { serverVersion } from "../version.js"; describe("GET /health", () => { const app = express(); @@ -10,6 +11,6 @@ describe("GET /health", () => { it("returns 200 with status ok", async () => { const res = await request(app).get("/health"); expect(res.status).toBe(200); - expect(res.body).toEqual({ status: "ok" }); + expect(res.body).toEqual({ status: "ok", version: serverVersion }); }); }); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index bca52142..a7189fca 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; +import type { agents } from "@paperclipai/db"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { + parseSessionCompactionPolicy, resolveRuntimeSessionParamsForWorkspace, shouldResetTaskSessionForWake, type ResolvedWorkspaceForRun, @@ -20,6 +22,32 @@ function buildResolvedWorkspace(overrides: Partial = {} }; } +function buildAgent(adapterType: string, runtimeConfig: Record = {}) { + return { + id: "agent-1", + companyId: "company-1", + projectId: null, + goalId: null, + name: "Agent", + role: "engineer", + title: null, + icon: null, + status: "running", + reportsTo: null, + capabilities: null, + adapterType, + adapterConfig: {}, + runtimeConfig, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + permissions: {}, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as typeof agents.$inferSelect; +} + describe("resolveRuntimeSessionParamsForWorkspace", () => { it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => { const agentId = "agent-123"; @@ -151,3 +179,55 @@ describe("shouldResetTaskSessionForWake", () => { ).toBe(false); }); }); + +describe("parseSessionCompactionPolicy", () => { + it("disables Paperclip-managed rotation by default for codex and claude local", () => { + expect(parseSessionCompactionPolicy(buildAgent("codex_local"))).toEqual({ + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, + }); + expect(parseSessionCompactionPolicy(buildAgent("claude_local"))).toEqual({ + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, + }); + }); + + it("keeps conservative defaults for adapters without confirmed native compaction", () => { + expect(parseSessionCompactionPolicy(buildAgent("cursor"))).toEqual({ + enabled: true, + maxSessionRuns: 200, + maxRawInputTokens: 2_000_000, + maxSessionAgeHours: 72, + }); + expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({ + enabled: true, + maxSessionRuns: 200, + maxRawInputTokens: 2_000_000, + maxSessionAgeHours: 72, + }); + }); + + it("lets explicit agent overrides win over adapter defaults", () => { + expect( + parseSessionCompactionPolicy( + buildAgent("codex_local", { + heartbeat: { + sessionCompaction: { + maxSessionRuns: 25, + maxRawInputTokens: 500_000, + }, + }, + }), + ), + ).toEqual({ + enabled: true, + maxSessionRuns: 25, + maxRawInputTokens: 500_000, + maxSessionAgeHours: 0, + }); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index a25ebf7e..67a8e95b 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,4 +1,5 @@ import type { ServerAdapterModule } from "./types.js"; +import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; import { execute as claudeExecute, listClaudeSkills, @@ -84,6 +85,7 @@ const claudeLocalAdapter: ServerAdapterModule = { listSkills: listClaudeSkills, syncSkills: syncClaudeSkills, sessionCodec: claudeSessionCodec, + sessionManagement: getAdapterSessionManagement("claude_local") ?? undefined, models: claudeModels, supportsLocalAgentJwt: true, agentConfigurationDoc: claudeAgentConfigurationDoc, @@ -97,6 +99,7 @@ const codexLocalAdapter: ServerAdapterModule = { listSkills: listCodexSkills, syncSkills: syncCodexSkills, sessionCodec: codexSessionCodec, + sessionManagement: getAdapterSessionManagement("codex_local") ?? undefined, models: codexModels, listModels: listCodexModels, supportsLocalAgentJwt: true, @@ -111,6 +114,7 @@ const cursorLocalAdapter: ServerAdapterModule = { listSkills: listCursorSkills, syncSkills: syncCursorSkills, sessionCodec: cursorSessionCodec, + sessionManagement: getAdapterSessionManagement("cursor") ?? undefined, models: cursorModels, listModels: listCursorModels, supportsLocalAgentJwt: true, @@ -124,6 +128,7 @@ const geminiLocalAdapter: ServerAdapterModule = { listSkills: listGeminiSkills, syncSkills: syncGeminiSkills, sessionCodec: geminiSessionCodec, + sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined, models: geminiModels, supportsLocalAgentJwt: true, agentConfigurationDoc: geminiAgentConfigurationDoc, @@ -145,6 +150,7 @@ const openCodeLocalAdapter: ServerAdapterModule = { listSkills: listOpenCodeSkills, syncSkills: syncOpenCodeSkills, sessionCodec: openCodeSessionCodec, + sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined, models: [], listModels: listOpenCodeModels, supportsLocalAgentJwt: true, @@ -158,6 +164,7 @@ const piLocalAdapter: ServerAdapterModule = { listSkills: listPiSkills, syncSkills: syncPiSkills, sessionCodec: piSessionCodec, + sessionManagement: getAdapterSessionManagement("pi_local") ?? undefined, models: [], listModels: listPiModels, supportsLocalAgentJwt: true, diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index a43a4f54..8a0219ec 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -3,6 +3,7 @@ // imports (process/, http/, heartbeat.ts) don't need rewriting. export type { AdapterAgent, + AdapterSessionManagement, AdapterRuntime, UsageSummary, AdapterExecutionResult, @@ -20,5 +21,8 @@ export type { AdapterSkillContext, AdapterSessionCodec, AdapterModel, + NativeContextManagement, + ResolvedSessionCompactionPolicy, + SessionCompactionPolicy, ServerAdapterModule, } from "@paperclipai/adapter-utils"; diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index ee156091..a966d12b 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -100,6 +100,7 @@ function readSkillMarkdown(skillName: string): string | null { if ( normalized !== "paperclip" && normalized !== "paperclip-create-agent" && + normalized !== "paperclip-create-plugin" && normalized !== "para-memory-files" ) return null; @@ -119,6 +120,90 @@ function readSkillMarkdown(skillName: string): string | null { return null; } +/** Resolve the Paperclip repo skills directory (built-in / managed skills). */ +function resolvePaperclipSkillsDir(): string | null { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.resolve(moduleDir, "../../skills"), // published + path.resolve(process.cwd(), "skills"), // cwd (monorepo root) + path.resolve(moduleDir, "../../../skills"), // dev + ]; + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isDirectory()) return candidate; + } catch { /* skip */ } + } + return null; +} + +/** Parse YAML frontmatter from a SKILL.md file to extract the description. */ +function parseSkillFrontmatter(markdown: string): { description: string } { + const match = markdown.match(/^---\n([\s\S]*?)\n---/); + if (!match) return { description: "" }; + const yaml = match[1]; + // Extract description โ€” handles both single-line and multi-line YAML values + const descMatch = yaml.match( + /^description:\s*(?:>\s*\n((?:\s{2,}[^\n]*\n?)+)|[|]\s*\n((?:\s{2,}[^\n]*\n?)+)|["']?(.*?)["']?\s*$)/m + ); + if (!descMatch) return { description: "" }; + const raw = descMatch[1] ?? descMatch[2] ?? descMatch[3] ?? ""; + return { + description: raw + .split("\n") + .map((l: string) => l.trim()) + .filter(Boolean) + .join(" ") + .trim(), + }; +} + +interface AvailableSkill { + name: string; + description: string; + isPaperclipManaged: boolean; +} + +/** Discover all available Claude Code skills from ~/.claude/skills/. */ +function listAvailableSkills(): AvailableSkill[] { + const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + const claudeSkillsDir = path.join(homeDir, ".claude", "skills"); + const paperclipSkillsDir = resolvePaperclipSkillsDir(); + + // Build set of Paperclip-managed skill names + const paperclipSkillNames = new Set(); + if (paperclipSkillsDir) { + try { + for (const entry of fs.readdirSync(paperclipSkillsDir, { withFileTypes: true })) { + if (entry.isDirectory()) paperclipSkillNames.add(entry.name); + } + } catch { /* skip */ } + } + + const skills: AvailableSkill[] = []; + + try { + const entries = fs.readdirSync(claudeSkillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + if (entry.name.startsWith(".")) continue; + const skillMdPath = path.join(claudeSkillsDir, entry.name, "SKILL.md"); + let description = ""; + try { + const md = fs.readFileSync(skillMdPath, "utf8"); + description = parseSkillFrontmatter(md).description; + } catch { /* no SKILL.md or unreadable */ } + skills.push({ + name: entry.name, + description, + isPaperclipManaged: paperclipSkillNames.has(entry.name), + }); + } + } catch { /* ~/.claude/skills/ doesn't exist */ } + + skills.sort((a, b) => a.name.localeCompare(b.name)); + return skills; +} + function toJoinRequestResponse(row: typeof joinRequests.$inferSelect) { const { claimSecretHash: _claimSecretHash, ...safe } = row; return safe; @@ -1610,6 +1695,10 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } + router.get("/skills/available", (_req, res) => { + res.json({ skills: listAvailableSkills() }); + }); + router.get("/skills/index", (_req, res) => { res.json({ skills: [ diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index ddc7c441..59897a89 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db"; import { and, count, eq, gt, isNull, sql } from "drizzle-orm"; import { instanceUserRoles, invites } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; +import { serverVersion } from "../version.js"; export function healthRoutes( db?: Db, @@ -22,7 +23,7 @@ export function healthRoutes( router.get("/", async (_req, res) => { if (!db) { - res.json({ status: "ok" }); + res.json({ status: "ok", version: serverVersion }); return; } @@ -56,6 +57,7 @@ export function healthRoutes( res.json({ status: "ok", + version: serverVersion, deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, authReady: opts.authReady, diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index d9da3094..51555ff5 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -116,7 +116,11 @@ export function projectRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); - const project = await svc.update(id, req.body); + const body = { ...req.body }; + if (typeof body.archivedAt === "string") { + body.archivedAt = new Date(body.archivedAt); + } + const project = await svc.update(id, body); if (!project) { res.status(404).json({ error: "Project not found" }); return; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index a7758e39..ea9f8ff9 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -43,6 +43,11 @@ import { resolveExecutionWorkspaceMode, } from "./execution-workspace-policy.js"; import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js"; +import { + hasSessionCompactionThresholds, + resolveSessionCompactionPolicy, + type SessionCompactionPolicy, +} from "@paperclipai/adapter-utils"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; @@ -50,14 +55,6 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; const startLocksByAgent = new Map>(); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; -const SESSIONED_LOCAL_ADAPTERS = new Set([ - "claude_local", - "codex_local", - "cursor", - "gemini_local", - "opencode_local", - "pi_local", -]); const heartbeatRunListColumns = { id: heartbeatRuns.id, @@ -134,13 +131,6 @@ type UsageTotals = { outputTokens: number; }; -type SessionCompactionPolicy = { - enabled: boolean; - maxSessionRuns: number; - maxRawInputTokens: number; - maxSessionAgeHours: number; -}; - type SessionCompactionDecision = { rotate: boolean; reason: string | null; @@ -297,23 +287,8 @@ function formatCount(value: number | null | undefined) { return value.toLocaleString("en-US"); } -function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy { - const runtimeConfig = parseObject(agent.runtimeConfig); - const heartbeat = parseObject(runtimeConfig.heartbeat); - const compaction = parseObject( - heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtimeConfig.sessionCompaction, - ); - const supportsSessions = SESSIONED_LOCAL_ADAPTERS.has(agent.adapterType); - const enabled = compaction.enabled === undefined - ? supportsSessions - : asBoolean(compaction.enabled, supportsSessions); - - return { - enabled, - maxSessionRuns: Math.max(0, Math.floor(asNumber(compaction.maxSessionRuns, 200))), - maxRawInputTokens: Math.max(0, Math.floor(asNumber(compaction.maxRawInputTokens, 2_000_000))), - maxSessionAgeHours: Math.max(0, Math.floor(asNumber(compaction.maxSessionAgeHours, 72))), - }; +export function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy { + return resolveSessionCompactionPolicy(agent.adapterType, agent.runtimeConfig).policy; } export function resolveRuntimeSessionParamsForWorkspace(input: { @@ -745,7 +720,7 @@ export function heartbeatService(db: Db) { } const policy = parseSessionCompactionPolicy(agent); - if (!policy.enabled) { + if (!policy.enabled || !hasSessionCompactionThresholds(policy)) { return { rotate: false, reason: null, diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 2a3f5ecc..e007c1b8 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -16,6 +16,7 @@ import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; import { issueService } from "./issues.js"; import { goalService } from "./goals.js"; +import { documentService } from "./documents.js"; import { heartbeatService } from "./heartbeat.js"; import { subscribeCompanyLiveEvents } from "./live-events.js"; import { randomUUID } from "node:crypto"; @@ -450,6 +451,7 @@ export function buildHostServices( const heartbeat = heartbeatService(db); const projects = projectService(db); const issues = issueService(db); + const documents = documentService(db); const goals = goalService(db); const activity = activityService(db); const costs = costService(db); @@ -796,6 +798,43 @@ export function buildHostServices( }, }, + issueDocuments: { + async list(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const rows = await documents.listIssueDocuments(params.issueId); + return rows as any; + }, + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const doc = await documents.getIssueDocumentByKey(params.issueId, params.key); + return (doc ?? null) as any; + }, + async upsert(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + const result = await documents.upsertIssueDocument({ + issueId: params.issueId, + key: params.key, + body: params.body, + title: params.title ?? null, + format: params.format ?? "markdown", + changeSummary: params.changeSummary ?? null, + }); + return result.document as any; + }, + async delete(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + await documents.deleteIssueDocument(params.issueId, params.key); + }, + }, + agents: { async list(params) { const companyId = ensureCompanyId(params.companyId); diff --git a/server/src/version.ts b/server/src/version.ts new file mode 100644 index 00000000..39a16a4c --- /dev/null +++ b/server/src/version.ts @@ -0,0 +1,10 @@ +import { createRequire } from "node:module"; + +type PackageJson = { + version?: string; +}; + +const require = createRequire(import.meta.url); +const pkg = require("../package.json") as PackageJson; + +export const serverVersion = pkg.version ?? "0.0.0"; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index cb2d61b8..f2eed28e 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -149,4 +149,12 @@ export const agentsApi = { ) => api.post(agentPath(id, companyId, "/wakeup"), data), loginWithClaude: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/claude-login"), {}), + availableSkills: () => + api.get<{ skills: AvailableSkill[] }>("/skills/available"), }; + +export interface AvailableSkill { + name: string; + description: string; + isPaperclipManaged: boolean; +} diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index cb1b1374..b1573805 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -1,5 +1,6 @@ export type HealthStatus = { status: "ok"; + version?: string; deploymentMode?: "local_trusted" | "authenticated"; deploymentExposure?: "private" | "public"; authReady?: boolean; diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 81b8b4e3..93c048a9 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,6 +1,11 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; +import { + hasSessionCompactionThresholds, + resolveSessionCompactionPolicy, + type ResolvedSessionCompactionPolicy, +} from "@paperclipai/adapter-utils"; import type { Agent, AdapterEnvironmentTestResult, @@ -393,6 +398,31 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) : false; + const effectiveRuntimeConfig = useMemo(() => { + if (isCreate) { + return { + heartbeat: { + enabled: val!.heartbeatEnabled, + intervalSec: val!.intervalSec, + }, + }; + } + const mergedHeartbeat = { + ...(runtimeConfig.heartbeat && typeof runtimeConfig.heartbeat === "object" + ? runtimeConfig.heartbeat as Record + : {}), + ...overlay.heartbeat, + }; + return { + ...runtimeConfig, + heartbeat: mergedHeartbeat, + }; + }, [isCreate, overlay.heartbeat, runtimeConfig, val]); + const sessionCompaction = useMemo( + () => resolveSessionCompactionPolicy(adapterType, effectiveRuntimeConfig), + [adapterType, effectiveRuntimeConfig], + ); + const showSessionCompactionCard = Boolean(sessionCompaction.adapterSessionManagement); return (
@@ -829,6 +859,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { numberHint={help.intervalSec} showNumber={val!.heartbeatEnabled} /> + {showSessionCompactionCard && ( + + )}
) : !isCreate ? ( @@ -851,6 +887,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { numberHint={help.intervalSec} showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)} /> + {showSessionCompactionCard && ( + + )} +
+
Session compaction
+ + {sourceLabel} + +
+

+ {nativeSummary} +

+

+ {rotationDisabled + ? "No Paperclip-managed fresh-session thresholds are active for this adapter." + : "Paperclip will start a fresh session when one of these thresholds is reached."} +

+
+
+
Runs
+
{formatSessionThreshold(policy.maxSessionRuns, "runs")}
+
+
+
Raw input
+
{formatSessionThreshold(policy.maxRawInputTokens, "tokens")}
+
+
+
Age
+
{formatSessionThreshold(policy.maxSessionAgeHours, "hours")}
+
+
+

+ A large cumulative raw token total does not mean the full session is resent on every heartbeat. + {source === "agent_override" && " This agent has an explicit runtimeConfig session compaction override."} +

+ + ); +} + /* ---- Internal sub-components ---- */ const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]); diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 4781aea5..1c15f451 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -545,7 +545,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp {currentProjectSupportsExecutionWorkspace && ( -
+
{usesIsolatedExecutionWorkspace ? "Isolated issue checkout" : "Project primary checkout"} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index e6aaf22a..66e61bd8 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -313,6 +313,9 @@ export function Layout() { Documentation + {health?.version && ( + v{health.version} + )}
{currentProjectSupportsExecutionWorkspace && ( -
-
+
+
Use isolated issue checkout
diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index 38dc1a33..f829265a 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -154,6 +154,71 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: ( ); } +function ArchiveDangerZone({ + project, + onArchive, + archivePending, +}: { + project: Project; + onArchive: (archived: boolean) => void; + archivePending?: boolean; +}) { + const [confirming, setConfirming] = useState(false); + const isArchive = !project.archivedAt; + const action = isArchive ? "Archive" : "Unarchive"; + + return ( +
+

+ {isArchive + ? "Archive this project to hide it from the sidebar and project selectors." + : "Unarchive this project to restore it in the sidebar and project selectors."} +

+ {archivePending ? ( + + ) : confirming ? ( +
+ + {action} “{project.name}”? + + + +
+ ) : ( + + )} +
+ ); +} + export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); @@ -420,9 +485,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa alignStart valueClassName="space-y-2" > - {linkedGoals.length === 0 ? ( - None - ) : ( + {linkedGoals.length > 0 && (
{linkedGoals.map((goal) => ( 0 && "ml-1")} disabled={availableGoals.length === 0} > @@ -964,34 +1027,11 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
Danger Zone
-
-

- {project.archivedAt - ? "Unarchive this project to restore it in the sidebar and project selectors." - : "Archive this project to hide it from the sidebar and project selectors."} -

- -
+
)} diff --git a/ui/src/index.css b/ui/src/index.css index c9ee652f..6a821296 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -377,21 +377,21 @@ } .paperclip-mdxeditor-content h1 { - margin: 0 0 0.9em; + margin: 1.4em 0 0.9em; font-size: 1.75em; font-weight: 700; line-height: 1.2; } .paperclip-mdxeditor-content h2 { - margin: 0 0 0.85em; + margin: 1.3em 0 0.85em; font-size: 1.35em; font-weight: 700; line-height: 1.3; } .paperclip-mdxeditor-content h3 { - margin: 0 0 0.8em; + margin: 1.2em 0 0.8em; font-size: 1.15em; font-weight: 600; line-height: 1.35; @@ -585,8 +585,11 @@ color: var(--muted-foreground); } -.paperclip-markdown :where(h1, h2, h3, h4) { - margin-top: 1.15rem; +.paperclip-markdown h1, +.paperclip-markdown h2, +.paperclip-markdown h3, +.paperclip-markdown h4 { + margin-top: 1.75rem; margin-bottom: 0.45rem; color: var(--foreground); font-weight: 600; diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 3eb8603b..6159c1b2 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -105,6 +105,9 @@ export const queryKeys = { liveRuns: (companyId: string) => ["live-runs", companyId] as const, runIssues: (runId: string) => ["run-issues", runId] as const, org: (companyId: string) => ["org", companyId] as const, + skills: { + available: ["skills", "available"] as const, + }, plugins: { all: ["plugins"] as const, examples: ["plugins", "examples"] as const, diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 4de40782..8727e80b 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -10,6 +10,7 @@ import { heartbeatsApi } from "../api/heartbeats"; import { assetsApi } from "../api/assets"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; +import { useToast } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties"; @@ -210,6 +211,7 @@ export function ProjectDetail() { const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); + const { pushToast } = useToast(); const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); @@ -284,10 +286,14 @@ export function ProjectDetail() { { archivedAt: archived ? new Date().toISOString() : null }, resolvedCompanyId ?? lookupCompanyId, ), - onSuccess: (_, archived) => { + onSuccess: (updatedProject, archived) => { invalidateProject(); + const name = updatedProject?.name ?? project?.name ?? "Project"; if (archived) { - navigate("/projects"); + pushToast({ title: `"${name}" has been archived`, tone: "success" }); + navigate("/dashboard"); + } else { + pushToast({ title: `"${name}" has been unarchived`, tone: "success" }); } }, }); @@ -443,8 +449,24 @@ export function ProjectDetail() { return ; } - // Redirect bare /projects/:id to /projects/:id/issues + // Redirect bare /projects/:id to cached tab or default /issues if (routeProjectRef && activeTab === null) { + let cachedTab: string | null = null; + if (project?.id) { + try { cachedTab = localStorage.getItem(`paperclip:project-tab:${project.id}`); } catch {} + } + if (cachedTab === "overview") { + return ; + } + if (cachedTab === "configuration") { + return ; + } + if (cachedTab === "budget") { + return ; + } + if (isProjectPluginTab(cachedTab)) { + return ; + } return ; } @@ -453,6 +475,10 @@ export function ProjectDetail() { if (!project) return null; const handleTabChange = (tab: ProjectTab) => { + // Cache the active tab per project + if (project?.id) { + try { localStorage.setItem(`paperclip:project-tab:${project.id}`, tab); } catch {} + } if (isProjectPluginTab(tab)) { navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`); return; @@ -527,8 +553,8 @@ export function ProjectDetail() { handleTabChange(value as ProjectTab)}> ({