Merge public-gh/master into fix/hmr-websocket-reverse-proxy

Reconcile the PR with current master, preserve both PWA capability meta tags, and add websocket lifecycle coverage for the StrictMode-safe live updates fix.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-30 07:17:23 -05:00
489 changed files with 164284 additions and 6078 deletions
+47 -33
View File
@@ -1,4 +1,3 @@
import { useEffect, useRef } from "react";
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
@@ -14,6 +13,9 @@ import { Projects } from "./pages/Projects";
import { ProjectDetail } from "./pages/ProjectDetail";
import { Issues } from "./pages/Issues";
import { IssueDetail } from "./pages/IssueDetail";
import { Routines } from "./pages/Routines";
import { RoutineDetail } from "./pages/RoutineDetail";
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
import { Goals } from "./pages/Goals";
import { GoalDetail } from "./pages/GoalDetail";
import { Approvals } from "./pages/Approvals";
@@ -22,8 +24,13 @@ import { Costs } from "./pages/Costs";
import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { CompanySkills } from "./pages/CompanySkills";
import { CompanyExport } from "./pages/CompanyExport";
import { CompanyImport } from "./pages/CompanyImport";
import { DesignGuide } from "./pages/DesignGuide";
import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings";
import { InstanceSettings } from "./pages/InstanceSettings";
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
import { PluginManager } from "./pages/PluginManager";
import { PluginSettings } from "./pages/PluginSettings";
import { PluginPage } from "./pages/PluginPage";
@@ -32,12 +39,14 @@ import { OrgChart } from "./pages/OrgChart";
import { NewAgent } from "./pages/NewAgent";
import { AuthPage } from "./pages/Auth";
import { BoardClaimPage } from "./pages/BoardClaim";
import { CliAuthPage } from "./pages/CliAuth";
import { InviteLandingPage } from "./pages/InviteLanding";
import { NotFoundPage } from "./pages/NotFound";
import { queryKeys } from "./lib/queryKeys";
import { useCompany } from "./context/CompanyContext";
import { useDialog } from "./context/DialogContext";
import { loadLastInboxTab } from "./lib/inbox";
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
return (
@@ -114,6 +123,9 @@ function boardRoutes() {
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="company/export/*" element={<CompanyExport />} />
<Route path="company/import" element={<CompanyImport />} />
<Route path="skills/*" element={<CompanySkills />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="plugins/:pluginId" element={<PluginPage />} />
@@ -141,6 +153,9 @@ function boardRoutes() {
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="routines" element={<Routines />} />
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
@@ -150,10 +165,11 @@ function boardRoutes() {
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<Activity />} />
<Route path="inbox" element={<InboxRootRedirect />} />
<Route path="inbox/mine" element={<Inbox />} />
<Route path="inbox/recent" element={<Inbox />} />
<Route path="inbox/unread" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} />
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
<Route path=":pluginRoutePath" element={<PluginPage />} />
@@ -168,28 +184,17 @@ function InboxRootRedirect() {
function LegacySettingsRedirect() {
const location = useLocation();
return <Navigate to={`/instance/settings/heartbeats${location.search}${location.hash}`} replace />;
return <Navigate to={`/instance/settings/general${location.search}${location.hash}`} replace />;
}
function OnboardingRoutePage() {
const { companies, loading } = useCompany();
const { onboardingOpen, openOnboarding } = useDialog();
const { companies } = useCompany();
const { openOnboarding } = useDialog();
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const opened = useRef(false);
const matchedCompany = companyPrefix
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
: null;
useEffect(() => {
if (loading || opened.current || onboardingOpen) return;
opened.current = true;
if (matchedCompany) {
openOnboarding({ initialStep: 2, companyId: matchedCompany.id });
return;
}
openOnboarding();
}, [companyPrefix, loading, matchedCompany, onboardingOpen, openOnboarding]);
const title = matchedCompany
? `Add another agent to ${matchedCompany.name}`
: companies.length > 0
@@ -224,19 +229,22 @@ function OnboardingRoutePage() {
function CompanyRootRedirect() {
const { companies, selectedCompany, loading } = useCompany();
const { onboardingOpen } = useDialog();
const location = useLocation();
if (loading) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
}
// Keep the first-run onboarding mounted until it completes.
if (onboardingOpen) {
return <NoCompaniesStartPage autoOpen={false} />;
}
const targetCompany = selectedCompany ?? companies[0] ?? null;
if (!targetCompany) {
if (
shouldRedirectCompanylessRouteToOnboarding({
pathname: location.pathname,
hasCompanies: false,
})
) {
return <Navigate to="/onboarding" replace />;
}
return <NoCompaniesStartPage />;
}
@@ -253,6 +261,14 @@ function UnprefixedBoardRedirect() {
const targetCompany = selectedCompany ?? companies[0] ?? null;
if (!targetCompany) {
if (
shouldRedirectCompanylessRouteToOnboarding({
pathname: location.pathname,
hasCompanies: false,
})
) {
return <Navigate to="/onboarding" replace />;
}
return <NoCompaniesStartPage />;
}
@@ -264,16 +280,8 @@ function UnprefixedBoardRedirect() {
);
}
function NoCompaniesStartPage({ autoOpen = true }: { autoOpen?: boolean }) {
function NoCompaniesStartPage() {
const { openOnboarding } = useDialog();
const opened = useRef(false);
useEffect(() => {
if (!autoOpen) return;
if (opened.current) return;
opened.current = true;
openOnboarding();
}, [autoOpen, openOnboarding]);
return (
<div className="mx-auto max-w-xl py-10">
@@ -296,21 +304,27 @@ export function App() {
<Routes>
<Route path="auth" element={<AuthPage />} />
<Route path="board-claim/:token" element={<BoardClaimPage />} />
<Route path="cli-auth/:id" element={<CliAuthPage />} />
<Route path="invite/:token" element={<InviteLandingPage />} />
<Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="instance" element={<Navigate to="/instance/settings/heartbeats" replace />} />
<Route path="instance" element={<Navigate to="/instance/settings/general" replace />} />
<Route path="instance/settings" element={<Layout />}>
<Route index element={<Navigate to="heartbeats" replace />} />
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<InstanceGeneralSettings />} />
<Route path="heartbeats" element={<InstanceSettings />} />
<Route path="experimental" element={<InstanceExperimentalSettings />} />
<Route path="plugins" element={<PluginManager />} />
<Route path="plugins/:pluginId" element={<PluginSettings />} />
</Route>
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="routines" element={<UnprefixedBoardRedirect />} />
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="agents" element={<UnprefixedBoardRedirect />} />
+27 -24
View File
@@ -25,33 +25,36 @@ export function ClaudeLocalConfigFields({
eff,
mark,
models,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
return (
<>
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
{!hideInstructionsFile && (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
)}
<LocalWorkspaceRuntimeFields
isCreate={isCreate}
values={values}
+28 -25
View File
@@ -11,7 +11,7 @@ import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime. Note: Codex may still auto-apply repo-scoped AGENTS.md files from the workspace.";
export function CodexLocalConfigFields({
mode,
@@ -23,36 +23,39 @@ export function CodexLocalConfigFields({
eff,
mark,
models,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
const bypassEnabled =
config.dangerouslyBypassApprovalsAndSandbox === true || config.dangerouslyBypassSandbox === true;
return (
<>
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
{!hideInstructionsFile && (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
)}
<ToggleField
label="Bypass sandbox"
hint={help.dangerouslyBypassSandbox}
+2
View File
@@ -17,7 +17,9 @@ export function CursorLocalConfigFields({
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
@@ -17,7 +17,9 @@ export function GeminiLocalConfigFields({
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return (
<>
<Field label="Agent instructions file" hint={instructionsFileHint}>
@@ -0,0 +1,49 @@
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
DraftInput,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
export function HermesLocalConfigFields({
isCreate,
values,
set,
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { UIAdapterModule } from "../types";
import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui";
import { HermesLocalConfigFields } from "./config-fields";
import { buildHermesConfig } from "hermes-paperclip-adapter/ui";
export const hermesLocalUIAdapter: UIAdapterModule = {
type: "hermes_local",
label: "Hermes Agent",
parseStdoutLine: parseHermesStdoutLine,
ConfigFields: HermesLocalConfigFields,
buildAdapterConfig: buildHermesConfig,
};
+1 -1
View File
@@ -1,4 +1,4 @@
export { getUIAdapter } from "./registry";
export { getUIAdapter, listUIAdapters } from "./registry";
export { buildTranscript } from "./transcript";
export type {
TranscriptEntry,
@@ -1,7 +1,9 @@
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
ToggleField,
DraftInput,
help,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
@@ -17,31 +19,54 @@ export function OpenCodeLocalConfigFields({
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
<>
{!hideInstructionsFile && (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
)}
<ToggleField
label="Skip permissions"
hint={help.dangerouslySkipPermissions}
checked={
isCreate
? values!.dangerouslySkipPermissions
: eff(
"adapterConfig",
"dangerouslySkipPermissions",
config.dangerouslySkipPermissions !== false,
)
}
onChange={(v) =>
isCreate
? set!({ dangerouslySkipPermissions: v })
: mark("adapterConfig", "dangerouslySkipPermissions", v)
}
/>
</>
);
}
@@ -17,7 +17,9 @@ export function PiLocalConfigFields({
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
+19 -11
View File
@@ -3,26 +3,34 @@ import { claudeLocalUIAdapter } from "./claude-local";
import { codexLocalUIAdapter } from "./codex-local";
import { cursorLocalUIAdapter } from "./cursor";
import { geminiLocalUIAdapter } from "./gemini-local";
import { hermesLocalUIAdapter } from "./hermes-local";
import { openCodeLocalUIAdapter } from "./opencode-local";
import { piLocalUIAdapter } from "./pi-local";
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
import { processUIAdapter } from "./process";
import { httpUIAdapter } from "./http";
const uiAdapters: UIAdapterModule[] = [
claudeLocalUIAdapter,
codexLocalUIAdapter,
geminiLocalUIAdapter,
hermesLocalUIAdapter,
openCodeLocalUIAdapter,
piLocalUIAdapter,
cursorLocalUIAdapter,
openClawGatewayUIAdapter,
processUIAdapter,
httpUIAdapter,
];
const adaptersByType = new Map<string, UIAdapterModule>(
[
claudeLocalUIAdapter,
codexLocalUIAdapter,
geminiLocalUIAdapter,
openCodeLocalUIAdapter,
piLocalUIAdapter,
cursorLocalUIAdapter,
openClawGatewayUIAdapter,
processUIAdapter,
httpUIAdapter,
].map((a) => [a.type, a]),
uiAdapters.map((a) => [a.type, a]),
);
export function getUIAdapter(type: string): UIAdapterModule {
return adaptersByType.get(type) ?? processUIAdapter;
}
export function listUIAdapters(): UIAdapterModule[] {
return [...uiAdapters];
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { buildTranscript, type RunLogChunk } from "./transcript";
describe("buildTranscript", () => {
const ts = "2026-03-20T13:00:00.000Z";
const chunks: RunLogChunk[] = [
{ ts, stream: "stdout", chunk: "opened /Users/dotta/project\n" },
{ ts, stream: "stderr", chunk: "stderr /Users/dotta/project" },
];
it("defaults username censoring to off when options are omitted", () => {
const entries = buildTranscript(chunks, (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }]);
expect(entries).toEqual([
{ kind: "stdout", ts, text: "opened /Users/dotta/project" },
{ kind: "stderr", ts, text: "stderr /Users/dotta/project" },
]);
});
it("still redacts usernames when explicitly enabled", () => {
const entries = buildTranscript(chunks, (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }], {
censorUsernameInLogs: true,
});
expect(entries).toEqual([
{ kind: "stdout", ts, text: "opened /Users/d****/project" },
{ kind: "stderr", ts, text: "stderr /Users/d****/project" },
]);
});
});
+11 -5
View File
@@ -2,6 +2,7 @@ import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@papercl
import type { TranscriptEntry, StdoutLineParser } from "./types";
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
@@ -21,17 +22,22 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr
}
}
export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser): TranscriptEntry[] {
export function buildTranscript(
chunks: RunLogChunk[],
parser: StdoutLineParser,
opts?: TranscriptBuildOptions,
): TranscriptEntry[] {
const entries: TranscriptEntry[] = [];
let stdoutBuffer = "";
const redactionOptions = { enabled: opts?.censorUsernameInLogs ?? false };
for (const chunk of chunks) {
if (chunk.stream === "stderr") {
entries.push({ kind: "stderr", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) });
entries.push({ kind: "stderr", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk, redactionOptions) });
continue;
}
if (chunk.stream === "system") {
entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) });
entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk, redactionOptions) });
continue;
}
@@ -41,14 +47,14 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser)
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map(redactTranscriptEntryPaths));
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
}
}
const trailing = stdoutBuffer.trim();
if (trailing) {
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
appendTranscriptEntries(entries, parser(trailing, ts).map(redactTranscriptEntryPaths));
appendTranscriptEntries(entries, parser(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
}
return entries;
+2
View File
@@ -20,6 +20,8 @@ export interface AdapterConfigFieldsProps {
mark: (group: "adapterConfig", field: string, value: unknown) => void;
/** Available models for dropdowns */
models: { id: string; label: string }[];
/** When true, hides the instructions file path field (e.g. during import where it's set automatically) */
hideInstructionsFile?: boolean;
}
export interface UIAdapterModule {
+29
View File
@@ -64,6 +64,23 @@ type BoardClaimStatus = {
claimedByUserId: string | null;
};
type CliAuthChallengeStatus = {
id: string;
status: "pending" | "approved" | "cancelled" | "expired";
command: string;
clientName: string | null;
requestedAccess: "board" | "instance_admin_required";
requestedCompanyId: string | null;
requestedCompanyName: string | null;
approvedAt: string | null;
cancelledAt: string | null;
expiresAt: string;
approvedByUser: { id: string; name: string; email: string } | null;
requiresSignIn: boolean;
canApprove: boolean;
currentUserId: string | null;
};
type CompanyInviteCreated = {
id: string;
token: string;
@@ -127,4 +144,16 @@ export const accessApi = {
claimBoard: (token: string, code: string) =>
api.post<{ claimed: true; userId: string }>(`/board-claim/${token}/claim`, { code }),
getCliAuthChallenge: (id: string, token: string) =>
api.get<CliAuthChallengeStatus>(`/cli-auth/challenges/${id}?token=${encodeURIComponent(token)}`),
approveCliAuthChallenge: (id: string, token: string) =>
api.post<{ approved: boolean; status: string; userId: string; keyId: string | null; expiresAt: string }>(
`/cli-auth/challenges/${id}/approve`,
{ token },
),
cancelCliAuthChallenge: (id: string, token: string) =>
api.post<{ cancelled: boolean; status: string }>(`/cli-auth/challenges/${id}/cancel`, { token }),
};
+8 -1
View File
@@ -22,7 +22,14 @@ export interface IssueForRun {
}
export const activityApi = {
list: (companyId: string) => api.get<ActivityEvent[]>(`/companies/${companyId}/activity`),
list: (companyId: string, filters?: { entityType?: string; entityId?: string; agentId?: string }) => {
const params = new URLSearchParams();
if (filters?.entityType) params.set("entityType", filters.entityType);
if (filters?.entityId) params.set("entityId", filters.entityId);
if (filters?.agentId) params.set("agentId", filters.agentId);
const qs = params.toString();
return api.get<ActivityEvent[]>(`/companies/${companyId}/activity${qs ? `?${qs}` : ""}`);
},
forIssue: (issueId: string) => api.get<ActivityEvent[]>(`/issues/${issueId}/activity`),
runsForIssue: (issueId: string) => api.get<RunForIssue[]>(`/issues/${issueId}/runs`),
issuesForRun: (runId: string) => api.get<IssueForRun[]>(`/heartbeat-runs/${runId}/issues`),
+60 -4
View File
@@ -1,5 +1,9 @@
import type {
Agent,
AgentDetail,
AgentInstructionsBundle,
AgentInstructionsFileDetail,
AgentSkillSnapshot,
AdapterEnvironmentTestResult,
AgentKeyCreated,
AgentRuntimeState,
@@ -23,6 +27,12 @@ export interface AdapterModel {
label: string;
}
export interface DetectedAdapterModel {
model: string;
provider: string;
source: string;
}
export interface ClaudeLoginResult {
exitCode: number | null;
signal: string | null;
@@ -45,6 +55,11 @@ export interface AgentHireResponse {
approval: Approval | null;
}
export interface AgentPermissionUpdate {
canCreateAgents: boolean;
canAssignTasks: boolean;
}
function withCompanyScope(path: string, companyId?: string) {
if (!companyId) return path;
const separator = path.includes("?") ? "&" : "?";
@@ -62,7 +77,7 @@ export const agentsApi = {
api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`),
get: async (id: string, companyId?: string) => {
try {
return await api.get<Agent>(agentPath(id, companyId));
return await api.get<AgentDetail>(agentPath(id, companyId));
} catch (error) {
// Backward-compat fallback: if backend shortname lookup reports ambiguity,
// resolve using company agent list while ignoring terminated agents.
@@ -83,7 +98,7 @@ export const agentsApi = {
(agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey,
);
if (matches.length !== 1) throw error;
return api.get<Agent>(agentPath(matches[0]!.id, companyId));
return api.get<AgentDetail>(agentPath(matches[0]!.id, companyId));
}
},
getConfiguration: (id: string, companyId?: string) =>
@@ -100,13 +115,42 @@ export const agentsApi = {
api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data),
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
api.patch<Agent>(agentPath(id, companyId), data),
updatePermissions: (id: string, data: { canCreateAgents: boolean }, companyId?: string) =>
api.patch<Agent>(agentPath(id, companyId, "/permissions"), data),
updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
api.patch<AgentDetail>(agentPath(id, companyId, "/permissions"), data),
instructionsBundle: (id: string, companyId?: string) =>
api.get<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle")),
updateInstructionsBundle: (
id: string,
data: {
mode?: "managed" | "external";
rootPath?: string | null;
entryFile?: string;
clearLegacyPromptTemplate?: boolean;
},
companyId?: string,
) => api.patch<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle"), data),
instructionsFile: (id: string, relativePath: string, companyId?: string) =>
api.get<AgentInstructionsFileDetail>(
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
),
saveInstructionsFile: (
id: string,
data: { path: string; content: string; clearLegacyPromptTemplate?: boolean },
companyId?: string,
) => api.put<AgentInstructionsFileDetail>(agentPath(id, companyId, "/instructions-bundle/file"), data),
deleteInstructionsFile: (id: string, relativePath: string, companyId?: string) =>
api.delete<AgentInstructionsBundle>(
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
),
pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)),
listKeys: (id: string, companyId?: string) => api.get<AgentKey[]>(agentPath(id, companyId, "/keys")),
skills: (id: string, companyId?: string) =>
api.get<AgentSkillSnapshot>(agentPath(id, companyId, "/skills")),
syncSkills: (id: string, desiredSkills: string[], companyId?: string) =>
api.post<AgentSkillSnapshot>(agentPath(id, companyId, "/skills/sync"), { desiredSkills }),
createKey: (id: string, name: string, companyId?: string) =>
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name }),
revokeKey: (agentId: string, keyId: string, companyId?: string) =>
@@ -121,6 +165,10 @@ export const agentsApi = {
api.get<AdapterModel[]>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
),
detectModel: (companyId: string, type: string) =>
api.get<DetectedAdapterModel | null>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
),
testEnvironment: (
companyId: string,
type: string,
@@ -144,4 +192,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;
}
+1
View File
@@ -32,6 +32,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
errorBody,
);
}
if (res.status === 204) return undefined as T;
return res.json();
}
+19 -1
View File
@@ -1,10 +1,13 @@
import type {
Company,
CompanyPortabilityExportRequest,
CompanyPortabilityExportPreviewResult,
CompanyPortabilityExportResult,
CompanyPortabilityImportRequest,
CompanyPortabilityImportResult,
CompanyPortabilityPreviewRequest,
CompanyPortabilityPreviewResult,
UpdateCompanyBranding,
} from "@paperclipai/shared";
import { api } from "./client";
@@ -29,10 +32,25 @@ export const companiesApi = {
>
>,
) => api.patch<Company>(`/companies/${companyId}`, data),
updateBranding: (companyId: string, data: UpdateCompanyBranding) =>
api.patch<Company>(`/companies/${companyId}/branding`, data),
archive: (companyId: string) => api.post<Company>(`/companies/${companyId}/archive`, {}),
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
exportBundle: (companyId: string, data: { include?: { company?: boolean; agents?: boolean } }) =>
exportBundle: (
companyId: string,
data: CompanyPortabilityExportRequest,
) =>
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/export`, data),
exportPreview: (
companyId: string,
data: CompanyPortabilityExportRequest,
) =>
api.post<CompanyPortabilityExportPreviewResult>(`/companies/${companyId}/exports/preview`, data),
exportPackage: (
companyId: string,
data: CompanyPortabilityExportRequest,
) =>
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/exports`, data),
importPreview: (data: CompanyPortabilityPreviewRequest) =>
api.post<CompanyPortabilityPreviewResult>("/companies/import/preview", data),
importBundle: (data: CompanyPortabilityImportRequest) =>
+54
View File
@@ -0,0 +1,54 @@
import type {
CompanySkill,
CompanySkillCreateRequest,
CompanySkillDetail,
CompanySkillFileDetail,
CompanySkillImportResult,
CompanySkillListItem,
CompanySkillProjectScanRequest,
CompanySkillProjectScanResult,
CompanySkillUpdateStatus,
} from "@paperclipai/shared";
import { api } from "./client";
export const companySkillsApi = {
list: (companyId: string) =>
api.get<CompanySkillListItem[]>(`/companies/${encodeURIComponent(companyId)}/skills`),
detail: (companyId: string, skillId: string) =>
api.get<CompanySkillDetail>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
),
updateStatus: (companyId: string, skillId: string) =>
api.get<CompanySkillUpdateStatus>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/update-status`,
),
file: (companyId: string, skillId: string, relativePath: string) =>
api.get<CompanySkillFileDetail>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/files?path=${encodeURIComponent(relativePath)}`,
),
updateFile: (companyId: string, skillId: string, path: string, content: string) =>
api.patch<CompanySkillFileDetail>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/files`,
{ path, content },
),
create: (companyId: string, payload: CompanySkillCreateRequest) =>
api.post<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills`,
payload,
),
importFromSource: (companyId: string, source: string) =>
api.post<CompanySkillImportResult>(
`/companies/${encodeURIComponent(companyId)}/skills/import`,
{ source },
),
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
api.post<CompanySkillProjectScanResult>(
`/companies/${encodeURIComponent(companyId)}/skills/scan-projects`,
payload,
),
installUpdate: (companyId: string, skillId: string) =>
api.post<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`,
{},
),
};
+26
View File
@@ -0,0 +1,26 @@
import type { ExecutionWorkspace } from "@paperclipai/shared";
import { api } from "./client";
export const executionWorkspacesApi = {
list: (
companyId: string,
filters?: {
projectId?: string;
projectWorkspaceId?: string;
issueId?: string;
status?: string;
reuseEligible?: boolean;
},
) => {
const params = new URLSearchParams();
if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId);
if (filters?.issueId) params.set("issueId", filters.issueId);
if (filters?.status) params.set("status", filters.status);
if (filters?.reuseEligible) params.set("reuseEligible", "true");
const qs = params.toString();
return api.get<ExecutionWorkspace[]>(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`);
},
get: (id: string) => api.get<ExecutionWorkspace>(`/execution-workspaces/${id}`),
update: (id: string, data: Record<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
};
+16
View File
@@ -1,5 +1,20 @@
export type DevServerHealthStatus = {
enabled: true;
restartRequired: boolean;
reason: "backend_changes" | "pending_migrations" | "backend_changes_and_pending_migrations" | null;
lastChangedAt: string | null;
changedPathCount: number;
changedPathsSample: string[];
pendingMigrations: string[];
autoRestartEnabled: boolean;
activeRunCount: number;
waitingForIdle: boolean;
lastRestartAt: string | null;
};
export type HealthStatus = {
status: "ok";
version?: string;
deploymentMode?: "local_trusted" | "authenticated";
deploymentExposure?: "private" | "public";
authReady?: boolean;
@@ -8,6 +23,7 @@ export type HealthStatus = {
features?: {
companyDeletionEnabled?: boolean;
};
devServer?: DevServerHealthStatus;
};
export const healthApi = {
+7
View File
@@ -2,6 +2,7 @@ import type {
HeartbeatRun,
HeartbeatRunEvent,
InstanceSchedulerHeartbeatAgent,
WorkspaceOperation,
} from "@paperclipai/shared";
import { api } from "./client";
@@ -42,6 +43,12 @@ export const heartbeatsApi = {
api.get<{ runId: string; store: string; logRef: string; content: string; nextOffset?: number }>(
`/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
),
workspaceOperations: (runId: string) =>
api.get<WorkspaceOperation[]>(`/heartbeat-runs/${runId}/workspace-operations`),
workspaceOperationLog: (operationId: string, offset = 0, limitBytes = 256000) =>
api.get<{ operationId: string; store: string; logRef: string; content: string; nextOffset?: number }>(
`/workspace-operations/${operationId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
),
cancel: (runId: string) => api.post<void>(`/heartbeat-runs/${runId}/cancel`, {}),
liveRunsForIssue: (issueId: string) =>
api.get<LiveRunForIssue[]>(`/issues/${issueId}/live-runs`),
+3
View File
@@ -6,10 +6,13 @@ export { companiesApi } from "./companies";
export { agentsApi } from "./agents";
export { projectsApi } from "./projects";
export { issuesApi } from "./issues";
export { routinesApi } from "./routines";
export { goalsApi } from "./goals";
export { approvalsApi } from "./approvals";
export { costsApi } from "./costs";
export { activityApi } from "./activity";
export { dashboardApi } from "./dashboard";
export { heartbeatsApi } from "./heartbeats";
export { instanceSettingsApi } from "./instanceSettings";
export { sidebarBadgesApi } from "./sidebarBadges";
export { companySkillsApi } from "./companySkills";
+18
View File
@@ -0,0 +1,18 @@
import type {
InstanceExperimentalSettings,
InstanceGeneralSettings,
PatchInstanceGeneralSettings,
PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
import { api } from "./client";
export const instanceSettingsApi = {
getGeneral: () =>
api.get<InstanceGeneralSettings>("/instance/settings/general"),
updateGeneral: (patch: PatchInstanceGeneralSettings) =>
api.patch<InstanceGeneralSettings>("/instance/settings/general", patch),
getExperimental: () =>
api.get<InstanceExperimentalSettings>("/instance/settings/experimental"),
updateExperimental: (patch: PatchInstanceExperimentalSettings) =>
api.patch<InstanceExperimentalSettings>("/instance/settings/experimental", patch),
};
+21
View File
@@ -6,6 +6,7 @@ import type {
IssueComment,
IssueDocument,
IssueLabel,
IssueWorkProduct,
UpsertIssueDocument,
} from "@paperclipai/shared";
import { api } from "./client";
@@ -17,10 +18,15 @@ export const issuesApi = {
status?: string;
projectId?: string;
assigneeAgentId?: string;
participantAgentId?: string;
assigneeUserId?: string;
touchedByUserId?: string;
inboxArchivedByUserId?: string;
unreadForUserId?: string;
labelId?: string;
originKind?: string;
originId?: string;
includeRoutineExecutions?: boolean;
q?: string;
},
) => {
@@ -28,10 +34,15 @@ export const issuesApi = {
if (filters?.status) params.set("status", filters.status);
if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
if (filters?.inboxArchivedByUserId) params.set("inboxArchivedByUserId", filters.inboxArchivedByUserId);
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
if (filters?.labelId) params.set("labelId", filters.labelId);
if (filters?.originKind) params.set("originKind", filters.originKind);
if (filters?.originId) params.set("originId", filters.originId);
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
if (filters?.q) params.set("q", filters.q);
const qs = params.toString();
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
@@ -42,6 +53,10 @@ export const issuesApi = {
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
get: (id: string) => api.get<Issue>(`/issues/${id}`),
markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}),
archiveFromInbox: (id: string) =>
api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}),
unarchiveFromInbox: (id: string) =>
api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Issue>(`/companies/${companyId}/issues`, data),
update: (id: string, data: Record<string, unknown>) => api.patch<Issue>(`/issues/${id}`, data),
@@ -90,4 +105,10 @@ export const issuesApi = {
api.post<Approval[]>(`/issues/${id}/approvals`, { approvalId }),
unlinkApproval: (id: string, approvalId: string) =>
api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`),
listWorkProducts: (id: string) => api.get<IssueWorkProduct[]>(`/issues/${id}/work-products`),
createWorkProduct: (id: string, data: Record<string, unknown>) =>
api.post<IssueWorkProduct>(`/issues/${id}/work-products`, data),
updateWorkProduct: (id: string, data: Record<string, unknown>) =>
api.patch<IssueWorkProduct>(`/work-products/${id}`, data),
deleteWorkProduct: (id: string) => api.delete<IssueWorkProduct>(`/work-products/${id}`),
};
+58
View File
@@ -0,0 +1,58 @@
import type {
ActivityEvent,
Routine,
RoutineDetail,
RoutineListItem,
RoutineRun,
RoutineRunSummary,
RoutineTrigger,
RoutineTriggerSecretMaterial,
} from "@paperclipai/shared";
import { activityApi } from "./activity";
import { api } from "./client";
export interface RoutineTriggerResponse {
trigger: RoutineTrigger;
secretMaterial: RoutineTriggerSecretMaterial | null;
}
export interface RotateRoutineTriggerResponse {
trigger: RoutineTrigger;
secretMaterial: RoutineTriggerSecretMaterial;
}
export const routinesApi = {
list: (companyId: string) => api.get<RoutineListItem[]>(`/companies/${companyId}/routines`),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Routine>(`/companies/${companyId}/routines`, data),
get: (id: string) => api.get<RoutineDetail>(`/routines/${id}`),
update: (id: string, data: Record<string, unknown>) => api.patch<Routine>(`/routines/${id}`, data),
listRuns: (id: string, limit: number = 50) => api.get<RoutineRunSummary[]>(`/routines/${id}/runs?limit=${limit}`),
createTrigger: (id: string, data: Record<string, unknown>) =>
api.post<RoutineTriggerResponse>(`/routines/${id}/triggers`, data),
updateTrigger: (id: string, data: Record<string, unknown>) =>
api.patch<RoutineTrigger>(`/routine-triggers/${id}`, data),
deleteTrigger: (id: string) => api.delete<void>(`/routine-triggers/${id}`),
rotateTriggerSecret: (id: string) =>
api.post<RotateRoutineTriggerResponse>(`/routine-triggers/${id}/rotate-secret`, {}),
run: (id: string, data?: Record<string, unknown>) =>
api.post<RoutineRun>(`/routines/${id}/run`, data ?? {}),
activity: async (
companyId: string,
routineId: string,
related?: { triggerIds?: string[]; runIds?: string[] },
) => {
const requests = [
activityApi.list(companyId, { entityType: "routine", entityId: routineId }),
...(related?.triggerIds ?? []).map((triggerId) =>
activityApi.list(companyId, { entityType: "routine_trigger", entityId: triggerId })),
...(related?.runIds ?? []).map((runId) =>
activityApi.list(companyId, { entityType: "routine_run", entityId: runId })),
];
const events = (await Promise.all(requests)).flat();
const deduped = new Map(events.map((event) => [event.id, event]));
return [...deduped.values()].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
},
};
+51
View File
@@ -0,0 +1,51 @@
import { Pause, Play } from "lucide-react";
import { Button } from "@/components/ui/button";
export function RunButton({
onClick,
disabled,
label = "Run now",
size = "sm",
}: {
onClick: () => void;
disabled?: boolean;
label?: string;
size?: "sm" | "default";
}) {
return (
<Button variant="outline" size={size} onClick={onClick} disabled={disabled}>
<Play className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{label}</span>
</Button>
);
}
export function PauseResumeButton({
isPaused,
onPause,
onResume,
disabled,
size = "sm",
}: {
isPaused: boolean;
onPause: () => void;
onResume: () => void;
disabled?: boolean;
size?: "sm" | "default";
}) {
if (isPaused) {
return (
<Button variant="outline" size={size} onClick={onResume} disabled={disabled}>
<Play className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Resume</span>
</Button>
);
}
return (
<Button variant="outline" size={size} onClick={onPause} disabled={disabled}>
<Pause className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Pause</span>
</Button>
);
}
+332 -115
View File
@@ -44,6 +44,8 @@ import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-field
import { MarkdownEditor } from "./MarkdownEditor";
import { ChoosePathButton } from "./PathInstructionsModal";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { ReportsToPicker } from "./ReportsToPicker";
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
/* ---- Create mode values ---- */
@@ -60,6 +62,12 @@ type AgentConfigFormProps = {
onSaveActionChange?: (save: (() => void) | null) => void;
onCancelActionChange?: (cancel: (() => void) | null) => void;
hideInlineSave?: boolean;
showAdapterTypeField?: boolean;
showAdapterTestEnvironmentButton?: boolean;
showCreateRunPolicySection?: boolean;
hideInstructionsFile?: boolean;
/** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */
hidePromptTemplate?: boolean;
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
sectionLayout?: "inline" | "cards";
} & (
@@ -163,6 +171,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const { mode, adapterModels: externalModels } = props;
const isCreate = mode === "create";
const cards = props.sectionLayout === "cards";
const showAdapterTypeField = props.showAdapterTypeField ?? true;
const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true;
const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true;
const hideInstructionsFile = props.hideInstructionsFile ?? false;
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
@@ -236,9 +248,26 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
if (overlay.adapterType !== undefined) {
patch.adapterType = overlay.adapterType;
// When adapter type changes, send only the new config — don't merge
// with old config since old adapter fields are meaningless for the new type
patch.adapterConfig = overlay.adapterConfig;
// When adapter type changes, replace adapter-specific fields but preserve
// adapter-agnostic fields (env, promptTemplate, etc.) that are shared
// across all adapter types.
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
const adapterAgnosticKeys = [
"env",
"promptTemplate",
"instructionsFilePath",
"cwd",
"timeoutSec",
"graceSec",
"bootstrapPromptTemplate",
];
const preserved: Record<string, unknown> = {};
for (const key of adapterAgnosticKeys) {
if (key in existing) {
preserved[key] = existing[key];
}
}
patch.adapterConfig = { ...preserved, ...overlay.adapterConfig };
} else if (Object.keys(overlay.adapterConfig).length > 0) {
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
@@ -284,8 +313,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor";
const isHermesLocal = adapterType === "hermes_local";
const showLegacyWorkingDirectoryField =
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
// Fetch adapter models for the effective adapter type
@@ -300,6 +334,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
enabled: Boolean(selectedCompanyId),
});
const models = fetchedModels ?? externalModels ?? [];
const {
data: detectedModelData,
refetch: refetchDetectedModel,
} = useQuery({
queryKey: selectedCompanyId
? queryKeys.agents.detectModel(selectedCompanyId, adapterType)
: ["agents", "none", "detect-model", adapterType],
queryFn: () => {
if (!selectedCompanyId) {
throw new Error("Select a company to detect the Hermes model");
}
return agentsApi.detectModel(selectedCompanyId, adapterType);
},
enabled: Boolean(selectedCompanyId && isHermesLocal),
});
const detectedModel = detectedModelData?.model ?? null;
const { data: companyAgents = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: Boolean(!isCreate && selectedCompanyId),
});
/** Props passed to adapter-specific config field components */
const adapterFieldProps = {
@@ -312,6 +368,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
eff: eff as <T>(group: "adapterConfig", field: string, original: T) => T,
mark: mark as (group: "adapterConfig", field: string, value: unknown) => void,
models,
hideInstructionsFile,
};
// Section toggle state — advanced always starts collapsed
@@ -383,7 +440,26 @@ 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]);
return (
<div className={cn("relative", cards && "space-y-6")}>
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
@@ -428,6 +504,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
placeholder="e.g. VP of Engineering"
/>
</Field>
<Field label="Reports to" hint={help.reportsTo}>
<ReportsToPicker
agents={companyAgents}
value={eff("identity", "reportsTo", props.agent.reportsTo ?? null)}
onChange={(id) => mark("identity", "reportsTo", id)}
excludeAgentIds={[props.agent.id]}
chooseLabel="Choose manager…"
/>
</Field>
<Field label="Capabilities" hint={help.capabilities}>
<MarkdownEditor
value={eff("identity", "capabilities", props.agent.capabilities ?? "")}
@@ -443,7 +528,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}}
/>
</Field>
{isLocal && (
{isLocal && !props.hidePromptTemplate && (
<>
<Field label="Prompt Template" hint={help.promptTemplate}>
<MarkdownEditor
@@ -478,69 +563,73 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? <h3 className="text-sm font-medium">Adapter</h3>
: <span className="text-xs font-medium text-muted-foreground">Adapter</span>
}
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() => testEnvironment.mutate()}
disabled={testEnvironment.isPending || !selectedCompanyId}
>
{testEnvironment.isPending ? "Testing..." : "Test environment"}
</Button>
{showAdapterTestEnvironmentButton && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() => testEnvironment.mutate()}
disabled={testEnvironment.isPending || !selectedCompanyId}
>
{testEnvironment.isPending ? "Testing..." : "Test environment"}
</Button>
)}
</div>
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<Field label="Adapter type" hint={help.adapterType}>
<AdapterTypeDropdown
value={adapterType}
onChange={(t) => {
if (isCreate) {
// Reset all adapter-specific fields to defaults when switching adapter type
const { adapterType: _at, ...defaults } = defaultCreateValues;
const nextValues: CreateConfigValues = { ...defaults, adapterType: t };
if (t === "codex_local") {
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
nextValues.dangerouslyBypassSandbox =
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
} else if (t === "gemini_local") {
nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL;
} else if (t === "cursor") {
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
} else if (t === "opencode_local") {
nextValues.model = "";
{showAdapterTypeField && (
<Field label="Adapter type" hint={help.adapterType}>
<AdapterTypeDropdown
value={adapterType}
onChange={(t) => {
if (isCreate) {
// Reset all adapter-specific fields to defaults when switching adapter type
const { adapterType: _at, ...defaults } = defaultCreateValues;
const nextValues: CreateConfigValues = { ...defaults, adapterType: t };
if (t === "codex_local") {
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
nextValues.dangerouslyBypassSandbox =
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
} else if (t === "gemini_local") {
nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL;
} else if (t === "cursor") {
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
} else if (t === "opencode_local") {
nextValues.model = "";
}
set!(nextValues);
} else {
// Clear all adapter config and explicitly blank out model + effort/mode keys
// so the old adapter's values don't bleed through via eff()
setOverlay((prev) => ({
...prev,
adapterType: t,
adapterConfig: {
model:
t === "codex_local"
? DEFAULT_CODEX_LOCAL_MODEL
: t === "gemini_local"
? DEFAULT_GEMINI_LOCAL_MODEL
: t === "cursor"
? DEFAULT_CURSOR_LOCAL_MODEL
: "",
effort: "",
modelReasoningEffort: "",
variant: "",
mode: "",
...(t === "codex_local"
? {
dangerouslyBypassApprovalsAndSandbox:
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
}
: {}),
},
}));
}
set!(nextValues);
} else {
// Clear all adapter config and explicitly blank out model + effort/mode keys
// so the old adapter's values don't bleed through via eff()
setOverlay((prev) => ({
...prev,
adapterType: t,
adapterConfig: {
model:
t === "codex_local"
? DEFAULT_CODEX_LOCAL_MODEL
: t === "gemini_local"
? DEFAULT_GEMINI_LOCAL_MODEL
: t === "cursor"
? DEFAULT_CURSOR_LOCAL_MODEL
: "",
effort: "",
modelReasoningEffort: "",
variant: "",
mode: "",
...(t === "codex_local"
? {
dangerouslyBypassApprovalsAndSandbox:
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
}
: {}),
},
}));
}
}}
/>
</Field>
}}
/>
</Field>
)}
{testEnvironment.error && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
@@ -555,8 +644,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
{/* Working directory */}
{isLocal && (
<Field label="Working directory" hint={help.cwd}>
{showLegacyWorkingDirectoryField && (
<Field label="Working directory (deprecated)" hint={help.cwd}>
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<DraftInput
@@ -634,8 +723,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? "codex"
: adapterType === "gemini_local"
? "gemini"
: adapterType === "cursor"
? "agent"
: adapterType === "hermes_local"
? "hermes"
: adapterType === "pi_local"
? "pi"
: adapterType === "cursor"
? "agent"
: adapterType === "opencode_local"
? "opencode"
: "claude"
@@ -653,9 +746,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={adapterType !== "opencode_local"}
required={adapterType === "opencode_local"}
allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"}
required={adapterType === "opencode_local" || adapterType === "hermes_local"}
groupByProvider={adapterType === "opencode_local"}
creatable={adapterType === "hermes_local"}
detectedModel={adapterType === "hermes_local" ? detectedModel : null}
onDetectModel={adapterType === "hermes_local"
? async () => {
const result = await refetchDetectedModel();
return result.data?.model ?? null;
}
: undefined}
detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined}
/>
{fetchedModelsError && (
<p className="text-xs text-destructive">
@@ -687,36 +789,32 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
</>
)}
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
<MarkdownEditor
value={
isCreate
? val!.bootstrapPrompt
: eff(
"adapterConfig",
"bootstrapPromptTemplate",
String(config.bootstrapPromptTemplate ?? ""),
)
}
onChange={(v) =>
isCreate
? set!({ bootstrapPrompt: v })
: mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
}
placeholder="Optional initial setup prompt for the first run"
contentClassName="min-h-[44px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = isCreate
? "agents/drafts/bootstrap-prompt"
: `agents/${props.agent.id}/bootstrap-prompt`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<div className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-2 text-xs text-sky-100">
Bootstrap prompt is only sent for fresh sessions. Put stable setup, habits, and longer reusable guidance here. Frequent changes reduce the value of session reuse because new sessions must replay it.
</div>
{!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && (
<>
<Field label="Bootstrap prompt (legacy)" hint={help.bootstrapPrompt}>
<MarkdownEditor
value={eff(
"adapterConfig",
"bootstrapPromptTemplate",
String(config.bootstrapPromptTemplate ?? ""),
)}
onChange={(v) =>
mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
}
placeholder="Optional initial setup prompt for the first run"
contentClassName="min-h-[44px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = `agents/${props.agent.id}/bootstrap-prompt`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
Bootstrap prompt is legacy and will be removed in a future release. Consider moving this content into the agent&apos;s prompt template or instructions file instead.
</div>
</>
)}
{adapterType === "claude_local" && (
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
)}
@@ -794,7 +892,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
{/* ---- Run Policy ---- */}
{isCreate ? (
{isCreate && showCreateRunPolicySection ? (
<div className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
@@ -815,7 +913,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
/>
</div>
</div>
) : (
) : !isCreate ? (
<div className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
@@ -881,7 +979,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</CollapsibleSection>
</div>
</div>
)}
) : null}
</div>
);
@@ -924,7 +1022,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
/* ---- Internal sub-components ---- */
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
@@ -1241,6 +1339,10 @@ function ModelDropdown({
allowDefault,
required,
groupByProvider,
creatable,
detectedModel,
onDetectModel,
detectModelLabel,
}: {
models: AdapterModel[];
value: string;
@@ -1250,9 +1352,20 @@ function ModelDropdown({
allowDefault: boolean;
required: boolean;
groupByProvider: boolean;
creatable?: boolean;
detectedModel?: string | null;
onDetectModel?: () => Promise<string | null>;
detectModelLabel?: string;
}) {
const [modelSearch, setModelSearch] = useState("");
const [detectingModel, setDetectingModel] = useState(false);
const selected = models.find((m) => m.id === value);
const manualModel = modelSearch.trim();
const canCreateManualModel = Boolean(
creatable &&
manualModel &&
!models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()),
);
const filteredModels = useMemo(() => {
return models.filter((m) => {
if (!modelSearch.trim()) return true;
@@ -1289,6 +1402,21 @@ function ModelDropdown({
}));
}, [filteredModels, groupByProvider]);
async function handleDetectModel() {
if (!onDetectModel) return;
setDetectingModel(true);
try {
const nextModel = await onDetectModel();
if (nextModel) {
onChange(nextModel);
onOpenChange(false);
setModelSearch("");
}
} finally {
setDetectingModel(false);
}
}
return (
<Field label="Model" hint={help.model}>
<Popover
@@ -1299,7 +1427,7 @@ function ModelDropdown({
}}
>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>
{selected
? selected.label
@@ -1309,16 +1437,84 @@ function ModelDropdown({
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search models..."
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
<div className="relative mb-1">
<input
className="w-full px-2 py-1.5 pr-6 text-xs bg-transparent outline-none border-b border-border placeholder:text-muted-foreground/50"
placeholder={creatable ? "Search models... (type to create)" : "Search models..."}
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
{modelSearch && (
<button
type="button"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setModelSearch("")}
>
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
{onDetectModel && !detectedModel && !modelSearch.trim() && (
<button
type="button"
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
onClick={() => {
void handleDetectModel();
}}
disabled={detectingModel}
>
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
{detectingModel ? "Detecting..." : (detectModelLabel ?? "Detect from config")}
</button>
)}
{value && !models.some((m) => m.id === value) && (
<button
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded bg-accent/50",
)}
onClick={() => {
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
{value}
</span>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 border border-green-500/20">
current
</span>
</button>
)}
{detectedModel && detectedModel !== value && (
<button
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
)}
onClick={() => {
onChange(detectedModel);
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
{detectedModel}
</span>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/20">
detected
</span>
</button>
)}
<div className="max-h-[240px] overflow-y-auto">
{allowDefault && (
<button
type="button"
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!value && "bg-accent",
@@ -1331,6 +1527,20 @@ function ModelDropdown({
Default
</button>
)}
{canCreateManualModel && (
<button
type="button"
className="flex items-center justify-between gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50"
onClick={() => {
onChange(manualModel);
onOpenChange(false);
setModelSearch("");
}}
>
<span>Use manual model</span>
<span className="text-xs font-mono text-muted-foreground">{manualModel}</span>
</button>
)}
{groupedModels.map((group) => (
<div key={group.provider} className="mb-1 last:mb-0">
{groupByProvider && (
@@ -1340,6 +1550,7 @@ function ModelDropdown({
)}
{group.entries.map((m) => (
<button
type="button"
key={m.id}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
@@ -1357,8 +1568,14 @@ function ModelDropdown({
))}
</div>
))}
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
{filteredModels.length === 0 && !canCreateManualModel && (
<div className="px-2 py-2 space-y-2">
<p className="text-xs text-muted-foreground">
{onDetectModel
? "No Hermes model detected yet. Configure Hermes or enter a provider/model manually."
: "No models found."}
</p>
</div>
)}
</div>
</PopoverContent>
+1 -92
View File
@@ -1,46 +1,5 @@
import { useState, useMemo } from "react";
import {
Bot,
Cpu,
Brain,
Zap,
Rocket,
Code,
Terminal,
Shield,
Eye,
Search,
Wrench,
Hammer,
Lightbulb,
Sparkles,
Star,
Heart,
Flame,
Bug,
Cog,
Database,
Globe,
Lock,
Mail,
MessageSquare,
FileCode,
GitBranch,
Package,
Puzzle,
Target,
Wand2,
Atom,
CircuitBoard,
Radar,
Swords,
Telescope,
Microscope,
Crown,
Gem,
Hexagon,
Pentagon,
Fingerprint,
type LucideIcon,
} from "lucide-react";
import { AGENT_ICON_NAMES, type AgentIconName } from "@paperclipai/shared";
@@ -51,60 +10,10 @@ import {
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
export const AGENT_ICONS: Record<AgentIconName, LucideIcon> = {
bot: Bot,
cpu: Cpu,
brain: Brain,
zap: Zap,
rocket: Rocket,
code: Code,
terminal: Terminal,
shield: Shield,
eye: Eye,
search: Search,
wrench: Wrench,
hammer: Hammer,
lightbulb: Lightbulb,
sparkles: Sparkles,
star: Star,
heart: Heart,
flame: Flame,
bug: Bug,
cog: Cog,
database: Database,
globe: Globe,
lock: Lock,
mail: Mail,
"message-square": MessageSquare,
"file-code": FileCode,
"git-branch": GitBranch,
package: Package,
puzzle: Puzzle,
target: Target,
wand: Wand2,
atom: Atom,
"circuit-board": CircuitBoard,
radar: Radar,
swords: Swords,
telescope: Telescope,
microscope: Microscope,
crown: Crown,
gem: Gem,
hexagon: Hexagon,
pentagon: Pentagon,
fingerprint: Fingerprint,
};
import { AGENT_ICONS, getAgentIcon } from "../lib/agent-icons";
const DEFAULT_ICON: AgentIconName = "bot";
export function getAgentIcon(iconName: string | null | undefined): LucideIcon {
if (iconName && AGENT_ICON_NAMES.includes(iconName as AgentIconName)) {
return AGENT_ICONS[iconName as AgentIconName];
}
return AGENT_ICONS[DEFAULT_ICON];
}
interface AgentIconProps {
icon: string | null | undefined;
className?: string;
+2 -2
View File
@@ -2,7 +2,7 @@ import { CheckCircle2, XCircle, Clock } from "lucide-react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { Identity } from "./Identity";
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
import { timeAgo } from "../lib/timeAgo";
import type { Approval, Agent } from "@paperclipai/shared";
@@ -32,7 +32,7 @@ export function ApprovalCard({
isPending: boolean;
}) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
const label = typeLabel[approval.type] ?? approval.type;
const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
const showResolutionButtons =
approval.type !== "budget_override_required" &&
(approval.status === "pending" || approval.status === "revision_requested");
+35
View File
@@ -7,6 +7,15 @@ export const typeLabel: Record<string, string> = {
budget_override_required: "Budget Override",
};
/** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */
export function approvalLabel(type: string, payload?: Record<string, unknown> | null): string {
const base = typeLabel[type] ?? type;
if (type === "hire_agent" && payload?.name) {
return `${base}: ${String(payload.name)}`;
}
return base;
}
export const typeIcon: Record<string, typeof UserPlus> = {
hire_agent: UserPlus,
approve_ceo_strategy: Lightbulb,
@@ -25,6 +34,31 @@ function PayloadField({ label, value }: { label: string; value: unknown }) {
);
}
function SkillList({ values }: { values: unknown }) {
if (!Array.isArray(values)) return null;
const items = values
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean);
if (items.length === 0) return null;
return (
<div className="flex items-start gap-2">
<span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs pt-0.5">Skills</span>
<div className="flex flex-wrap gap-1.5">
{items.map((item) => (
<span
key={item}
className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
>
{item}
</span>
))}
</div>
</div>
);
}
export function HireAgentPayload({ payload }: { payload: Record<string, unknown> }) {
return (
<div className="mt-3 space-y-1.5 text-sm">
@@ -49,6 +83,7 @@ export function HireAgentPayload({ payload }: { payload: Record<string, unknown>
</span>
</div>
)}
<SkillList values={payload.desiredSkills} />
</div>
);
}
+32 -25
View File
@@ -46,10 +46,10 @@ interface CommentThreadProps {
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[];
}
const CLOSED_STATUSES = new Set(["done", "cancelled"]);
const DRAFT_DEBOUNCE_MS = 800;
function loadDraft(draftKey: string): string {
@@ -260,7 +260,6 @@ export function CommentThread({
companyId,
projectId,
onAdd,
issueStatus,
agentMap,
imageUploadHandler,
onAttachImage,
@@ -269,13 +268,15 @@ export function CommentThread({
enableReassign = false,
reassignOptions = [],
currentAssigneeValue = "",
suggestedAssigneeValue,
mentions: providedMentions,
}: CommentThreadProps) {
const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
const [reassignTarget, setReassignTarget] = useState(currentAssigneeValue);
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null);
@@ -283,8 +284,6 @@ export function CommentThread({
const location = useLocation();
const hasScrolledRef = useRef(false);
const isClosed = issueStatus ? CLOSED_STATUSES.has(issueStatus) : false;
const timeline = useMemo<TimelineItem[]>(() => {
const commentItems: TimelineItem[] = comments.map((comment) => ({
kind: "comment",
@@ -312,8 +311,11 @@ export function CommentThread({
return Array.from(agentMap.values())
.filter((a) => a.status !== "terminated")
.map((a) => ({
id: a.id,
id: `agent:${a.id}`,
name: a.name,
kind: "agent",
agentId: a.id,
agentIcon: a.icon,
}));
}, [agentMap, providedMentions]);
@@ -337,8 +339,8 @@ export function CommentThread({
}, []);
useEffect(() => {
setReassignTarget(currentAssigneeValue);
}, [currentAssigneeValue]);
setReassignTarget(effectiveSuggestedAssigneeValue);
}, [effectiveSuggestedAssigneeValue]);
// Scroll to comment when URL hash matches #comment-{id}
useEffect(() => {
@@ -366,11 +368,11 @@ export function CommentThread({
setSubmitting(true);
try {
await onAdd(trimmed, isClosed && reopen ? true : undefined, reassignment ?? undefined);
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined);
setBody("");
if (draftKey) clearDraft(draftKey);
setReopen(false);
setReassignTarget(currentAssigneeValue);
setReopen(true);
setReassignTarget(effectiveSuggestedAssigneeValue);
} finally {
setSubmitting(false);
}
@@ -378,10 +380,17 @@ export function CommentThread({
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
const file = evt.target.files?.[0];
if (!file || !onAttachImage) return;
if (!file) return;
setAttaching(true);
try {
await onAttachImage(file);
if (imageUploadHandler) {
const url = await imageUploadHandler(file);
const safeName = file.name.replace(/[[\]]/g, "\\$&");
const markdown = `![${safeName}](${url})`;
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
} else if (onAttachImage) {
await onAttachImage(file);
}
} finally {
setAttaching(false);
if (attachInputRef.current) attachInputRef.current.value = "";
@@ -416,7 +425,7 @@ export function CommentThread({
contentClassName="min-h-[60px] text-sm"
/>
<div className="flex items-center justify-end gap-3">
{onAttachImage && (
{(imageUploadHandler || onAttachImage) && (
<div className="mr-auto flex items-center gap-3">
<input
ref={attachInputRef}
@@ -436,17 +445,15 @@ export function CommentThread({
</Button>
</div>
)}
{isClosed && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={reopen}
onChange={(e) => setReopen(e.target.checked)}
className="rounded border-border"
/>
Re-open
</label>
)}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={reopen}
onChange={(e) => setReopen(e.target.checked)}
className="rounded border-border"
/>
Re-open
</label>
{enableReassign && reassignOptions.length > 0 && (
<InlineEntitySelector
value={reassignTarget}
+89
View File
@@ -0,0 +1,89 @@
import { AlertTriangle, RotateCcw, TimerReset } from "lucide-react";
import type { DevServerHealthStatus } from "../api/health";
function formatRelativeTimestamp(value: string | null): string | null {
if (!value) return null;
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) return null;
const deltaMs = Date.now() - timestamp;
if (deltaMs < 60_000) return "just now";
const deltaMinutes = Math.round(deltaMs / 60_000);
if (deltaMinutes < 60) return `${deltaMinutes}m ago`;
const deltaHours = Math.round(deltaMinutes / 60);
if (deltaHours < 24) return `${deltaHours}h ago`;
const deltaDays = Math.round(deltaHours / 24);
return `${deltaDays}d ago`;
}
function describeReason(devServer: DevServerHealthStatus): string {
if (devServer.reason === "backend_changes_and_pending_migrations") {
return "backend files changed and migrations are pending";
}
if (devServer.reason === "pending_migrations") {
return "pending migrations need a fresh boot";
}
return "backend files changed since this server booted";
}
export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthStatus }) {
if (!devServer?.enabled || !devServer.restartRequired) return null;
const changedAt = formatRelativeTimestamp(devServer.lastChangedAt);
const sample = devServer.changedPathsSample.slice(0, 3);
return (
<div className="border-b border-amber-300/60 bg-amber-50 text-amber-950 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-100">
<div className="flex flex-col gap-3 px-3 py-2.5 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[12px] font-semibold uppercase tracking-[0.18em]">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
<span>Restart Required</span>
{devServer.autoRestartEnabled ? (
<span className="rounded-full bg-amber-900/10 px-2 py-0.5 text-[10px] tracking-[0.14em] dark:bg-amber-100/10">
Auto-Restart On
</span>
) : null}
</div>
<p className="mt-1 text-sm">
{describeReason(devServer)}
{changedAt ? ` · updated ${changedAt}` : ""}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-amber-900/80 dark:text-amber-100/75">
{sample.length > 0 ? (
<span>
Changed: {sample.join(", ")}
{devServer.changedPathCount > sample.length ? ` +${devServer.changedPathCount - sample.length} more` : ""}
</span>
) : null}
{devServer.pendingMigrations.length > 0 ? (
<span>
Pending migrations: {devServer.pendingMigrations.slice(0, 2).join(", ")}
{devServer.pendingMigrations.length > 2 ? ` +${devServer.pendingMigrations.length - 2} more` : ""}
</span>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs font-medium">
{devServer.waitingForIdle ? (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<TimerReset className="h-3.5 w-3.5" />
<span>Waiting for {devServer.activeRunCount} live run{devServer.activeRunCount === 1 ? "" : "s"} to finish</span>
</div>
) : devServer.autoRestartEnabled ? (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<RotateCcw className="h-3.5 w-3.5" />
<span>Auto-restart will trigger when the instance is idle</span>
</div>
) : (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<RotateCcw className="h-3.5 w-3.5" />
<span>Restart <code>pnpm dev:once</code> after the active work is safe to interrupt</span>
</div>
)}
</div>
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { cn } from "../lib/utils";
interface HermesIconProps {
className?: string;
}
/**
* Hermes caduceus icon — winged staff with two intertwined serpents.
* Replaces the generic Zap icon for the hermes_local adapter type.
*
* ⚕️ inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings.
*/
export function HermesIcon({ className }: HermesIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(className)}
>
{/* Central staff */}
<line x1="12" y1="6" x2="12" y2="23" />
{/* Left serpent curves */}
<path d="M12 8 C10 9 9.5 11 10.5 13 C11.5 15 10 17 12 18" />
{/* Right serpent curves */}
<path d="M12 8 C14 9 14.5 11 13.5 13 C12.5 15 14 17 12 18" />
{/* Snake heads facing outward */}
<circle cx="10" cy="8" r="0.8" fill="currentColor" stroke="none" />
<circle cx="14" cy="8" r="0.8" fill="currentColor" stroke="none" />
{/* Wings at top of staff */}
<path d="M12 6 L8 3 L6 5 L9 6" strokeWidth="1.2" />
<path d="M12 6 L16 3 L18 5 L15 6" strokeWidth="1.2" />
{/* Wing feather details */}
<line x1="7.5" y1="4" x2="7" y2="5.2" strokeWidth="1" />
<line x1="16.5" y1="4" x2="17" y2="5.2" strokeWidth="1" />
{/* Staff sphere at top */}
<circle cx="12" cy="6.5" r="1.2" />
</svg>
);
}
+3 -1
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { Clock3, Puzzle, Settings } from "lucide-react";
import { Clock3, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
@@ -22,7 +22,9 @@ export function InstanceSidebar() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/instance/settings/general" label="General" icon={SlidersHorizontal} end />
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
{(plugins ?? []).length > 0 ? (
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
+15 -13
View File
@@ -519,21 +519,23 @@ export function IssueDocumentsSection({
return (
<div className="space-y-3">
{isEmpty && !draft?.isNew ? (
<div className="flex items-center justify-end gap-2">
<div className="flex items-center justify-end gap-2 min-w-0">
{extraActions}
<Button variant="outline" size="sm" onClick={beginNewDocument}>
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
<Plus className="mr-1.5 h-3.5 w-3.5" />
New document
<span className="hidden sm:inline">New document</span>
<span className="sm:hidden">New</span>
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
<div className="flex items-center gap-2">
<div className="flex items-center justify-between gap-2 min-w-0">
<h3 className="text-sm font-medium text-muted-foreground shrink-0">Documents</h3>
<div className="flex items-center gap-2 min-w-0">
{extraActions}
<Button variant="outline" size="sm" onClick={beginNewDocument}>
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
<Plus className="mr-1.5 h-3.5 w-3.5" />
New document
<span className="hidden sm:inline">New document</span>
<span className="sm:hidden">New</span>
</Button>
</div>
</div>
@@ -634,29 +636,29 @@ export function IssueDocumentsSection({
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 min-w-0">
<button
type="button"
className="inline-flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
onClick={() => toggleFoldedDocument(doc.key)}
aria-label={isFolded ? `Expand ${doc.key} document` : `Collapse ${doc.key} document`}
aria-expanded={!isFolded}
>
{isFolded ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
<span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
{doc.key}
</span>
<a
href={`#document-${encodeURIComponent(doc.key)}`}
className="text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline"
className="truncate text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline"
>
rev {doc.latestRevisionNumber} updated {relativeTime(doc.updatedAt)}
</a>
</div>
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon-xs"
+32 -51
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link } from "@/lib/router";
import type { Issue } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -21,8 +22,23 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
function defaultProjectWorkspaceIdForProject(project: {
workspaces?: Array<{ id: string; isPrimary: boolean }>;
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
} | null | undefined) {
if (!project) return null;
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
?? project.workspaces?.[0]?.id
?? null;
}
function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) {
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
if (defaultMode === "isolated_workspace" || defaultMode === "operator_branch") return defaultMode;
if (defaultMode === "adapter_default") return "agent_default";
return "shared_workspace";
}
interface IssuePropertiesProps {
issue: Issue;
@@ -187,15 +203,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
const currentProject = issue.projectId
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
: null;
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const usesIsolatedExecutionWorkspace = issue.executionWorkspaceSettings?.mode === "isolated"
? true
: issue.executionWorkspaceSettings?.mode === "project_primary"
? false
: currentProjectExecutionWorkspacePolicy?.defaultMode === "isolated";
const projectLink = (id: string | null) => {
if (!id) return null;
const project = projects?.find((p) => p.id === id) ?? null;
@@ -224,7 +231,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
style={{
borderColor: label.color,
backgroundColor: `${label.color}22`,
color: label.color,
color: pickTextColorForPillBg(label.color, 0.13),
}}
>
{label.name}
@@ -431,7 +438,13 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
!issue.projectId && "bg-accent"
)}
onClick={() => {
onUpdate({ projectId: null, executionWorkspaceSettings: null });
onUpdate({
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
});
setProjectOpen(false);
}}
>
@@ -451,10 +464,14 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
p.id === issue.projectId && "bg-accent"
)}
onClick={() => {
const defaultMode = defaultExecutionWorkspaceModeForProject(p);
onUpdate({
projectId: p.id,
executionWorkspaceSettings: SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI && p.executionWorkspacePolicy?.enabled
? { mode: p.executionWorkspacePolicy.defaultMode === "isolated" ? "isolated" : "project_primary" }
projectWorkspaceId: defaultProjectWorkspaceIdForProject(p),
executionWorkspaceId: null,
executionWorkspacePreference: defaultMode,
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
? { mode: defaultMode }
: null,
});
setProjectOpen(false);
@@ -543,42 +560,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
{projectContent}
</PropertyPicker>
{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="min-w-0">
<div className="text-sm">
{usesIsolatedExecutionWorkspace ? "Isolated issue checkout" : "Project primary checkout"}
</div>
<div className="text-[11px] text-muted-foreground">
Toggle whether this issue runs in its own execution workspace.
</div>
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
usesIsolatedExecutionWorkspace ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
onUpdate({
executionWorkspaceSettings: {
mode: usesIsolatedExecutionWorkspace ? "project_primary" : "isolated",
},
})
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
usesIsolatedExecutionWorkspace ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</PropertyRow>
)}
{issue.parentId && (
<PropertyRow label="Parent">
<Link
+26 -5
View File
@@ -1,8 +1,8 @@
import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { X } from "lucide-react";
import { cn } from "../lib/utils";
import { PriorityIcon } from "./PriorityIcon";
import { StatusIcon } from "./StatusIcon";
type UnreadState = "hidden" | "visible" | "fading";
@@ -18,6 +18,8 @@ interface IssueRowProps {
trailingMeta?: ReactNode;
unreadState?: UnreadState | null;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
}
@@ -32,6 +34,8 @@ export function IssueRow({
trailingMeta,
unreadState = null,
onMarkRead,
onArchive,
archiveDisabled,
className,
}: IssueRowProps) {
const issuePathId = issue.identifier ?? issue.id;
@@ -44,7 +48,7 @@ export function IssueRow({
to={`/issues/${issuePathId}`}
state={issueLinkState}
className={cn(
"flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors hover:bg-accent/50 last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors hover:bg-accent/50 last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
className,
)}
>
@@ -61,9 +65,6 @@ export function IssueRow({
) : null}
{desktopMetaLeading ?? (
<>
<span className="hidden sm:inline-flex">
<PriorityIcon priority={issue.priority} />
</span>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</span>
@@ -117,6 +118,26 @@ export function IssueRow({
)}
/>
</button>
) : onArchive ? (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onArchive();
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
onArchive();
}}
disabled={archiveDisabled}
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
aria-label="Dismiss from inbox"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
+403
View File
@@ -0,0 +1,403 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "@/lib/router";
import type { Issue, ExecutionWorkspace } from "@paperclipai/shared";
import { useQuery } from "@tanstack/react-query";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { instanceSettingsApi } from "../api/instanceSettings";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
/* -------------------------------------------------------------------------- */
/* Utility helpers (mirrored from IssueProperties for self-containment) */
/* -------------------------------------------------------------------------- */
const EXECUTION_WORKSPACE_OPTIONS = [
{ value: "shared_workspace", label: "Project default" },
{ value: "isolated_workspace", label: "New isolated workspace" },
{ value: "reuse_existing", label: "Reuse existing workspace" },
] as const;
function issueModeForExistingWorkspace(mode: string | null | undefined) {
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") return mode;
if (mode === "adapter_managed" || mode === "cloud_sandbox") return "agent_default";
return "shared_workspace";
}
function shouldPresentExistingWorkspaceSelection(issue: Issue) {
const persistedMode =
issue.currentExecutionWorkspace?.mode
?? issue.executionWorkspaceSettings?.mode
?? issue.executionWorkspacePreference;
return Boolean(
issue.executionWorkspaceId &&
(persistedMode === "isolated_workspace" || persistedMode === "operator_branch"),
);
}
function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) {
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
if (defaultMode === "isolated_workspace" || defaultMode === "operator_branch") return defaultMode;
if (defaultMode === "adapter_default") return "agent_default";
return "shared_workspace";
}
/* -------------------------------------------------------------------------- */
/* Sub-components */
/* -------------------------------------------------------------------------- */
function BreakablePath({ text }: { text: string }) {
const parts: React.ReactNode[] = [];
const segments = text.split(/(?<=[\/-])/);
for (let i = 0; i < segments.length; i++) {
if (i > 0) parts.push(<wbr key={i} />);
parts.push(segments[i]);
}
return <>{parts}</>;
}
function CopyableInline({ value, label, mono }: { value: string; label?: string; mono?: boolean }) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
} catch { /* noop */ }
}, [value]);
return (
<span className="inline-flex items-center gap-1 group/copy">
{label && <span className="text-muted-foreground">{label}</span>}
<span className={cn("min-w-0", mono && "font-mono")} style={{ overflowWrap: "anywhere" }}>
<BreakablePath text={value} />
</span>
<button
type="button"
className="shrink-0 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground opacity-0 group-hover/copy:opacity-100 focus:opacity-100"
onClick={handleCopy}
title={copied ? "Copied!" : "Copy"}
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
</button>
</span>
);
}
function workspaceModeLabel(mode: string | null | undefined) {
switch (mode) {
case "isolated_workspace": return "Isolated workspace";
case "operator_branch": return "Operator branch";
case "cloud_sandbox": return "Cloud sandbox";
case "adapter_managed": return "Adapter managed";
default: return "Workspace";
}
}
function configuredWorkspaceLabel(
selection: string | null | undefined,
reusableWorkspace: ExecutionWorkspace | null,
) {
switch (selection) {
case "isolated_workspace":
return "New isolated workspace";
case "reuse_existing":
return reusableWorkspace?.mode === "isolated_workspace"
? "Existing isolated workspace"
: "Reuse existing workspace";
default:
return "Project default";
}
}
function statusBadge(status: string) {
const colors: Record<string, string> = {
active: "bg-green-500/15 text-green-700 dark:text-green-400",
idle: "bg-muted text-muted-foreground",
in_review: "bg-blue-500/15 text-blue-700 dark:text-blue-400",
archived: "bg-muted text-muted-foreground",
};
return (
<span className={cn("text-[10px] px-1.5 py-0.5 rounded-full font-medium", colors[status] ?? colors.idle)}>
{status.replace(/_/g, " ")}
</span>
);
}
/* -------------------------------------------------------------------------- */
/* Main component */
/* -------------------------------------------------------------------------- */
interface IssueWorkspaceCardProps {
issue: Issue;
project: { id: string; executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null; defaultProjectWorkspaceId?: string | null } | null; workspaces?: Array<{ id: string; isPrimary: boolean }> } | null;
onUpdate: (data: Record<string, unknown>) => void;
}
export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceCardProps) {
const { selectedCompanyId } = useCompany();
const companyId = issue.companyId ?? selectedCompanyId;
const [editing, setEditing] = useState(false);
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
&& Boolean(project?.executionWorkspacePolicy?.enabled);
const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined;
const { data: reusableExecutionWorkspaces } = useQuery({
queryKey: queryKeys.executionWorkspaces.list(companyId!, {
projectId: issue.projectId ?? undefined,
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
reuseEligible: true,
}),
queryFn: () =>
executionWorkspacesApi.list(companyId!, {
projectId: issue.projectId ?? undefined,
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
reuseEligible: true,
}),
enabled: Boolean(companyId) && Boolean(issue.projectId) && editing,
});
const deduplicatedReusableWorkspaces = useMemo(() => {
const workspaces = reusableExecutionWorkspaces ?? [];
const seen = new Map<string, typeof workspaces[number]>();
for (const ws of workspaces) {
const key = ws.cwd ?? ws.id;
const existing = seen.get(key);
if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) {
seen.set(key, ws);
}
}
return Array.from(seen.values());
}, [reusableExecutionWorkspaces]);
const selectedReusableExecutionWorkspace =
deduplicatedReusableWorkspaces.find((w) => w.id === issue.executionWorkspaceId)
?? workspace
?? null;
const currentSelection = shouldPresentExistingWorkspaceSelection(issue)
? "reuse_existing"
: (
issue.executionWorkspacePreference
?? issue.executionWorkspaceSettings?.mode
?? defaultExecutionWorkspaceModeForProject(project)
);
const [draftSelection, setDraftSelection] = useState(currentSelection);
const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? "");
useEffect(() => {
if (editing) return;
setDraftSelection(currentSelection);
setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? "");
}, [currentSelection, editing, issue.executionWorkspaceId]);
const activeNonDefaultWorkspace = Boolean(workspace && workspace.mode !== "shared_workspace");
const configuredReusableWorkspace =
deduplicatedReusableWorkspaces.find((w) => w.id === draftExecutionWorkspaceId)
?? (draftExecutionWorkspaceId === issue.executionWorkspaceId ? selectedReusableExecutionWorkspace : null);
const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0;
const handleSave = useCallback(() => {
if (!canSaveWorkspaceConfig) return;
onUpdate({
executionWorkspacePreference: draftSelection,
executionWorkspaceId: draftSelection === "reuse_existing" ? draftExecutionWorkspaceId || null : null,
executionWorkspaceSettings: {
mode:
draftSelection === "reuse_existing"
? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode)
: draftSelection,
},
});
setEditing(false);
}, [
canSaveWorkspaceConfig,
configuredReusableWorkspace?.mode,
draftExecutionWorkspaceId,
draftSelection,
onUpdate,
]);
const handleCancel = useCallback(() => {
setDraftSelection(currentSelection);
setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? "");
setEditing(false);
}, [currentSelection, issue.executionWorkspaceId]);
if (!policyEnabled || !project) return null;
return (
<div className="rounded-lg border border-border p-3 space-y-2">
{/* Header row */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<GitBranch className="h-3.5 w-3.5 text-muted-foreground" />
{activeNonDefaultWorkspace && workspace
? workspaceModeLabel(workspace.mode)
: configuredWorkspaceLabel(currentSelection, selectedReusableExecutionWorkspace)}
{workspace ? statusBadge(workspace.status) : statusBadge("idle")}
</div>
<div className="flex items-center gap-1">
{editing ? (
<>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={handleCancel}
>
<X className="h-3 w-3 mr-1" />Cancel
</Button>
<Button
size="sm"
className="h-6 px-2 text-xs"
onClick={handleSave}
disabled={!canSaveWorkspaceConfig}
>
Save
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={() => setEditing(true)}
>
<Pencil className="h-3 w-3 mr-1" />Edit
</Button>
)}
</div>
</div>
{/* Read-only info */}
{!editing && (
<div className="space-y-1.5 text-xs">
{workspace?.branchName && (
<div className="flex items-center gap-1.5">
<GitBranch className="h-3 w-3 text-muted-foreground shrink-0" />
<CopyableInline value={workspace.branchName} mono />
</div>
)}
{workspace?.cwd && (
<div className="flex items-center gap-1.5">
<FolderOpen className="h-3 w-3 text-muted-foreground shrink-0" />
<CopyableInline value={workspace.cwd} mono />
</div>
)}
{workspace?.repoUrl && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<span className="text-[11px]">Repo:</span>
<CopyableInline value={workspace.repoUrl} mono />
</div>
)}
{!workspace && (
<div className="text-muted-foreground">
{currentSelection === "isolated_workspace"
? "A fresh isolated workspace will be created when this issue runs."
: currentSelection === "reuse_existing"
? "This issue will reuse an existing workspace when it runs."
: "This issue will use the project default workspace configuration when it runs."}
</div>
)}
{currentSelection === "reuse_existing" && selectedReusableExecutionWorkspace && (
<div className="text-muted-foreground" style={{ overflowWrap: "anywhere" }}>
Reusing:{" "}
<Link
to={`/execution-workspaces/${selectedReusableExecutionWorkspace.id}`}
className="hover:text-foreground hover:underline"
>
<BreakablePath text={selectedReusableExecutionWorkspace.name} />
</Link>
</div>
)}
{workspace && (
<div className="pt-0.5">
<Link
to={`/execution-workspaces/${workspace.id}`}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
>
View workspace details
</Link>
</div>
)}
</div>
)}
{/* Editing controls */}
{editing && (
<div className="space-y-2 pt-1">
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={draftSelection}
onChange={(e) => {
const nextMode = e.target.value;
setDraftSelection(nextMode);
if (nextMode !== "reuse_existing") {
setDraftExecutionWorkspaceId("");
} else if (!draftExecutionWorkspaceId && issue.executionWorkspaceId) {
setDraftExecutionWorkspaceId(issue.executionWorkspaceId);
}
}}
>
{EXECUTION_WORKSPACE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.value === "reuse_existing" && configuredReusableWorkspace?.mode === "isolated_workspace"
? "Existing isolated workspace"
: option.label}
</option>
))}
</select>
{draftSelection === "reuse_existing" && (
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={draftExecutionWorkspaceId}
onChange={(e) => {
setDraftExecutionWorkspaceId(e.target.value);
}}
>
<option value="">Choose an existing workspace</option>
{deduplicatedReusableWorkspaces.map((w) => (
<option key={w.id} value={w.id}>
{w.name} · {w.status} · {w.branchName ?? w.cwd ?? w.id.slice(0, 8)}
</option>
))}
</select>
)}
{/* Current workspace summary when editing */}
{workspace && (
<div className="text-[11px] text-muted-foreground space-y-0.5 pt-1 border-t border-border/50">
<div style={{ overflowWrap: "anywhere" }}>
Current:{" "}
<Link
to={`/execution-workspaces/${workspace.id}`}
className="hover:text-foreground hover:underline"
>
<BreakablePath text={workspace.name} />
</Link>
{" · "}
{workspace.status}
</div>
</div>
)}
</div>
)}
</div>
);
}
+93 -30
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
import { startTransition, useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { issuesApi } from "../api/issues";
@@ -40,6 +41,7 @@ export type IssueViewState = {
priorities: string[];
assignees: string[];
labels: string[];
projects: string[];
sortField: "status" | "priority" | "title" | "created" | "updated";
sortDir: "asc" | "desc";
groupBy: "status" | "priority" | "assignee" | "none";
@@ -52,6 +54,7 @@ const defaultViewState: IssueViewState = {
priorities: [],
assignees: [],
labels: [],
projects: [],
sortField: "updated",
sortDir: "desc",
groupBy: "none",
@@ -65,6 +68,7 @@ const quickFilterPresets = [
{ label: "Backlog", statuses: ["backlog"] },
{ label: "Done", statuses: ["done", "cancelled"] },
];
const ISSUE_SEARCH_COMMIT_DELAY_MS = 150;
function getViewState(key: string): IssueViewState {
try {
@@ -104,6 +108,7 @@ function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: st
});
}
if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id)));
if (state.projects.length > 0) result = result.filter((i) => i.projectId != null && state.projects.includes(i.projectId));
return result;
}
@@ -135,6 +140,7 @@ function countActiveFilters(state: IssueViewState): number {
if (state.priorities.length > 0) count++;
if (state.assignees.length > 0) count++;
if (state.labels.length > 0) count++;
if (state.projects.length > 0) count++;
return count;
}
@@ -145,32 +151,81 @@ interface Agent {
name: string;
}
interface ProjectOption {
id: string;
name: string;
}
interface IssuesListProps {
issues: Issue[];
isLoading?: boolean;
error?: Error | null;
agents?: Agent[];
projects?: ProjectOption[];
liveIssueIds?: Set<string>;
projectId?: string;
viewStateKey: string;
issueLinkState?: unknown;
initialAssignees?: string[];
initialSearch?: string;
searchFilters?: {
participantAgentId?: string;
};
onSearchChange?: (search: string) => void;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
}
interface IssuesSearchInputProps {
initialValue: string;
onValueCommitted: (value: string) => void;
}
function IssuesSearchInput({ initialValue, onValueCommitted }: IssuesSearchInputProps) {
const [value, setValue] = useState(initialValue);
const onValueCommittedRef = useRef(onValueCommitted);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
useEffect(() => {
onValueCommittedRef.current = onValueCommitted;
}, [onValueCommitted]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
onValueCommittedRef.current(value);
}, ISSUE_SEARCH_COMMIT_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [value]);
return (
<div className="relative w-48 sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Search issues..."
className="pl-7 text-xs sm:text-sm"
aria-label="Search issues"
/>
</div>
);
}
export function IssuesList({
issues,
isLoading,
error,
agents,
projects,
liveIssueIds,
projectId,
viewStateKey,
issueLinkState,
initialAssignees,
initialSearch,
searchFilters,
onSearchChange,
onUpdateIssue,
}: IssuesListProps) {
@@ -194,20 +249,12 @@ export function IssuesList({
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
const [debouncedIssueSearch, setDebouncedIssueSearch] = useState(issueSearch);
const normalizedIssueSearch = debouncedIssueSearch.trim();
const normalizedIssueSearch = issueSearch.trim();
useEffect(() => {
setIssueSearch(initialSearch ?? "");
}, [initialSearch]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedIssueSearch(issueSearch);
}, 300);
return () => window.clearTimeout(timeoutId);
}, [issueSearch]);
// Reload view state from localStorage when company changes (scopedKey changes).
const prevScopedKey = useRef(scopedKey);
useEffect(() => {
@@ -219,6 +266,13 @@ export function IssuesList({
}
}, [scopedKey, initialAssignees]);
const handleIssueSearchCommit = useCallback((nextSearch: string) => {
startTransition(() => {
setIssueSearch(nextSearch);
});
onSearchChange?.(nextSearch);
}, [onSearchChange]);
const updateView = useCallback((patch: Partial<IssueViewState>) => {
setViewState((prev) => {
const next = { ...prev, ...patch };
@@ -228,9 +282,13 @@ export function IssuesList({
}, [scopedKey]);
const { data: searchedIssues = [] } = useQuery({
queryKey: queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId }),
queryKey: [
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
searchFilters ?? {},
],
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
placeholderData: (previousData) => previousData,
});
const agentName = useCallback((id: string | null) => {
@@ -314,19 +372,10 @@ export function IssuesList({
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">New Issue</span>
</Button>
<div className="relative w-48 sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={issueSearch}
onChange={(e) => {
setIssueSearch(e.target.value);
onSearchChange?.(e.target.value);
}}
placeholder="Search issues..."
className="pl-7 text-xs sm:text-sm"
aria-label="Search issues"
/>
</div>
<IssuesSearchInput
initialValue={initialSearch ?? ""}
onValueCommitted={handleIssueSearchCommit}
/>
</div>
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
@@ -362,7 +411,7 @@ export function IssuesList({
className="h-3 w-3 ml-1 hidden sm:block"
onClick={(e) => {
e.stopPropagation();
updateView({ statuses: [], priorities: [], assignees: [], labels: [] });
updateView({ statuses: [], priorities: [], assignees: [], labels: [], projects: [] });
}}
/>
)}
@@ -495,6 +544,23 @@ export function IssuesList({
</div>
</div>
)}
{projects && projects.length > 0 && (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Project</span>
<div className="space-y-0.5 max-h-32 overflow-y-auto">
{projects.map((project) => (
<label key={project.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.projects.includes(project.id)}
onCheckedChange={() => updateView({ projects: toggleInArray(viewState.projects, project.id) })}
/>
<span className="text-sm">{project.name}</span>
</label>
))}
</div>
</div>
)}
</div>
</div>
</div>
@@ -652,9 +718,6 @@ export function IssuesList({
)}
desktopMetaLeading={(
<>
<span className="hidden sm:inline-flex">
<PriorityIcon priority={issue.priority} />
</span>
<span
className="hidden shrink-0 sm:inline-flex"
onClick={(e) => {
@@ -694,7 +757,7 @@ export function IssuesList({
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: label.color,
color: pickTextColorForPillBg(label.color, 0.12),
backgroundColor: `${label.color}1f`,
}}
>
+28 -20
View File
@@ -15,6 +15,7 @@ import { NewAgentDialog } from "./NewAgentDialog";
import { ToastViewport } from "./ToastViewport";
import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
@@ -24,32 +25,17 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import {
DEFAULT_INSTANCE_SETTINGS_PATH,
normalizeRememberedInstanceSettingsPath,
} from "../lib/instance-settings";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
const match = rawPath.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
const pathname = match?.[1] ?? rawPath;
const search = match?.[2] ?? "";
const hash = match?.[3] ?? "";
if (pathname === "/instance/settings/heartbeats" || pathname === "/instance/settings/plugins") {
return `${pathname}${search}${hash}`;
}
if (/^\/instance\/settings\/plugins\/[^/?#]+$/.test(pathname)) {
return `${pathname}${search}${hash}`;
}
return DEFAULT_INSTANCE_SETTINGS_PATH;
}
function readRememberedInstanceSettingsPath(): string {
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
@@ -93,6 +79,11 @@ export function Layout() {
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
refetchInterval: (query) => {
const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined;
return data?.devServer?.enabled ? 2000 : false;
},
refetchIntervalInBackground: true,
});
useEffect(() => {
@@ -281,6 +272,7 @@ export function Layout() {
Skip to Main Content
</a>
<WorktreeBanner />
<DevRestartBanner devServer={health?.devServer} />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
{isMobile && sidebarOpen && (
<button
@@ -313,6 +305,14 @@ export function Layout() {
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
{health?.version && (
<Tooltip>
<TooltipTrigger asChild>
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
</TooltipTrigger>
<TooltipContent>v{health.version}</TooltipContent>
</Tooltip>
)}
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to={instanceSettingsTarget}
@@ -363,6 +363,14 @@ export function Layout() {
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
{health?.version && (
<Tooltip>
<TooltipTrigger asChild>
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
</TooltipTrigger>
<TooltipContent>v{health.version}</TooltipContent>
</Tooltip>
)}
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to={instanceSettingsTarget}
+49
View File
@@ -0,0 +1,49 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody";
describe("MarkdownBody", () => {
it("renders markdown images without a resolver", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<MarkdownBody>{"![](/api/attachments/test/content)"}</MarkdownBody>
</ThemeProvider>,
);
expect(html).toContain('<img src="/api/attachments/test/content" alt=""/>');
});
it("resolves relative image paths when a resolver is provided", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<MarkdownBody resolveImageSrc={(src) => `/resolved/${src}`}>
{"![Org chart](images/org-chart.png)"}
</MarkdownBody>
</ThemeProvider>,
);
expect(html).toContain('src="/resolved/images/org-chart.png"');
expect(html).toContain('alt="Org chart"');
});
it("renders agent and project mentions as chips", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<MarkdownBody>
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")})`}
</MarkdownBody>
</ThemeProvider>,
);
expect(html).toContain('href="/agents/agent-123"');
expect(html).toContain('data-mention-kind="agent"');
expect(html).toContain("--paperclip-mention-icon-mask");
expect(html).toContain('href="/projects/project-456"');
expect(html).toContain('data-mention-kind="project"');
expect(html).toContain("--paperclip-mention-project-color:#336699");
});
});
+50 -59
View File
@@ -1,13 +1,15 @@
import { isValidElement, useEffect, useId, useState, type CSSProperties, type ReactNode } from "react";
import Markdown from "react-markdown";
import { isValidElement, useEffect, useId, useState, type ReactNode } from "react";
import Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { parseProjectMentionHref } from "@paperclipai/shared";
import { cn } from "../lib/utils";
import { useTheme } from "../context/ThemeContext";
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
interface MarkdownBodyProps {
children: string;
className?: string;
/** Optional resolver for relative image paths (e.g. within export packages) */
resolveImageSrc?: (src: string) => string | null;
}
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
@@ -34,29 +36,6 @@ function extractMermaidSource(children: ReactNode): string | null {
return flattenText(childProps.children).replace(/\n$/, "");
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!match) return null;
const value = match[1];
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
};
}
function mentionChipStyle(color: string | null): CSSProperties | undefined {
if (!color) return undefined;
const rgb = hexToRgb(color);
if (!rgb) return undefined;
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
return {
borderColor: color,
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`,
color: luminance > 0.55 ? "#111827" : "#f8fafc",
};
}
function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) {
const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
const [svg, setSvg] = useState<string | null>(null);
@@ -112,8 +91,51 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
);
}
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownBodyProps) {
const { theme } = useTheme();
const components: Components = {
pre: ({ node: _node, children: preChildren, ...preProps }) => {
const mermaidSource = extractMermaidSource(preChildren);
if (mermaidSource) {
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
}
return <pre {...preProps}>{preChildren}</pre>;
},
a: ({ href, children: linkChildren }) => {
const parsed = href ? parseMentionChipHref(href) : null;
if (parsed) {
const targetHref = parsed.kind === "project"
? `/projects/${parsed.projectId}`
: `/agents/${parsed.agentId}`;
return (
<a
href={targetHref}
className={cn(
"paperclip-mention-chip",
`paperclip-mention-chip--${parsed.kind}`,
parsed.kind === "project" && "paperclip-project-mention-chip",
)}
data-mention-kind={parsed.kind}
style={mentionChipInlineStyle(parsed)}
>
{linkChildren}
</a>
);
}
return (
<a href={href} rel="noreferrer">
{linkChildren}
</a>
);
},
};
if (resolveImageSrc) {
components.img = ({ node: _node, src, alt, ...imgProps }) => {
const resolved = src ? resolveImageSrc(src) : null;
return <img {...imgProps} src={resolved ?? src} alt={alt ?? ""} />;
};
}
return (
<div
className={cn(
@@ -122,38 +144,7 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
className,
)}
>
<Markdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ node: _node, children: preChildren, ...preProps }) => {
const mermaidSource = extractMermaidSource(preChildren);
if (mermaidSource) {
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
}
return <pre {...preProps}>{preChildren}</pre>;
},
a: ({ href, children: linkChildren }) => {
const parsed = href ? parseProjectMentionHref(href) : null;
if (parsed) {
const label = linkChildren;
return (
<a
href={`/projects/${parsed.projectId}`}
className="paperclip-project-mention-chip"
style={mentionChipStyle(parsed.color)}
>
{label}
</a>
);
}
return (
<a href={href} rel="noreferrer">
{linkChildren}
</a>
);
},
}}
>
<Markdown remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) => url}>
{children}
</Markdown>
</div>
+169 -173
View File
@@ -6,9 +6,9 @@ import {
useMemo,
useRef,
useState,
type CSSProperties,
type DragEvent,
} from "react";
import { createPortal } from "react-dom";
import {
CodeMirrorEditor,
MDXEditor,
@@ -27,7 +27,11 @@ import {
thematicBreakPlugin,
type RealmPlugin,
} from "@mdxeditor/editor";
import { buildProjectMentionHref, parseProjectMentionHref } from "@paperclipai/shared";
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
import { AgentIcon } from "./AgentIconPicker";
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
import { mentionDeletionPlugin } from "../lib/mention-deletion";
import { cn } from "../lib/utils";
/* ---- Mention types ---- */
@@ -36,6 +40,8 @@ export interface MentionOption {
id: string;
name: string;
kind?: "agent" | "project";
agentId?: string;
agentIcon?: string | null;
projectId?: string;
projectColor?: string | null;
}
@@ -61,12 +67,25 @@ export interface MarkdownEditorRef {
focus: () => void;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function isSafeMarkdownLinkUrl(url: string): boolean {
const trimmed = url.trim();
if (!trimmed) return true;
return !/^(javascript|data|vbscript):/i.test(trimmed);
}
/* ---- Mention detection helpers ---- */
interface MentionState {
query: string;
top: number;
left: number;
/** Viewport-relative coords for portal positioning */
viewportTop: number;
viewportLeft: number;
textNode: Text;
atPos: number;
endPos: number;
@@ -140,6 +159,8 @@ function detectMention(container: HTMLElement): MentionState | null {
query,
top: rect.bottom - containerRect.top,
left: rect.left - containerRect.left,
viewportTop: rect.bottom,
viewportLeft: rect.left,
textNode: textNode as Text,
atPos,
endPos: offset,
@@ -150,7 +171,8 @@ function mentionMarkdown(option: MentionOption): string {
if (option.kind === "project" && option.projectId) {
return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `;
}
return `@${option.name} `;
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
}
/** Replace `@<query>` in the markdown string with the selected mention token. */
@@ -162,31 +184,6 @@ function applyMention(markdown: string, query: string, option: MentionOption): s
return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length);
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const trimmed = hex.trim();
const match = /^#([0-9a-f]{6})$/i.exec(trimmed);
if (!match) return null;
const value = match[1];
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
};
}
function mentionChipStyle(color: string | null): CSSProperties | undefined {
if (!color) return undefined;
const rgb = hexToRgb(color);
if (!rgb) return undefined;
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
const textColor = luminance > 0.55 ? "#111827" : "#f8fafc";
return {
borderColor: color,
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`,
color: textColor,
};
}
/* ---- Component ---- */
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(function MarkdownEditor({
@@ -217,11 +214,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const mentionStateRef = useRef<MentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const mentionActive = mentionState !== null && mentions && mentions.length > 0;
const projectColorById = useMemo(() => {
const map = new Map<string, string | null>();
const mentionOptionByKey = useMemo(() => {
const map = new Map<string, MentionOption>();
for (const mention of mentions ?? []) {
if (mention.kind === "agent") {
const agentId = mention.agentId ?? mention.id.replace(/^agent:/, "");
map.set(`agent:${agentId}`, mention);
}
if (mention.kind === "project" && mention.projectId) {
map.set(mention.projectId, mention.projectColor ?? null);
map.set(`project:${mention.projectId}`, mention);
}
}
return map;
@@ -251,6 +252,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";
@@ -264,8 +283,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
listsPlugin(),
quotePlugin(),
tablePlugin(),
linkPlugin(),
linkPlugin({ validateUrl: isSafeMarkdownLinkUrl }),
linkDialogPlugin(),
mentionDeletionPlugin(),
thematicBreakPlugin(),
codeBlockPlugin({
defaultCodeBlockLanguage: "txt",
@@ -293,31 +313,28 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const links = editable.querySelectorAll("a");
for (const node of links) {
const link = node as HTMLAnchorElement;
const parsed = parseProjectMentionHref(link.getAttribute("href") ?? "");
const parsed = parseMentionChipHref(link.getAttribute("href") ?? "");
if (!parsed) {
if (link.dataset.projectMention === "true") {
link.dataset.projectMention = "false";
link.classList.remove("paperclip-project-mention-chip");
link.removeAttribute("contenteditable");
link.style.removeProperty("border-color");
link.style.removeProperty("background-color");
link.style.removeProperty("color");
}
clearMentionChipDecoration(link);
continue;
}
const color = parsed.color ?? projectColorById.get(parsed.projectId) ?? null;
link.dataset.projectMention = "true";
link.classList.add("paperclip-project-mention-chip");
link.setAttribute("contenteditable", "false");
const style = mentionChipStyle(color);
if (style) {
link.style.borderColor = style.borderColor ?? "";
link.style.backgroundColor = style.backgroundColor ?? "";
link.style.color = style.color ?? "";
if (parsed.kind === "project") {
const option = mentionOptionByKey.get(`project:${parsed.projectId}`);
applyMentionChipDecoration(link, {
...parsed,
color: parsed.color ?? option?.projectColor ?? null,
});
continue;
}
const option = mentionOptionByKey.get(`agent:${parsed.agentId}`);
applyMentionChipDecoration(link, {
...parsed,
icon: parsed.icon ?? option?.agentIcon ?? null,
});
}
}, [projectColorById]);
}, [mentionOptionByKey]);
// Mention detection: listen for selection changes and input events
const checkMention = useCallback(() => {
@@ -373,94 +390,67 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// update state between the last render and this callback firing).
const state = mentionStateRef.current;
if (!state) return;
if (option.kind === "project" && option.projectId) {
const current = latestValueRef.current;
const next = applyMention(current, state.query, option);
if (next !== current) {
latestValueRef.current = next;
ref.current?.setMarkdown(next);
onChange(next);
}
requestAnimationFrame(() => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
decorateProjectMentions();
});
mentionStateRef.current = null;
setMentionState(null);
return;
}
const replacement = mentionMarkdown(option);
// Replace @query directly via DOM selection so the cursor naturally
// lands after the inserted text. Lexical picks up the change through
// its normal input-event handling.
const sel = window.getSelection();
if (sel && state.textNode.isConnected) {
const range = document.createRange();
range.setStart(state.textNode, state.atPos);
range.setEnd(state.textNode, state.endPos);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, replacement);
// After Lexical reconciles the DOM, the cursor position set by
// execCommand may be lost. Explicitly reposition it after the
// inserted mention text.
const cursorTarget = state.atPos + replacement.length;
requestAnimationFrame(() => {
const newSel = window.getSelection();
if (!newSel) return;
// Try the original text node first (it may still be valid)
if (state.textNode.isConnected) {
const len = state.textNode.textContent?.length ?? 0;
if (cursorTarget <= len) {
const r = document.createRange();
r.setStart(state.textNode, cursorTarget);
r.collapse(true);
newSel.removeAllRanges();
newSel.addRange(r);
return;
}
}
// Fallback: search for the replacement in text nodes
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
if (!editable) return;
const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const text = node.textContent ?? "";
const idx = text.indexOf(replacement);
if (idx !== -1) {
const pos = idx + replacement.length;
if (pos <= text.length) {
const r = document.createRange();
r.setStart(node, pos);
r.collapse(true);
newSel.removeAllRanges();
newSel.addRange(r);
return;
}
}
}
});
} else {
// Fallback: full markdown replacement when DOM node is stale
const current = latestValueRef.current;
const next = applyMention(current, state.query, option);
if (next !== current) {
latestValueRef.current = next;
ref.current?.setMarkdown(next);
onChange(next);
}
requestAnimationFrame(() => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
});
const current = latestValueRef.current;
const next = applyMention(current, state.query, option);
if (next !== current) {
latestValueRef.current = next;
ref.current?.setMarkdown(next);
onChange(next);
}
requestAnimationFrame(() => {
decorateProjectMentions();
requestAnimationFrame(() => {
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
if (!(editable instanceof HTMLElement)) return;
decorateProjectMentions();
editable.focus();
const mentionHref = option.kind === "project" && option.projectId
? buildProjectMentionHref(option.projectId, option.projectColor ?? null)
: buildAgentMentionHref(
option.agentId ?? option.id.replace(/^agent:/, ""),
option.agentIcon ?? null,
);
const matchingMentions = Array.from(editable.querySelectorAll("a"))
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
.filter((link) => {
const href = link.getAttribute("href") ?? "";
return href === mentionHref && link.textContent === `@${option.name}`;
});
const containerRect = containerRef.current?.getBoundingClientRect();
const target = matchingMentions.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
const leftA = containerRect ? rectA.left - containerRect.left : rectA.left;
const topA = containerRect ? rectA.top - containerRect.top : rectA.top;
const leftB = containerRect ? rectB.left - containerRect.left : rectB.left;
const topB = containerRect ? rectB.top - containerRect.top : rectB.top;
const distA = Math.hypot(leftA - state.left, topA - state.top);
const distB = Math.hypot(leftB - state.left, topB - state.top);
return distA - distB;
})[0] ?? null;
if (!target) return;
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
const nextSibling = target.nextSibling;
if (nextSibling?.nodeType === Node.TEXT_NODE) {
const text = nextSibling.textContent ?? "";
if (text.startsWith(" ")) {
range.setStart(nextSibling, 1);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return;
}
}
range.setStartAfter(target);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
});
});
mentionStateRef.current = null;
@@ -566,46 +556,52 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
contentClassName,
)}
additionalLexicalNodes={[MentionAwareLinkNode, mentionAwareLinkNodeReplacement]}
plugins={plugins}
/>
{/* Mention dropdown */}
{mentionActive && filteredMentions.length > 0 && (
<div
className="absolute z-50 min-w-[180px] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
style={{ top: mentionState.top + 4, left: mentionState.left }}
>
{filteredMentions.map((option, i) => (
<button
key={option.id}
className={cn(
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
i === mentionIndex && "bg-accent",
)}
onMouseDown={(e) => {
e.preventDefault(); // prevent blur
selectMention(option);
}}
onMouseEnter={() => setMentionIndex(i)}
>
{option.kind === "project" && option.projectId ? (
<span
className="inline-flex h-2 w-2 rounded-full border border-border/50"
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
/>
) : (
<span className="text-muted-foreground">@</span>
)}
<span>{option.name}</span>
{option.kind === "project" && option.projectId && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Project
</span>
)}
</button>
))}
</div>
)}
{/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */}
{mentionActive && filteredMentions.length > 0 &&
createPortal(
<div
className="fixed z-[9999] min-w-[180px] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
style={{ top: mentionState.viewportTop + 4, left: mentionState.viewportLeft }}
>
{filteredMentions.map((option, i) => (
<button
key={option.id}
className={cn(
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
i === mentionIndex && "bg-accent",
)}
onMouseDown={(e) => {
e.preventDefault(); // prevent blur
selectMention(option);
}}
onMouseEnter={() => setMentionIndex(i)}
>
{option.kind === "project" && option.projectId ? (
<span
className="inline-flex h-2 w-2 rounded-full border border-border/50"
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
/>
) : (
<AgentIcon
icon={option.agentIcon}
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
)}
<span>{option.name}</span>
{option.kind === "project" && option.projectId && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Project
</span>
)}
</button>
))}
</div>,
document.body,
)}
{isDragOver && canDropImage && (
<div
+9 -1
View File
@@ -21,6 +21,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { HermesIcon } from "./HermesIcon";
type AdvancedAdapterType =
| "claude_local"
@@ -29,7 +30,8 @@ type AdvancedAdapterType =
| "opencode_local"
| "pi_local"
| "cursor"
| "openclaw_gateway";
| "openclaw_gateway"
| "hermes_local";
const ADVANCED_ADAPTER_OPTIONS: Array<{
value: AdvancedAdapterType;
@@ -64,6 +66,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
icon: OpenCodeLogoIcon,
desc: "Local multi-provider agent",
},
{
value: "hermes_local",
label: "Hermes Agent",
icon: HermesIcon,
desc: "Local multi-provider agent",
},
{
value: "pi_local",
label: "Pi",
+193 -66
View File
@@ -1,8 +1,11 @@
import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { pickTextColorForSolidBg } from "@/lib/color-contrast";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { issuesApi } from "../api/issues";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
@@ -53,18 +56,7 @@ import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySel
const DRAFT_KEY = "paperclip:issue-draft";
const DEBOUNCE_MS = 800;
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
/** Return black or white hex based on background luminance (WCAG perceptual weights). */
function getContrastTextColor(hexColor: string): string {
const hex = hexColor.replace("#", "");
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? "#000000" : "#ffffff";
}
interface IssueDraft {
title: string;
@@ -74,10 +66,13 @@ interface IssueDraft {
assigneeValue: string;
assigneeId?: string;
projectId: string;
projectWorkspaceId?: string;
assigneeModelOverride: string;
assigneeThinkingEffort: string;
assigneeChrome: boolean;
useIsolatedExecutionWorkspace: boolean;
executionWorkspaceMode?: string;
selectedExecutionWorkspaceId?: string;
useIsolatedExecutionWorkspace?: boolean;
}
type StagedIssueFile = {
@@ -236,6 +231,42 @@ const priorities = [
{ value: "low", label: "Low", icon: ArrowDown, color: priorityColor.low ?? priorityColorDefault },
];
const EXECUTION_WORKSPACE_MODES = [
{ value: "shared_workspace", label: "Project default" },
{ value: "isolated_workspace", label: "New isolated workspace" },
{ value: "reuse_existing", label: "Reuse existing workspace" },
] as const;
function defaultProjectWorkspaceIdForProject(project: { workspaces?: Array<{ id: string; isPrimary: boolean }>; executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null } | null | undefined) {
if (!project) return "";
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
?? project.workspaces?.[0]?.id
?? "";
}
function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) {
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
if (
defaultMode === "isolated_workspace" ||
defaultMode === "operator_branch" ||
defaultMode === "adapter_default"
) {
return defaultMode === "adapter_default" ? "agent_default" : defaultMode;
}
return "shared_workspace";
}
function issueExecutionWorkspaceModeForExistingWorkspace(mode: string | null | undefined) {
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
return mode;
}
if (mode === "adapter_managed" || mode === "cloud_sandbox") {
return "agent_default";
}
return "shared_workspace";
}
export function NewIssueDialog() {
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
const { companies, selectedCompanyId, selectedCompany } = useCompany();
@@ -247,11 +278,13 @@ export function NewIssueDialog() {
const [priority, setPriority] = useState("");
const [assigneeValue, setAssigneeValue] = useState("");
const [projectId, setProjectId] = useState("");
const [projectWorkspaceId, setProjectWorkspaceId] = useState("");
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
const [assigneeChrome, setAssigneeChrome] = useState(false);
const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false);
const [executionWorkspaceMode, setExecutionWorkspaceMode] = useState<string>("shared_workspace");
const [selectedExecutionWorkspaceId, setSelectedExecutionWorkspaceId] = useState("");
const [expanded, setExpanded] = useState(false);
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
const [stagedFiles, setStagedFiles] = useState<StagedIssueFile[]>([]);
@@ -283,10 +316,29 @@ export function NewIssueDialog() {
queryFn: () => projectsApi.list(effectiveCompanyId!),
enabled: !!effectiveCompanyId && newIssueOpen,
});
const { data: reusableExecutionWorkspaces } = useQuery({
queryKey: queryKeys.executionWorkspaces.list(effectiveCompanyId!, {
projectId,
projectWorkspaceId: projectWorkspaceId || undefined,
reuseEligible: true,
}),
queryFn: () =>
executionWorkspacesApi.list(effectiveCompanyId!, {
projectId,
projectWorkspaceId: projectWorkspaceId || undefined,
reuseEligible: true,
}),
enabled: Boolean(effectiveCompanyId) && newIssueOpen && Boolean(projectId),
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
enabled: newIssueOpen,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const activeProjects = useMemo(
() => (projects ?? []).filter((p) => !p.archivedAt),
@@ -316,6 +368,8 @@ export function NewIssueDialog() {
id: `agent:${agent.id}`,
name: agent.name,
kind: "agent",
agentId: agent.id,
agentIcon: agent.icon,
});
}
for (const project of orderedProjects) {
@@ -370,6 +424,10 @@ export function NewIssueDialog() {
},
onSuccess: ({ issue, companyId, failures }) => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
if (draftTimer.current) clearTimeout(draftTimer.current);
if (failures.length > 0) {
const prefix = (companies.find((company) => company.id === companyId)?.issuePrefix ?? "").trim();
@@ -417,10 +475,12 @@ export function NewIssueDialog() {
priority,
assigneeValue,
projectId,
projectWorkspaceId,
assigneeModelOverride,
assigneeThinkingEffort,
assigneeChrome,
useIsolatedExecutionWorkspace,
executionWorkspaceMode,
selectedExecutionWorkspaceId,
});
}, [
title,
@@ -429,10 +489,12 @@ export function NewIssueDialog() {
priority,
assigneeValue,
projectId,
projectWorkspaceId,
assigneeModelOverride,
assigneeThinkingEffort,
assigneeChrome,
useIsolatedExecutionWorkspace,
executionWorkspaceMode,
selectedExecutionWorkspaceId,
newIssueOpen,
scheduleSave,
]);
@@ -449,13 +511,20 @@ export function NewIssueDialog() {
setDescription(newIssueDefaults.description ?? "");
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(newIssueDefaults.projectId ?? "");
const defaultProjectId = newIssueDefaults.projectId ?? "";
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
setProjectId(defaultProjectId);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setUseIsolatedExecutionWorkspace(false);
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
setSelectedExecutionWorkspaceId("");
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
} else if (draft && draft.title.trim()) {
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
setTitle(draft.title);
setDescription(draft.description);
setStatus(draft.status || "todo");
@@ -465,22 +534,33 @@ export function NewIssueDialog() {
? assigneeValueFromSelection(newIssueDefaults)
: (draft.assigneeValue ?? draft.assigneeId ?? ""),
);
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
setProjectId(restoredProjectId);
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
setAssigneeChrome(draft.assigneeChrome ?? false);
setUseIsolatedExecutionWorkspace(draft.useIsolatedExecutionWorkspace ?? false);
setExecutionWorkspaceMode(
draft.executionWorkspaceMode
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject)),
);
setSelectedExecutionWorkspaceId(draft.selectedExecutionWorkspaceId ?? "");
executionWorkspaceDefaultProjectId.current = restoredProjectId || null;
} else {
const defaultProjectId = newIssueDefaults.projectId ?? "";
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(newIssueDefaults.projectId ?? "");
setProjectId(defaultProjectId);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setUseIsolatedExecutionWorkspace(false);
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
setSelectedExecutionWorkspaceId("");
executionWorkspaceDefaultProjectId.current = defaultProjectId || null;
}
}, [newIssueOpen, newIssueDefaults]);
}, [newIssueOpen, newIssueDefaults, orderedProjects]);
useEffect(() => {
if (!supportsAssigneeOverrides) {
@@ -516,11 +596,13 @@ export function NewIssueDialog() {
setPriority("");
setAssigneeValue("");
setProjectId("");
setProjectWorkspaceId("");
setAssigneeOptionsOpen(false);
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setUseIsolatedExecutionWorkspace(false);
setExecutionWorkspaceMode("shared_workspace");
setSelectedExecutionWorkspaceId("");
setExpanded(false);
setDialogCompanyId(null);
setStagedFiles([]);
@@ -534,10 +616,12 @@ export function NewIssueDialog() {
setDialogCompanyId(companyId);
setAssigneeValue("");
setProjectId("");
setProjectWorkspaceId("");
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setUseIsolatedExecutionWorkspace(false);
setExecutionWorkspaceMode("shared_workspace");
setSelectedExecutionWorkspaceId("");
}
function discardDraft() {
@@ -555,13 +639,19 @@ export function NewIssueDialog() {
chrome: assigneeChrome,
});
const selectedProject = orderedProjects.find((project) => project.id === projectId);
const executionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? selectedProject?.executionWorkspacePolicy
: null;
const executionWorkspacePolicy =
experimentalSettings?.enableIsolatedWorkspaces === true
? selectedProject?.executionWorkspacePolicy ?? null
: null;
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
(workspace) => workspace.id === selectedExecutionWorkspaceId,
);
const requestedExecutionWorkspaceMode =
executionWorkspaceMode === "reuse_existing"
? issueExecutionWorkspaceModeForExistingWorkspace(selectedReusableExecutionWorkspace?.mode)
: executionWorkspaceMode;
const executionWorkspaceSettings = executionWorkspacePolicy?.enabled
? {
mode: useIsolatedExecutionWorkspace ? "isolated" : "project_primary",
}
? { mode: requestedExecutionWorkspaceMode }
: null;
createIssue.mutate({
companyId: effectiveCompanyId,
@@ -573,7 +663,12 @@ export function NewIssueDialog() {
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
...(projectId ? { projectId } : {}),
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
...(executionWorkspacePolicy?.enabled ? { executionWorkspacePreference: executionWorkspaceMode } : {}),
...(executionWorkspaceMode === "reuse_existing" && selectedExecutionWorkspaceId
? { executionWorkspaceId: selectedExecutionWorkspaceId }
: {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
});
}
@@ -655,10 +750,26 @@ export function NewIssueDialog() {
? (agents ?? []).find((a) => a.id === selectedAssigneeAgentId)
: null;
const currentProject = orderedProjects.find((project) => project.id === projectId);
const currentProjectExecutionWorkspacePolicy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectExecutionWorkspacePolicy =
experimentalSettings?.enableIsolatedWorkspaces === true
? currentProject?.executionWorkspacePolicy ?? null
: null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const deduplicatedReusableWorkspaces = useMemo(() => {
const workspaces = reusableExecutionWorkspaces ?? [];
const seen = new Map<string, typeof workspaces[number]>();
for (const ws of workspaces) {
const key = ws.cwd ?? ws.id;
const existing = seen.get(key);
if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) {
seen.set(key, ws);
}
}
return Array.from(seen.values());
}, [reusableExecutionWorkspaces]);
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
(workspace) => workspace.id === selectedExecutionWorkspaceId,
);
const assigneeOptionsTitle =
assigneeAdapterType === "claude_local"
? "Claude options"
@@ -708,9 +819,10 @@ export function NewIssueDialog() {
const handleProjectChange = useCallback((nextProjectId: string) => {
setProjectId(nextProjectId);
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
const policy = SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI ? nextProject?.executionWorkspacePolicy : null;
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
setUseIsolatedExecutionWorkspace(Boolean(policy?.enabled && policy.defaultMode === "isolated"));
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(nextProject));
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(nextProject));
setSelectedExecutionWorkspaceId("");
}, [orderedProjects]);
useEffect(() => {
@@ -720,13 +832,9 @@ export function NewIssueDialog() {
const project = orderedProjects.find((entry) => entry.id === projectId);
if (!project) return;
executionWorkspaceDefaultProjectId.current = projectId;
setUseIsolatedExecutionWorkspace(
Boolean(
SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI &&
project.executionWorkspacePolicy?.enabled &&
project.executionWorkspacePolicy.defaultMode === "isolated",
),
);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(project));
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(project));
setSelectedExecutionWorkspaceId("");
}, [newIssueOpen, orderedProjects, projectId]);
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
() => {
@@ -800,7 +908,7 @@ export function NewIssueDialog() {
dialogCompany?.brandColor
? {
backgroundColor: dialogCompany.brandColor,
color: getContrastTextColor(dialogCompany.brandColor),
color: pickTextColorForSolidBg(dialogCompany.brandColor),
}
: undefined
}
@@ -830,7 +938,7 @@ export function NewIssueDialog() {
c.brandColor
? {
backgroundColor: c.brandColor,
color: getContrastTextColor(c.brandColor),
color: pickTextColorForSolidBg(c.brandColor),
}
: undefined
}
@@ -1007,30 +1115,48 @@ export function NewIssueDialog() {
</div>
</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="space-y-0.5">
<div className="text-xs font-medium">Use isolated issue checkout</div>
<div className="text-[11px] text-muted-foreground">
Create an issue-specific execution workspace instead of using the project's primary checkout.
</div>
{currentProject && currentProjectSupportsExecutionWorkspace && (
<div className="px-4 py-3 shrink-0 space-y-2">
<div className="space-y-1.5">
<div className="text-xs font-medium">Execution workspace</div>
<div className="text-[11px] text-muted-foreground">
Control whether this issue runs in the shared workspace, a new isolated workspace, or an existing one.
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
useIsolatedExecutionWorkspace ? "bg-green-600" : "bg-muted",
)}
onClick={() => setUseIsolatedExecutionWorkspace((value) => !value)}
type="button"
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={executionWorkspaceMode}
onChange={(e) => {
setExecutionWorkspaceMode(e.target.value);
if (e.target.value !== "reuse_existing") {
setSelectedExecutionWorkspaceId("");
}
}}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
useIsolatedExecutionWorkspace ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
{EXECUTION_WORKSPACE_MODES.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{executionWorkspaceMode === "reuse_existing" && (
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={selectedExecutionWorkspaceId}
onChange={(e) => setSelectedExecutionWorkspaceId(e.target.value)}
>
<option value="">Choose an existing workspace</option>
{deduplicatedReusableWorkspaces.map((workspace) => (
<option key={workspace.id} value={workspace.id}>
{workspace.name} · {workspace.status} · {workspace.branchName ?? workspace.cwd ?? workspace.id.slice(0, 8)}
</option>
))}
</select>
)}
{executionWorkspaceMode === "reuse_existing" && selectedReusableExecutionWorkspace && (
<div className="text-[11px] text-muted-foreground">
Reusing {selectedReusableExecutionWorkspace.name} from {selectedReusableExecutionWorkspace.branchName ?? selectedReusableExecutionWorkspace.cwd ?? "existing execution workspace"}.
</div>
)}
</div>
</div>
)}
@@ -1080,6 +1206,7 @@ export function NewIssueDialog() {
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
<button
data-slot="toggle"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
assigneeChrome ? "bg-green-600" : "bg-muted"
+85 -112
View File
@@ -1,8 +1,9 @@
import { useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
import { goalsApi } from "../api/goals";
import { assetsApi } from "../api/assets";
import { queryKeys } from "../lib/queryKeys";
@@ -23,13 +24,16 @@ import {
Calendar,
Plus,
X,
FolderOpen,
Github,
GitBranch,
HelpCircle,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PROJECT_COLORS } from "@paperclipai/shared";
import { cn } from "../lib/utils";
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { StatusBadge } from "./StatusBadge";
import { ChoosePathButton } from "./PathInstructionsModal";
@@ -41,9 +45,6 @@ const projectStatuses = [
{ value: "cancelled", label: "Cancelled" },
];
type WorkspaceSetup = "none" | "local" | "repo" | "both";
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
export function NewProjectDialog() {
const { newProjectOpen, closeNewProject } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
@@ -54,7 +55,6 @@ export function NewProjectDialog() {
const [goalIds, setGoalIds] = useState<string[]>([]);
const [targetDate, setTargetDate] = useState("");
const [expanded, setExpanded] = useState(false);
const [workspaceSetup, setWorkspaceSetup] = useState<WorkspaceSetup>("none");
const [workspaceLocalPath, setWorkspaceLocalPath] = useState("");
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
@@ -69,6 +69,29 @@ export function NewProjectDialog() {
enabled: !!selectedCompanyId && newProjectOpen,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && newProjectOpen,
});
const mentionOptions = useMemo<MentionOption[]>(() => {
const options: MentionOption[] = [];
const activeAgents = [...(agents ?? [])]
.filter((agent) => agent.status !== "terminated")
.sort((a, b) => a.name.localeCompare(b.name));
for (const agent of activeAgents) {
options.push({
id: `agent:${agent.id}`,
name: agent.name,
kind: "agent",
agentId: agent.id,
agentIcon: agent.icon,
});
}
return options;
}, [agents]);
const createProject = useMutation({
mutationFn: (data: Record<string, unknown>) =>
projectsApi.create(selectedCompanyId!, data),
@@ -88,7 +111,6 @@ export function NewProjectDialog() {
setGoalIds([]);
setTargetDate("");
setExpanded(false);
setWorkspaceSetup("none");
setWorkspaceLocalPath("");
setWorkspaceRepoUrl("");
setWorkspaceError(null);
@@ -125,24 +147,17 @@ export function NewProjectDialog() {
}
};
const toggleWorkspaceSetup = (next: WorkspaceSetup) => {
setWorkspaceSetup((prev) => (prev === next ? "none" : next));
setWorkspaceError(null);
};
async function handleSubmit() {
if (!selectedCompanyId || !name.trim()) return;
const localRequired = workspaceSetup === "local" || workspaceSetup === "both";
const repoRequired = workspaceSetup === "repo" || workspaceSetup === "both";
const localPath = workspaceLocalPath.trim();
const repoUrl = workspaceRepoUrl.trim();
if (localRequired && !isAbsolutePath(localPath)) {
if (localPath && !isAbsolutePath(localPath)) {
setWorkspaceError("Local folder must be a full absolute path.");
return;
}
if (repoRequired && !isGitHubRepoUrl(repoUrl)) {
setWorkspaceError("Repo workspace must use a valid GitHub repo URL.");
if (repoUrl && !isGitHubRepoUrl(repoUrl)) {
setWorkspaceError("Repo must use a valid GitHub repo URL.");
return;
}
@@ -158,29 +173,15 @@ export function NewProjectDialog() {
...(targetDate ? { targetDate } : {}),
});
const workspacePayloads: Array<Record<string, unknown>> = [];
if (localRequired && repoRequired) {
workspacePayloads.push({
name: deriveWorkspaceNameFromPath(localPath),
cwd: localPath,
repoUrl,
});
} else if (localRequired) {
workspacePayloads.push({
name: deriveWorkspaceNameFromPath(localPath),
cwd: localPath,
});
} else if (repoRequired) {
workspacePayloads.push({
name: deriveWorkspaceNameFromRepo(repoUrl),
cwd: REPO_ONLY_CWD_SENTINEL,
repoUrl,
});
}
for (const workspacePayload of workspacePayloads) {
await projectsApi.createWorkspace(created.id, {
...workspacePayload,
});
if (localPath || repoUrl) {
const workspacePayload: Record<string, unknown> = {
name: localPath
? deriveWorkspaceNameFromPath(localPath)
: deriveWorkspaceNameFromRepo(repoUrl),
...(localPath ? { cwd: localPath } : {}),
...(repoUrl ? { repoUrl } : {}),
};
await projectsApi.createWorkspace(created.id, workspacePayload);
}
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) });
@@ -273,6 +274,7 @@ export function NewProjectDialog() {
onChange={setDescription}
placeholder="Add description..."
bordered={false}
mentions={mentionOptions}
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")}
imageUploadHandler={async (file) => {
const asset = await uploadDescriptionImage.mutateAsync(file);
@@ -281,81 +283,52 @@ export function NewProjectDialog() {
/>
</div>
<div className="px-4 pb-3 space-y-3 border-t border-border">
<div className="pt-3">
<p className="text-sm font-medium">Where will work be done on this project?</p>
<p className="text-xs text-muted-foreground">Add local folder and/or GitHub repo workspace hints.</p>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<button
type="button"
className={cn(
"rounded-lg border px-3 py-3 text-left transition-colors",
workspaceSetup === "local" ? "border-foreground bg-accent/40" : "border-border hover:bg-accent/30",
)}
onClick={() => toggleWorkspaceSetup("local")}
>
<div className="flex items-center gap-2 text-sm font-medium">
<FolderOpen className="h-4 w-4" />
A local folder
</div>
<p className="mt-1 text-xs text-muted-foreground">Use a full path on this machine.</p>
</button>
<button
type="button"
className={cn(
"rounded-lg border px-3 py-3 text-left transition-colors",
workspaceSetup === "repo" ? "border-foreground bg-accent/40" : "border-border hover:bg-accent/30",
)}
onClick={() => toggleWorkspaceSetup("repo")}
>
<div className="flex items-center gap-2 text-sm font-medium">
<Github className="h-4 w-4" />
A github repo
</div>
<p className="mt-1 text-xs text-muted-foreground">Paste a GitHub URL.</p>
</button>
<button
type="button"
className={cn(
"rounded-lg border px-3 py-3 text-left transition-colors",
workspaceSetup === "both" ? "border-foreground bg-accent/40" : "border-border hover:bg-accent/30",
)}
onClick={() => toggleWorkspaceSetup("both")}
>
<div className="flex items-center gap-2 text-sm font-medium">
<GitBranch className="h-4 w-4" />
Both
</div>
<p className="mt-1 text-xs text-muted-foreground">Configure local + repo hints.</p>
</button>
<div className="px-4 pt-3 pb-3 space-y-3 border-t border-border">
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="block text-xs text-muted-foreground">Repo URL</label>
<span className="text-xs text-muted-foreground/50">optional</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
Link a GitHub repository so agents can clone, read, and push code for this project.
</TooltipContent>
</Tooltip>
</div>
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceRepoUrl}
onChange={(e) => { setWorkspaceRepoUrl(e.target.value); setWorkspaceError(null); }}
placeholder="https://github.com/org/repo"
/>
</div>
{(workspaceSetup === "local" || workspaceSetup === "both") && (
<div className="rounded-md border border-border p-2">
<label className="mb-1 block text-xs text-muted-foreground">Local folder (full path)</label>
<div className="flex items-center gap-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceLocalPath}
onChange={(e) => setWorkspaceLocalPath(e.target.value)}
placeholder="/absolute/path/to/workspace"
/>
<ChoosePathButton />
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="block text-xs text-muted-foreground">Local folder</label>
<span className="text-xs text-muted-foreground/50">optional</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
Set an absolute path on this machine where local agents will read and write files for this project.
</TooltipContent>
</Tooltip>
</div>
)}
{(workspaceSetup === "repo" || workspaceSetup === "both") && (
<div className="rounded-md border border-border p-2">
<label className="mb-1 block text-xs text-muted-foreground">GitHub repo URL</label>
<div className="flex items-center gap-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceRepoUrl}
onChange={(e) => setWorkspaceRepoUrl(e.target.value)}
placeholder="https://github.com/org/repo"
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceLocalPath}
onChange={(e) => { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }}
placeholder="/absolute/path/to/workspace"
/>
<ChoosePathButton />
</div>
)}
</div>
{workspaceError && (
<p className="text-xs text-destructive">{workspaceError}</p>
)}
+117 -87
View File
@@ -1,13 +1,14 @@
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
import { useLocation, useNavigate, useParams } from "@/lib/router";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { companiesApi } from "../api/companies";
import { goalsApi } from "../api/goals";
import { agentsApi } from "../api/agents";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { Dialog, DialogPortal } from "@/components/ui/dialog";
import {
@@ -24,15 +25,19 @@ import {
import { getUIAdapter } from "../adapters";
import { defaultCreateValues } from "./agent-config-defaults";
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
import {
buildOnboardingIssuePayload,
buildOnboardingProjectPayload,
selectDefaultCompanyGoalId
} from "../lib/onboarding-launch";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { ChoosePathButton } from "./PathInstructionsModal";
import { HintIcon } from "./agent-config-primitives";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import {
Building2,
@@ -48,39 +53,54 @@ import {
MousePointer2,
Check,
Loader2,
FolderOpen,
ChevronDown,
X
} from "lucide-react";
import { HermesIcon } from "./HermesIcon";
type Step = 1 | 2 | 3 | 4;
type AdapterType =
| "claude_local"
| "codex_local"
| "gemini_local"
| "hermes_local"
| "opencode_local"
| "pi_local"
| "cursor"
| "process"
| "http"
| "openclaw_gateway";
const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here:
const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md
Ensure you have a folder agents/ceo and then download this AGENTS.md, and sibling HEARTBEAT.md, SOUL.md, and TOOLS.md. and set that AGENTS.md as the path to your agents instruction file
After that, hire yourself a Founding Engineer agent and then plan the roadmap and tasks for your new company.`;
- hire a founding engineer
- write a hiring plan
- break the roadmap into concrete tasks and start delegating work`;
export function OnboardingWizard() {
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany();
const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const [routeDismissed, setRouteDismissed] = useState(false);
const initialStep = onboardingOptions.initialStep ?? 1;
const existingCompanyId = onboardingOptions.companyId;
const routeOnboardingOptions =
companyPrefix && companiesLoading
? null
: resolveRouteOnboardingOptions({
pathname: location.pathname,
companyPrefix,
companies,
});
const effectiveOnboardingOpen =
onboardingOpen || (routeOnboardingOptions !== null && !routeDismissed);
const effectiveOnboardingOptions = onboardingOpen
? onboardingOptions
: routeOnboardingOptions ?? {};
const initialStep = effectiveOnboardingOptions.initialStep ?? 1;
const existingCompanyId = effectiveOnboardingOptions.companyId;
const [step, setStep] = useState<Step>(initialStep);
const [loading, setLoading] = useState(false);
@@ -95,7 +115,6 @@ export function OnboardingWizard() {
// Step 2
const [agentName, setAgentName] = useState("CEO");
const [adapterType, setAdapterType] = useState<AdapterType>("claude_local");
const [cwd, setCwd] = useState("");
const [model, setModel] = useState("");
const [command, setCommand] = useState("");
const [args, setArgs] = useState("");
@@ -110,7 +129,9 @@ export function OnboardingWizard() {
const [showMoreAdapters, setShowMoreAdapters] = useState(false);
// Step 3
const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md");
const [taskTitle, setTaskTitle] = useState(
"Hire your first engineer and create a hiring plan"
);
const [taskDescription, setTaskDescription] = useState(
DEFAULT_TASK_DESCRIPTION
);
@@ -131,30 +152,42 @@ export function OnboardingWizard() {
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<
string | null
>(null);
const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState<string | null>(
null
);
const [createdAgentId, setCreatedAgentId] = useState<string | null>(null);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
useEffect(() => {
setRouteDismissed(false);
}, [location.pathname]);
// Sync step and company when onboarding opens with options.
// Keep this independent from company-list refreshes so Step 1 completion
// doesn't get reset after creating a company.
useEffect(() => {
if (!onboardingOpen) return;
const cId = onboardingOptions.companyId ?? null;
setStep(onboardingOptions.initialStep ?? 1);
if (!effectiveOnboardingOpen) return;
const cId = effectiveOnboardingOptions.companyId ?? null;
setStep(effectiveOnboardingOptions.initialStep ?? 1);
setCreatedCompanyId(cId);
setCreatedCompanyPrefix(null);
setCreatedCompanyGoalId(null);
setCreatedProjectId(null);
setCreatedAgentId(null);
setCreatedIssueRef(null);
}, [
onboardingOpen,
onboardingOptions.companyId,
onboardingOptions.initialStep
effectiveOnboardingOpen,
effectiveOnboardingOptions.companyId,
effectiveOnboardingOptions.initialStep
]);
// Backfill issue prefix for an existing company once companies are loaded.
useEffect(() => {
if (!onboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
if (!effectiveOnboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
const company = companies.find((c) => c.id === createdCompanyId);
if (company) setCreatedCompanyPrefix(company.issuePrefix);
}, [onboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
}, [effectiveOnboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
// Resize textarea when step 3 is shown or description changes
useEffect(() => {
@@ -171,13 +204,15 @@ export function OnboardingWizard() {
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
: ["agents", "none", "adapter-models", adapterType],
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
});
const isLocalAdapter =
adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor";
const effectiveAdapterCommand =
command.trim() ||
@@ -185,6 +220,10 @@ export function OnboardingWizard() {
? "codex"
: adapterType === "gemini_local"
? "gemini"
: adapterType === "hermes_local"
? "hermes"
: adapterType === "pi_local"
? "pi"
: adapterType === "cursor"
? "agent"
: adapterType === "opencode_local"
@@ -195,7 +234,7 @@ export function OnboardingWizard() {
if (step !== 2) return;
setAdapterEnvResult(null);
setAdapterEnvError(null);
}, [step, adapterType, cwd, model, command, args, url]);
}, [step, adapterType, model, command, args, url]);
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
const hasAnthropicApiKeyOverrideCheck =
@@ -251,7 +290,6 @@ export function OnboardingWizard() {
setCompanyGoal("");
setAgentName("CEO");
setAdapterType("claude_local");
setCwd("");
setModel("");
setCommand("");
setArgs("");
@@ -261,11 +299,13 @@ export function OnboardingWizard() {
setAdapterEnvLoading(false);
setForceUnsetAnthropicApiKey(false);
setUnsetAnthropicLoading(false);
setTaskTitle("Create your CEO HEARTBEAT.md");
setTaskTitle("Hire your first engineer and create a hiring plan");
setTaskDescription(DEFAULT_TASK_DESCRIPTION);
setCreatedCompanyId(null);
setCreatedCompanyPrefix(null);
setCreatedCompanyGoalId(null);
setCreatedAgentId(null);
setCreatedProjectId(null);
setCreatedIssueRef(null);
}
@@ -279,7 +319,6 @@ export function OnboardingWizard() {
const config = adapter.buildAdapterConfig({
...defaultCreateValues,
adapterType,
cwd,
model:
adapterType === "codex_local"
? model || DEFAULT_CODEX_LOCAL_MODEL
@@ -291,7 +330,8 @@ export function OnboardingWizard() {
command,
args,
url,
dangerouslySkipPermissions: adapterType === "claude_local",
dangerouslySkipPermissions:
adapterType === "claude_local" || adapterType === "opencode_local",
dangerouslyBypassSandbox:
adapterType === "codex_local"
? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX
@@ -353,7 +393,7 @@ export function OnboardingWizard() {
if (companyGoal.trim()) {
const parsedGoal = parseOnboardingGoalInput(companyGoal);
await goalsApi.create(company.id, {
const goal = await goalsApi.create(company.id, {
title: parsedGoal.title,
...(parsedGoal.description
? { description: parsedGoal.description }
@@ -361,9 +401,12 @@ export function OnboardingWizard() {
level: "company",
status: "active"
});
setCreatedCompanyGoalId(goal.id);
queryClient.invalidateQueries({
queryKey: queryKeys.goals.list(company.id)
});
} else {
setCreatedCompanyGoalId(null);
}
setStep(2);
@@ -504,16 +547,38 @@ export function OnboardingWizard() {
setLoading(true);
setError(null);
try {
let goalId = createdCompanyGoalId;
if (!goalId) {
const goals = await goalsApi.list(createdCompanyId);
goalId = selectDefaultCompanyGoalId(goals);
setCreatedCompanyGoalId(goalId);
}
let projectId = createdProjectId;
if (!projectId) {
const project = await projectsApi.create(
createdCompanyId,
buildOnboardingProjectPayload(goalId)
);
projectId = project.id;
setCreatedProjectId(projectId);
queryClient.invalidateQueries({
queryKey: queryKeys.projects.list(createdCompanyId)
});
}
let issueRef = createdIssueRef;
if (!issueRef) {
const issue = await issuesApi.create(createdCompanyId, {
title: taskTitle.trim(),
...(taskDescription.trim()
? { description: taskDescription.trim() }
: {}),
assigneeAgentId: createdAgentId,
status: "todo"
});
const issue = await issuesApi.create(
createdCompanyId,
buildOnboardingIssuePayload({
title: taskTitle,
description: taskDescription,
assigneeAgentId: createdAgentId,
projectId,
goalId
})
);
issueRef = issue.identifier ?? issue.id;
setCreatedIssueRef(issueRef);
queryClient.invalidateQueries({
@@ -546,13 +611,16 @@ export function OnboardingWizard() {
}
}
if (!onboardingOpen) return null;
if (!effectiveOnboardingOpen) return null;
return (
<Dialog
open={onboardingOpen}
open={effectiveOnboardingOpen}
onOpenChange={(open) => {
if (!open) handleClose();
if (!open) {
setRouteDismissed(true);
handleClose();
}
}}
>
<DialogPortal>
@@ -780,6 +848,12 @@ export function OnboardingWizard() {
icon: MousePointer2,
desc: "Local Cursor agent"
},
{
value: "hermes_local" as const,
label: "Hermes Agent",
icon: HermesIcon,
desc: "Local multi-provider agent"
},
{
value: "openclaw_gateway" as const,
label: "OpenClaw Gateway",
@@ -839,28 +913,11 @@ export function OnboardingWizard() {
{(adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor") && (
<div className="space-y-3">
<div>
<div className="flex items-center gap-1.5 mb-1">
<label className="text-xs text-muted-foreground">
Working directory
</label>
<HintIcon text="Paperclip works best if you create a new folder for your agents to keep their memories and stay organized. Create a new folder and put the path here." />
</div>
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<input
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/50"
placeholder="/path/to/project"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
/>
<ChoosePathButton />
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Model
@@ -1079,33 +1136,6 @@ export function OnboardingWizard() {
</div>
)}
{adapterType === "process" && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Command
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. node, python"
value={command}
onChange={(e) => setCommand(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Args (comma-separated)
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. script.js, --flag"
value={args}
onChange={(e) => setArgs(e.target.value)}
/>
</div>
</div>
)}
{(adapterType === "http" ||
adapterType === "openclaw_gateway") && (
<div>
+318
View File
@@ -0,0 +1,318 @@
import type { ReactNode } from "react";
import { cn } from "../lib/utils";
import {
ChevronDown,
ChevronRight,
FileCode2,
FileText,
Folder,
FolderOpen,
} from "lucide-react";
// ── Tree types ────────────────────────────────────────────────────────
export type FileTreeNode = {
name: string;
path: string;
kind: "dir" | "file";
children: FileTreeNode[];
/** Optional per-node metadata (e.g. import action) */
action?: string | null;
};
const TREE_BASE_INDENT = 16;
const TREE_STEP_INDENT = 24;
const TREE_ROW_HEIGHT_CLASS = "min-h-9";
// ── Helpers ───────────────────────────────────────────────────────────
export function buildFileTree(
files: Record<string, unknown>,
actionMap?: Map<string, string>,
): FileTreeNode[] {
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
for (const filePath of Object.keys(files)) {
const segments = filePath.split("/").filter(Boolean);
let current = root;
let currentPath = "";
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
const isLeaf = i === segments.length - 1;
let next = current.children.find((c) => c.name === segment);
if (!next) {
next = {
name: segment,
path: currentPath,
kind: isLeaf ? "file" : "dir",
children: [],
action: isLeaf ? (actionMap?.get(filePath) ?? null) : null,
};
current.children.push(next);
}
current = next;
}
}
function sortNode(node: FileTreeNode) {
node.children.sort((a, b) => {
// Files before directories so PROJECT.md appears above tasks/
if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
return a.name.localeCompare(b.name);
});
node.children.forEach(sortNode);
}
sortNode(root);
return root.children;
}
export function countFiles(nodes: FileTreeNode[]): number {
let count = 0;
for (const node of nodes) {
if (node.kind === "file") count++;
else count += countFiles(node.children);
}
return count;
}
export function collectAllPaths(
nodes: FileTreeNode[],
type: "file" | "dir" | "all" = "all",
): Set<string> {
const paths = new Set<string>();
for (const node of nodes) {
if (type === "all" || node.kind === type) paths.add(node.path);
for (const p of collectAllPaths(node.children, type)) paths.add(p);
}
return paths;
}
function fileIcon(name: string) {
if (name.endsWith(".yaml") || name.endsWith(".yml")) return FileCode2;
return FileText;
}
// ── Frontmatter helpers ───────────────────────────────────────────────
export type FrontmatterData = Record<string, string | string[]>;
export function parseFrontmatter(content: string): { data: FrontmatterData; body: string } | null {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return null;
const data: FrontmatterData = {};
const rawYaml = match[1];
const body = match[2];
let currentKey: string | null = null;
let currentList: string[] | null = null;
for (const line of rawYaml.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
if (trimmed.startsWith("- ") && currentKey) {
if (!currentList) currentList = [];
currentList.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ""));
continue;
}
if (currentKey && currentList) {
data[currentKey] = currentList;
currentList = null;
currentKey = null;
}
const kvMatch = trimmed.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
if (kvMatch) {
const key = kvMatch[1];
const val = kvMatch[2].trim().replace(/^["']|["']$/g, "");
if (val === "null") {
currentKey = null;
continue;
}
if (val) {
data[key] = val;
currentKey = null;
} else {
currentKey = key;
}
}
}
if (currentKey && currentList) {
data[currentKey] = currentList;
}
return Object.keys(data).length > 0 ? { data, body } : null;
}
export const FRONTMATTER_FIELD_LABELS: Record<string, string> = {
name: "Name",
title: "Title",
kind: "Kind",
reportsTo: "Reports to",
skills: "Skills",
status: "Status",
description: "Description",
priority: "Priority",
assignee: "Assignee",
project: "Project",
recurring: "Recurring",
targetDate: "Target date",
};
// ── File tree component ───────────────────────────────────────────────
export function PackageFileTree({
nodes,
selectedFile,
expandedDirs,
checkedFiles,
onToggleDir,
onSelectFile,
onToggleCheck,
renderFileExtra,
fileRowClassName,
showCheckboxes = true,
depth = 0,
}: {
nodes: FileTreeNode[];
selectedFile: string | null;
expandedDirs: Set<string>;
checkedFiles?: Set<string>;
onToggleDir: (path: string) => void;
onSelectFile: (path: string) => void;
onToggleCheck?: (path: string, kind: "file" | "dir") => void;
/** Optional extra content rendered at the end of each file row (e.g. action badge) */
renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode;
/** Optional additional className for file rows */
fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined;
showCheckboxes?: boolean;
depth?: number;
}) {
const effectiveCheckedFiles = checkedFiles ?? new Set<string>();
return (
<div>
{nodes.map((node) => {
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
if (node.kind === "dir") {
const childFiles = collectAllPaths(node.children, "file");
const allChecked = [...childFiles].every((p) => effectiveCheckedFiles.has(p));
const someChecked = [...childFiles].some((p) => effectiveCheckedFiles.has(p));
return (
<div key={node.path}>
<div
className={cn(
showCheckboxes
? "group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground"
: "group grid w-full grid-cols-[minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground",
TREE_ROW_HEIGHT_CLASS,
)}
style={{
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
}}
>
{showCheckboxes && (
<label className="flex items-center pl-2">
<input
type="checkbox"
checked={allChecked}
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked; }}
onChange={() => onToggleCheck?.(node.path, "dir")}
className="mr-2 accent-foreground"
/>
</label>
)}
<button
type="button"
className="flex min-w-0 items-center gap-2 py-1 text-left"
onClick={() => onToggleDir(node.path)}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{expanded ? (
<FolderOpen className="h-3.5 w-3.5" />
) : (
<Folder className="h-3.5 w-3.5" />
)}
</span>
<span className="truncate">{node.name}</span>
</button>
<button
type="button"
className="flex h-9 w-9 items-center justify-center self-center rounded-sm text-muted-foreground opacity-70 transition-[background-color,color,opacity] hover:bg-accent hover:text-foreground group-hover:opacity-100"
onClick={() => onToggleDir(node.path)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
</div>
{expanded && (
<PackageFileTree
nodes={node.children}
selectedFile={selectedFile}
expandedDirs={expandedDirs}
checkedFiles={effectiveCheckedFiles}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
onToggleCheck={onToggleCheck}
renderFileExtra={renderFileExtra}
fileRowClassName={fileRowClassName}
showCheckboxes={showCheckboxes}
depth={depth + 1}
/>
)}
</div>
);
}
const FileIcon = fileIcon(node.name);
const checked = effectiveCheckedFiles.has(node.path);
const extraClassName = fileRowClassName?.(node, checked);
return (
<div
key={node.path}
className={cn(
"flex w-full items-center gap-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer",
TREE_ROW_HEIGHT_CLASS,
node.path === selectedFile && "text-foreground bg-accent/20",
extraClassName,
)}
style={{
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
}}
onClick={() => onSelectFile(node.path)}
>
{showCheckboxes && (
<label className="flex items-center pl-2">
<input
type="checkbox"
checked={checked}
onChange={() => onToggleCheck?.(node.path, "file")}
className="mr-2 accent-foreground"
/>
</label>
)}
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left"
onClick={() => onSelectFile(node.path)}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<FileIcon className="h-3.5 w-3.5" />
</span>
<span className="truncate">{node.name}</span>
</button>
{renderFileExtra?.(node, checked)}
</div>
);
})}
</div>
);
}
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
import { useState } from "react";
import type { Agent } from "@paperclipai/shared";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { User } from "lucide-react";
import { cn } from "../lib/utils";
import { roleLabels } from "./agent-config-primitives";
import { AgentIcon } from "./AgentIconPicker";
export function ReportsToPicker({
agents,
value,
onChange,
disabled = false,
excludeAgentIds = [],
disabledEmptyLabel = "Reports to: N/A (CEO)",
chooseLabel = "Reports to...",
}: {
agents: Agent[];
value: string | null;
onChange: (id: string | null) => void;
disabled?: boolean;
excludeAgentIds?: string[];
disabledEmptyLabel?: string;
chooseLabel?: string;
}) {
const [open, setOpen] = useState(false);
const exclude = new Set(excludeAgentIds);
const rows = agents.filter(
(a) => a.status !== "terminated" && !exclude.has(a.id),
);
const current = value ? agents.find((a) => a.id === value) : null;
const terminatedManager = current?.status === "terminated";
const unknownManager = Boolean(value && !current);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
terminatedManager && "border-amber-600/45 bg-amber-500/5",
disabled && "opacity-60 cursor-not-allowed",
)}
disabled={disabled}
>
{unknownManager ? (
<>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-muted-foreground">Unknown manager (stale ID)</span>
</>
) : current ? (
<>
<AgentIcon icon={current.icon} className="h-3 w-3 shrink-0 text-muted-foreground" />
<span
className={cn(
"min-w-0 truncate",
terminatedManager && "text-amber-900 dark:text-amber-200",
)}
>
{`Reports to ${current.name}${terminatedManager ? " (terminated)" : ""}`}
</span>
</>
) : (
<>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">
{disabled ? disabledEmptyLabel : chooseLabel}
</span>
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="start">
<button
type="button"
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
value === null && "bg-accent",
)}
onClick={() => {
onChange(null);
setOpen(false);
}}
>
No manager
</button>
{terminatedManager && (
<div className="flex min-w-0 items-center gap-2 overflow-hidden px-2 py-1.5 text-xs text-muted-foreground border-b border-border mb-0.5">
<AgentIcon icon={current.icon} className="shrink-0 h-3 w-3" />
<span className="min-w-0 truncate">
Current: {current.name} (terminated)
</span>
</div>
)}
{unknownManager && (
<div className="px-2 py-1.5 text-xs text-muted-foreground border-b border-border mb-0.5">
Saved manager is missing from this company. Choose a new manager or clear.
</div>
)}
{rows.map((a) => (
<button
type="button"
key={a.id}
className={cn(
"flex items-center gap-2 w-full min-w-0 px-2 py-1.5 text-xs rounded hover:bg-accent/50 overflow-hidden",
a.id === value && "bg-accent",
)}
onClick={() => {
onChange(a.id);
setOpen(false);
}}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
<span className="min-w-0 truncate">{a.name}</span>
<span className="text-muted-foreground ml-auto shrink-0">{roleLabels[a.role] ?? a.role}</span>
</button>
))}
</PopoverContent>
</Popover>
);
}
+344
View File
@@ -0,0 +1,344 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { ChevronDown, ChevronRight } from "lucide-react";
type SchedulePreset = "every_minute" | "every_hour" | "every_day" | "weekdays" | "weekly" | "monthly" | "custom";
const PRESETS: { value: SchedulePreset; label: string }[] = [
{ value: "every_minute", label: "Every minute" },
{ value: "every_hour", label: "Every hour" },
{ value: "every_day", label: "Every day" },
{ value: "weekdays", label: "Weekdays" },
{ value: "weekly", label: "Weekly" },
{ value: "monthly", label: "Monthly" },
{ value: "custom", label: "Custom (cron)" },
];
const HOURS = Array.from({ length: 24 }, (_, i) => ({
value: String(i),
label: i === 0 ? "12 AM" : i < 12 ? `${i} AM` : i === 12 ? "12 PM" : `${i - 12} PM`,
}));
const MINUTES = Array.from({ length: 12 }, (_, i) => ({
value: String(i * 5),
label: String(i * 5).padStart(2, "0"),
}));
const DAYS_OF_WEEK = [
{ value: "1", label: "Mon" },
{ value: "2", label: "Tue" },
{ value: "3", label: "Wed" },
{ value: "4", label: "Thu" },
{ value: "5", label: "Fri" },
{ value: "6", label: "Sat" },
{ value: "0", label: "Sun" },
];
const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => ({
value: String(i + 1),
label: String(i + 1),
}));
function parseCronToPreset(cron: string): {
preset: SchedulePreset;
hour: string;
minute: string;
dayOfWeek: string;
dayOfMonth: string;
} {
const defaults = { hour: "10", minute: "0", dayOfWeek: "1", dayOfMonth: "1" };
if (!cron || !cron.trim()) {
return { preset: "every_day", ...defaults };
}
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
return { preset: "custom", ...defaults };
}
const [min, hr, dom, , dow] = parts;
// Every minute: "* * * * *"
if (min === "*" && hr === "*" && dom === "*" && dow === "*") {
return { preset: "every_minute", ...defaults };
}
// Every hour: "0 * * * *"
if (hr === "*" && dom === "*" && dow === "*") {
return { preset: "every_hour", ...defaults, minute: min === "*" ? "0" : min };
}
// Every day: "M H * * *"
if (dom === "*" && dow === "*" && hr !== "*") {
return { preset: "every_day", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
}
// Weekdays: "M H * * 1-5"
if (dom === "*" && dow === "1-5" && hr !== "*") {
return { preset: "weekdays", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
}
// Weekly: "M H * * D" (single day)
if (dom === "*" && /^\d$/.test(dow) && hr !== "*") {
return { preset: "weekly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfWeek: dow };
}
// Monthly: "M H D * *"
if (/^\d{1,2}$/.test(dom) && dow === "*" && hr !== "*") {
return { preset: "monthly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfMonth: dom };
}
return { preset: "custom", ...defaults };
}
function buildCron(preset: SchedulePreset, hour: string, minute: string, dayOfWeek: string, dayOfMonth: string): string {
switch (preset) {
case "every_minute":
return "* * * * *";
case "every_hour":
return `${minute} * * * *`;
case "every_day":
return `${minute} ${hour} * * *`;
case "weekdays":
return `${minute} ${hour} * * 1-5`;
case "weekly":
return `${minute} ${hour} * * ${dayOfWeek}`;
case "monthly":
return `${minute} ${hour} ${dayOfMonth} * *`;
case "custom":
return "";
}
}
function describeSchedule(cron: string): string {
const { preset, hour, minute, dayOfWeek, dayOfMonth } = parseCronToPreset(cron);
const hourLabel = HOURS.find((h) => h.value === hour)?.label ?? `${hour}`;
const timeStr = `${hourLabel.replace(/ (AM|PM)$/, "")}:${minute.padStart(2, "0")} ${hourLabel.match(/(AM|PM)$/)?.[0] ?? ""}`;
switch (preset) {
case "every_minute":
return "Every minute";
case "every_hour":
return `Every hour at :${minute.padStart(2, "0")}`;
case "every_day":
return `Every day at ${timeStr}`;
case "weekdays":
return `Weekdays at ${timeStr}`;
case "weekly": {
const day = DAYS_OF_WEEK.find((d) => d.value === dayOfWeek)?.label ?? dayOfWeek;
return `Every ${day} at ${timeStr}`;
}
case "monthly":
return `Monthly on the ${dayOfMonth}${ordinalSuffix(Number(dayOfMonth))} at ${timeStr}`;
case "custom":
return cron || "No schedule set";
}
}
function ordinalSuffix(n: number): string {
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return s[(v - 20) % 10] || s[v] || s[0];
}
export { describeSchedule };
export function ScheduleEditor({
value,
onChange,
}: {
value: string;
onChange: (cron: string) => void;
}) {
const parsed = useMemo(() => parseCronToPreset(value), [value]);
const [preset, setPreset] = useState<SchedulePreset>(parsed.preset);
const [hour, setHour] = useState(parsed.hour);
const [minute, setMinute] = useState(parsed.minute);
const [dayOfWeek, setDayOfWeek] = useState(parsed.dayOfWeek);
const [dayOfMonth, setDayOfMonth] = useState(parsed.dayOfMonth);
const [customCron, setCustomCron] = useState(preset === "custom" ? value : "");
// Sync from external value changes
useEffect(() => {
const p = parseCronToPreset(value);
setPreset(p.preset);
setHour(p.hour);
setMinute(p.minute);
setDayOfWeek(p.dayOfWeek);
setDayOfMonth(p.dayOfMonth);
if (p.preset === "custom") setCustomCron(value);
}, [value]);
const emitChange = useCallback(
(p: SchedulePreset, h: string, m: string, dow: string, dom: string, custom: string) => {
if (p === "custom") {
onChange(custom);
} else {
onChange(buildCron(p, h, m, dow, dom));
}
},
[onChange],
);
const handlePresetChange = (newPreset: SchedulePreset) => {
setPreset(newPreset);
if (newPreset === "custom") {
setCustomCron(value);
} else {
emitChange(newPreset, hour, minute, dayOfWeek, dayOfMonth, customCron);
}
};
return (
<div className="space-y-3">
<Select value={preset} onValueChange={(v) => handlePresetChange(v as SchedulePreset)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose frequency..." />
</SelectTrigger>
<SelectContent>
{PRESETS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
{preset === "custom" ? (
<div className="space-y-1.5">
<Input
value={customCron}
onChange={(e) => {
setCustomCron(e.target.value);
emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, e.target.value);
}}
placeholder="0 10 * * *"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Five fields: minute hour day-of-month month day-of-week
</p>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{preset !== "every_minute" && preset !== "every_hour" && (
<>
<span className="text-sm text-muted-foreground">at</span>
<Select
value={hour}
onValueChange={(h) => {
setHour(h);
emitChange(preset, h, minute, dayOfWeek, dayOfMonth, customCron);
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOURS.map((h) => (
<SelectItem key={h.value} value={h.value}>
{h.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">:</span>
<Select
value={minute}
onValueChange={(m) => {
setMinute(m);
emitChange(preset, hour, m, dayOfWeek, dayOfMonth, customCron);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
{preset === "every_hour" && (
<>
<span className="text-sm text-muted-foreground">at minute</span>
<Select
value={minute}
onValueChange={(m) => {
setMinute(m);
emitChange(preset, hour, m, dayOfWeek, dayOfMonth, customCron);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m.value} value={m.value}>
:{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
{preset === "weekly" && (
<>
<span className="text-sm text-muted-foreground">on</span>
<div className="flex gap-1">
{DAYS_OF_WEEK.map((d) => (
<Button
key={d.value}
type="button"
variant={dayOfWeek === d.value ? "default" : "outline"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
setDayOfWeek(d.value);
emitChange(preset, hour, minute, d.value, dayOfMonth, customCron);
}}
>
{d.label}
</Button>
))}
</div>
</>
)}
{preset === "monthly" && (
<>
<span className="text-sm text-muted-foreground">on day</span>
<Select
value={dayOfMonth}
onValueChange={(dom) => {
setDayOfMonth(dom);
emitChange(preset, hour, minute, dayOfWeek, dom, customCron);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DAYS_OF_MONTH.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
)}
</div>
);
}
+4
View File
@@ -8,6 +8,8 @@ import {
Search,
SquarePen,
Network,
Boxes,
Repeat,
Settings,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
@@ -97,6 +99,7 @@ export function Sidebar() {
<SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} textBadge="Beta" textBadgeTone="amber" />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
</SidebarSection>
@@ -106,6 +109,7 @@ export function Sidebar() {
<SidebarSection label="Company">
<SidebarNavItem to="/org" label="Org" icon={Network} />
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
+18 -26
View File
@@ -6,9 +6,11 @@ import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
import { useAgentOrder } from "../hooks/useAgentOrder";
import { AgentIcon } from "./AgentIconPicker";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import {
@@ -17,28 +19,6 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { Agent } from "@paperclipai/shared";
/** BFS sort: roots first (no reportsTo), then their direct reports, etc. */
function sortByHierarchy(agents: Agent[]): Agent[] {
const byId = new Map(agents.map((a) => [a.id, a]));
const childrenOf = new Map<string | null, Agent[]>();
for (const a of agents) {
const parent = a.reportsTo && byId.has(a.reportsTo) ? a.reportsTo : null;
const list = childrenOf.get(parent) ?? [];
list.push(a);
childrenOf.set(parent, list);
}
const sorted: Agent[] = [];
const queue = childrenOf.get(null) ?? [];
while (queue.length > 0) {
const agent = queue.shift()!;
sorted.push(agent);
const children = childrenOf.get(agent.id);
if (children) queue.push(...children);
}
return sorted;
}
export function SidebarAgents() {
const [open, setOpen] = useState(true);
const { selectedCompanyId } = useCompany();
@@ -51,6 +31,10 @@ export function SidebarAgents() {
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
@@ -71,11 +55,19 @@ export function SidebarAgents() {
const filtered = (agents ?? []).filter(
(a: Agent) => a.status !== "terminated"
);
return sortByHierarchy(filtered);
return filtered;
}, [agents]);
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const { orderedAgents } = useAgentOrder({
agents: visibleAgents,
companyId: selectedCompanyId,
userId: currentUserId,
});
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)/);
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
const activeAgentId = agentMatch?.[1] ?? null;
const activeTab = agentMatch?.[2] ?? null;
return (
<Collapsible open={open} onOpenChange={setOpen}>
@@ -107,12 +99,12 @@ export function SidebarAgents() {
<CollapsibleContent>
<div className="flex flex-col gap-0.5 mt-0.5">
{visibleAgents.map((agent: Agent) => {
{orderedAgents.map((agent: Agent) => {
const runCount = liveCountByAgent.get(agent.id) ?? 0;
return (
<NavLink
key={agent.id}
to={agentUrl(agent)}
to={activeTab ? `${agentUrl(agent)}/${activeTab}` : agentUrl(agent)}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
+16
View File
@@ -11,6 +11,8 @@ interface SidebarNavItemProps {
className?: string;
badge?: number;
badgeTone?: "default" | "danger";
textBadge?: string;
textBadgeTone?: "default" | "amber";
alert?: boolean;
liveCount?: number;
}
@@ -23,6 +25,8 @@ export function SidebarNavItem({
className,
badge,
badgeTone = "default",
textBadge,
textBadgeTone = "default",
alert = false,
liveCount,
}: SidebarNavItemProps) {
@@ -50,6 +54,18 @@ export function SidebarNavItem({
)}
</span>
<span className="flex-1 truncate">{label}</span>
{textBadge && (
<span
className={cn(
"ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none",
textBadgeTone === "amber"
? "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
: "bg-muted text-muted-foreground",
)}
>
{textBadge}
</span>
)}
{liveCount != null && liveCount > 0 && (
<span className="ml-auto flex items-center gap-1.5">
<span className="relative flex h-2 w-2">
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Archive } from "lucide-react";
import { cn } from "../lib/utils";
interface SwipeToArchiveProps {
children: ReactNode;
onArchive: () => void;
disabled?: boolean;
className?: string;
}
const COMMIT_THRESHOLD = 0.4;
const MAX_SWIPE = 0.92;
const COMMIT_DELAY_MS = 210;
export function SwipeToArchive({
children,
onArchive,
disabled = false,
className,
}: SwipeToArchiveProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const startPointRef = useRef<{ x: number; y: number } | null>(null);
const widthRef = useRef(0);
const timeoutRef = useRef<number | null>(null);
const [offsetX, setOffsetX] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [isCollapsing, setIsCollapsing] = useState(false);
const [lockedHeight, setLockedHeight] = useState<number | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);
const reset = () => {
startPointRef.current = null;
setIsDragging(false);
setOffsetX(0);
};
const commitArchive = () => {
const node = containerRef.current;
if (!node) {
onArchive();
return;
}
setIsDragging(false);
setLockedHeight(node.offsetHeight);
setOffsetX(-Math.max(widthRef.current, node.offsetWidth));
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
setIsCollapsing(true);
});
});
timeoutRef.current = window.setTimeout(() => {
onArchive();
}, COMMIT_DELAY_MS);
};
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
if (disabled || event.touches.length !== 1) return;
const touch = event.touches[0];
const node = containerRef.current;
widthRef.current = node?.offsetWidth ?? 0;
setLockedHeight(node?.offsetHeight ?? null);
setIsCollapsing(false);
startPointRef.current = { x: touch.clientX, y: touch.clientY };
};
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
if (disabled || isCollapsing) return;
const startPoint = startPointRef.current;
if (!startPoint || event.touches.length !== 1) return;
const touch = event.touches[0];
const deltaX = touch.clientX - startPoint.x;
const deltaY = touch.clientY - startPoint.y;
if (!isDragging) {
if (Math.abs(deltaX) < 6) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) {
startPointRef.current = null;
return;
}
}
if (deltaX >= 0) {
event.preventDefault();
setIsDragging(true);
setOffsetX(0);
return;
}
const maxSwipe = widthRef.current > 0 ? widthRef.current * MAX_SWIPE : Number.POSITIVE_INFINITY;
event.preventDefault();
setIsDragging(true);
setOffsetX(Math.max(deltaX, -maxSwipe));
};
const handleTouchEnd = () => {
if (disabled || isCollapsing) return;
const shouldCommit =
widthRef.current > 0 && Math.abs(offsetX) >= widthRef.current * COMMIT_THRESHOLD;
if (shouldCommit) {
commitArchive();
return;
}
reset();
};
const archiveReveal = widthRef.current > 0 ? Math.min(Math.abs(offsetX) / widthRef.current, 1) : 0;
return (
<div
ref={containerRef}
className={cn("relative overflow-hidden touch-pan-y", className)}
style={{
height: lockedHeight === null ? undefined : isCollapsing ? 0 : lockedHeight,
opacity: isCollapsing ? 0 : 1,
transition: isCollapsing ? "height 200ms ease, opacity 200ms ease" : undefined,
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-end bg-emerald-600 px-4 text-white"
style={{ opacity: Math.max(archiveReveal, 0.2) }}
>
<span className="inline-flex items-center gap-2 text-sm font-medium">
<Archive className="h-4 w-4" />
Archive
</span>
</div>
<div
className="relative bg-card will-change-transform"
style={{
transform: `translate3d(${offsetX}px, 0, 0)`,
transition: isDragging ? "none" : "transform 180ms ease-out",
}}
>
{children}
</div>
</div>
);
}
@@ -25,12 +25,12 @@ export const help: Record<string, string> = {
reportsTo: "The agent this one reports to in the org hierarchy.",
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.",
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
cwd: "Deprecated legacy working directory fallback for local adapters. Existing agents may still carry this value, but new configurations should use project workspaces instead.",
promptTemplate: "Sent on every heartbeat. Keep this small and dynamic. Use it for current-task framing, not large static instructions. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} and other template variables.",
model: "Override the default model used by the adapter.",
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
chrome: "Enable Claude's Chrome integration by passing --chrome.",
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.",
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
search: "Enable Codex web search capability during runs.",
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",
@@ -64,6 +64,7 @@ export const adapterLabels: Record<string, string> = {
opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
hermes_local: "Hermes Agent",
process: "Process",
http: "HTTP",
};
@@ -117,6 +118,7 @@ export function ToggleField({
{hint && <HintIcon text={hint} />}
</div>
<button
data-slot="toggle"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
checked ? "bg-green-600" : "bg-muted"
@@ -165,6 +167,7 @@ export function ToggleWithNumber({
{hint && <HintIcon text={hint} />}
</div>
<button
data-slot="toggle"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0",
checked ? "bg-green-600" : "bg-muted"
@@ -72,6 +72,26 @@ type TranscriptBlock =
status: "running" | "completed" | "error";
}>;
}
| {
type: "tool_group";
ts: string;
endTs?: string;
items: Array<{
ts: string;
endTs?: string;
name: string;
input: unknown;
result?: string;
isError?: boolean;
status: "running" | "completed" | "error";
}>;
}
| {
type: "stderr_group";
ts: string;
endTs?: string;
lines: Array<{ ts: string; text: string }>;
}
| {
type: "stdout";
ts: string;
@@ -325,6 +345,48 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
return grouped;
}
/** Group consecutive non-command tool blocks into a single tool_group accordion. */
function groupToolBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
const grouped: TranscriptBlock[] = [];
let pending: Array<Extract<TranscriptBlock, { type: "tool_group" }>["items"][number]> = [];
let groupTs: string | null = null;
let groupEndTs: string | undefined;
const flush = () => {
if (pending.length === 0 || !groupTs) return;
grouped.push({
type: "tool_group",
ts: groupTs,
endTs: groupEndTs,
items: pending,
});
pending = [];
groupTs = null;
groupEndTs = undefined;
};
for (const block of blocks) {
if (block.type === "tool" && !isCommandTool(block.name, block.input)) {
if (!groupTs) groupTs = block.ts;
groupEndTs = block.endTs ?? block.ts;
pending.push({
ts: block.ts,
endTs: block.endTs,
name: block.name,
input: block.input,
result: block.result,
isError: block.isError,
status: block.status,
});
continue;
}
flush();
grouped.push(block);
}
flush();
return grouped;
}
export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
const blocks: TranscriptBlock[] = [];
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
@@ -400,7 +462,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
type: "tool",
ts: entry.ts,
endTs: entry.ts,
name: "tool",
name: entry.toolName ?? "tool",
toolUseId: entry.toolUseId,
input: null,
result: entry.content,
@@ -437,13 +499,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
if (shouldHideNiceModeStderr(entry.text)) {
continue;
}
blocks.push({
type: "event",
ts: entry.ts,
label: "stderr",
tone: "error",
text: entry.text,
});
// Batch consecutive stderr entries into a single group
const prev = blocks[blocks.length - 1];
if (prev && prev.type === "stderr_group") {
prev.lines.push({ ts: entry.ts, text: entry.text });
prev.endTs = entry.ts;
} else {
blocks.push({
type: "stderr_group",
ts: entry.ts,
endTs: entry.ts,
lines: [{ ts: entry.ts, text: entry.text }],
});
}
continue;
}
@@ -508,7 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
}
}
return groupCommandBlocks(blocks);
return groupToolBlocks(groupCommandBlocks(blocks));
}
function TranscriptMessageBlock({
@@ -805,6 +873,139 @@ function TranscriptCommandGroup({
);
}
function TranscriptToolGroup({
block,
density,
}: {
block: Extract<TranscriptBlock, { type: "tool_group" }>;
density: TranscriptDensity;
}) {
const [open, setOpen] = useState(false);
const compact = density === "compact";
const runningItem = [...block.items].reverse().find((item) => item.status === "running");
const hasError = block.items.some((item) => item.status === "error");
const isRunning = Boolean(runningItem);
const uniqueNames = [...new Set(block.items.map((item) => item.name))];
const toolLabel =
uniqueNames.length === 1
? humanizeLabel(uniqueNames[0])
: `${uniqueNames.length} tools`;
const title = isRunning
? `Using ${toolLabel}`
: block.items.length === 1
? `Used ${toolLabel}`
: `Used ${toolLabel} (${block.items.length} calls)`;
const subtitle = runningItem
? summarizeToolInput(runningItem.name, runningItem.input, density)
: null;
const statusTone = isRunning
? "text-cyan-700 dark:text-cyan-300"
: "text-foreground/70";
return (
<div className="rounded-xl border border-border/40 bg-muted/[0.25]">
<div
role="button"
tabIndex={0}
className={cn("flex cursor-pointer gap-2 px-3 py-2.5", subtitle ? "items-start" : "items-center")}
onClick={() => { if (hasSelectedText()) return; setOpen((v) => !v); }}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
>
<div className={cn("flex shrink-0 items-center", subtitle && "mt-0.5")}>
{block.items.slice(0, Math.min(block.items.length, 3)).map((item, index) => {
const isItemRunning = item.status === "running";
const isItemError = item.status === "error";
return (
<span
key={`${item.ts}-${index}`}
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm",
index > 0 && "-ml-1.5",
isItemRunning
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
: isItemError
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
: "border-border/70 bg-background text-foreground/55",
isItemRunning && "animate-pulse",
)}
>
<Wrench className="h-3.5 w-3.5" />
</span>
);
})}
</div>
<div className="min-w-0 flex-1">
<div className={cn("font-semibold uppercase leading-none tracking-[0.1em]", compact ? "text-[10px]" : "text-[11px]", "text-muted-foreground/70")}>
{title}
</div>
{subtitle && (
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
{subtitle}
</div>
)}
</div>
<button
type="button"
className={cn("inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground", subtitle && "mt-0.5")}
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
aria-label={open ? "Collapse tool details" : "Expand tool details"}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
</div>
{open && (
<div className={cn("space-y-2 border-t border-border/30 px-3 py-3", hasError && "rounded-b-xl")}>
{block.items.map((item, index) => (
<div key={`${item.ts}-${index}`} className="space-y-1.5">
<div className="flex items-center gap-2">
<span className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border",
item.status === "error"
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
: item.status === "running"
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
: "border-border/70 bg-background text-foreground/55",
)}>
<Wrench className="h-3 w-3" />
</span>
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground")}>
{humanizeLabel(item.name)}
</span>
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]",
item.status === "running" ? "text-cyan-700 dark:text-cyan-300"
: item.status === "error" ? "text-red-700 dark:text-red-300"
: "text-emerald-700 dark:text-emerald-300"
)}>
{item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"}
</span>
</div>
<div className={cn("grid gap-2 pl-7", compact ? "grid-cols-1" : "lg:grid-cols-2")}>
<div>
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Input</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-foreground/80">
{formatToolPayload(item.input) || "<empty>"}
</pre>
</div>
{item.result && (
<div>
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Result</div>
<pre className={cn(
"overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px]",
item.status === "error" ? "text-red-700 dark:text-red-300" : "text-foreground/80",
)}>
{formatToolPayload(item.result)}
</pre>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
function TranscriptActivityRow({
block,
density,
@@ -883,6 +1084,43 @@ function TranscriptEventRow({
);
}
function TranscriptStderrGroup({
block,
density,
}: {
block: Extract<TranscriptBlock, { type: "stderr_group" }>;
density: TranscriptDensity;
}) {
const [open, setOpen] = useState(false);
const compact = density === "compact";
return (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/[0.06] p-2 text-amber-700 dark:text-amber-300">
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center gap-2"
onClick={() => setOpen((v) => !v)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
>
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]")}>
{block.lines.length} log {block.lines.length === 1 ? "line" : "lines"}
</span>
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</div>
{open && (
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-amber-700/80 dark:text-amber-300/80 pl-5">
{block.lines.map((line, i) => (
<span key={`${line.ts}-${i}`}>
<span className="select-none text-amber-500/50 dark:text-amber-400/40">{i > 0 ? "\n" : ""}</span>
{line.text}
</span>
))}
</pre>
)}
</div>
);
}
function TranscriptStdoutRow({
block,
density,
@@ -1003,6 +1241,8 @@ export function RunTranscriptView({
)}
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
{block.type === "tool_group" && <TranscriptToolGroup block={block} density={density} />}
{block.type === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
{block.type === "stdout" && (
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
)}
@@ -1,7 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclipai/shared";
import { instanceSettingsApi } from "../../api/instanceSettings";
import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats";
import { buildTranscript, getUIAdapter, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import { queryKeys } from "../../lib/queryKeys";
const LOG_POLL_INTERVAL_MS = 2000;
const LOG_READ_LIMIT_BYTES = 256_000;
@@ -65,6 +68,10 @@ export function useLiveRunTranscripts({
const seenChunkKeysRef = useRef(new Set<string>());
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>());
const { data: generalSettings } = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
});
const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]);
const activeRunIds = useMemo(
@@ -267,12 +274,18 @@ export function useLiveRunTranscripts({
const transcriptByRun = useMemo(() => {
const next = new Map<string, TranscriptEntry[]>();
const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true;
for (const run of runs) {
const adapter = getUIAdapter(run.adapterType);
next.set(run.id, buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine));
next.set(
run.id,
buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine, {
censorUsernameInLogs,
}),
);
}
return next;
}, [chunksByRun, runs]);
}, [chunksByRun, generalSettings?.censorUsernameInLogs, runs]);
return {
transcriptByRun,
+171
View File
@@ -0,0 +1,171 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
import { queryKeys } from "../lib/queryKeys";
describe("LiveUpdatesProvider issue invalidation", () => {
it("refreshes touched inbox queries for issue activity", () => {
const invalidations: unknown[] = [];
const queryClient = {
invalidateQueries: (input: unknown) => {
invalidations.push(input);
},
getQueryData: () => undefined,
};
__liveUpdatesTestUtils.invalidateActivityQueries(
queryClient as never,
"company-1",
{
entityType: "issue",
entityId: "issue-1",
details: null,
},
);
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.listMineByMe("company-1"),
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.listTouchedByMe("company-1"),
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"),
});
});
});
describe("LiveUpdatesProvider visible issue toast suppression", () => {
it("suppresses activity toasts for the issue page currently in view", () => {
const queryClient = {
getQueryData: (key: unknown) => {
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
return {
id: "issue-1",
identifier: "PAP-759",
assigneeAgentId: "agent-1",
};
}
return undefined;
},
};
expect(
__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(
queryClient as never,
"/PAP/issues/PAP-759",
{
entityType: "issue",
entityId: "issue-1",
details: { identifier: "PAP-759" },
},
{ isForegrounded: true },
),
).toBe(true);
expect(
__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(
queryClient as never,
"/PAP/issues/PAP-759",
{
entityType: "issue",
entityId: "issue-2",
details: { identifier: "PAP-760" },
},
{ isForegrounded: true },
),
).toBe(false);
});
it("suppresses run and agent status toasts for the assignee of the visible issue", () => {
const queryClient = {
getQueryData: (key: unknown) => {
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
return {
id: "issue-1",
identifier: "PAP-759",
assigneeAgentId: "agent-1",
};
}
return undefined;
},
};
expect(
__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(
queryClient as never,
"/PAP/issues/PAP-759",
{
runId: "run-1",
agentId: "agent-1",
},
{ isForegrounded: true },
),
).toBe(true);
expect(
__liveUpdatesTestUtils.shouldSuppressAgentStatusToastForVisibleIssue(
queryClient as never,
"/PAP/issues/PAP-759",
{
agentId: "agent-1",
status: "running",
},
{ isForegrounded: true },
),
).toBe(true);
});
});
describe("LiveUpdatesProvider socket helpers", () => {
it("waits for the selected company object to catch up before connecting", () => {
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", null)).toBeNull();
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-2")).toBeNull();
expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-1")).toBe("company-1");
});
it("defers close until onopen for sockets that are still connecting", () => {
const socket = {
readyState: 0,
onopen: (() => undefined) as (() => void) | null,
onmessage: (() => undefined) as (() => void) | null,
onerror: (() => undefined) as (() => void) | null,
onclose: (() => undefined) as (() => void) | null,
close: vi.fn(),
};
__liveUpdatesTestUtils.closeSocketQuietly(socket as never, "provider_unmount");
expect(socket.close).not.toHaveBeenCalled();
expect(socket.onmessage).toBeNull();
expect(socket.onclose).toBeNull();
expect(socket.onopen).toBeTypeOf("function");
expect(socket.onerror).toBeTypeOf("function");
socket.onopen?.();
expect(socket.close).toHaveBeenCalledWith(1000, "provider_unmount");
expect(socket.onopen).toBeNull();
expect(socket.onerror).toBeNull();
});
it("closes open sockets immediately without leaving handlers behind", () => {
const socket = {
readyState: 1,
onopen: (() => undefined) as (() => void) | null,
onmessage: (() => undefined) as (() => void) | null,
onerror: (() => undefined) as (() => void) | null,
onclose: (() => undefined) as (() => void) | null,
close: vi.fn(),
};
__liveUpdatesTestUtils.closeSocketQuietly(socket as never, "stale_connection");
expect(socket.close).toHaveBeenCalledWith(1000, "stale_connection");
expect(socket.onopen).toBeNull();
expect(socket.onmessage).toBeNull();
expect(socket.onerror).toBeNull();
expect(socket.onclose).toBeNull();
});
});
+213 -36
View File
@@ -1,15 +1,30 @@
import { useEffect, useRef, type ReactNode } from "react";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import type { Agent, Issue, LiveEvent } from "@paperclipai/shared";
import type { RunForIssue } from "../api/activity";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { authApi } from "../api/auth";
import { useCompany } from "./CompanyContext";
import type { ToastInput } from "./ToastContext";
import { useToast } from "./ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { toCompanyRelativePath } from "../lib/company-routes";
import { useLocation } from "../lib/router";
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
const TOAST_COOLDOWN_MAX = 3;
const RECONNECT_SUPPRESS_MS = 2000;
const SOCKET_CONNECTING = 0;
const SOCKET_OPEN = 1;
type LiveUpdatesSocketLike = {
readyState: number;
onopen: ((this: WebSocket, ev: Event) => unknown) | null;
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null;
onerror: ((this: WebSocket, ev: Event) => unknown) | null;
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null;
close: (code?: number, reason?: string) => void;
};
function readString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
@@ -63,6 +78,16 @@ interface IssueToastContext {
href: string;
}
interface VisibleRouteOptions {
isForegrounded?: boolean;
}
interface VisibleIssueRouteContext {
issueRefs: Set<string>;
assigneeAgentId: string | null;
runIds: Set<string>;
}
function resolveIssueQueryRefs(
queryClient: QueryClient,
companyId: string,
@@ -125,6 +150,110 @@ function resolveIssueToastContext(
};
}
function isPageForegrounded(): boolean {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
if (typeof document.hasFocus === "function" && !document.hasFocus()) return false;
return true;
}
function resolveVisibleIssueRouteContext(
queryClient: QueryClient,
pathname: string,
options?: VisibleRouteOptions,
): VisibleIssueRouteContext | null {
const isForegrounded = options?.isForegrounded ?? isPageForegrounded();
if (!isForegrounded) return null;
const relativePath = toCompanyRelativePath(pathname);
const segments = relativePath.split("/").filter(Boolean);
if (segments[0] !== "issues" || !segments[1]) return null;
const issueRef = decodeURIComponent(segments[1]);
const issue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueRef)) ?? null;
const issueRefs = new Set<string>([issueRef]);
if (issue?.id) issueRefs.add(issue.id);
if (issue?.identifier) issueRefs.add(issue.identifier);
const runIds = new Set<string>();
const activeRun = queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(issueRef));
const liveRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueRef)) ?? [];
const linkedRuns = queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(issueRef)) ?? [];
if (activeRun?.id) runIds.add(activeRun.id);
for (const run of liveRuns) {
if (run.id) runIds.add(run.id);
}
for (const run of linkedRuns) {
if (run.runId) runIds.add(run.runId);
}
return {
issueRefs,
assigneeAgentId: issue?.assigneeAgentId ?? null,
runIds,
};
}
function buildIssueRefsForPayload(entityId: string, details: Record<string, unknown> | null): Set<string> {
const refs = new Set<string>([entityId]);
const identifier = readString(details?.identifier) ?? readString(details?.issueIdentifier);
if (identifier) refs.add(identifier);
return refs;
}
function overlaps(a: Set<string>, b: Set<string>): boolean {
for (const value of a) {
if (b.has(value)) return true;
}
return false;
}
function shouldSuppressActivityToastForVisibleIssue(
queryClient: QueryClient,
pathname: string,
payload: Record<string, unknown>,
options?: VisibleRouteOptions,
): boolean {
const entityType = readString(payload.entityType);
const entityId = readString(payload.entityId);
if (entityType !== "issue" || !entityId) return false;
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
if (!context) return false;
return overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, readRecord(payload.details)));
}
function shouldSuppressRunStatusToastForVisibleIssue(
queryClient: QueryClient,
pathname: string,
payload: Record<string, unknown>,
options?: VisibleRouteOptions,
): boolean {
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
if (!context) return false;
const runId = readString(payload.runId);
if (runId && context.runIds.has(runId)) return true;
const agentId = readString(payload.agentId);
return !!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId;
}
function shouldSuppressAgentStatusToastForVisibleIssue(
queryClient: QueryClient,
pathname: string,
payload: Record<string, unknown>,
options?: VisibleRouteOptions,
): boolean {
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
if (!context?.assigneeAgentId) return false;
const agentId = readString(payload.agentId);
return !!agentId && agentId === context.assigneeAgentId;
}
const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
const AGENT_TOAST_STATUSES = new Set(["running", "error"]);
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
@@ -256,7 +385,7 @@ function buildJoinRequestToast(
title: `${label} wants to join`,
body: "A new join request is waiting for approval.",
tone: "info",
action: { label: "View inbox", href: "/inbox/unread" },
action: { label: "View inbox", href: "/inbox/mine" },
dedupeKey: `join-request:${entityId}`,
};
}
@@ -361,6 +490,9 @@ function invalidateActivityQueries(
if (entityType === "issue") {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) });
if (entityId) {
const details = readRecord(payload.details);
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
@@ -420,6 +552,11 @@ function invalidateActivityQueries(
return;
}
if (entityType === "routine" || entityType === "routine_trigger" || entityType === "routine_run") {
queryClient.invalidateQueries({ queryKey: ["routines"] });
return;
}
if (entityType === "company") {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
}
@@ -463,6 +600,7 @@ function gatedPushToast(
function handleLiveEvent(
queryClient: QueryClient,
expectedCompanyId: string,
pathname: string,
event: LiveEvent,
pushToast: (toast: ToastInput) => string | null,
gate: ToastGate,
@@ -480,7 +618,12 @@ function handleLiveEvent(
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
if (event.type === "heartbeat.run.status") {
const toast = buildRunStatusToast(payload, nameOf);
if (toast) gatedPushToast(gate, pushToast, "run-status", toast);
if (
toast &&
!shouldSuppressRunStatusToastForVisibleIssue(queryClient, pathname, payload)
) {
gatedPushToast(gate, pushToast, "run-status", toast);
}
}
return;
}
@@ -496,7 +639,12 @@ function handleLiveEvent(
const agentId = readString(payload.agentId);
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
const toast = buildAgentStatusToast(payload, nameOf, queryClient, expectedCompanyId);
if (toast) gatedPushToast(gate, pushToast, "agent-status", toast);
if (
toast &&
!shouldSuppressAgentStatusToastForVisibleIssue(queryClient, pathname, payload)
) {
gatedPushToast(gate, pushToast, "agent-status", toast);
}
return;
}
@@ -506,15 +654,70 @@ function handleLiveEvent(
const toast =
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
buildJoinRequestToast(payload);
if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
if (
toast &&
!shouldSuppressActivityToastForVisibleIssue(queryClient, pathname, payload)
) {
gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
}
}
}
function resolveLiveCompanyId(
selectedCompanyId: string | null,
selectedCompanyLiveId: string | null,
): string | null {
return selectedCompanyId && selectedCompanyId === selectedCompanyLiveId
? selectedCompanyId
: null;
}
function resetSocketHandlers(target: LiveUpdatesSocketLike) {
target.onopen = null;
target.onmessage = null;
target.onerror = null;
target.onclose = null;
}
function closeSocketQuietly(target: LiveUpdatesSocketLike | null, reason: string) {
if (!target) return;
if (target.readyState === SOCKET_CONNECTING) {
// Let the handshake complete and then close. Calling close() while the
// socket is still CONNECTING is what triggers the noisy browser error.
target.onopen = () => {
resetSocketHandlers(target);
target.close(1000, reason);
};
target.onmessage = null;
target.onerror = () => undefined;
target.onclose = null;
return;
}
resetSocketHandlers(target);
if (target.readyState === SOCKET_OPEN) {
target.close(1000, reason);
}
}
export const __liveUpdatesTestUtils = {
closeSocketQuietly,
invalidateActivityQueries,
resolveLiveCompanyId,
shouldSuppressActivityToastForVisibleIssue,
shouldSuppressRunStatusToastForVisibleIssue,
shouldSuppressAgentStatusToastForVisibleIssue,
};
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const { selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const location = useLocation();
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
const pathnameRef = useRef(location.pathname);
const { data: session, status: sessionStatus } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
@@ -522,13 +725,17 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const socketAuthKey = session?.session?.id ?? currentUserId ?? "signed_out";
const liveCompanyId = selectedCompany?.id === selectedCompanyId ? selectedCompanyId : null;
const liveCompanyId = resolveLiveCompanyId(selectedCompanyId, selectedCompany?.id ?? null);
const canConnectSocket = sessionStatus === "success" && session !== null && liveCompanyId !== null;
const currentActorRef = useRef<{ userId: string | null; agentId: string | null }>({
userId: currentUserId,
agentId: null,
});
useEffect(() => {
pathnameRef.current = location.pathname;
}, [location.pathname]);
useEffect(() => {
currentActorRef.current = {
userId: currentUserId,
@@ -543,7 +750,6 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
let reconnectAttempt = 0;
let reconnectTimer: number | null = null;
let socket: WebSocket | null = null;
const noop = () => undefined;
const clearReconnect = () => {
if (reconnectTimer !== null) {
@@ -552,35 +758,6 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
}
};
const closeSocketQuietly = (target: WebSocket | null, reason: string) => {
if (!target) return;
if (target.readyState === WebSocket.CONNECTING) {
// Let the handshake complete and then close. Calling close() while the
// socket is still CONNECTING is what triggers the noisy browser error.
target.onopen = () => {
target.onopen = null;
target.onmessage = null;
target.onerror = null;
target.onclose = null;
target.close(1000, reason);
};
target.onmessage = null;
target.onerror = noop;
target.onclose = null;
return;
}
target.onopen = null;
target.onmessage = null;
target.onerror = null;
target.onclose = null;
if (target.readyState === WebSocket.OPEN) {
target.close(1000, reason);
}
};
const scheduleReconnect = () => {
if (closed) return;
reconnectAttempt += 1;
@@ -615,7 +792,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
try {
const parsed = JSON.parse(raw) as LiveEvent;
handleLiveEvent(queryClient, liveCompanyId, parsed, pushToast, gateRef.current, {
handleLiveEvent(queryClient, liveCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, {
userId: currentActorRef.current.userId,
agentId: currentActorRef.current.agentId,
});
+104
View File
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Agent } from "@paperclipai/shared";
import {
AGENT_ORDER_UPDATED_EVENT,
getAgentOrderStorageKey,
readAgentOrder,
sortAgentsByStoredOrder,
writeAgentOrder,
} from "../lib/agent-order";
type UseAgentOrderParams = {
agents: Agent[];
companyId: string | null | undefined;
userId: string | null | undefined;
};
type AgentOrderUpdatedDetail = {
storageKey: string;
orderedIds: string[];
};
function areEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
function buildOrderIds(agents: Agent[], orderedIds: string[]) {
return sortAgentsByStoredOrder(agents, orderedIds).map((agent) => agent.id);
}
export function useAgentOrder({ agents, companyId, userId }: UseAgentOrderParams) {
const storageKey = useMemo(() => {
if (!companyId) return null;
return getAgentOrderStorageKey(companyId, userId);
}, [companyId, userId]);
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (!storageKey) return agents.map((agent) => agent.id);
return buildOrderIds(agents, readAgentOrder(storageKey));
});
useEffect(() => {
const nextIds = storageKey
? buildOrderIds(agents, readAgentOrder(storageKey))
: agents.map((agent) => agent.id);
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
}, [agents, storageKey]);
useEffect(() => {
if (!storageKey) return;
const syncFromIds = (ids: string[]) => {
const nextIds = buildOrderIds(agents, ids);
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
};
const onStorage = (event: StorageEvent) => {
if (event.key !== storageKey) return;
syncFromIds(readAgentOrder(storageKey));
};
const onCustomEvent = (event: Event) => {
const detail = (event as CustomEvent<AgentOrderUpdatedDetail>).detail;
if (!detail || detail.storageKey !== storageKey) return;
syncFromIds(detail.orderedIds);
};
window.addEventListener("storage", onStorage);
window.addEventListener(AGENT_ORDER_UPDATED_EVENT, onCustomEvent);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(AGENT_ORDER_UPDATED_EVENT, onCustomEvent);
};
}, [agents, storageKey]);
const orderedAgents = useMemo(
() => sortAgentsByStoredOrder(agents, orderedIds),
[agents, orderedIds],
);
const persistOrder = useCallback(
(ids: string[]) => {
const idSet = new Set(agents.map((agent) => agent.id));
const filtered = ids.filter((id) => idSet.has(id));
for (const agent of sortAgentsByStoredOrder(agents, [])) {
if (!filtered.includes(agent.id)) filtered.push(agent.id);
}
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
if (storageKey) {
writeAgentOrder(storageKey, filtered);
}
},
[agents, storageKey],
);
return {
orderedAgents,
orderedIds,
persistOrder,
};
}
+19
View File
@@ -39,6 +39,16 @@ describe("getRememberedPathOwnerCompanyId", () => {
}),
).toBe("pap");
});
it("treats unprefixed skills routes as board routes instead of company prefixes", () => {
expect(
getRememberedPathOwnerCompanyId({
companies,
pathname: "/skills/skill-123/files/SKILL.md",
fallbackCompanyId: "pap",
}),
).toBe("pap");
});
});
describe("sanitizeRememberedPathForCompany", () => {
@@ -68,4 +78,13 @@ describe("sanitizeRememberedPathForCompany", () => {
}),
).toBe("/dashboard");
});
it("keeps remembered skills paths intact for the target company", () => {
expect(
sanitizeRememberedPathForCompany({
path: "/skills/skill-123/files/SKILL.md",
companyPrefix: "PAP",
}),
).toBe("/skills/skill-123/files/SKILL.md");
});
});
+33 -9
View File
@@ -12,7 +12,9 @@ import {
getRecentTouchedIssues,
loadDismissedInboxItems,
saveDismissedInboxItems,
getUnreadTouchedIssues,
loadReadInboxItems,
saveReadInboxItems,
READ_ITEMS_KEY,
} from "../lib/inbox";
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
@@ -41,6 +43,30 @@ export function useDismissedInboxItems() {
return { dismissed, dismiss };
}
export function useReadInboxItems() {
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== READ_ITEMS_KEY) return;
setReadItems(loadReadInboxItems());
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const markRead = (id: string) => {
setReadItems((prev) => {
const next = new Set(prev);
next.add(id);
saveReadInboxItems(next);
return next;
});
};
return { readItems, markRead };
}
export function useInboxBadge(companyId: string | null | undefined) {
const { dismissed } = useDismissedInboxItems();
@@ -72,20 +98,18 @@ export function useInboxBadge(companyId: string | null | undefined) {
enabled: !!companyId,
});
const { data: touchedIssues = [] } = useQuery({
queryKey: queryKeys.issues.listTouchedByMe(companyId!),
const { data: mineIssuesRaw = [] } = useQuery({
queryKey: queryKeys.issues.listMineByMe(companyId!),
queryFn: () =>
issuesApi.list(companyId!, {
touchedByUserId: "me",
inboxArchivedByUserId: "me",
status: INBOX_ISSUE_STATUSES,
}),
enabled: !!companyId,
});
const unreadIssues = useMemo(
() => getUnreadTouchedIssues(getRecentTouchedIssues(touchedIssues)),
[touchedIssues],
);
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
const { data: heartbeatRuns = [] } = useQuery({
queryKey: queryKeys.heartbeats(companyId!),
@@ -100,9 +124,9 @@ export function useInboxBadge(companyId: string | null | undefined) {
joinRequests,
dashboard,
heartbeatRuns,
unreadIssues,
mineIssues,
dismissed,
}),
[approvals, joinRequests, dashboard, heartbeatRuns, unreadIssues, dismissed],
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissed],
);
}
+58 -13
View File
@@ -153,6 +153,10 @@
[data-slot="select-trigger"] {
min-height: 44px;
}
[data-slot="toggle"] {
min-height: 0;
}
}
/* Dark mode scrollbars */
@@ -178,13 +182,16 @@
background: oklch(0.5 0 0);
}
/* Auto-hide scrollbar: fully invisible by default, visible on container hover */
/* Auto-hide scrollbar: always reserves space, thumb visible only on hover */
.scrollbar-auto-hide::-webkit-scrollbar {
width: 8px !important;
background: transparent !important;
}
.scrollbar-auto-hide::-webkit-scrollbar-track {
background: transparent !important;
}
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
background: transparent !important;
transition: background 150ms ease;
}
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
background: oklch(0.205 0 0) !important;
@@ -336,6 +343,7 @@
margin-top: 1.1em;
}
.paperclip-mdxeditor-content a.paperclip-mention-chip,
.paperclip-mdxeditor-content a.paperclip-project-mention-chip {
display: inline-flex;
align-items: center;
@@ -347,11 +355,40 @@
font-size: 0.75rem;
line-height: 1.3;
text-decoration: none;
vertical-align: baseline;
vertical-align: middle;
white-space: nowrap;
user-select: none;
}
.paperclip-mdxeditor-content a.paperclip-mention-chip::before,
a.paperclip-mention-chip::before {
content: "";
flex: none;
}
.paperclip-mdxeditor-content a.paperclip-mention-chip[data-mention-kind="project"]::before,
a.paperclip-mention-chip[data-mention-kind="project"]::before {
width: 0.45rem;
height: 0.45rem;
border-radius: 999px;
background-color: var(--paperclip-mention-project-color, currentColor);
}
.paperclip-mdxeditor-content a.paperclip-mention-chip[data-mention-kind="agent"]::before,
a.paperclip-mention-chip[data-mention-kind="agent"]::before {
width: 0.75rem;
height: 0.75rem;
background-color: currentColor;
-webkit-mask-image: var(--paperclip-mention-icon-mask);
mask-image: var(--paperclip-mention-icon-mask);
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-size: contain;
}
.paperclip-mdxeditor-content ul,
.paperclip-mdxeditor-content ol {
margin: 1.1em 0;
@@ -377,21 +414,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;
@@ -412,7 +449,7 @@
.paperclip-mdxeditor-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.78em;
font-size: 1em;
}
.paperclip-mdxeditor-content pre {
@@ -430,7 +467,7 @@
.paperclip-mdxeditor .cm-editor {
background-color: #1e1e2e !important;
color: #cdd6f4 !important;
font-size: 0.78em;
font-size: 1em;
}
.paperclip-mdxeditor .cm-gutters {
@@ -510,14 +547,14 @@
color: #cdd6f4 !important;
padding: 0.5rem 0.65rem !important;
margin: 0.4rem 0 !important;
font-size: 0.78em !important;
font-size: 1em !important;
overflow-x: auto;
white-space: pre;
}
.paperclip-markdown code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.78em;
font-size: 1em;
}
.paperclip-markdown pre code {
@@ -540,6 +577,10 @@
font-weight: 500;
}
.dark .prose :not(pre) > code {
background-color: #ffffff0f;
}
.paperclip-markdown {
color: var(--foreground);
font-size: 0.9375rem;
@@ -585,8 +626,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;
@@ -690,6 +734,7 @@
}
/* Project mention chips rendered inside MarkdownBody */
a.paperclip-mention-chip,
a.paperclip-project-mention-chip {
display: inline-flex;
align-items: center;
@@ -701,7 +746,7 @@ a.paperclip-project-mention-chip {
font-size: 0.75rem;
line-height: 1.3;
text-decoration: none;
vertical-align: baseline;
vertical-align: middle;
white-space: nowrap;
}
+98
View File
@@ -0,0 +1,98 @@
import {
Atom,
Bot,
Brain,
Bug,
CircuitBoard,
Code,
Cog,
Cpu,
Crown,
Database,
Eye,
FileCode,
Fingerprint,
Flame,
Gem,
GitBranch,
Globe,
Hammer,
Heart,
Hexagon,
Lightbulb,
Lock,
Mail,
MessageSquare,
Microscope,
Package,
Pentagon,
Puzzle,
Radar,
Rocket,
Search,
Shield,
Sparkles,
Star,
Swords,
Target,
Telescope,
Terminal,
Wand2,
Wrench,
Zap,
type LucideIcon,
} from "lucide-react";
import { AGENT_ICON_NAMES, type AgentIconName } from "@paperclipai/shared";
export const AGENT_ICONS: Record<AgentIconName, LucideIcon> = {
bot: Bot,
cpu: Cpu,
brain: Brain,
zap: Zap,
rocket: Rocket,
code: Code,
terminal: Terminal,
shield: Shield,
eye: Eye,
search: Search,
wrench: Wrench,
hammer: Hammer,
lightbulb: Lightbulb,
sparkles: Sparkles,
star: Star,
heart: Heart,
flame: Flame,
bug: Bug,
cog: Cog,
database: Database,
globe: Globe,
lock: Lock,
mail: Mail,
"message-square": MessageSquare,
"file-code": FileCode,
"git-branch": GitBranch,
package: Package,
puzzle: Puzzle,
target: Target,
wand: Wand2,
atom: Atom,
"circuit-board": CircuitBoard,
radar: Radar,
swords: Swords,
telescope: Telescope,
microscope: Microscope,
crown: Crown,
gem: Gem,
hexagon: Hexagon,
pentagon: Pentagon,
fingerprint: Fingerprint,
};
const DEFAULT_ICON: AgentIconName = "bot";
export function getAgentIcon(iconName: string | null | undefined): LucideIcon {
if (iconName && AGENT_ICON_NAMES.includes(iconName as AgentIconName)) {
return AGENT_ICONS[iconName as AgentIconName];
}
return AGENT_ICONS[DEFAULT_ICON];
}
+106
View File
@@ -0,0 +1,106 @@
import type { Agent } from "@paperclipai/shared";
export const AGENT_ORDER_UPDATED_EVENT = "paperclip:agent-order-updated";
const AGENT_ORDER_STORAGE_PREFIX = "paperclip.agentOrder";
const ANONYMOUS_USER_ID = "anonymous";
type AgentOrderUpdatedDetail = {
storageKey: string;
orderedIds: string[];
};
function normalizeIdList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
}
function resolveUserId(userId: string | null | undefined): string {
if (!userId) return ANONYMOUS_USER_ID;
const trimmed = userId.trim();
return trimmed.length > 0 ? trimmed : ANONYMOUS_USER_ID;
}
export function getAgentOrderStorageKey(companyId: string, userId: string | null | undefined): string {
return `${AGENT_ORDER_STORAGE_PREFIX}:${companyId}:${resolveUserId(userId)}`;
}
export function readAgentOrder(storageKey: string): string[] {
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return [];
return normalizeIdList(JSON.parse(raw));
} catch {
return [];
}
}
export function writeAgentOrder(storageKey: string, orderedIds: string[]) {
const normalized = normalizeIdList(orderedIds);
try {
localStorage.setItem(storageKey, JSON.stringify(normalized));
} catch {
// Ignore storage write failures in restricted browser contexts.
}
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent<AgentOrderUpdatedDetail>(AGENT_ORDER_UPDATED_EVENT, {
detail: { storageKey, orderedIds: normalized },
}),
);
}
}
export function sortAgentsByDefaultSidebarOrder(agents: Agent[]): Agent[] {
if (agents.length === 0) return [];
const byId = new Map(agents.map((agent) => [agent.id, agent]));
const childrenOf = new Map<string | null, Agent[]>();
for (const agent of agents) {
const parentId = agent.reportsTo && byId.has(agent.reportsTo) ? agent.reportsTo : null;
const siblings = childrenOf.get(parentId) ?? [];
siblings.push(agent);
childrenOf.set(parentId, siblings);
}
for (const siblings of childrenOf.values()) {
siblings.sort((left, right) => left.name.localeCompare(right.name));
}
const sorted: Agent[] = [];
const queue = [...(childrenOf.get(null) ?? [])];
while (queue.length > 0) {
const agent = queue.shift();
if (!agent) continue;
sorted.push(agent);
const children = childrenOf.get(agent.id);
if (children) queue.push(...children);
}
return sorted;
}
export function sortAgentsByStoredOrder(agents: Agent[], orderedIds: string[]): Agent[] {
if (agents.length === 0) return [];
const defaultSorted = sortAgentsByDefaultSidebarOrder(agents);
if (orderedIds.length === 0) return defaultSorted;
const byId = new Map(defaultSorted.map((agent) => [agent.id, agent]));
const sorted: Agent[] = [];
for (const id of orderedIds) {
const agent = byId.get(id);
if (!agent) continue;
sorted.push(agent);
byId.delete(id);
}
for (const agent of defaultSorted) {
if (byId.has(agent.id)) {
sorted.push(agent);
byId.delete(agent.id);
}
}
return sorted;
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { applyAgentSkillSnapshot, isReadOnlyUnmanagedSkillEntry } from "./agent-skills-state";
describe("applyAgentSkillSnapshot", () => {
it("hydrates the initial snapshot without arming autosave", () => {
const result = applyAgentSkillSnapshot(
{
draft: [],
lastSaved: [],
hasHydratedSnapshot: false,
},
["paperclip", "para-memory-files"],
);
expect(result).toEqual({
draft: ["paperclip", "para-memory-files"],
lastSaved: ["paperclip", "para-memory-files"],
hasHydratedSnapshot: true,
shouldSkipAutosave: true,
});
});
it("keeps unsaved local edits when a fresh snapshot arrives", () => {
const result = applyAgentSkillSnapshot(
{
draft: ["paperclip", "custom-skill"],
lastSaved: ["paperclip"],
hasHydratedSnapshot: true,
},
["paperclip"],
);
expect(result).toEqual({
draft: ["paperclip", "custom-skill"],
lastSaved: ["paperclip"],
hasHydratedSnapshot: true,
shouldSkipAutosave: false,
});
});
it("adopts server state after a successful save and skips the follow-up autosave pass", () => {
const result = applyAgentSkillSnapshot(
{
draft: ["paperclip", "custom-skill"],
lastSaved: ["paperclip", "custom-skill"],
hasHydratedSnapshot: true,
},
["paperclip", "custom-skill"],
);
expect(result).toEqual({
draft: ["paperclip", "custom-skill"],
lastSaved: ["paperclip", "custom-skill"],
hasHydratedSnapshot: true,
shouldSkipAutosave: true,
});
});
it("treats user-installed entries outside the company library as read-only unmanaged skills", () => {
expect(isReadOnlyUnmanagedSkillEntry({
key: "crack-python",
runtimeName: "crack-python",
desired: false,
managed: false,
state: "external",
origin: "user_installed",
}, new Set(["paperclip"]))).toBe(true);
});
it("keeps company-library entries in the managed section even when the adapter reports an external conflict", () => {
expect(isReadOnlyUnmanagedSkillEntry({
key: "paperclip",
runtimeName: "paperclip",
desired: true,
managed: false,
state: "external",
origin: "company_managed",
}, new Set(["paperclip"]))).toBe(false);
});
it("falls back to legacy snapshots that only mark unmanaged external entries", () => {
expect(isReadOnlyUnmanagedSkillEntry({
key: "legacy-external",
runtimeName: "legacy-external",
desired: false,
managed: false,
state: "external",
}, new Set())).toBe(true);
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { AgentSkillEntry } from "@paperclipai/shared";
export interface AgentSkillDraftState {
draft: string[];
lastSaved: string[];
hasHydratedSnapshot: boolean;
}
export interface AgentSkillSnapshotApplyResult extends AgentSkillDraftState {
shouldSkipAutosave: boolean;
}
export function arraysEqual(a: string[], b: string[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
return a.every((value, index) => value === b[index]);
}
export function applyAgentSkillSnapshot(
state: AgentSkillDraftState,
desiredSkills: string[],
): AgentSkillSnapshotApplyResult {
const shouldReplaceDraft = !state.hasHydratedSnapshot || arraysEqual(state.draft, state.lastSaved);
return {
draft: shouldReplaceDraft ? desiredSkills : state.draft,
lastSaved: desiredSkills,
hasHydratedSnapshot: true,
shouldSkipAutosave: shouldReplaceDraft,
};
}
export function isReadOnlyUnmanagedSkillEntry(
entry: AgentSkillEntry,
companySkillKeys: Set<string>,
): boolean {
if (companySkillKeys.has(entry.key)) return false;
if (entry.origin === "user_installed" || entry.origin === "external_unknown") return true;
return entry.managed === false && entry.state === "external";
}
+39
View File
@@ -4,6 +4,7 @@ import {
currentUserAssigneeOption,
formatAssigneeUserLabel,
parseAssigneeValue,
suggestedCommentAssigneeValue,
} from "./assignees";
describe("assignee selection helpers", () => {
@@ -50,4 +51,42 @@ describe("assignee selection helpers", () => {
expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board");
expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-");
});
it("suggests the last non-me commenter without changing the actual assignee encoding", () => {
expect(
suggestedCommentAssigneeValue(
{ assigneeUserId: "board-user" },
[
{ authorUserId: "board-user" },
{ authorAgentId: "agent-123" },
],
"board-user",
),
).toBe("agent:agent-123");
});
it("falls back to the actual assignee when there is no better commenter hint", () => {
expect(
suggestedCommentAssigneeValue(
{ assigneeUserId: "board-user" },
[{ authorUserId: "board-user" }],
"board-user",
),
).toBe("user:board-user");
});
it("skips the current agent when choosing a suggested commenter assignee", () => {
expect(
suggestedCommentAssigneeValue(
{ assigneeUserId: "board-user" },
[
{ authorUserId: "board-user" },
{ authorAgentId: "agent-self" },
{ authorAgentId: "agent-123" },
],
null,
"agent-self",
),
).toBe("agent:agent-123");
});
});
+31
View File
@@ -9,12 +9,43 @@ export interface AssigneeOption {
searchText?: string;
}
interface CommentAssigneeSuggestionInput {
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
}
interface CommentAssigneeSuggestionComment {
authorAgentId?: string | null;
authorUserId?: string | null;
}
export function assigneeValueFromSelection(selection: Partial<AssigneeSelection>): string {
if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`;
if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`;
return "";
}
export function suggestedCommentAssigneeValue(
issue: CommentAssigneeSuggestionInput,
comments: CommentAssigneeSuggestionComment[] | null | undefined,
currentUserId: string | null | undefined,
currentAgentId?: string | null | undefined,
): string {
if (comments && comments.length > 0 && (currentUserId || currentAgentId)) {
for (let i = comments.length - 1; i >= 0; i--) {
const comment = comments[i];
if (comment.authorAgentId && comment.authorAgentId !== currentAgentId) {
return assigneeValueFromSelection({ assigneeAgentId: comment.authorAgentId });
}
if (comment.authorUserId && comment.authorUserId !== currentUserId) {
return assigneeValueFromSelection({ assigneeUserId: comment.authorUserId });
}
}
}
return assigneeValueFromSelection(issue);
}
export function parseAssigneeValue(value: string): AssigneeSelection {
if (!value) {
return { assigneeAgentId: null, assigneeUserId: null };
+107
View File
@@ -0,0 +1,107 @@
/**
* Shared color-contrast utilities for pill / badge / chip components.
*
* Uses WCAG 2.1 relative-luminance contrast ratios so text is always
* readable, even on semi-transparent backgrounds composited over dark or
* light page backgrounds.
*/
const DARK_BG = { r: 24, g: 24, b: 27 }; // zinc-900 (#18181b)
const LIGHT_BG = { r: 255, g: 255, b: 255 }; // white
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const match = /^#?([0-9a-f]{3,6})$/i.exec(hex.trim());
if (!match) return null;
let value = match[1];
if (value.length === 3) {
value = value
.split("")
.map((c) => `${c}${c}`)
.join("");
}
if (value.length !== 6) return null;
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
};
}
function relativeLuminanceChannel(value: number): number {
const normalized = value / 255;
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
}
function relativeLuminance(r: number, g: number, b: number): number {
return (
0.2126 * relativeLuminanceChannel(r) +
0.7152 * relativeLuminanceChannel(g) +
0.0722 * relativeLuminanceChannel(b)
);
}
function contrastRatio(l1: number, l2: number): number {
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
function isDarkMode(): boolean {
if (typeof document === "undefined") return true;
return document.documentElement.classList.contains("dark");
}
/**
* Composite a foreground RGB at the given alpha over a background RGB.
*/
function composite(
fg: { r: number; g: number; b: number },
bg: { r: number; g: number; b: number },
alpha: number,
): { r: number; g: number; b: number } {
return {
r: Math.round(alpha * fg.r + (1 - alpha) * bg.r),
g: Math.round(alpha * fg.g + (1 - alpha) * bg.g),
b: Math.round(alpha * fg.b + (1 - alpha) * bg.b),
};
}
const TEXT_LIGHT = "#f8fafc";
const TEXT_DARK = "#111827";
/**
* Pick a readable text color for a solid background.
* Uses WCAG contrast ratios to choose between light and dark text.
*/
export function pickTextColorForSolidBg(hexColor: string): string {
const rgb = hexToRgb(hexColor);
if (!rgb) return TEXT_LIGHT;
const bgLum = relativeLuminance(rgb.r, rgb.g, rgb.b);
const whiteLum = relativeLuminance(248, 250, 252);
const blackLum = relativeLuminance(17, 24, 39);
return contrastRatio(bgLum, whiteLum) >= contrastRatio(bgLum, blackLum)
? TEXT_LIGHT
: TEXT_DARK;
}
/**
* Pick a readable text color for a semi-transparent pill background.
*
* Composites `rgba(hexColor, alpha)` over the current page background
* (dark or light mode) and then picks the text color with better
* WCAG contrast ratio.
*/
export function pickTextColorForPillBg(hexColor: string, alpha = 0.22): string {
const fg = hexToRgb(hexColor);
if (!fg) return TEXT_LIGHT;
const pageBg = isDarkMode() ? DARK_BG : LIGHT_BG;
const effectiveBg = composite(fg, pageBg, alpha);
const bgLum = relativeLuminance(effectiveBg.r, effectiveBg.g, effectiveBg.b);
const whiteLum = relativeLuminance(248, 250, 252);
const blackLum = relativeLuminance(17, 24, 39);
return contrastRatio(bgLum, whiteLum) >= contrastRatio(bgLum, blackLum)
? TEXT_LIGHT
: TEXT_DARK;
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { buildInitialExportCheckedFiles } from "./company-export-selection";
describe("buildInitialExportCheckedFiles", () => {
it("checks non-task files and recurring task packages by default", () => {
const checked = buildInitialExportCheckedFiles(
[
"README.md",
".paperclip.yaml",
"tasks/one-off/TASK.md",
"tasks/recurring/TASK.md",
"tasks/recurring/notes.md",
],
[
{ path: "tasks/one-off/TASK.md", recurring: false },
{ path: "tasks/recurring/TASK.md", recurring: true },
],
new Set<string>(),
);
expect(Array.from(checked).sort()).toEqual([
".paperclip.yaml",
"README.md",
"tasks/recurring/TASK.md",
"tasks/recurring/notes.md",
]);
});
it("preserves previous manual selections for one-time tasks", () => {
const checked = buildInitialExportCheckedFiles(
["README.md", "tasks/one-off/TASK.md"],
[{ path: "tasks/one-off/TASK.md", recurring: false }],
new Set(["tasks/one-off/TASK.md"]),
);
expect(Array.from(checked).sort()).toEqual([
"README.md",
"tasks/one-off/TASK.md",
]);
});
});
+56
View File
@@ -0,0 +1,56 @@
import type { CompanyPortabilityIssueManifestEntry } from "@paperclipai/shared";
function isTaskPath(filePath: string): boolean {
return /(?:^|\/)tasks\//.test(filePath);
}
function buildRecurringTaskPrefixes(
issues: Array<Pick<CompanyPortabilityIssueManifestEntry, "path" | "recurring">>,
): Set<string> {
const prefixes = new Set<string>();
for (const issue of issues) {
if (!issue.recurring) continue;
const filePath = issue.path.trim();
if (!filePath) continue;
prefixes.add(filePath);
const lastSlash = filePath.lastIndexOf("/");
if (lastSlash >= 0) {
prefixes.add(`${filePath.slice(0, lastSlash + 1)}`);
}
}
return prefixes;
}
function isRecurringTaskFile(filePath: string, recurringTaskPrefixes: Set<string>): boolean {
for (const prefix of recurringTaskPrefixes) {
if (filePath === prefix || filePath.startsWith(prefix)) return true;
}
return false;
}
export function buildInitialExportCheckedFiles(
filePaths: string[],
issues: Array<Pick<CompanyPortabilityIssueManifestEntry, "path" | "recurring">>,
previousCheckedFiles: Set<string>,
): Set<string> {
const next = new Set<string>();
const recurringTaskPrefixes = buildRecurringTaskPrefixes(issues);
for (const filePath of filePaths) {
if (previousCheckedFiles.has(filePath)) {
next.add(filePath);
continue;
}
if (!isTaskPath(filePath) || isRecurringTaskFile(filePath, recurringTaskPrefixes)) {
next.add(filePath);
}
}
return next;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import {
toCompanyRelativePath,
} from "./company-routes";
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "cli-auth", "docs"]);
export function isRememberableCompanyPath(path: string): boolean {
const pathname = path.split("?")[0] ?? "";
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import type { Agent, Project } from "@paperclipai/shared";
import {
buildPortableAgentSlugMap,
buildPortableProjectSlugMap,
buildPortableSidebarOrder,
} from "./company-portability-sidebar";
function makeAgent(id: string, name: string): Agent {
return {
id,
companyId: "company-1",
name,
role: "engineer",
title: null,
icon: null,
status: "idle",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
urlKey: name.toLowerCase(),
};
}
function makeProject(id: string, name: string): Project {
return {
id,
companyId: "company-1",
goalId: null,
urlKey: name.toLowerCase(),
name,
description: null,
status: "planned",
leadAgentId: null,
targetDate: null,
color: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
archivedAt: null,
goalIds: [],
goals: [],
primaryWorkspace: null,
workspaces: [],
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/managed",
effectiveLocalFolder: "/tmp/managed",
origin: "managed_checkout",
},
createdAt: new Date(),
updatedAt: new Date(),
};
}
describe("company portability sidebar order", () => {
it("uses the same unique slug allocation as export and preserves the requested order", () => {
const alphaOne = makeAgent("agent-1", "Alpha");
const alphaTwo = makeAgent("agent-2", "Alpha");
const beta = makeAgent("agent-3", "Beta");
const launch = makeProject("project-1", "Launch");
const launchTwo = makeProject("project-2", "Launch");
expect(Array.from(buildPortableAgentSlugMap([alphaOne, alphaTwo, beta]).entries())).toEqual([
["agent-1", "alpha"],
["agent-2", "alpha-2"],
["agent-3", "beta"],
]);
expect(Array.from(buildPortableProjectSlugMap([launch, launchTwo]).entries())).toEqual([
["project-1", "launch"],
["project-2", "launch-2"],
]);
expect(buildPortableSidebarOrder({
agents: [alphaOne, alphaTwo, beta],
orderedAgents: [beta, alphaTwo, alphaOne],
projects: [launch, launchTwo],
orderedProjects: [launchTwo, launch],
})).toEqual({
agents: ["beta", "alpha-2", "alpha"],
projects: ["launch-2", "launch"],
});
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { Agent, CompanyPortabilitySidebarOrder, Project } from "@paperclipai/shared";
import { deriveProjectUrlKey, normalizeAgentUrlKey } from "@paperclipai/shared";
function uniqueSlug(base: string, used: Set<string>) {
if (!used.has(base)) {
used.add(base);
return base;
}
let index = 2;
while (true) {
const candidate = `${base}-${index}`;
if (!used.has(candidate)) {
used.add(candidate);
return candidate;
}
index += 1;
}
}
export function buildPortableAgentSlugMap(agents: Agent[]): Map<string, string> {
const usedSlugs = new Set<string>();
const byId = new Map<string, string>();
const sortedAgents = [...agents].sort((left, right) => left.name.localeCompare(right.name));
for (const agent of sortedAgents) {
const baseSlug = normalizeAgentUrlKey(agent.name) ?? "agent";
byId.set(agent.id, uniqueSlug(baseSlug, usedSlugs));
}
return byId;
}
export function buildPortableProjectSlugMap(projects: Project[]): Map<string, string> {
const usedSlugs = new Set<string>();
const byId = new Map<string, string>();
const sortedProjects = [...projects].sort((left, right) => left.name.localeCompare(right.name));
for (const project of sortedProjects) {
const baseSlug = deriveProjectUrlKey(project.name, project.name);
byId.set(project.id, uniqueSlug(baseSlug, usedSlugs));
}
return byId;
}
export function buildPortableSidebarOrder(input: {
agents: Agent[];
orderedAgents: Agent[];
projects: Project[];
orderedProjects: Project[];
}): CompanyPortabilitySidebarOrder | undefined {
const agentSlugById = buildPortableAgentSlugMap(input.agents);
const projectSlugById = buildPortableProjectSlugMap(input.projects);
const sidebar = {
agents: input.orderedAgents.map((agent) => agentSlugById.get(agent.id)).filter((slug): slug is string => Boolean(slug)),
projects: input.orderedProjects.map((project) => projectSlugById.get(project.id)).filter((slug): slug is string => Boolean(slug)),
};
return sidebar.agents.length > 0 || sidebar.projects.length > 0 ? sidebar : undefined;
}
+3 -1
View File
@@ -2,10 +2,12 @@ const BOARD_ROUTE_ROOTS = new Set([
"dashboard",
"companies",
"company",
"skills",
"org",
"agents",
"projects",
"issues",
"routines",
"goals",
"approvals",
"costs",
@@ -15,7 +17,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"design-guide",
]);
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "docs", "instance"]);
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "cli-auth", "docs", "instance"]);
export function normalizeCompanyPrefix(prefix: string): string {
return prefix.trim().toUpperCase();
+155 -8
View File
@@ -4,11 +4,14 @@ import { beforeEach, describe, expect, it } from "vitest";
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import {
computeInboxBadgeData,
getApprovalsForTab,
getInboxWorkItems,
getRecentTouchedIssues,
getUnreadTouchedIssues,
loadLastInboxTab,
RECENT_ISSUES_LIMIT,
saveLastInboxTab,
shouldShowInboxSection,
} from "./inbox";
const storage = new Map<string, string>();
@@ -46,6 +49,19 @@ function makeApproval(status: Approval["status"]): Approval {
};
}
function makeApprovalWithTimestamps(
id: string,
status: Approval["status"],
updatedAt: string,
): Approval {
return {
...makeApproval(status),
id,
createdAt: new Date(updatedAt),
updatedAt: new Date(updatedAt),
};
}
function makeJoinRequest(id: string): JoinRequest {
return {
id,
@@ -95,6 +111,10 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
logCompressed: false,
errorCode: null,
externalRunId: null,
processPid: null,
processStartedAt: null,
retryOfRunId: null,
processLossRetryCount: 0,
stdoutExcerpt: null,
stderrExcerpt: null,
contextSnapshot: null,
@@ -110,6 +130,7 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
id,
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: `Issue ${id}`,
@@ -125,6 +146,8 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
@@ -187,7 +210,7 @@ describe("inbox helpers", () => {
makeRun("run-latest", "timed_out", "2026-03-11T01:00:00.000Z"),
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
],
unreadIssues: [makeIssue("1", true)],
mineIssues: [makeIssue("1", true)],
dismissed: new Set<string>(),
});
@@ -196,7 +219,7 @@ describe("inbox helpers", () => {
approvals: 1,
failedRuns: 2,
joinRequests: 1,
unreadTouchedIssues: 1,
mineIssues: 1,
alerts: 1,
});
});
@@ -207,7 +230,7 @@ describe("inbox helpers", () => {
joinRequests: [],
dashboard,
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
unreadIssues: [],
mineIssues: [],
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
});
@@ -216,7 +239,7 @@ describe("inbox helpers", () => {
approvals: 0,
failedRuns: 0,
joinRequests: 0,
unreadTouchedIssues: 0,
mineIssues: 0,
alerts: 0,
});
});
@@ -228,6 +251,130 @@ describe("inbox helpers", () => {
expect(issues).toHaveLength(2);
});
it("shows recent approvals in updated order and unread approvals as actionable only", () => {
const approvals = [
makeApprovalWithTimestamps("approval-approved", "approved", "2026-03-11T02:00:00.000Z"),
makeApprovalWithTimestamps("approval-pending", "pending", "2026-03-11T01:00:00.000Z"),
makeApprovalWithTimestamps(
"approval-revision",
"revision_requested",
"2026-03-11T03:00:00.000Z",
),
];
expect(getApprovalsForTab(approvals, "mine", "all").map((approval) => approval.id)).toEqual([
"approval-revision",
"approval-approved",
"approval-pending",
]);
expect(getApprovalsForTab(approvals, "recent", "all").map((approval) => approval.id)).toEqual([
"approval-revision",
"approval-approved",
"approval-pending",
]);
expect(getApprovalsForTab(approvals, "unread", "all").map((approval) => approval.id)).toEqual([
"approval-revision",
"approval-pending",
]);
expect(getApprovalsForTab(approvals, "all", "resolved").map((approval) => approval.id)).toEqual([
"approval-approved",
]);
});
it("mixes approvals into the inbox feed by most recent activity", () => {
const newerIssue = makeIssue("1", true);
newerIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
const olderIssue = makeIssue("2", false);
olderIssue.lastExternalCommentAt = new Date("2026-03-11T02:00:00.000Z");
const approval = makeApprovalWithTimestamps(
"approval-between",
"pending",
"2026-03-11T03:00:00.000Z",
);
expect(
getInboxWorkItems({
issues: [olderIssue, newerIssue],
approvals: [approval],
}).map((item) => {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
if (item.kind === "join_request") return `join:${item.joinRequest.id}`;
return `run:${item.run.id}`;
}),
).toEqual([
"issue:1",
"approval:approval-between",
"issue:2",
]);
});
it("mixes join requests into the inbox feed by most recent activity", () => {
const issue = makeIssue("1", true);
issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
const joinRequest = makeJoinRequest("join-1");
joinRequest.createdAt = new Date("2026-03-11T03:00:00.000Z");
const approval = makeApprovalWithTimestamps(
"approval-oldest",
"pending",
"2026-03-11T02:00:00.000Z",
);
expect(
getInboxWorkItems({
issues: [issue],
approvals: [approval],
joinRequests: [joinRequest],
}).map((item) => {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
if (item.kind === "join_request") return `join:${item.joinRequest.id}`;
return `run:${item.run.id}`;
}),
).toEqual([
"issue:1",
"join:join-1",
"approval:approval-oldest",
]);
});
it("can include sections on recent without forcing them to be unread", () => {
expect(
shouldShowInboxSection({
tab: "mine",
hasItems: true,
showOnMine: true,
showOnRecent: false,
showOnUnread: false,
showOnAll: false,
}),
).toBe(true);
expect(
shouldShowInboxSection({
tab: "recent",
hasItems: true,
showOnMine: false,
showOnRecent: true,
showOnUnread: false,
showOnAll: false,
}),
).toBe(true);
expect(
shouldShowInboxSection({
tab: "unread",
hasItems: true,
showOnMine: true,
showOnRecent: true,
showOnUnread: false,
showOnAll: false,
}),
).toBe(false);
});
it("limits recent touched issues before unread badge counting", () => {
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
const issue = makeIssue(String(index + 1), index < 3);
@@ -241,16 +388,16 @@ describe("inbox helpers", () => {
expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]);
});
it("defaults the remembered inbox tab to recent and persists all", () => {
it("defaults the remembered inbox tab to mine and persists all", () => {
localStorage.clear();
expect(loadLastInboxTab()).toBe("recent");
expect(loadLastInboxTab()).toBe("mine");
saveLastInboxTab("all");
expect(loadLastInboxTab()).toBe("all");
});
it("maps legacy new-tab storage to recent", () => {
it("maps legacy new-tab storage to mine", () => {
localStorage.setItem("paperclip:inbox:last-tab", "new");
expect(loadLastInboxTab()).toBe("recent");
expect(loadLastInboxTab()).toBe("mine");
});
});
+155 -14
View File
@@ -10,15 +10,38 @@ export const RECENT_ISSUES_LIMIT = 100;
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export type InboxTab = "recent" | "unread" | "all";
export type InboxTab = "mine" | "recent" | "unread" | "all";
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
export type InboxWorkItem =
| {
kind: "issue";
timestamp: number;
issue: Issue;
}
| {
kind: "approval";
timestamp: number;
approval: Approval;
}
| {
kind: "failed_run";
timestamp: number;
run: HeartbeatRun;
}
| {
kind: "join_request";
timestamp: number;
joinRequest: JoinRequest;
};
export interface InboxBadgeData {
inbox: number;
approvals: number;
failedRuns: number;
joinRequests: number;
unreadTouchedIssues: number;
mineIssues: number;
alerts: number;
}
@@ -39,14 +62,31 @@ export function saveDismissedInboxItems(ids: Set<string>) {
}
}
export function loadReadInboxItems(): Set<string> {
try {
const raw = localStorage.getItem(READ_ITEMS_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
export function saveReadInboxItems(ids: Set<string>) {
try {
localStorage.setItem(READ_ITEMS_KEY, JSON.stringify([...ids]));
} catch {
// Ignore localStorage failures.
}
}
export function loadLastInboxTab(): InboxTab {
try {
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
if (raw === "all" || raw === "unread" || raw === "recent") return raw;
if (raw === "new") return "recent";
return "recent";
if (raw === "all" || raw === "unread" || raw === "recent" || raw === "mine") return raw;
if (raw === "new") return "mine";
return "mine";
} catch {
return "recent";
return "mine";
}
}
@@ -104,28 +144,129 @@ export function getUnreadTouchedIssues(issues: Issue[]): Issue[] {
return issues.filter((issue) => issue.isUnreadForMe);
}
export function getApprovalsForTab(
approvals: Approval[],
tab: InboxTab,
filter: InboxApprovalFilter,
): Approval[] {
const sortedApprovals = [...approvals].sort(
(a, b) => normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt),
);
if (tab === "mine" || tab === "recent") return sortedApprovals;
if (tab === "unread") {
return sortedApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status));
}
if (filter === "all") return sortedApprovals;
return sortedApprovals.filter((approval) => {
const isActionable = ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
return filter === "actionable" ? isActionable : !isActionable;
});
}
export function approvalActivityTimestamp(approval: Approval): number {
const updatedAt = normalizeTimestamp(approval.updatedAt);
if (updatedAt > 0) return updatedAt;
return normalizeTimestamp(approval.createdAt);
}
export function getInboxWorkItems({
issues,
approvals,
failedRuns = [],
joinRequests = [],
}: {
issues: Issue[];
approvals: Approval[];
failedRuns?: HeartbeatRun[];
joinRequests?: JoinRequest[];
}): InboxWorkItem[] {
return [
...issues.map((issue) => ({
kind: "issue" as const,
timestamp: issueLastActivityTimestamp(issue),
issue,
})),
...approvals.map((approval) => ({
kind: "approval" as const,
timestamp: approvalActivityTimestamp(approval),
approval,
})),
...failedRuns.map((run) => ({
kind: "failed_run" as const,
timestamp: normalizeTimestamp(run.createdAt),
run,
})),
...joinRequests.map((joinRequest) => ({
kind: "join_request" as const,
timestamp: normalizeTimestamp(joinRequest.createdAt),
joinRequest,
})),
].sort((a, b) => {
const timestampDiff = b.timestamp - a.timestamp;
if (timestampDiff !== 0) return timestampDiff;
if (a.kind === "issue" && b.kind === "issue") {
return sortIssuesByMostRecentActivity(a.issue, b.issue);
}
if (a.kind === "approval" && b.kind === "approval") {
return approvalActivityTimestamp(b.approval) - approvalActivityTimestamp(a.approval);
}
return a.kind === "approval" ? -1 : 1;
});
}
export function shouldShowInboxSection({
tab,
hasItems,
showOnMine,
showOnRecent,
showOnUnread,
showOnAll,
}: {
tab: InboxTab;
hasItems: boolean;
showOnMine: boolean;
showOnRecent: boolean;
showOnUnread: boolean;
showOnAll: boolean;
}): boolean {
if (!hasItems) return false;
if (tab === "mine") return showOnMine;
if (tab === "recent") return showOnRecent;
if (tab === "unread") return showOnUnread;
return showOnAll;
}
export function computeInboxBadgeData({
approvals,
joinRequests,
dashboard,
heartbeatRuns,
unreadIssues,
mineIssues,
dismissed,
}: {
approvals: Approval[];
joinRequests: JoinRequest[];
dashboard: DashboardSummary | undefined;
heartbeatRuns: HeartbeatRun[];
unreadIssues: Issue[];
mineIssues: Issue[];
dismissed: Set<string>;
}): InboxBadgeData {
const actionableApprovals = approvals.filter((approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status),
const actionableApprovals = approvals.filter(
(approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
!dismissed.has(`approval:${approval.id}`),
).length;
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !dismissed.has(`run:${run.id}`),
).length;
const unreadTouchedIssues = unreadIssues.length;
const visibleJoinRequests = joinRequests.filter(
(jr) => !dismissed.has(`join:${jr.id}`),
).length;
const visibleMineIssues = mineIssues.length;
const agentErrorCount = dashboard?.agents.error ?? 0;
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0;
@@ -140,11 +281,11 @@ export function computeInboxBadgeData({
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return {
inbox: actionableApprovals + joinRequests.length + failedRuns + unreadTouchedIssues + alerts,
inbox: actionableApprovals + visibleJoinRequests + failedRuns + visibleMineIssues + alerts,
approvals: actionableApprovals,
failedRuns,
joinRequests: joinRequests.length,
unreadTouchedIssues,
joinRequests: visibleJoinRequests,
mineIssues: visibleMineIssues,
alerts,
};
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_INSTANCE_SETTINGS_PATH,
normalizeRememberedInstanceSettingsPath,
} from "./instance-settings";
describe("normalizeRememberedInstanceSettingsPath", () => {
it("keeps known instance settings pages", () => {
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/general")).toBe(
"/instance/settings/general",
);
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/experimental")).toBe(
"/instance/settings/experimental",
);
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/plugins/example?tab=config#logs")).toBe(
"/instance/settings/plugins/example?tab=config#logs",
);
});
it("falls back to the default page for unknown paths", () => {
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/nope")).toBe(
DEFAULT_INSTANCE_SETTINGS_PATH,
);
expect(normalizeRememberedInstanceSettingsPath(null)).toBe(DEFAULT_INSTANCE_SETTINGS_PATH);
});
});
+25
View File
@@ -0,0 +1,25 @@
export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/general";
export function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
const match = rawPath.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
const pathname = match?.[1] ?? rawPath;
const search = match?.[2] ?? "";
const hash = match?.[3] ?? "";
if (
pathname === "/instance/settings/general" ||
pathname === "/instance/settings/heartbeats" ||
pathname === "/instance/settings/plugins" ||
pathname === "/instance/settings/experimental"
) {
return `${pathname}${search}${hash}`;
}
if (/^\/instance\/settings\/plugins\/[^/?#]+$/.test(pathname)) {
return `${pathname}${search}${hash}`;
}
return DEFAULT_INSTANCE_SETTINGS_PATH;
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
hasLegacyWorkingDirectory,
shouldShowLegacyWorkingDirectoryField,
} from "./legacy-agent-config";
describe("legacy agent config helpers", () => {
it("treats non-empty cwd values as legacy working directories", () => {
expect(hasLegacyWorkingDirectory("/tmp/workspace")).toBe(true);
expect(hasLegacyWorkingDirectory(" /tmp/workspace ")).toBe(true);
});
it("ignores nullish and blank cwd values", () => {
expect(hasLegacyWorkingDirectory("")).toBe(false);
expect(hasLegacyWorkingDirectory(" ")).toBe(false);
expect(hasLegacyWorkingDirectory(null)).toBe(false);
expect(hasLegacyWorkingDirectory(undefined)).toBe(false);
});
it("shows the deprecated field only for edit forms with an existing cwd", () => {
expect(
shouldShowLegacyWorkingDirectoryField({
isCreate: true,
adapterConfig: { cwd: "/tmp/workspace" },
}),
).toBe(false);
expect(
shouldShowLegacyWorkingDirectoryField({
isCreate: false,
adapterConfig: { cwd: "" },
}),
).toBe(false);
expect(
shouldShowLegacyWorkingDirectoryField({
isCreate: false,
adapterConfig: { cwd: "/tmp/workspace" },
}),
).toBe(true);
});
});
+17
View File
@@ -0,0 +1,17 @@
function asNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function hasLegacyWorkingDirectory(value: unknown): boolean {
return asNonEmptyString(value) !== null;
}
export function shouldShowLegacyWorkingDirectoryField(input: {
isCreate: boolean;
adapterConfig: Record<string, unknown> | null | undefined;
}): boolean {
if (input.isCreate) return false;
return hasLegacyWorkingDirectory(input.adapterConfig?.cwd);
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { $createLinkNode } from "@lexical/link";
import { createEditor } from "lexical";
import {
MentionAwareLinkNode,
getMentionAwareLinkNodeInit,
mentionAwareLinkNodeReplacement,
} from "./mention-aware-link-node";
function createTestEditor() {
return createEditor({
namespace: "mention-aware-link-node-test",
nodes: [MentionAwareLinkNode, mentionAwareLinkNodeReplacement],
onError(error: Error) {
throw error;
},
});
}
describe("getMentionAwareLinkNodeInit", () => {
it("copies link attributes without carrying over a node key", () => {
const init = getMentionAwareLinkNodeInit({
getURL: () => "agent://agent-123",
getRel: () => "noreferrer",
getTarget: () => "_blank",
getTitle: () => "Agent mention",
});
expect(Object.keys(init)).toEqual(["url", "attributes"]);
expect(init).toEqual({
url: "agent://agent-123",
attributes: {
rel: "noreferrer",
target: "_blank",
title: "Agent mention",
},
});
});
it("replaces LinkNode creation with MentionAwareLinkNode without throwing", () => {
const editor = createTestEditor();
let created: unknown;
editor.update(() => {
created = $createLinkNode("agent://agent-123");
});
expect(created).toBeInstanceOf(MentionAwareLinkNode);
});
});
+67
View File
@@ -0,0 +1,67 @@
import {
LinkNode,
type LinkAttributes,
type SerializedLinkNode,
} from "@lexical/link";
const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//;
export class MentionAwareLinkNode extends LinkNode {
static getType(): string {
return "mention-aware-link";
}
static clone(node: MentionAwareLinkNode): MentionAwareLinkNode {
return new MentionAwareLinkNode(
node.getURL(),
{
rel: node.getRel(),
target: node.getTarget(),
title: node.getTitle(),
},
node.getKey(),
);
}
static importJSON(serializedNode: SerializedLinkNode): MentionAwareLinkNode {
return new MentionAwareLinkNode(
serializedNode.url ?? "",
{
rel: serializedNode.rel ?? null,
target: serializedNode.target ?? null,
title: serializedNode.title ?? null,
},
);
}
constructor(url?: string, attributes?: LinkAttributes, key?: string) {
super(url, attributes, key);
}
sanitizeUrl(url: string): string {
if (CUSTOM_MENTION_URL_RE.test(url)) return url;
return super.sanitizeUrl(url);
}
}
type MentionAwareLinkSource = Pick<LinkNode, "getURL" | "getRel" | "getTarget" | "getTitle">;
export function getMentionAwareLinkNodeInit(node: MentionAwareLinkSource) {
return {
url: node.getURL(),
attributes: {
rel: node.getRel(),
target: node.getTarget(),
title: node.getTitle(),
},
};
}
export const mentionAwareLinkNodeReplacement = {
replace: LinkNode,
with: (node: LinkNode) => {
const { url, attributes } = getMentionAwareLinkNodeInit(node);
return new MentionAwareLinkNode(url, attributes);
},
withKlass: MentionAwareLinkNode,
} as const;
+167
View File
@@ -0,0 +1,167 @@
import type { CSSProperties } from "react";
import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared";
import { getAgentIcon } from "./agent-icons";
import { hexToRgb, pickTextColorForPillBg } from "./color-contrast";
export type ParsedMentionChip =
| {
kind: "agent";
agentId: string;
icon: string | null;
}
| {
kind: "project";
projectId: string;
color: string | null;
};
const iconMaskCache = new Map<string, string>();
export function parseMentionChipHref(href: string): ParsedMentionChip | null {
const agent = parseAgentMentionHref(href);
if (agent) {
return {
kind: "agent",
agentId: agent.agentId,
icon: agent.icon,
};
}
const project = parseProjectMentionHref(href);
if (project) {
return {
kind: "project",
projectId: project.projectId,
color: project.color,
};
}
return null;
}
export function mentionChipInlineStyle(mention: ParsedMentionChip): CSSProperties | undefined {
const style: CSSProperties & Record<string, string> = {};
if (mention.kind === "project" && mention.color) {
const projectStyle = projectMentionColors(mention.color);
Object.assign(style, projectStyle);
style["--paperclip-mention-project-color"] = mention.color;
}
if (mention.kind === "agent") {
const iconMask = buildAgentIconMask(mention.icon);
if (iconMask) {
style["--paperclip-mention-icon-mask"] = iconMask;
}
}
return Object.keys(style).length > 0 ? (style as CSSProperties) : undefined;
}
export function applyMentionChipDecoration(element: HTMLElement, mention: ParsedMentionChip) {
clearMentionChipDecoration(element);
element.dataset.mentionKind = mention.kind;
element.setAttribute("contenteditable", "false");
element.classList.add("paperclip-mention-chip", `paperclip-mention-chip--${mention.kind}`);
if (mention.kind === "project") {
element.classList.add("paperclip-project-mention-chip");
}
const style = mentionChipInlineStyle(mention);
if (!style) return;
for (const [key, value] of Object.entries(style)) {
if (typeof value === "string") {
if (key.startsWith("--")) {
element.style.setProperty(key, value);
} else {
(element.style as CSSStyleDeclaration & Record<string, string>)[key] = value;
}
}
}
}
export function clearMentionChipDecoration(element: HTMLElement) {
delete element.dataset.mentionKind;
element.classList.remove(
"paperclip-mention-chip",
"paperclip-mention-chip--agent",
"paperclip-mention-chip--project",
"paperclip-project-mention-chip",
);
element.removeAttribute("contenteditable");
element.style.removeProperty("border-color");
element.style.removeProperty("background-color");
element.style.removeProperty("color");
element.style.removeProperty("--paperclip-mention-project-color");
element.style.removeProperty("--paperclip-mention-icon-mask");
}
function projectMentionColors(color: string): Pick<CSSProperties, "borderColor" | "backgroundColor" | "color"> {
const rgb = hexToRgb(color);
if (!rgb) return {};
return {
borderColor: color,
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`,
color: pickTextColorForPillBg(color),
};
}
function buildAgentIconMask(iconName: string | null): string | null {
const cacheKey = iconName ?? "__default__";
const cached = iconMaskCache.get(cacheKey);
if (cached) return cached;
const Icon = getAgentIcon(iconName);
const iconNode = resolveLucideIconNode(Icon);
if (!Array.isArray(iconNode) || iconNode.length === 0) return null;
const body = iconNode.map(([tag, attrs]) => {
const attrString = Object.entries(attrs)
.filter(([key]) => key !== "key")
.map(([key, value]) => `${key}="${escapeAttribute(String(value))}"`)
.join(" ");
return `<${tag}${attrString ? ` ${attrString}` : ""}></${tag}>`;
}).join("");
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ` +
`fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" ` +
`stroke-linejoin="round">${body}</svg>`;
const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
iconMaskCache.set(cacheKey, url);
return url;
}
function resolveLucideIconNode(
icon: unknown,
): Array<[string, Record<string, string>]> | null {
const staticIconNode = (
icon as {
iconNode?: Array<[string, Record<string, string>]>;
}
).iconNode;
if (Array.isArray(staticIconNode) && staticIconNode.length > 0) {
return staticIconNode;
}
const render = (
icon as {
render?: (props: Record<string, unknown>, ref: unknown) => {
props?: { iconNode?: Array<[string, Record<string, string>]> };
} | null;
}
).render;
const rendered = typeof render === "function" ? render({}, null) : null;
const renderedIconNode = rendered?.props?.iconNode;
return Array.isArray(renderedIconNode) && renderedIconNode.length > 0
? renderedIconNode
: null;
}
function escapeAttribute(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { $createLinkNode, LinkNode } from "@lexical/link";
import { buildAgentMentionHref } from "@paperclipai/shared";
import {
createEditor,
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isRangeSelection,
} from "lexical";
import { deleteSelectedMentionChip } from "./mention-deletion";
function createTestEditor() {
return createEditor({
namespace: "mention-deletion-test",
nodes: [LinkNode],
onError(error: Error) {
throw error;
},
});
}
describe("mention deletion", () => {
it("removes the full mention when backspacing from inside the chip", () => {
const editor = createTestEditor();
editor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const before = $createTextNode("Hello ");
const mention = $createLinkNode(buildAgentMentionHref("agent-123", "code"));
const mentionText = $createTextNode("@QA");
const after = $createTextNode(" world");
mention.append(mentionText);
paragraph.append(before, mention, after);
root.append(paragraph);
mentionText.selectEnd();
expect(deleteSelectedMentionChip("backward")).toBe(true);
expect(root.getTextContent()).toBe("Hello world");
const selection = $getSelection();
expect($isRangeSelection(selection)).toBe(true);
if (!$isRangeSelection(selection)) {
throw new Error("Expected range selection after backward mention deletion");
}
expect(selection.isCollapsed()).toBe(true);
expect(selection.anchor.getNode().is(before)).toBe(true);
expect(selection.anchor.offset).toBe(before.getTextContentSize());
});
});
it("removes the full mention when deleting forward from adjacent text", () => {
const editor = createTestEditor();
editor.update(() => {
const root = $getRoot();
const paragraph = $createParagraphNode();
const before = $createTextNode("Hello ");
const mention = $createLinkNode(buildAgentMentionHref("agent-123", "code"));
const mentionText = $createTextNode("@QA");
const after = $createTextNode(" world");
mention.append(mentionText);
paragraph.append(before, mention, after);
root.append(paragraph);
before.selectEnd();
expect(deleteSelectedMentionChip("forward")).toBe(true);
expect(root.getTextContent()).toBe("Hello world");
const selection = $getSelection();
expect($isRangeSelection(selection)).toBe(true);
if (!$isRangeSelection(selection)) {
throw new Error("Expected range selection after forward mention deletion");
}
expect(selection.isCollapsed()).toBe(true);
expect(selection.anchor.getNode().is(after)).toBe(true);
expect(selection.anchor.offset).toBe(0);
});
});
});
+143
View File
@@ -0,0 +1,143 @@
import { createRootEditorSubscription$, realmPlugin } from "@mdxeditor/editor";
import { $isLinkNode, type LinkNode } from "@lexical/link";
import {
$getSelection,
$isElementNode,
$isNodeSelection,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_HIGH,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
type LexicalNode,
type PointType,
} from "lexical";
import { parseMentionChipHref } from "./mention-chips";
export type MentionDeletionDirection = "backward" | "forward";
function isMentionLinkNode(node: LexicalNode | null | undefined): node is LinkNode {
return Boolean(node && $isLinkNode(node) && parseMentionChipHref(node.getURL()));
}
function findMentionLinkNode(node: LexicalNode | null | undefined): LinkNode | null {
if (!node) return null;
if (isMentionLinkNode(node)) return node;
let parent = node.getParent();
while (parent) {
if (isMentionLinkNode(parent)) return parent;
parent = parent.getParent();
}
return null;
}
function findMentionLinkNodeAtPoint(point: PointType, direction: MentionDeletionDirection): LinkNode | null {
const node = point.getNode();
const directMention = findMentionLinkNode(node);
if (directMention) return directMention;
if (point.type === "element" && $isElementNode(node)) {
const childIndex = direction === "backward" ? point.offset - 1 : point.offset;
if (childIndex < 0) return null;
return findMentionLinkNode(node.getChildAtIndex(childIndex));
}
if (point.type === "text" && $isTextNode(node)) {
if (direction === "backward" && point.offset === 0) {
return findMentionLinkNode(node.getPreviousSibling());
}
if (direction === "forward" && point.offset === node.getTextContentSize()) {
return findMentionLinkNode(node.getNextSibling());
}
}
return null;
}
export function findMentionLinkForDeletion(direction: MentionDeletionDirection): LinkNode | null {
const selection = $getSelection();
if (!selection) return null;
if ($isNodeSelection(selection)) {
const [selectedNode] = selection.getNodes();
return selectedNode ? findMentionLinkNode(selectedNode) : null;
}
if (!$isRangeSelection(selection)) return null;
const anchorMention = findMentionLinkNode(selection.anchor.getNode());
const focusMention = findMentionLinkNode(selection.focus.getNode());
if (anchorMention && focusMention && anchorMention.is(focusMention)) {
return anchorMention;
}
if (!selection.isCollapsed()) return null;
return findMentionLinkNodeAtPoint(selection.anchor, direction);
}
export function deleteSelectedMentionChip(direction: MentionDeletionDirection): boolean {
const mentionNode = findMentionLinkForDeletion(direction);
if (!mentionNode) return false;
const previousSibling = mentionNode.getPreviousSibling();
const nextSibling = mentionNode.getNextSibling();
const parent = mentionNode.getParentOrThrow();
mentionNode.remove();
if (direction === "backward") {
if (previousSibling) {
previousSibling.selectEnd();
return true;
}
if (nextSibling) {
nextSibling.selectStart();
return true;
}
parent.selectStart();
return true;
}
if (nextSibling) {
nextSibling.selectStart();
return true;
}
if (previousSibling) {
previousSibling.selectEnd();
return true;
}
parent.selectEnd();
return true;
}
function handleMentionDelete(direction: MentionDeletionDirection, event: KeyboardEvent | null): boolean {
const didDelete = deleteSelectedMentionChip(direction);
if (!didDelete) return false;
event?.preventDefault();
event?.stopPropagation();
return true;
}
export const mentionDeletionPlugin = realmPlugin({
init(realm) {
realm.pub(createRootEditorSubscription$, [
(editor) =>
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
(event) => handleMentionDelete("backward", event as KeyboardEvent | null),
COMMAND_PRIORITY_HIGH,
),
(editor) =>
editor.registerCommand(
KEY_DELETE_COMMAND,
(event) => handleMentionDelete("forward", event as KeyboardEvent | null),
COMMAND_PRIORITY_HIGH,
),
]);
},
});
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import {
buildOnboardingIssuePayload,
buildOnboardingProjectPayload,
selectDefaultCompanyGoalId,
} from "./onboarding-launch";
describe("selectDefaultCompanyGoalId", () => {
it("prefers the earliest active root company goal", () => {
expect(
selectDefaultCompanyGoalId([
{
id: "team-goal",
companyId: "company-1",
title: "Nested",
description: null,
level: "team",
status: "active",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-04T00:00:00Z"),
updatedAt: new Date("2026-03-04T00:00:00Z"),
},
{
id: "goal-2",
companyId: "company-1",
title: "Later active root",
description: null,
level: "company",
status: "active",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-03T00:00:00Z"),
updatedAt: new Date("2026-03-03T00:00:00Z"),
},
{
id: "goal-1",
companyId: "company-1",
title: "Earliest active root",
description: null,
level: "company",
status: "active",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-02T00:00:00Z"),
updatedAt: new Date("2026-03-02T00:00:00Z"),
},
]),
).toBe("goal-1");
});
it("falls back to the earliest root company goal when none are active", () => {
expect(
selectDefaultCompanyGoalId([
{
id: "goal-2",
companyId: "company-1",
title: "Cancelled root",
description: null,
level: "company",
status: "cancelled",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-03T00:00:00Z"),
updatedAt: new Date("2026-03-03T00:00:00Z"),
},
{
id: "goal-1",
companyId: "company-1",
title: "Earliest root",
description: null,
level: "company",
status: "planned",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-02T00:00:00Z"),
updatedAt: new Date("2026-03-02T00:00:00Z"),
},
]),
).toBe("goal-1");
});
});
describe("onboarding launch payloads", () => {
it("links the onboarding project and first issue to the selected goal", () => {
expect(buildOnboardingProjectPayload("goal-1")).toEqual({
name: "Onboarding",
status: "in_progress",
goalIds: ["goal-1"],
});
expect(
buildOnboardingIssuePayload({
title: " Hire your first engineer ",
description: " Kick off the hiring plan ",
assigneeAgentId: "agent-1",
projectId: "project-1",
goalId: "goal-1",
}),
).toEqual({
title: "Hire your first engineer",
description: "Kick off the hiring plan",
assigneeAgentId: "agent-1",
projectId: "project-1",
goalId: "goal-1",
status: "todo",
});
});
it("omits goal links when no default company goal exists", () => {
expect(buildOnboardingProjectPayload(null)).toEqual({
name: "Onboarding",
status: "in_progress",
});
expect(
buildOnboardingIssuePayload({
title: "Task",
description: "",
assigneeAgentId: "agent-1",
projectId: "project-1",
goalId: null,
}),
).toEqual({
title: "Task",
assigneeAgentId: "agent-1",
projectId: "project-1",
status: "todo",
});
});
});
+53
View File
@@ -0,0 +1,53 @@
import type { Goal } from "@paperclipai/shared";
export const ONBOARDING_PROJECT_NAME = "Onboarding";
function goalCreatedAt(goal: Goal) {
const createdAt = goal.createdAt instanceof Date ? goal.createdAt : new Date(goal.createdAt);
return Number.isNaN(createdAt.getTime()) ? 0 : createdAt.getTime();
}
function pickEarliestGoal(goals: Goal[]) {
return [...goals].sort((a, b) => goalCreatedAt(a) - goalCreatedAt(b))[0] ?? null;
}
export function selectDefaultCompanyGoalId(goals: Goal[]): string | null {
const companyGoals = goals.filter((goal) => goal.level === "company");
const rootGoals = companyGoals.filter((goal) => !goal.parentId);
const activeRootGoals = rootGoals.filter((goal) => goal.status === "active");
return (
pickEarliestGoal(activeRootGoals)?.id ??
pickEarliestGoal(rootGoals)?.id ??
pickEarliestGoal(companyGoals)?.id ??
null
);
}
export function buildOnboardingProjectPayload(goalId: string | null) {
return {
name: ONBOARDING_PROJECT_NAME,
status: "in_progress" as const,
...(goalId ? { goalIds: [goalId] } : {}),
};
}
export function buildOnboardingIssuePayload(input: {
title: string;
description: string;
assigneeAgentId: string;
projectId: string;
goalId: string | null;
}) {
const title = input.title.trim();
const description = input.description.trim();
return {
title,
...(description ? { description } : {}),
assigneeAgentId: input.assigneeAgentId,
projectId: input.projectId,
...(input.goalId ? { goalId: input.goalId } : {}),
status: "todo" as const,
};
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
isOnboardingPath,
resolveRouteOnboardingOptions,
shouldRedirectCompanylessRouteToOnboarding,
} from "./onboarding-route";
describe("isOnboardingPath", () => {
it("matches the global onboarding route", () => {
expect(isOnboardingPath("/onboarding")).toBe(true);
});
it("matches a company-prefixed onboarding route", () => {
expect(isOnboardingPath("/pap/onboarding")).toBe(true);
});
it("ignores non-onboarding routes", () => {
expect(isOnboardingPath("/pap/dashboard")).toBe(false);
});
});
describe("resolveRouteOnboardingOptions", () => {
it("opens company creation for the global onboarding route", () => {
expect(
resolveRouteOnboardingOptions({
pathname: "/onboarding",
companies: [],
}),
).toEqual({ initialStep: 1 });
});
it("opens agent creation when the prefixed company exists", () => {
expect(
resolveRouteOnboardingOptions({
pathname: "/pap/onboarding",
companyPrefix: "pap",
companies: [{ id: "company-1", issuePrefix: "PAP" }],
}),
).toEqual({ initialStep: 2, companyId: "company-1" });
});
it("falls back to company creation when the prefixed company is missing", () => {
expect(
resolveRouteOnboardingOptions({
pathname: "/pap/onboarding",
companyPrefix: "pap",
companies: [],
}),
).toEqual({ initialStep: 1 });
});
});
describe("shouldRedirectCompanylessRouteToOnboarding", () => {
it("redirects companyless entry routes into onboarding", () => {
expect(
shouldRedirectCompanylessRouteToOnboarding({
pathname: "/",
hasCompanies: false,
}),
).toBe(true);
});
it("does not redirect when already on onboarding", () => {
expect(
shouldRedirectCompanylessRouteToOnboarding({
pathname: "/onboarding",
hasCompanies: false,
}),
).toBe(false);
});
it("does not redirect when companies exist", () => {
expect(
shouldRedirectCompanylessRouteToOnboarding({
pathname: "/issues",
hasCompanies: true,
}),
).toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
type OnboardingRouteCompany = {
id: string;
issuePrefix: string;
};
export function isOnboardingPath(pathname: string): boolean {
const segments = pathname.split("/").filter(Boolean);
if (segments.length === 1) {
return segments[0]?.toLowerCase() === "onboarding";
}
if (segments.length === 2) {
return segments[1]?.toLowerCase() === "onboarding";
}
return false;
}
export function resolveRouteOnboardingOptions(params: {
pathname: string;
companyPrefix?: string;
companies: OnboardingRouteCompany[];
}): { initialStep: 1 | 2; companyId?: string } | null {
const { pathname, companyPrefix, companies } = params;
if (!isOnboardingPath(pathname)) return null;
if (!companyPrefix) {
return { initialStep: 1 };
}
const matchedCompany =
companies.find(
(company) =>
company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase(),
) ?? null;
if (!matchedCompany) {
return { initialStep: 1 };
}
return { initialStep: 2, companyId: matchedCompany.id };
}
export function shouldRedirectCompanylessRouteToOnboarding(params: {
pathname: string;
hasCompanies: boolean;
}): boolean {
return !params.hasCompanies && !isOnboardingPath(params.pathname);
}
+41
View File
@@ -0,0 +1,41 @@
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
const contentTypeByExtension: Record<string, string> = {
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
export function getPortableFileText(entry: CompanyPortabilityFileEntry | null | undefined) {
return typeof entry === "string" ? entry : null;
}
export function getPortableFileContentType(
filePath: string,
entry: CompanyPortabilityFileEntry | null | undefined,
) {
if (entry && typeof entry === "object" && entry.contentType) return entry.contentType;
const extensionIndex = filePath.toLowerCase().lastIndexOf(".");
if (extensionIndex === -1) return null;
return contentTypeByExtension[filePath.toLowerCase().slice(extensionIndex)] ?? null;
}
export function getPortableFileDataUrl(
filePath: string,
entry: CompanyPortabilityFileEntry | null | undefined,
) {
if (!entry || typeof entry === "string") return null;
const contentType = getPortableFileContentType(filePath, entry) ?? "application/octet-stream";
return `data:${contentType};base64,${entry.data}`;
}
export function isPortableImageFile(
filePath: string,
entry: CompanyPortabilityFileEntry | null | undefined,
) {
const contentType = getPortableFileContentType(filePath, entry);
return typeof contentType === "string" && contentType.startsWith("image/");
}
+33
View File
@@ -4,21 +4,36 @@ export const queryKeys = {
detail: (id: string) => ["companies", id] as const,
stats: ["companies", "stats"] as const,
},
companySkills: {
list: (companyId: string) => ["company-skills", companyId] as const,
detail: (companyId: string, skillId: string) => ["company-skills", companyId, skillId] as const,
updateStatus: (companyId: string, skillId: string) =>
["company-skills", companyId, skillId, "update-status"] as const,
file: (companyId: string, skillId: string, relativePath: string) =>
["company-skills", companyId, skillId, "file", relativePath] as const,
},
agents: {
list: (companyId: string) => ["agents", companyId] as const,
detail: (id: string) => ["agents", "detail", id] as const,
runtimeState: (id: string) => ["agents", "runtime-state", id] as const,
taskSessions: (id: string) => ["agents", "task-sessions", id] as const,
skills: (id: string) => ["agents", "skills", id] as const,
instructionsBundle: (id: string) => ["agents", "instructions-bundle", id] as const,
instructionsFile: (id: string, relativePath: string) =>
["agents", "instructions-bundle", id, "file", relativePath] as const,
keys: (agentId: string) => ["agents", "keys", agentId] as const,
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
adapterModels: (companyId: string, adapterType: string) =>
["agents", companyId, "adapter-models", adapterType] as const,
detectModel: (companyId: string, adapterType: string) =>
["agents", companyId, "detect-model", adapterType] as const,
},
issues: {
list: (companyId: string) => ["issues", companyId] as const,
search: (companyId: string, q: string, projectId?: string) =>
["issues", companyId, "search", q, projectId ?? "__all-projects__"] as const,
listAssignedToMe: (companyId: string) => ["issues", companyId, "assigned-to-me"] as const,
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const,
listUnreadTouchedByMe: (companyId: string) => ["issues", companyId, "unread-touched-by-me"] as const,
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
@@ -34,6 +49,18 @@ export const queryKeys = {
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
liveRuns: (issueId: string) => ["issues", "live-runs", issueId] as const,
activeRun: (issueId: string) => ["issues", "active-run", issueId] as const,
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
},
routines: {
list: (companyId: string) => ["routines", companyId] as const,
detail: (id: string) => ["routines", "detail", id] as const,
runs: (id: string) => ["routines", "runs", id] as const,
activity: (companyId: string, id: string) => ["routines", "activity", companyId, id] as const,
},
executionWorkspaces: {
list: (companyId: string, filters?: Record<string, string | boolean | undefined>) =>
["execution-workspaces", companyId, filters ?? {}] as const,
detail: (id: string) => ["execution-workspaces", "detail", id] as const,
},
projects: {
list: (companyId: string) => ["projects", companyId] as const,
@@ -62,7 +89,9 @@ export const queryKeys = {
session: ["auth", "session"] as const,
},
instance: {
generalSettings: ["instance", "general-settings"] as const,
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
experimentalSettings: ["instance", "experimental-settings"] as const,
},
health: ["health"] as const,
secrets: {
@@ -93,9 +122,13 @@ export const queryKeys = {
heartbeats: (companyId: string, agentId?: string) =>
["heartbeats", companyId, agentId] as const,
runDetail: (runId: string) => ["heartbeat-run", runId] as const,
runWorkspaceOperations: (runId: string) => ["heartbeat-run", runId, "workspace-operations"] as const,
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
runIssues: (runId: string) => ["run-issues", runId] as const,
org: (companyId: string) => ["org", companyId] as const,
skills: {
available: ["skills", "available"] as const,
},
plugins: {
all: ["plugins"] as const,
examples: ["plugins", "examples"] as const,
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import type { RoutineTrigger } from "@paperclipai/shared";
import { buildRoutineTriggerPatch } from "./routine-trigger-patch";
function makeScheduleTrigger(overrides: Partial<RoutineTrigger> = {}): RoutineTrigger {
return {
id: "trigger-1",
companyId: "company-1",
routineId: "routine-1",
kind: "schedule",
label: "Daily",
enabled: true,
cronExpression: "0 10 * * *",
timezone: "UTC",
nextRunAt: null,
lastFiredAt: null,
publicId: null,
secretId: null,
signingMode: null,
replayWindowSec: null,
lastRotatedAt: null,
lastResult: null,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
};
}
describe("buildRoutineTriggerPatch", () => {
it("preserves an existing schedule trigger timezone when saving edits", () => {
const patch = buildRoutineTriggerPatch(
makeScheduleTrigger({ timezone: "UTC" }),
{
label: "Daily label edit",
cronExpression: "0 10 * * *",
signingMode: "bearer",
replayWindowSec: "300",
},
"America/Chicago",
);
expect(patch).toEqual({
label: "Daily label edit",
cronExpression: "0 10 * * *",
timezone: "UTC",
});
});
it("falls back to the local timezone when a schedule trigger has none", () => {
const patch = buildRoutineTriggerPatch(
makeScheduleTrigger({ timezone: null }),
{
label: "",
cronExpression: "15 9 * * 1-5",
signingMode: "bearer",
replayWindowSec: "300",
},
"America/Chicago",
);
expect(patch).toEqual({
label: null,
cronExpression: "15 9 * * 1-5",
timezone: "America/Chicago",
});
});
});
+30
View File
@@ -0,0 +1,30 @@
import type { RoutineTrigger } from "@paperclipai/shared";
export type RoutineTriggerEditorDraft = {
label: string;
cronExpression: string;
signingMode: string;
replayWindowSec: string;
};
export function buildRoutineTriggerPatch(
trigger: RoutineTrigger,
draft: RoutineTriggerEditorDraft,
fallbackTimezone: string,
) {
const patch: Record<string, unknown> = {
label: draft.label.trim() || null,
};
if (trigger.kind === "schedule") {
patch.cronExpression = draft.cronExpression.trim();
patch.timezone = trigger.timezone ?? fallbackTimezone;
}
if (trigger.kind === "webhook") {
patch.signingMode = draft.signingMode;
patch.replayWindowSec = Number(draft.replayWindowSec || "300");
}
return patch;
}
+289
View File
@@ -0,0 +1,289 @@
// @vitest-environment node
import { deflateRawSync } from "node:zlib";
import { describe, expect, it } from "vitest";
import { createZipArchive, readZipArchive } from "./zip";
function readUint16(bytes: Uint8Array, offset: number) {
return bytes[offset]! | (bytes[offset + 1]! << 8);
}
function readUint32(bytes: Uint8Array, offset: number) {
return (
bytes[offset]! |
(bytes[offset + 1]! << 8) |
(bytes[offset + 2]! << 16) |
(bytes[offset + 3]! << 24)
) >>> 0;
}
function readString(bytes: Uint8Array, offset: number, length: number) {
return new TextDecoder().decode(bytes.slice(offset, offset + length));
}
function writeUint16(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
}
function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
target[offset + 2] = (value >>> 16) & 0xff;
target[offset + 3] = (value >>> 24) & 0xff;
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function createDeflatedZipArchive(files: Record<string, string>, rootPath: string) {
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
let entryCount = 0;
for (const [relativePath, content] of Object.entries(files).sort(([a], [b]) => a.localeCompare(b))) {
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
const rawBody = encoder.encode(content);
const deflatedBody = new Uint8Array(deflateRawSync(rawBody));
const checksum = crc32(rawBody);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, 8);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, deflatedBody.length);
writeUint32(localHeader, 22, rawBody.length);
writeUint16(localHeader, 26, fileName.length);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, 8);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, deflatedBody.length);
writeUint32(centralHeader, 24, rawBody.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, deflatedBody);
centralChunks.push(centralHeader);
localOffset += localHeader.length + deflatedBody.length;
entryCount += 1;
}
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
);
let offset = 0;
for (const chunk of localChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
const centralDirectoryOffset = offset;
for (const chunk of centralChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
writeUint32(archive, offset, 0x06054b50);
writeUint16(archive, offset + 8, entryCount);
writeUint16(archive, offset + 10, entryCount);
writeUint32(archive, offset + 12, centralDirectoryLength);
writeUint32(archive, offset + 16, centralDirectoryOffset);
return archive;
}
function createZipArchiveWithDirectoryEntries(rootPath: string) {
const encoder = new TextEncoder();
const entries = [
{ path: `${rootPath}/`, body: new Uint8Array(0), compressionMethod: 0 },
{ path: `${rootPath}/agents/`, body: new Uint8Array(0), compressionMethod: 0 },
{ path: `${rootPath}/agents/ceo/`, body: new Uint8Array(0), compressionMethod: 0 },
{ path: `${rootPath}/COMPANY.md`, body: encoder.encode("# Company\n"), compressionMethod: 8 },
{ path: `${rootPath}/agents/ceo/AGENTS.md`, body: encoder.encode("# CEO\n"), compressionMethod: 8 },
].map((entry) => ({
...entry,
data: entry.compressionMethod === 8 ? new Uint8Array(deflateRawSync(entry.body)) : entry.body,
checksum: crc32(entry.body),
}));
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
for (const entry of entries) {
const fileName = encoder.encode(entry.path);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, entry.compressionMethod);
writeUint32(localHeader, 14, entry.checksum);
writeUint32(localHeader, 18, entry.data.length);
writeUint32(localHeader, 22, entry.body.length);
writeUint16(localHeader, 26, fileName.length);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, entry.compressionMethod);
writeUint32(centralHeader, 16, entry.checksum);
writeUint32(centralHeader, 20, entry.data.length);
writeUint32(centralHeader, 24, entry.body.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, entry.data);
centralChunks.push(centralHeader);
localOffset += localHeader.length + entry.data.length;
}
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
);
let offset = 0;
for (const chunk of localChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
const centralDirectoryOffset = offset;
for (const chunk of centralChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
writeUint32(archive, offset, 0x06054b50);
writeUint16(archive, offset + 8, entries.length);
writeUint16(archive, offset + 10, entries.length);
writeUint32(archive, offset + 12, centralDirectoryLength);
writeUint32(archive, offset + 16, centralDirectoryOffset);
return archive;
}
describe("createZipArchive", () => {
it("writes a zip archive with the export root path prefixed into each entry", () => {
const archive = createZipArchive(
{
"COMPANY.md": "# Company\n",
"agents/ceo/AGENTS.md": "# CEO\n",
},
"paperclip-demo",
);
expect(readUint32(archive, 0)).toBe(0x04034b50);
const firstNameLength = readUint16(archive, 26);
const firstBodyLength = readUint32(archive, 18);
expect(readString(archive, 30, firstNameLength)).toBe("paperclip-demo/agents/ceo/AGENTS.md");
expect(readString(archive, 30 + firstNameLength, firstBodyLength)).toBe("# CEO\n");
const secondOffset = 30 + firstNameLength + firstBodyLength;
expect(readUint32(archive, secondOffset)).toBe(0x04034b50);
const secondNameLength = readUint16(archive, secondOffset + 26);
const secondBodyLength = readUint32(archive, secondOffset + 18);
expect(readString(archive, secondOffset + 30, secondNameLength)).toBe("paperclip-demo/COMPANY.md");
expect(readString(archive, secondOffset + 30 + secondNameLength, secondBodyLength)).toBe("# Company\n");
const endOffset = archive.length - 22;
expect(readUint32(archive, endOffset)).toBe(0x06054b50);
expect(readUint16(archive, endOffset + 8)).toBe(2);
expect(readUint16(archive, endOffset + 10)).toBe(2);
});
it("reads a Paperclip zip archive back into rootPath and file contents", async () => {
const archive = createZipArchive(
{
"COMPANY.md": "# Company\n",
"agents/ceo/AGENTS.md": "# CEO\n",
".paperclip.yaml": "schema: paperclip/v1\n",
},
"paperclip-demo",
);
await expect(readZipArchive(archive)).resolves.toEqual({
rootPath: "paperclip-demo",
files: {
"COMPANY.md": "# Company\n",
"agents/ceo/AGENTS.md": "# CEO\n",
".paperclip.yaml": "schema: paperclip/v1\n",
},
});
});
it("round-trips binary image files without coercing them to text", async () => {
const archive = createZipArchive(
{
"images/company-logo.png": {
encoding: "base64",
data: Buffer.from("png-bytes").toString("base64"),
contentType: "image/png",
},
},
"paperclip-demo",
);
await expect(readZipArchive(archive)).resolves.toEqual({
rootPath: "paperclip-demo",
files: {
"images/company-logo.png": {
encoding: "base64",
data: Buffer.from("png-bytes").toString("base64"),
contentType: "image/png",
},
},
});
});
it("reads standard DEFLATE zip archives created outside Paperclip", async () => {
const archive = createDeflatedZipArchive(
{
"COMPANY.md": "# Company\n",
"agents/ceo/AGENTS.md": "# CEO\n",
},
"paperclip-demo",
);
await expect(readZipArchive(archive)).resolves.toEqual({
rootPath: "paperclip-demo",
files: {
"COMPANY.md": "# Company\n",
"agents/ceo/AGENTS.md": "# CEO\n",
},
});
});
it("ignores directory entries from standard zip archives", async () => {
const archive = createZipArchiveWithDirectoryEntries("paperclip-demo");
await expect(readZipArchive(archive)).resolves.toEqual({
rootPath: "paperclip-demo",
files: {
"COMPANY.md": "# Company\n",
"agents/ceo/AGENTS.md": "# CEO\n",
},
});
});
});
+283
View File
@@ -0,0 +1,283 @@
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const crcTable = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let crc = i;
for (let bit = 0; bit < 8; bit++) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
crcTable[i] = crc >>> 0;
}
function normalizeArchivePath(pathValue: string) {
return pathValue
.replace(/\\/g, "/")
.split("/")
.filter(Boolean)
.join("/");
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 0xff]!;
}
return (crc ^ 0xffffffff) >>> 0;
}
function writeUint16(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
}
function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
target[offset + 2] = (value >>> 16) & 0xff;
target[offset + 3] = (value >>> 24) & 0xff;
}
function readUint16(source: Uint8Array, offset: number) {
return source[offset]! | (source[offset + 1]! << 8);
}
function readUint32(source: Uint8Array, offset: number) {
return (
source[offset]! |
(source[offset + 1]! << 8) |
(source[offset + 2]! << 16) |
(source[offset + 3]! << 24)
) >>> 0;
}
function getDosDateTime(date: Date) {
const year = Math.min(Math.max(date.getFullYear(), 1980), 2107);
const month = date.getMonth() + 1;
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = Math.floor(date.getSeconds() / 2);
return {
time: (hours << 11) | (minutes << 5) | seconds,
date: ((year - 1980) << 9) | (month << 5) | day,
};
}
function concatChunks(chunks: Uint8Array[]) {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
return archive;
}
function sharedArchiveRoot(paths: string[]) {
if (paths.length === 0) return null;
const firstSegments = paths
.map((entry) => normalizeArchivePath(entry).split("/").filter(Boolean))
.filter((parts) => parts.length > 0);
if (firstSegments.length === 0) return null;
const candidate = firstSegments[0]![0]!;
return firstSegments.every((parts) => parts.length > 1 && parts[0] === candidate)
? candidate
: null;
}
const binaryContentTypeByExtension: Record<string, string> = {
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
function inferBinaryContentType(pathValue: string) {
const normalized = normalizeArchivePath(pathValue);
const extensionIndex = normalized.lastIndexOf(".");
if (extensionIndex === -1) return null;
return binaryContentTypeByExtension[normalized.slice(extensionIndex).toLowerCase()] ?? null;
}
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToBytes(base64: string) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function bytesToPortableFileEntry(pathValue: string, bytes: Uint8Array): CompanyPortabilityFileEntry {
const contentType = inferBinaryContentType(pathValue);
if (!contentType) return textDecoder.decode(bytes);
return {
encoding: "base64",
data: bytesToBase64(bytes),
contentType,
};
}
function portableFileEntryToBytes(entry: CompanyPortabilityFileEntry): Uint8Array {
if (typeof entry === "string") return textEncoder.encode(entry);
return base64ToBytes(entry.data);
}
async function inflateZipEntry(compressionMethod: number, bytes: Uint8Array) {
if (compressionMethod === 0) return bytes;
if (compressionMethod !== 8) {
throw new Error("Unsupported zip archive: only STORE and DEFLATE entries are supported.");
}
if (typeof DecompressionStream !== "function") {
throw new Error("Unsupported zip archive: this browser cannot read compressed zip entries.");
}
const body = new Uint8Array(bytes.byteLength);
body.set(bytes);
const stream = new Blob([body]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise<{
rootPath: string | null;
files: Record<string, CompanyPortabilityFileEntry>;
}> {
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = [];
let offset = 0;
while (offset + 4 <= bytes.length) {
const signature = readUint32(bytes, offset);
if (signature === 0x02014b50 || signature === 0x06054b50) break;
if (signature !== 0x04034b50) {
throw new Error("Invalid zip archive: unsupported local file header.");
}
if (offset + 30 > bytes.length) {
throw new Error("Invalid zip archive: truncated local file header.");
}
const generalPurposeFlag = readUint16(bytes, offset + 6);
const compressionMethod = readUint16(bytes, offset + 8);
const compressedSize = readUint32(bytes, offset + 18);
const fileNameLength = readUint16(bytes, offset + 26);
const extraFieldLength = readUint16(bytes, offset + 28);
if ((generalPurposeFlag & 0x0008) !== 0) {
throw new Error("Unsupported zip archive: data descriptors are not supported.");
}
const nameOffset = offset + 30;
const bodyOffset = nameOffset + fileNameLength + extraFieldLength;
const bodyEnd = bodyOffset + compressedSize;
if (bodyEnd > bytes.length) {
throw new Error("Invalid zip archive: truncated file contents.");
}
const rawArchivePath = textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength));
const archivePath = normalizeArchivePath(rawArchivePath);
const isDirectoryEntry = /\/$/.test(rawArchivePath.replace(/\\/g, "/"));
if (archivePath && !isDirectoryEntry) {
const entryBytes = await inflateZipEntry(compressionMethod, bytes.slice(bodyOffset, bodyEnd));
entries.push({
path: archivePath,
body: bytesToPortableFileEntry(archivePath, entryBytes),
});
}
offset = bodyEnd;
}
const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path));
const files: Record<string, CompanyPortabilityFileEntry> = {};
for (const entry of entries) {
const normalizedPath =
rootPath && entry.path.startsWith(`${rootPath}/`)
? entry.path.slice(rootPath.length + 1)
: entry.path;
if (!normalizedPath) continue;
files[normalizedPath] = entry.body;
}
return { rootPath, files };
}
export function createZipArchive(files: Record<string, CompanyPortabilityFileEntry>, rootPath: string): Uint8Array {
const normalizedRoot = normalizeArchivePath(rootPath);
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
const archiveDate = getDosDateTime(new Date());
let localOffset = 0;
let entryCount = 0;
for (const [relativePath, contents] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
const archivePath = normalizeArchivePath(`${normalizedRoot}/${relativePath}`);
const fileName = textEncoder.encode(archivePath);
const body = portableFileEntryToBytes(contents);
const checksum = crc32(body);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, 0);
writeUint16(localHeader, 10, archiveDate.time);
writeUint16(localHeader, 12, archiveDate.date);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, body.length);
writeUint32(localHeader, 22, body.length);
writeUint16(localHeader, 26, fileName.length);
writeUint16(localHeader, 28, 0);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, 0);
writeUint16(centralHeader, 12, archiveDate.time);
writeUint16(centralHeader, 14, archiveDate.date);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, body.length);
writeUint32(centralHeader, 24, body.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint16(centralHeader, 30, 0);
writeUint16(centralHeader, 32, 0);
writeUint16(centralHeader, 34, 0);
writeUint16(centralHeader, 36, 0);
writeUint32(centralHeader, 38, 0);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, body);
centralChunks.push(centralHeader);
localOffset += localHeader.length + body.length;
entryCount += 1;
}
const centralDirectory = concatChunks(centralChunks);
const endOfCentralDirectory = new Uint8Array(22);
writeUint32(endOfCentralDirectory, 0, 0x06054b50);
writeUint16(endOfCentralDirectory, 4, 0);
writeUint16(endOfCentralDirectory, 6, 0);
writeUint16(endOfCentralDirectory, 8, entryCount);
writeUint16(endOfCentralDirectory, 10, entryCount);
writeUint32(endOfCentralDirectory, 12, centralDirectory.length);
writeUint32(endOfCentralDirectory, 16, localOffset);
writeUint16(endOfCentralDirectory, 20, 0);
return concatChunks([...localChunks, centralDirectory, endOfCentralDirectory]);
}

Some files were not shown because too many files have changed in this diff Show More