Merge pull request #1132 from paperclipai/dotta-march-17-updates

dottas-march-17-updates
This commit is contained in:
Dotta
2026-03-17 09:33:57 -05:00
committed by GitHub
22 changed files with 833 additions and 92 deletions

1
.gitignore vendored
View File

@@ -47,3 +47,4 @@ tmp/
tests/e2e/test-results/
tests/e2e/playwright-report/
.superset/
.claude/worktrees/

View File

@@ -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!

View File

@@ -24,6 +24,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,

View File

@@ -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<SessionCompactionPolicy>;
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<string, AdapterSessionManagement> = {
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<string, unknown> {
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<SessionCompactionPolicy> {
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<string, unknown>
: {};
const explicit: Partial<SessionCompactionPolicy> = {};
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;
}

View File

@@ -216,6 +216,7 @@ export interface ServerAdapterModule {
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
sessionCodec?: AdapterSessionCodec;
sessionManagement?: import("./session-compaction.js").AdapterSessionManagement;
supportsLocalAgentJwt?: boolean;
models?: AdapterModel[];
listModels?: () => Promise<AdapterModel[]>;

71
scripts/kill-dev.sh Executable file
View File

@@ -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."

View File

@@ -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<ResolvedWorkspaceForRun> = {}
};
}
function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown> = {}) {
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,
});
});
});

View File

@@ -1,4 +1,5 @@
import type { ServerAdapterModule } from "./types.js";
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
import {
execute as claudeExecute,
testEnvironment as claudeTestEnvironment,
@@ -70,6 +71,7 @@ const claudeLocalAdapter: ServerAdapterModule = {
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
sessionCodec: claudeSessionCodec,
sessionManagement: getAdapterSessionManagement("claude_local") ?? undefined,
models: claudeModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: claudeAgentConfigurationDoc,
@@ -81,6 +83,7 @@ const codexLocalAdapter: ServerAdapterModule = {
execute: codexExecute,
testEnvironment: codexTestEnvironment,
sessionCodec: codexSessionCodec,
sessionManagement: getAdapterSessionManagement("codex_local") ?? undefined,
models: codexModels,
listModels: listCodexModels,
supportsLocalAgentJwt: true,
@@ -93,6 +96,7 @@ const cursorLocalAdapter: ServerAdapterModule = {
execute: cursorExecute,
testEnvironment: cursorTestEnvironment,
sessionCodec: cursorSessionCodec,
sessionManagement: getAdapterSessionManagement("cursor") ?? undefined,
models: cursorModels,
listModels: listCursorModels,
supportsLocalAgentJwt: true,
@@ -104,6 +108,7 @@ const geminiLocalAdapter: ServerAdapterModule = {
execute: geminiExecute,
testEnvironment: geminiTestEnvironment,
sessionCodec: geminiSessionCodec,
sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined,
models: geminiModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: geminiAgentConfigurationDoc,
@@ -123,6 +128,7 @@ const openCodeLocalAdapter: ServerAdapterModule = {
execute: openCodeExecute,
testEnvironment: openCodeTestEnvironment,
sessionCodec: openCodeSessionCodec,
sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined,
models: [],
listModels: listOpenCodeModels,
supportsLocalAgentJwt: true,
@@ -134,6 +140,7 @@ const piLocalAdapter: ServerAdapterModule = {
execute: piExecute,
testEnvironment: piTestEnvironment,
sessionCodec: piSessionCodec,
sessionManagement: getAdapterSessionManagement("pi_local") ?? undefined,
models: [],
listModels: listPiModels,
supportsLocalAgentJwt: true,

View File

@@ -3,6 +3,7 @@
// imports (process/, http/, heartbeat.ts) don't need rewriting.
export type {
AdapterAgent,
AdapterSessionManagement,
AdapterRuntime,
UsageSummary,
AdapterExecutionResult,
@@ -15,5 +16,8 @@ export type {
AdapterEnvironmentTestContext,
AdapterSessionCodec,
AdapterModel,
NativeContextManagement,
ResolvedSessionCompactionPolicy,
SessionCompactionPolicy,
ServerAdapterModule,
} from "@paperclipai/adapter-utils";

View File

@@ -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<string>();
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: [

View File

@@ -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;

View File

@@ -42,6 +42,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;
@@ -49,14 +54,6 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const startLocksByAgent = new Map<string, Promise<void>>();
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,
@@ -133,13 +130,6 @@ type UsageTotals = {
outputTokens: number;
};
type SessionCompactionPolicy = {
enabled: boolean;
maxSessionRuns: number;
maxRawInputTokens: number;
maxSessionAgeHours: number;
};
type SessionCompactionDecision = {
rotate: boolean;
reason: string | null;
@@ -296,23 +286,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: {
@@ -743,7 +718,7 @@ export function heartbeatService(db: Db) {
}
const policy = parseSessionCompactionPolicy(agent);
if (!policy.enabled) {
if (!policy.enabled || !hasSessionCompactionThresholds(policy)) {
return {
rotate: false,
reason: null,

View File

@@ -144,4 +144,12 @@ export const agentsApi = {
) => api.post<HeartbeatRun | { status: "skipped" }>(agentPath(id, companyId, "/wakeup"), data),
loginWithClaude: (id: string, companyId?: string) =>
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
availableSkills: () =>
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
};
export interface AvailableSkill {
name: string;
description: string;
isPaperclipManaged: boolean;
}

View File

@@ -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,
@@ -383,6 +388,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<string, unknown>
: {}),
...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 (
<div className={cn("relative", cards && "space-y-6")}>
@@ -813,6 +843,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
numberHint={help.intervalSec}
showNumber={val!.heartbeatEnabled}
/>
{showSessionCompactionCard && (
<SessionCompactionPolicyCard
adapterType={adapterType}
resolution={sessionCompaction}
/>
)}
</div>
</div>
) : (
@@ -835,6 +871,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
numberHint={help.intervalSec}
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
/>
{showSessionCompactionCard && (
<SessionCompactionPolicyCard
adapterType={adapterType}
resolution={sessionCompaction}
/>
)}
</div>
<CollapsibleSection
title="Advanced Run Policy"
@@ -922,6 +964,69 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
);
}
function formatSessionThreshold(value: number, suffix: string) {
if (value <= 0) return "Off";
return `${value.toLocaleString("en-US")} ${suffix}`;
}
function SessionCompactionPolicyCard({
adapterType,
resolution,
}: {
adapterType: string;
resolution: ResolvedSessionCompactionPolicy;
}) {
const { adapterSessionManagement, policy, source } = resolution;
if (!adapterSessionManagement) return null;
const adapterLabel = adapterLabels[adapterType] ?? adapterType;
const sourceLabel = source === "agent_override" ? "Agent override" : "Adapter default";
const rotationDisabled = !policy.enabled || !hasSessionCompactionThresholds(policy);
const nativeSummary =
adapterSessionManagement.nativeContextManagement === "confirmed"
? `${adapterLabel} is treated as natively managing long context, so Paperclip fresh-session rotation defaults to off.`
: adapterSessionManagement.nativeContextManagement === "likely"
? `${adapterLabel} likely manages long context itself, but Paperclip still keeps conservative rotation defaults for now.`
: `${adapterLabel} does not have verified native compaction behavior, so Paperclip keeps conservative rotation defaults.`;
return (
<div className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-medium text-sky-50">Session compaction</div>
<span className="rounded-full border border-sky-400/30 px-2 py-0.5 text-[11px] text-sky-100">
{sourceLabel}
</span>
</div>
<p className="text-xs text-sky-100/90">
{nativeSummary}
</p>
<p className="text-xs text-sky-100/80">
{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."}
</p>
<div className="grid grid-cols-3 gap-2 text-[11px] text-sky-100/85 tabular-nums">
<div>
<div className="text-sky-100/60">Runs</div>
<div>{formatSessionThreshold(policy.maxSessionRuns, "runs")}</div>
</div>
<div>
<div className="text-sky-100/60">Raw input</div>
<div>{formatSessionThreshold(policy.maxRawInputTokens, "tokens")}</div>
</div>
<div>
<div className="text-sky-100/60">Age</div>
<div>{formatSessionThreshold(policy.maxSessionAgeHours, "hours")}</div>
</div>
</div>
<p className="text-[11px] text-sky-100/75">
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."}
</p>
</div>
);
}
/* ---- Internal sub-components ---- */
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);

View File

@@ -545,7 +545,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
{currentProjectSupportsExecutionWorkspace && (
<PropertyRow label="Workspace">
<div className="flex items-center justify-between gap-3 rounded-md border border-border px-2 py-1.5 w-full">
<div className="flex items-center justify-between gap-3 w-full">
<div className="min-w-0">
<div className="text-sm">
{usesIsolatedExecutionWorkspace ? "Isolated issue checkout" : "Project primary checkout"}

View File

@@ -61,6 +61,10 @@ export interface MarkdownEditorRef {
focus: () => void;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/* ---- Mention detection helpers ---- */
interface MentionState {
@@ -251,6 +255,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
try {
const src = await handler(file);
setUploadError(null);
// After MDXEditor inserts the image, ensure two newlines follow it
// so the cursor isn't stuck right next to the image.
setTimeout(() => {
const current = latestValueRef.current;
const escapedSrc = escapeRegExp(src);
const updated = current.replace(
new RegExp(`(!\\[[^\\]]*\\]\\(${escapedSrc}\\))(?!\\n\\n)`, "g"),
"$1\n\n",
);
if (updated !== current) {
latestValueRef.current = updated;
ref.current?.setMarkdown(updated);
onChange(updated);
requestAnimationFrame(() => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
});
}
}, 100);
return src;
} catch (err) {
const message = err instanceof Error ? err.message : "Image upload failed";

View File

@@ -1008,8 +1008,8 @@ export function NewIssueDialog() {
</div>
{currentProjectSupportsExecutionWorkspace && (
<div className="px-4 pb-2 shrink-0">
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
<div className="px-4 py-3 shrink-0">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="text-xs font-medium">Use isolated issue checkout</div>
<div className="text-[11px] text-muted-foreground">

View File

@@ -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 (
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
<p className="text-sm text-muted-foreground">
{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."}
</p>
{archivePending ? (
<Button size="sm" variant="destructive" disabled>
<Loader2 className="h-3 w-3 animate-spin mr-1" />
{isArchive ? "Archiving..." : "Unarchiving..."}
</Button>
) : confirming ? (
<div className="flex items-center gap-2">
<span className="text-sm text-destructive font-medium">
{action} &ldquo;{project.name}&rdquo;?
</span>
<Button
size="sm"
variant="destructive"
onClick={() => {
setConfirming(false);
onArchive(isArchive);
}}
>
Confirm
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setConfirming(false)}
>
Cancel
</Button>
</div>
) : (
<Button
size="sm"
variant="destructive"
onClick={() => setConfirming(true)}
>
{isArchive ? (
<><Archive className="h-3 w-3 mr-1" />{action} project</>
) : (
<><ArchiveRestore className="h-3 w-3 mr-1" />{action} project</>
)}
</Button>
)}
</div>
);
}
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 ? (
<span className="text-sm text-muted-foreground">None</span>
) : (
{linkedGoals.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{linkedGoals.map((goal) => (
<span
@@ -452,7 +515,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
<Button
variant="outline"
size="xs"
className="h-6 w-fit px-2"
className={cn("h-6 w-fit px-2", linkedGoals.length > 0 && "ml-1")}
disabled={availableGoals.length === 0}
>
<Plus className="h-3 w-3 mr-1" />
@@ -964,34 +1027,11 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
Danger Zone
</div>
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
<p className="text-sm text-muted-foreground">
{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."}
</p>
<Button
size="sm"
variant="destructive"
disabled={archivePending}
onClick={() => {
const action = project.archivedAt ? "Unarchive" : "Archive";
const confirmed = window.confirm(
`${action} project "${project.name}"?`,
);
if (!confirmed) return;
onArchive(!project.archivedAt);
}}
>
{archivePending ? (
<><Loader2 className="h-3 w-3 animate-spin mr-1" />{project.archivedAt ? "Unarchiving..." : "Archiving..."}</>
) : project.archivedAt ? (
<><ArchiveRestore className="h-3 w-3 mr-1" />Unarchive project</>
) : (
<><Archive className="h-3 w-3 mr-1" />Archive project</>
)}
</Button>
</div>
<ArchiveDangerZone
project={project}
onArchive={onArchive}
archivePending={archivePending}
/>
</div>
</>
)}

View File

@@ -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;

View File

@@ -96,6 +96,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,

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
import { agentsApi, type AgentKey, type ClaudeLoginResult, type AvailableSkill } from "../api/agents";
import { budgetsApi } from "../api/budgets";
import { heartbeatsApi } from "../api/heartbeats";
import { ApiError } from "../api/client";
@@ -30,6 +30,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs } from "@/components/ui/tabs";
import {
Popover,
@@ -185,11 +186,12 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
container.scrollTo({ top: container.scrollHeight, behavior });
}
type AgentDetailView = "dashboard" | "configuration" | "runs" | "budget";
type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs" | "budget";
function parseAgentDetailView(value: string | null): AgentDetailView {
if (value === "configure" || value === "configuration") return "configuration";
if (value === "budget") return "budget";
if (value === "skills") return value;
if (value === "budget") return value;
if (value === "runs") return value;
return "dashboard";
}
@@ -364,10 +366,12 @@ export function AgentDetail() {
const canonicalTab =
activeView === "configuration"
? "configuration"
: activeView === "runs"
? "runs"
: activeView === "budget"
? "budget"
: activeView === "skills"
? "skills"
: activeView === "runs"
? "runs"
: activeView === "budget"
? "budget"
: "dashboard";
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
@@ -483,6 +487,8 @@ export function AgentDetail() {
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
} else if (activeView === "configuration") {
crumbs.push({ label: "Configuration" });
} else if (activeView === "skills") {
crumbs.push({ label: "Skills" });
} else if (activeView === "runs") {
crumbs.push({ label: "Runs" });
} else if (activeView === "budget") {
@@ -642,6 +648,7 @@ export function AgentDetail() {
items={[
{ value: "dashboard", label: "Dashboard" },
{ value: "configuration", label: "Configuration" },
{ value: "skills", label: "Skills" },
{ value: "runs", label: "Runs" },
{ value: "budget", label: "Budget" },
]}
@@ -659,14 +666,9 @@ export function AgentDetail() {
)}
{/* Floating Save/Cancel (desktop) */}
{!isMobile && (
{!isMobile && showConfigActionBar && (
<div
className={cn(
"sticky top-6 z-10 float-right transition-opacity duration-150",
showConfigActionBar
? "opacity-100"
: "opacity-0 pointer-events-none"
)}
className="sticky top-6 z-10 float-right transition-opacity duration-150"
>
<div className="flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5 shadow-lg">
<Button
@@ -739,6 +741,12 @@ export function AgentDetail() {
/>
)}
{activeView === "skills" && (
<SkillsTab
agent={agent}
/>
)}
{activeView === "runs" && (
<RunsTab
runs={heartbeats ?? []}
@@ -1205,6 +1213,78 @@ function ConfigurationTab({
);
}
function SkillsTab({ agent }: { agent: Agent }) {
const instructionsPath =
typeof agent.adapterConfig?.instructionsFilePath === "string" && agent.adapterConfig.instructionsFilePath.trim().length > 0
? agent.adapterConfig.instructionsFilePath
: null;
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.skills.available,
queryFn: () => agentsApi.availableSkills(),
});
const skills = data?.skills ?? [];
return (
<div className="space-y-4">
<div className="border border-border rounded-lg p-4 space-y-2">
<h3 className="text-sm font-medium">Skills</h3>
<p className="text-sm text-muted-foreground">
Skills are reusable instruction bundles the agent can invoke from its local tool environment.
This view shows the current instructions file and the skills currently visible to the local agent runtime.
</p>
<p className="text-xs text-muted-foreground">
Agent: <span className="font-mono">{agent.name}</span>
</p>
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
<div className="text-xs uppercase tracking-wide text-muted-foreground mb-1">
Instructions file
</div>
<div className="font-mono break-all">
{instructionsPath ?? "No instructions file configured for this agent."}
</div>
</div>
<div className="space-y-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Available skills
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading available skills</p>
) : error ? (
<p className="text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to load available skills."}
</p>
) : skills.length === 0 ? (
<p className="text-sm text-muted-foreground">No local skills were found.</p>
) : (
<div className="space-y-2">
{skills.map((skill) => (
<SkillRow key={skill.name} skill={skill} />
))}
</div>
)}
</div>
</div>
</div>
);
}
function SkillRow({ skill }: { skill: AvailableSkill }) {
return (
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-1.5">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{skill.name}</span>
<Badge variant={skill.isPaperclipManaged ? "secondary" : "outline"}>
{skill.isPaperclipManaged ? "Paperclip" : "Local"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{skill.description || "No description available."}
</p>
</div>
);
}
/* ---- Runs Tab ---- */
function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelected: boolean; agentId: string }) {

View File

@@ -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 <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
}
// 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 <Navigate to={`/projects/${canonicalProjectRef}/overview`} replace />;
}
if (cachedTab === "configuration") {
return <Navigate to={`/projects/${canonicalProjectRef}/configuration`} replace />;
}
if (cachedTab === "budget") {
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
}
if (isProjectPluginTab(cachedTab)) {
return <Navigate to={`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(cachedTab)}`} replace />;
}
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
}
@@ -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() {
<Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
<PageTabBar
items={[
{ value: "list", label: "Issues" },
{ value: "overview", label: "Overview" },
{ value: "list", label: "List" },
{ value: "configuration", label: "Configuration" },
{ value: "budget", label: "Budget" },
...pluginTabItems.map((item) => ({