mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
Merge branch 'master' into feature/change-reports-to
This commit is contained in:
commit
dfb83295de
191 changed files with 46471 additions and 1103 deletions
|
|
@ -22,7 +22,11 @@ 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";
|
||||
|
|
@ -116,6 +120,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 />} />
|
||||
|
|
@ -171,7 +178,7 @@ 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() {
|
||||
|
|
@ -296,9 +303,10 @@ export function App() {
|
|||
<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 />} />
|
||||
|
|
@ -307,6 +315,7 @@ export function App() {
|
|||
<Route path="companies" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||
<Route path="agents" element={<UnprefixedBoardRedirect />} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export { getUIAdapter } from "./registry";
|
||||
export { getUIAdapter, listUIAdapters } from "./registry";
|
||||
export { buildTranscript } from "./transcript";
|
||||
export type {
|
||||
TranscriptEntry,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ export function OpenCodeLocalConfigFields({
|
|||
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 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">
|
||||
|
|
|
|||
|
|
@ -9,20 +9,26 @@ import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
|||
import { processUIAdapter } from "./process";
|
||||
import { httpUIAdapter } from "./http";
|
||||
|
||||
const uiAdapters: UIAdapterModule[] = [
|
||||
claudeLocalUIAdapter,
|
||||
codexLocalUIAdapter,
|
||||
geminiLocalUIAdapter,
|
||||
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
ui/src/adapters/transcript.test.ts
Normal file
30
ui/src/adapters/transcript.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import type {
|
||||
Agent,
|
||||
AgentInstructionsBundle,
|
||||
AgentInstructionsFileDetail,
|
||||
AgentSkillSnapshot,
|
||||
AgentDetail,
|
||||
AdapterEnvironmentTestResult,
|
||||
AgentKeyCreated,
|
||||
|
|
@ -108,11 +111,40 @@ export const agentsApi = {
|
|||
api.patch<Agent>(agentPath(id, companyId), 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) =>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import type {
|
||||
Company,
|
||||
CompanyPortabilityExportPreviewResult,
|
||||
CompanyPortabilityExportResult,
|
||||
CompanyPortabilityImportRequest,
|
||||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityPreviewRequest,
|
||||
CompanyPortabilityPreviewResult,
|
||||
UpdateCompanyBranding,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
|
|
@ -29,10 +31,49 @@ 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: {
|
||||
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
|
||||
agents?: string[];
|
||||
skills?: string[];
|
||||
projects?: string[];
|
||||
issues?: string[];
|
||||
projectIssues?: string[];
|
||||
selectedFiles?: string[];
|
||||
},
|
||||
) =>
|
||||
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/export`, data),
|
||||
exportPreview: (
|
||||
companyId: string,
|
||||
data: {
|
||||
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
|
||||
agents?: string[];
|
||||
skills?: string[];
|
||||
projects?: string[];
|
||||
issues?: string[];
|
||||
projectIssues?: string[];
|
||||
selectedFiles?: string[];
|
||||
},
|
||||
) =>
|
||||
api.post<CompanyPortabilityExportPreviewResult>(`/companies/${companyId}/exports/preview`, data),
|
||||
exportPackage: (
|
||||
companyId: string,
|
||||
data: {
|
||||
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
|
||||
agents?: string[];
|
||||
skills?: string[];
|
||||
projects?: string[];
|
||||
issues?: string[];
|
||||
projectIssues?: string[];
|
||||
selectedFiles?: string[];
|
||||
},
|
||||
) =>
|
||||
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/exports`, data),
|
||||
importPreview: (data: CompanyPortabilityPreviewRequest) =>
|
||||
api.post<CompanyPortabilityPreviewResult>("/companies/import/preview", data),
|
||||
importBundle: (data: CompanyPortabilityImportRequest) =>
|
||||
|
|
|
|||
54
ui/src/api/companySkills.ts
Normal file
54
ui/src/api/companySkills.ts
Normal 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`,
|
||||
{},
|
||||
),
|
||||
};
|
||||
|
|
@ -1,3 +1,17 @@
|
|||
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;
|
||||
|
|
@ -9,6 +23,7 @@ export type HealthStatus = {
|
|||
features?: {
|
||||
companyDeletionEnabled?: boolean;
|
||||
};
|
||||
devServer?: DevServerHealthStatus;
|
||||
};
|
||||
|
||||
export const healthApi = {
|
||||
|
|
|
|||
|
|
@ -14,3 +14,4 @@ export { dashboardApi } from "./dashboard";
|
|||
export { heartbeatsApi } from "./heartbeats";
|
||||
export { instanceSettingsApi } from "./instanceSettings";
|
||||
export { sidebarBadgesApi } from "./sidebarBadges";
|
||||
export { companySkillsApi } from "./companySkills";
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
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) =>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ 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 ---- */
|
||||
|
||||
|
|
@ -61,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";
|
||||
} & (
|
||||
|
|
@ -164,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();
|
||||
|
||||
|
|
@ -286,7 +297,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor";
|
||||
const showLegacyWorkingDirectoryField =
|
||||
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||
|
||||
// Fetch adapter models for the effective adapter type
|
||||
|
|
@ -319,6 +333,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
|
||||
|
|
@ -478,7 +493,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}}
|
||||
/>
|
||||
</Field>
|
||||
{isLocal && (
|
||||
{isLocal && !props.hidePromptTemplate && (
|
||||
<>
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
|
|
@ -513,69 +528,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">
|
||||
|
|
@ -590,8 +609,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
|
||||
|
|
@ -669,8 +688,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
? "codex"
|
||||
: adapterType === "gemini_local"
|
||||
? "gemini"
|
||||
: adapterType === "cursor"
|
||||
? "agent"
|
||||
: adapterType === "pi_local"
|
||||
? "pi"
|
||||
: adapterType === "cursor"
|
||||
? "agent"
|
||||
: adapterType === "opencode_local"
|
||||
? "opencode"
|
||||
: "claude"
|
||||
|
|
@ -825,7 +846,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>
|
||||
|
|
@ -846,7 +867,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>
|
||||
|
|
@ -912,7 +933,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
</CollapsibleSection>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -34,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">
|
||||
|
|
@ -58,6 +83,7 @@ export function HireAgentPayload({ payload }: { payload: Record<string, unknown>
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
<SkillList values={payload.desiredSkills} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
89
ui/src/components/DevRestartBanner.tsx
Normal file
89
ui/src/components/DevRestartBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, FlaskConical, 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,6 +22,7 @@ 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} />
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -78,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(() => {
|
||||
|
|
@ -266,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
|
||||
|
|
|
|||
31
ui/src/components/MarkdownBody.test.tsx
Normal file
31
ui/src/components/MarkdownBody.test.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
|
||||
describe("MarkdownBody", () => {
|
||||
it("renders markdown images without a resolver", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>{""}</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}`}>
|
||||
{""}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('src="/resolved/images/org-chart.png"');
|
||||
expect(html).toContain('alt="Org chart"');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { isValidElement, useEffect, useId, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import Markdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { parseProjectMentionHref } from "@paperclipai/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
|
|
@ -8,6 +8,8 @@ import { useTheme } from "../context/ThemeContext";
|
|||
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;
|
||||
|
|
@ -112,8 +114,44 @@ 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 ? 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
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 +160,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}>
|
||||
{children}
|
||||
</Markdown>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -430,6 +430,9 @@ export function NewIssueDialog() {
|
|||
},
|
||||
onSuccess: ({ issue, companyId, failures }) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(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();
|
||||
|
|
|
|||
|
|
@ -32,8 +32,6 @@ 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,
|
||||
|
|
@ -49,7 +47,6 @@ import {
|
|||
MousePointer2,
|
||||
Check,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
ChevronDown,
|
||||
X
|
||||
} from "lucide-react";
|
||||
|
|
@ -62,17 +59,14 @@ type AdapterType =
|
|||
| "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();
|
||||
|
|
@ -113,7 +107,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("");
|
||||
|
|
@ -128,7 +121,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
|
||||
);
|
||||
|
|
@ -217,7 +212,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 =
|
||||
|
|
@ -273,7 +268,6 @@ export function OnboardingWizard() {
|
|||
setCompanyGoal("");
|
||||
setAgentName("CEO");
|
||||
setAdapterType("claude_local");
|
||||
setCwd("");
|
||||
setModel("");
|
||||
setCommand("");
|
||||
setArgs("");
|
||||
|
|
@ -283,7 +277,7 @@ 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);
|
||||
|
|
@ -301,7 +295,6 @@ export function OnboardingWizard() {
|
|||
const config = adapter.buildAdapterConfig({
|
||||
...defaultCreateValues,
|
||||
adapterType,
|
||||
cwd,
|
||||
model:
|
||||
adapterType === "codex_local"
|
||||
? model || DEFAULT_CODEX_LOCAL_MODEL
|
||||
|
|
@ -787,12 +780,6 @@ export function OnboardingWizard() {
|
|||
icon: Gem,
|
||||
desc: "Local Gemini agent"
|
||||
},
|
||||
{
|
||||
value: "process" as const,
|
||||
label: "Process",
|
||||
icon: Terminal,
|
||||
desc: "Run a local command"
|
||||
},
|
||||
{
|
||||
value: "opencode_local" as const,
|
||||
label: "OpenCode",
|
||||
|
|
@ -874,24 +861,6 @@ export function OnboardingWizard() {
|
|||
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
|
||||
|
|
@ -1110,33 +1079,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>
|
||||
|
|
|
|||
317
ui/src/components/PackageFileTree.tsx
Normal file
317
ui/src/components/PackageFileTree.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
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",
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Search,
|
||||
SquarePen,
|
||||
Network,
|
||||
Boxes,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
|
@ -106,6 +107,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} />
|
||||
|
|
|
|||
|
|
@ -74,8 +74,10 @@ export function SidebarAgents() {
|
|||
return sortByHierarchy(filtered);
|
||||
}, [agents]);
|
||||
|
||||
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}>
|
||||
|
|
@ -112,7 +114,7 @@ export function SidebarAgents() {
|
|||
return (
|
||||
<NavLink
|
||||
key={agent.id}
|
||||
to={agentUrl(agent)}
|
||||
to={activeTab ? `${agentUrl(agent)}/${activeTab}` : agentUrl(agent)}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ 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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
34
ui/src/context/LiveUpdatesProvider.test.ts
Normal file
34
ui/src/context/LiveUpdatesProvider.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } 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.listTouchedByMe("company-1"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -361,6 +361,8 @@ function invalidateActivityQueries(
|
|||
|
||||
if (entityType === "issue") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(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);
|
||||
|
|
@ -510,6 +512,10 @@ function handleLiveEvent(
|
|||
}
|
||||
}
|
||||
|
||||
export const __liveUpdatesTestUtils = {
|
||||
invalidateActivityQueries,
|
||||
};
|
||||
|
||||
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -178,13 +178,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;
|
||||
|
|
|
|||
90
ui/src/lib/agent-skills-state.test.ts
Normal file
90
ui/src/lib/agent-skills-state.test.ts
Normal 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
ui/src/lib/agent-skills-state.ts
Normal file
40
ui/src/lib/agent-skills-state.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
|||
"dashboard",
|
||||
"companies",
|
||||
"company",
|
||||
"skills",
|
||||
"org",
|
||||
"agents",
|
||||
"projects",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import {
|
|||
|
||||
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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
|
||||
export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/general";
|
||||
|
||||
export function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
|
||||
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
|
||||
|
|
@ -9,6 +9,7 @@ export function normalizeRememberedInstanceSettingsPath(rawPath: string | null):
|
|||
const hash = match?.[3] ?? "";
|
||||
|
||||
if (
|
||||
pathname === "/instance/settings/general" ||
|
||||
pathname === "/instance/settings/heartbeats" ||
|
||||
pathname === "/instance/settings/plugins" ||
|
||||
pathname === "/instance/settings/experimental"
|
||||
|
|
|
|||
40
ui/src/lib/legacy-agent-config.test.ts
Normal file
40
ui/src/lib/legacy-agent-config.test.ts
Normal 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
ui/src/lib/legacy-agent-config.ts
Normal file
17
ui/src/lib/legacy-agent-config.ts
Normal 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);
|
||||
}
|
||||
41
ui/src/lib/portable-files.ts
Normal file
41
ui/src/lib/portable-files.ts
Normal 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/");
|
||||
}
|
||||
|
|
@ -4,11 +4,23 @@ 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) =>
|
||||
|
|
@ -68,6 +80,7 @@ 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,
|
||||
},
|
||||
|
|
|
|||
289
ui/src/lib/zip.test.ts
Normal file
289
ui/src/lib/zip.test.ts
Normal 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
ui/src/lib/zip.ts
Normal file
283
ui/src/lib/zip.ts
Normal 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]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
919
ui/src/pages/CompanyExport.tsx
Normal file
919
ui/src/pages/CompanyExport.tsx
Normal file
|
|
@ -0,0 +1,919 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type {
|
||||
CompanyPortabilityFileEntry,
|
||||
CompanyPortabilityExportPreviewResult,
|
||||
CompanyPortabilityExportResult,
|
||||
CompanyPortabilityManifest,
|
||||
} from "@paperclipai/shared";
|
||||
import { useNavigate, useLocation } from "@/lib/router";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
import { cn } from "../lib/utils";
|
||||
import { createZipArchive } from "../lib/zip";
|
||||
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
|
||||
import {
|
||||
Download,
|
||||
Package,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type FileTreeNode,
|
||||
type FrontmatterData,
|
||||
buildFileTree,
|
||||
countFiles,
|
||||
collectAllPaths,
|
||||
parseFrontmatter,
|
||||
FRONTMATTER_FIELD_LABELS,
|
||||
PackageFileTree,
|
||||
} from "../components/PackageFileTree";
|
||||
|
||||
/** Returns true if the path looks like a task file (e.g. tasks/slug/TASK.md or projects/x/tasks/slug/TASK.md) */
|
||||
function isTaskPath(filePath: string): boolean {
|
||||
return /(?:^|\/)tasks\//.test(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the set of agent/project/task slugs that are "checked" based on
|
||||
* which file paths are in the checked set.
|
||||
* agents/{slug}/AGENT.md → agents slug
|
||||
* projects/{slug}/PROJECT.md → projects slug
|
||||
* tasks/{slug}/TASK.md → tasks slug
|
||||
*/
|
||||
function checkedSlugs(checkedFiles: Set<string>): {
|
||||
agents: Set<string>;
|
||||
projects: Set<string>;
|
||||
tasks: Set<string>;
|
||||
} {
|
||||
const agents = new Set<string>();
|
||||
const projects = new Set<string>();
|
||||
const tasks = new Set<string>();
|
||||
for (const p of checkedFiles) {
|
||||
const agentMatch = p.match(/^agents\/([^/]+)\//);
|
||||
if (agentMatch) agents.add(agentMatch[1]);
|
||||
const projectMatch = p.match(/^projects\/([^/]+)\//);
|
||||
if (projectMatch) projects.add(projectMatch[1]);
|
||||
const taskMatch = p.match(/^tasks\/([^/]+)\//);
|
||||
if (taskMatch) tasks.add(taskMatch[1]);
|
||||
}
|
||||
return { agents, projects, tasks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter .paperclip.yaml content so it only includes entries whose
|
||||
* corresponding files are checked. Works by line-level YAML parsing
|
||||
* since the file has a known, simple structure produced by our own
|
||||
* renderYamlBlock.
|
||||
*/
|
||||
function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
|
||||
const slugs = checkedSlugs(checkedFiles);
|
||||
const lines = yaml.split("\n");
|
||||
const out: string[] = [];
|
||||
|
||||
// Sections whose entries are slug-keyed and should be filtered
|
||||
const filterableSections = new Set(["agents", "projects", "tasks"]);
|
||||
|
||||
let currentSection: string | null = null; // top-level key (e.g. "agents")
|
||||
let currentEntry: string | null = null; // slug under that section
|
||||
let includeEntry = true;
|
||||
// Collect entries per section so we can omit empty section headers
|
||||
let sectionHeaderLine: string | null = null;
|
||||
let sectionBuffer: string[] = [];
|
||||
|
||||
function flushSection() {
|
||||
if (sectionHeaderLine !== null && sectionBuffer.length > 0) {
|
||||
out.push(sectionHeaderLine);
|
||||
out.push(...sectionBuffer);
|
||||
}
|
||||
sectionHeaderLine = null;
|
||||
sectionBuffer = [];
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
// Detect top-level key (no indentation)
|
||||
const topMatch = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
|
||||
if (topMatch && !line.startsWith(" ")) {
|
||||
// Flush previous section
|
||||
flushSection();
|
||||
currentEntry = null;
|
||||
includeEntry = true;
|
||||
|
||||
const key = topMatch[0].split(":")[0];
|
||||
if (filterableSections.has(key)) {
|
||||
currentSection = key;
|
||||
sectionHeaderLine = line;
|
||||
continue;
|
||||
} else {
|
||||
currentSection = null;
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Inside a filterable section
|
||||
if (currentSection && filterableSections.has(currentSection)) {
|
||||
// 2-space indented key = entry slug (slugs may start with digits/hyphens)
|
||||
const entryMatch = line.match(/^ ([\w][\w-]*):\s*(.*)$/);
|
||||
if (entryMatch && !line.startsWith(" ")) {
|
||||
const slug = entryMatch[1];
|
||||
currentEntry = slug;
|
||||
const sectionSlugs = slugs[currentSection as keyof typeof slugs];
|
||||
includeEntry = sectionSlugs.has(slug);
|
||||
if (includeEntry) sectionBuffer.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deeper indented line belongs to current entry
|
||||
if (currentEntry !== null) {
|
||||
if (includeEntry) sectionBuffer.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Shouldn't happen in well-formed output, but pass through
|
||||
sectionBuffer.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Outside filterable sections — pass through
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
// Flush last section
|
||||
flushSection();
|
||||
|
||||
let filtered = out.join("\n");
|
||||
const logoPathMatch = filtered.match(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*$/m);
|
||||
if (logoPathMatch && !checkedFiles.has(logoPathMatch[1]!)) {
|
||||
filtered = filtered.replace(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*\n?/m, "");
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/** Filter tree nodes whose path (or descendant paths) match a search string */
|
||||
function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] {
|
||||
if (!query) return nodes;
|
||||
const lower = query.toLowerCase();
|
||||
return nodes
|
||||
.map((node) => {
|
||||
if (node.kind === "file") {
|
||||
return node.name.toLowerCase().includes(lower) || node.path.toLowerCase().includes(lower)
|
||||
? node
|
||||
: null;
|
||||
}
|
||||
const filteredChildren = filterTree(node.children, query);
|
||||
return filteredChildren.length > 0
|
||||
? { ...node, children: filteredChildren }
|
||||
: null;
|
||||
})
|
||||
.filter((n): n is FileTreeNode => n !== null);
|
||||
}
|
||||
|
||||
/** Collect all ancestor dir paths for files that match a filter */
|
||||
function collectMatchedParentDirs(nodes: FileTreeNode[], query: string): Set<string> {
|
||||
const dirs = new Set<string>();
|
||||
const lower = query.toLowerCase();
|
||||
|
||||
function walk(node: FileTreeNode, ancestors: string[]) {
|
||||
if (node.kind === "file") {
|
||||
if (node.name.toLowerCase().includes(lower) || node.path.toLowerCase().includes(lower)) {
|
||||
for (const a of ancestors) dirs.add(a);
|
||||
}
|
||||
} else {
|
||||
for (const child of node.children) {
|
||||
walk(child, [...ancestors, node.path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) walk(node, []);
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/** Sort tree: checked files first, then unchecked */
|
||||
function sortByChecked(nodes: FileTreeNode[], checkedFiles: Set<string>): FileTreeNode[] {
|
||||
return nodes.map((node) => {
|
||||
if (node.kind === "dir") {
|
||||
return { ...node, children: sortByChecked(node.children, checkedFiles) };
|
||||
}
|
||||
return node;
|
||||
}).sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
|
||||
if (a.kind === "file" && b.kind === "file") {
|
||||
const aChecked = checkedFiles.has(a.path);
|
||||
const bChecked = checkedFiles.has(b.path);
|
||||
if (aChecked !== bChecked) return aChecked ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
const TASKS_PAGE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* Paginate children of `tasks/` directories: show up to `limit` entries,
|
||||
* but always include children that are checked or match the search query.
|
||||
* Returns the paginated tree and the total count of task children.
|
||||
*/
|
||||
function paginateTaskNodes(
|
||||
nodes: FileTreeNode[],
|
||||
limit: number,
|
||||
checkedFiles: Set<string>,
|
||||
searchQuery: string,
|
||||
): { nodes: FileTreeNode[]; totalTaskChildren: number; visibleTaskChildren: number } {
|
||||
let totalTaskChildren = 0;
|
||||
let visibleTaskChildren = 0;
|
||||
|
||||
const result = nodes.map((node) => {
|
||||
// Only paginate direct children of "tasks" directories
|
||||
if (node.kind === "dir" && node.name === "tasks") {
|
||||
totalTaskChildren = node.children.length;
|
||||
|
||||
// Partition children: pinned (checked or search-matched) vs rest
|
||||
const pinned: FileTreeNode[] = [];
|
||||
const rest: FileTreeNode[] = [];
|
||||
const lower = searchQuery.toLowerCase();
|
||||
|
||||
for (const child of node.children) {
|
||||
const childFiles = collectAllPaths([child], "file");
|
||||
const isChecked = [...childFiles].some((p) => checkedFiles.has(p));
|
||||
const isSearchMatch = searchQuery && (
|
||||
child.name.toLowerCase().includes(lower) ||
|
||||
child.path.toLowerCase().includes(lower) ||
|
||||
[...childFiles].some((p) => p.toLowerCase().includes(lower))
|
||||
);
|
||||
if (isChecked || isSearchMatch) {
|
||||
pinned.push(child);
|
||||
} else {
|
||||
rest.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
// Show pinned + up to `limit` from rest
|
||||
const remaining = Math.max(0, limit - pinned.length);
|
||||
const visible = [...pinned, ...rest.slice(0, remaining)];
|
||||
visibleTaskChildren = visible.length;
|
||||
|
||||
return { ...node, children: visible };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
return { nodes: result, totalTaskChildren, visibleTaskChildren };
|
||||
}
|
||||
|
||||
function downloadZip(
|
||||
exported: CompanyPortabilityExportResult,
|
||||
selectedFiles: Set<string>,
|
||||
effectiveFiles: Record<string, CompanyPortabilityFileEntry>,
|
||||
) {
|
||||
const filteredFiles: Record<string, CompanyPortabilityFileEntry> = {};
|
||||
for (const [path] of Object.entries(exported.files)) {
|
||||
if (selectedFiles.has(path)) filteredFiles[path] = effectiveFiles[path] ?? exported.files[path];
|
||||
}
|
||||
const zipBytes = createZipArchive(filteredFiles, exported.rootPath);
|
||||
const zipBuffer = new ArrayBuffer(zipBytes.byteLength);
|
||||
new Uint8Array(zipBuffer).set(zipBytes);
|
||||
const blob = new Blob([zipBuffer], { type: "application/zip" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${exported.rootPath}.zip`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
// ── Frontmatter card (export-specific: skill click support) ──────────
|
||||
|
||||
function FrontmatterCard({
|
||||
data,
|
||||
onSkillClick,
|
||||
}: {
|
||||
data: FrontmatterData;
|
||||
onSkillClick?: (skill: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-accent/20 px-4 py-3 mb-4">
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-sm">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<div key={key} className="contents">
|
||||
<dt className="text-muted-foreground whitespace-nowrap py-0.5">
|
||||
{FRONTMATTER_FIELD_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="py-0.5">
|
||||
{Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{value.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md border border-border bg-background px-2 py-0.5 text-xs",
|
||||
key === "skills" && onSkillClick && "cursor-pointer hover:bg-accent/50 hover:border-foreground/30 transition-colors",
|
||||
)}
|
||||
onClick={() => key === "skills" && onSkillClick?.(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span>{value}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Client-side README generation ────────────────────────────────────
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO", coo: "COO",
|
||||
vp: "VP", manager: "Manager", engineer: "Engineer", agent: "Agent",
|
||||
};
|
||||
|
||||
/**
|
||||
* Regenerate README.md content based on the currently checked files.
|
||||
* Only counts/lists entities whose files are in the checked set.
|
||||
*/
|
||||
function generateReadmeFromSelection(
|
||||
manifest: CompanyPortabilityManifest,
|
||||
checkedFiles: Set<string>,
|
||||
companyName: string,
|
||||
companyDescription: string | null,
|
||||
): string {
|
||||
const slugs = checkedSlugs(checkedFiles);
|
||||
|
||||
const agents = manifest.agents.filter((a) => slugs.agents.has(a.slug));
|
||||
const projects = manifest.projects.filter((p) => slugs.projects.has(p.slug));
|
||||
const tasks = manifest.issues.filter((t) => slugs.tasks.has(t.slug));
|
||||
const skills = manifest.skills.filter((s) => {
|
||||
// Skill files live under skills/{key}/...
|
||||
return [...checkedFiles].some((f) => f.startsWith(`skills/${s.key}/`) || f.startsWith(`skills/`) && f.includes(`/${s.slug}/`));
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`# ${companyName}`);
|
||||
lines.push("");
|
||||
if (companyDescription) {
|
||||
lines.push(`> ${companyDescription}`);
|
||||
lines.push("");
|
||||
}
|
||||
// Org chart image (generated during export as images/org-chart.png)
|
||||
if (agents.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## What's Inside");
|
||||
lines.push("");
|
||||
lines.push("This is an [Agent Company](https://paperclip.ing) package.");
|
||||
lines.push("");
|
||||
|
||||
const counts: Array<[string, number]> = [];
|
||||
if (agents.length > 0) counts.push(["Agents", agents.length]);
|
||||
if (projects.length > 0) counts.push(["Projects", projects.length]);
|
||||
if (skills.length > 0) counts.push(["Skills", skills.length]);
|
||||
if (tasks.length > 0) counts.push(["Tasks", tasks.length]);
|
||||
|
||||
if (counts.length > 0) {
|
||||
lines.push("| Content | Count |");
|
||||
lines.push("|---------|-------|");
|
||||
for (const [label, count] of counts) {
|
||||
lines.push(`| ${label} | ${count} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (agents.length > 0) {
|
||||
lines.push("### Agents");
|
||||
lines.push("");
|
||||
lines.push("| Agent | Role | Reports To |");
|
||||
lines.push("|-------|------|------------|");
|
||||
for (const agent of agents) {
|
||||
const roleLabel = ROLE_LABELS[agent.role] ?? agent.role;
|
||||
const reportsTo = agent.reportsToSlug ?? "\u2014";
|
||||
lines.push(`| ${agent.name} | ${roleLabel} | ${reportsTo} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (projects.length > 0) {
|
||||
lines.push("### Projects");
|
||||
lines.push("");
|
||||
for (const project of projects) {
|
||||
const desc = project.description ? ` \u2014 ${project.description}` : "";
|
||||
lines.push(`- **${project.name}**${desc}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## Getting Started");
|
||||
lines.push("");
|
||||
lines.push("```bash");
|
||||
lines.push("pnpm paperclipai company import this-github-url-or-folder");
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
lines.push("See [Paperclip](https://paperclip.ing) for more information.");
|
||||
lines.push("");
|
||||
lines.push("---");
|
||||
lines.push(`Exported from [Paperclip](https://paperclip.ing) on ${new Date().toISOString().split("T")[0]}`);
|
||||
lines.push("");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ── Preview pane ──────────────────────────────────────────────────────
|
||||
|
||||
function ExportPreviewPane({
|
||||
selectedFile,
|
||||
content,
|
||||
allFiles,
|
||||
onSkillClick,
|
||||
}: {
|
||||
selectedFile: string | null;
|
||||
content: CompanyPortabilityFileEntry | null;
|
||||
allFiles: Record<string, CompanyPortabilityFileEntry>;
|
||||
onSkillClick?: (skill: string) => void;
|
||||
}) {
|
||||
if (!selectedFile || content === null) {
|
||||
return (
|
||||
<EmptyState icon={Package} message="Select a file to preview its contents." />
|
||||
);
|
||||
}
|
||||
|
||||
const textContent = getPortableFileText(content);
|
||||
const isMarkdown = selectedFile.endsWith(".md") && textContent !== null;
|
||||
const parsed = isMarkdown && textContent ? parseFrontmatter(textContent) : null;
|
||||
const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null;
|
||||
|
||||
// Resolve relative image paths within the export package (e.g. images/org-chart.png)
|
||||
const resolveImageSrc = isMarkdown
|
||||
? (src: string) => {
|
||||
// Skip absolute URLs and data URIs
|
||||
if (/^(?:https?:|data:)/i.test(src)) return null;
|
||||
// Resolve relative to the directory of the current markdown file
|
||||
const dir = selectedFile.includes("/") ? selectedFile.slice(0, selectedFile.lastIndexOf("/") + 1) : "";
|
||||
const resolved = dir + src;
|
||||
const entry = allFiles[resolved] ?? allFiles[src];
|
||||
if (!entry) return null;
|
||||
return getPortableFileDataUrl(resolved in allFiles ? resolved : src, entry);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="border-b border-border px-5 py-3">
|
||||
<div className="truncate font-mono text-sm">{selectedFile}</div>
|
||||
</div>
|
||||
<div className="min-h-[560px] px-5 py-5">
|
||||
{parsed ? (
|
||||
<>
|
||||
<FrontmatterCard data={parsed.data} onSkillClick={onSkillClick} />
|
||||
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc}>{parsed.body}</MarkdownBody>}
|
||||
</>
|
||||
) : isMarkdown ? (
|
||||
<MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
|
||||
) : imageSrc ? (
|
||||
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
|
||||
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
|
||||
</div>
|
||||
) : textContent !== null ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-sm text-foreground">
|
||||
<code>{textContent}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-accent/10 px-4 py-3 text-sm text-muted-foreground">
|
||||
Binary asset preview is not available for this file type.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract the file path from the current URL pathname (after /company/export/files/) */
|
||||
function filePathFromLocation(pathname: string): string | null {
|
||||
const marker = "/company/export/files/";
|
||||
const idx = pathname.indexOf(marker);
|
||||
if (idx === -1) return null;
|
||||
const filePath = decodeURIComponent(pathname.slice(idx + marker.length));
|
||||
return filePath || null;
|
||||
}
|
||||
|
||||
/** Expand all ancestor directories for a given file path */
|
||||
function expandAncestors(filePath: string): string[] {
|
||||
const parts = filePath.split("/").slice(0, -1);
|
||||
const dirs: string[] = [];
|
||||
let current = "";
|
||||
for (const part of parts) {
|
||||
current = current ? `${current}/${part}` : part;
|
||||
dirs.push(current);
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
export function CompanyExport() {
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [exportData, setExportData] = useState<CompanyPortabilityExportPreviewResult | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
|
||||
const [treeSearch, setTreeSearch] = useState("");
|
||||
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
|
||||
const savedExpandedRef = useRef<Set<string> | null>(null);
|
||||
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
|
||||
|
||||
// Navigate-aware file selection: updates state + URL without page reload.
|
||||
// `replace` = true skips history entry (used for initial load); false = pushes (used for clicks).
|
||||
const selectFile = useCallback(
|
||||
(filePath: string | null, replace = false) => {
|
||||
setSelectedFile(filePath);
|
||||
if (filePath) {
|
||||
navigate(`/company/export/files/${encodeURI(filePath)}`, { replace });
|
||||
} else {
|
||||
navigate("/company/export", { replace });
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// Sync selectedFile from URL on browser back/forward
|
||||
useEffect(() => {
|
||||
if (!exportData) return;
|
||||
const urlFile = filePathFromLocation(location.pathname);
|
||||
if (urlFile && urlFile in exportData.files && urlFile !== selectedFile) {
|
||||
setSelectedFile(urlFile);
|
||||
// Expand ancestors so the file is visible in the tree
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const dir of expandAncestors(urlFile)) next.add(dir);
|
||||
return next;
|
||||
});
|
||||
} else if (!urlFile && selectedFile) {
|
||||
setSelectedFile(null);
|
||||
}
|
||||
}, [location.pathname, exportData]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Org Chart", href: "/org" },
|
||||
{ label: "Export" },
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const exportPreviewMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
companiesApi.exportPreview(selectedCompanyId!, {
|
||||
include: { company: true, agents: true, projects: true, issues: true },
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
setExportData(result);
|
||||
setCheckedFiles((prev) => {
|
||||
const next = new Set<string>();
|
||||
for (const filePath of Object.keys(result.files)) {
|
||||
if (prev.has(filePath)) next.add(filePath);
|
||||
else if (!isTaskPath(filePath)) next.add(filePath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Expand top-level dirs (except tasks — collapsed by default)
|
||||
const tree = buildFileTree(result.files);
|
||||
const topDirs = new Set<string>();
|
||||
for (const node of tree) {
|
||||
if (node.kind === "dir" && node.name !== "tasks") topDirs.add(node.path);
|
||||
}
|
||||
|
||||
// If URL contains a deep-linked file path, select it and expand ancestors
|
||||
const urlFile = initialFileFromUrl.current;
|
||||
if (urlFile && urlFile in result.files) {
|
||||
setSelectedFile(urlFile);
|
||||
const ancestors = expandAncestors(urlFile);
|
||||
setExpandedDirs(new Set([...topDirs, ...ancestors]));
|
||||
} else {
|
||||
// Default to README.md if present, otherwise fall back to first file
|
||||
const defaultFile = "README.md" in result.files
|
||||
? "README.md"
|
||||
: Object.keys(result.files)[0];
|
||||
if (defaultFile) {
|
||||
selectFile(defaultFile, true);
|
||||
}
|
||||
setExpandedDirs(topDirs);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Export failed",
|
||||
body: err instanceof Error ? err.message : "Failed to load export data.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
companiesApi.exportPackage(selectedCompanyId!, {
|
||||
include: { company: true, agents: true, projects: true, issues: true },
|
||||
selectedFiles: Array.from(checkedFiles).sort(),
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
const resultCheckedFiles = new Set(Object.keys(result.files));
|
||||
downloadZip(result, resultCheckedFiles, result.files);
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "Export downloaded",
|
||||
body: `${resultCheckedFiles.size} file${resultCheckedFiles.size === 1 ? "" : "s"} exported as ${result.rootPath}.zip`,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Export failed",
|
||||
body: err instanceof Error ? err.message : "Failed to build export package.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCompanyId || exportPreviewMutation.isPending) return;
|
||||
setExportData(null);
|
||||
exportPreviewMutation.mutate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
const tree = useMemo(
|
||||
() => (exportData ? buildFileTree(exportData.files) : []),
|
||||
[exportData],
|
||||
);
|
||||
|
||||
const { displayTree, totalTaskChildren, visibleTaskChildren } = useMemo(() => {
|
||||
let result = tree;
|
||||
if (treeSearch) result = filterTree(result, treeSearch);
|
||||
result = sortByChecked(result, checkedFiles);
|
||||
const paginated = paginateTaskNodes(result, taskLimit, checkedFiles, treeSearch);
|
||||
return {
|
||||
displayTree: paginated.nodes,
|
||||
totalTaskChildren: paginated.totalTaskChildren,
|
||||
visibleTaskChildren: paginated.visibleTaskChildren,
|
||||
};
|
||||
}, [tree, treeSearch, checkedFiles, taskLimit]);
|
||||
|
||||
// Recompute .paperclip.yaml and README.md content whenever checked files
|
||||
// change so the preview & download always reflect the current selection.
|
||||
const effectiveFiles = useMemo(() => {
|
||||
if (!exportData) return {} as Record<string, CompanyPortabilityFileEntry>;
|
||||
const filtered = { ...exportData.files };
|
||||
|
||||
// Filter .paperclip.yaml
|
||||
const yamlPath = exportData.paperclipExtensionPath;
|
||||
if (yamlPath && typeof exportData.files[yamlPath] === "string") {
|
||||
filtered[yamlPath] = filterPaperclipYaml(exportData.files[yamlPath], checkedFiles);
|
||||
}
|
||||
|
||||
// Regenerate README.md based on checked selection
|
||||
if (typeof exportData.files["README.md"] === "string") {
|
||||
const companyName = exportData.manifest.company?.name ?? selectedCompany?.name ?? "Company";
|
||||
const companyDescription = exportData.manifest.company?.description ?? null;
|
||||
filtered["README.md"] = generateReadmeFromSelection(
|
||||
exportData.manifest,
|
||||
checkedFiles,
|
||||
companyName,
|
||||
companyDescription,
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [exportData, checkedFiles, selectedCompany?.name]);
|
||||
|
||||
const totalFiles = useMemo(() => countFiles(tree), [tree]);
|
||||
const selectedCount = checkedFiles.size;
|
||||
|
||||
// Filter out terminated agent messages — they don't need to be shown
|
||||
const warnings = useMemo(() => {
|
||||
if (!exportData) return [] as string[];
|
||||
return exportData.warnings.filter((w) => !/terminated agent/i.test(w));
|
||||
}, [exportData]);
|
||||
|
||||
function handleToggleDir(path: string) {
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleToggleCheck(path: string, kind: "file" | "dir") {
|
||||
if (!exportData) return;
|
||||
setCheckedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (kind === "file") {
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
} else {
|
||||
// Find all child file paths under this dir
|
||||
const dirTree = buildFileTree(exportData.files);
|
||||
const findNode = (nodes: FileTreeNode[], target: string): FileTreeNode | null => {
|
||||
for (const n of nodes) {
|
||||
if (n.path === target) return n;
|
||||
const found = findNode(n.children, target);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const dirNode = findNode(dirTree, path);
|
||||
if (dirNode) {
|
||||
const childFiles = collectAllPaths(dirNode.children, "file");
|
||||
// Add the dir's own file children
|
||||
for (const child of dirNode.children) {
|
||||
if (child.kind === "file") childFiles.add(child.path);
|
||||
}
|
||||
const allChecked = [...childFiles].every((p) => next.has(p));
|
||||
for (const f of childFiles) {
|
||||
if (allChecked) next.delete(f);
|
||||
else next.add(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSearchChange(query: string) {
|
||||
const wasSearching = treeSearch.length > 0;
|
||||
const isSearching = query.length > 0;
|
||||
|
||||
if (isSearching && !wasSearching) {
|
||||
// Save current expansion state before search
|
||||
savedExpandedRef.current = new Set(expandedDirs);
|
||||
}
|
||||
|
||||
setTreeSearch(query);
|
||||
|
||||
if (isSearching) {
|
||||
// Expand all parent dirs of matched files
|
||||
const matchedParents = collectMatchedParentDirs(tree, query);
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const d of matchedParents) next.add(d);
|
||||
return next;
|
||||
});
|
||||
} else if (wasSearching) {
|
||||
// Restore pre-search expansion state
|
||||
if (savedExpandedRef.current) {
|
||||
setExpandedDirs(savedExpandedRef.current);
|
||||
savedExpandedRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSkillClick(skillKey: string) {
|
||||
if (!exportData) return;
|
||||
const manifestSkill = exportData.manifest.skills.find(
|
||||
(skill) => skill.key === skillKey || skill.slug === skillKey,
|
||||
);
|
||||
const skillPath = manifestSkill?.path ?? `skills/${skillKey}/SKILL.md`;
|
||||
if (!(skillPath in exportData.files)) return;
|
||||
selectFile(skillPath);
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add("skills");
|
||||
const parts = skillPath.split("/").slice(0, -1);
|
||||
let current = "";
|
||||
for (const part of parts) {
|
||||
current = current ? `${current}/${part}` : part;
|
||||
next.add(current);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!exportData || checkedFiles.size === 0 || downloadMutation.isPending) return;
|
||||
downloadMutation.mutate();
|
||||
}
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Package} message="Select a company to export." />;
|
||||
}
|
||||
|
||||
if (exportPreviewMutation.isPending && !exportData) {
|
||||
return <PageSkeleton variant="detail" />;
|
||||
}
|
||||
|
||||
if (!exportData) {
|
||||
return <EmptyState icon={Package} message="Loading export data..." />;
|
||||
}
|
||||
|
||||
const previewContent = selectedFile
|
||||
? (() => {
|
||||
return effectiveFiles[selectedFile] ?? null;
|
||||
})()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Sticky top action bar */}
|
||||
<div className="sticky top-0 z-10 border-b border-border bg-background px-5 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="font-medium">
|
||||
{selectedCompany?.name ?? "Company"} export
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected
|
||||
</span>
|
||||
{warnings.length > 0 && (
|
||||
<span className="text-amber-500">
|
||||
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={selectedCount === 0 || downloadMutation.isPending}
|
||||
>
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
{downloadMutation.isPending
|
||||
? "Building export..."
|
||||
: `Export ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="mx-5 mt-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
{warnings.map((w) => (
|
||||
<div key={w} className="text-xs text-amber-500">{w}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid h-[calc(100vh-12rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
||||
<aside className="flex flex-col border-r border-border overflow-hidden">
|
||||
<div className="border-b border-border px-4 py-3 shrink-0">
|
||||
<h2 className="text-base font-semibold">Package files</h2>
|
||||
</div>
|
||||
<div className="border-b border-border px-3 py-2 shrink-0">
|
||||
<div className="flex items-center gap-2 rounded-md border border-border px-2 py-1">
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={treeSearch}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Search files..."
|
||||
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<PackageFileTree
|
||||
nodes={displayTree}
|
||||
selectedFile={selectedFile}
|
||||
expandedDirs={expandedDirs}
|
||||
checkedFiles={checkedFiles}
|
||||
onToggleDir={handleToggleDir}
|
||||
onSelectFile={selectFile}
|
||||
onToggleCheck={handleToggleCheck}
|
||||
/>
|
||||
{totalTaskChildren > visibleTaskChildren && !treeSearch && (
|
||||
<div className="px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTaskLimit((prev) => prev + TASKS_PAGE_SIZE)}
|
||||
className="w-full rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent/30 hover:text-foreground transition-colors"
|
||||
>
|
||||
Show more issues ({visibleTaskChildren} of {totalTaskChildren})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 overflow-y-auto pl-6">
|
||||
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} allFiles={effectiveFiles} onSkillClick={handleSkillClick} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1295
ui/src/pages/CompanyImport.tsx
Normal file
1295
ui/src/pages/CompanyImport.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,12 +2,13 @@ import { ChangeEvent, useEffect, useState } from "react";
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { accessApi } from "../api/access";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings, Check } from "lucide-react";
|
||||
import { Settings, Check, Download, Upload } from "lucide-react";
|
||||
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
|
||||
import {
|
||||
Field,
|
||||
|
|
@ -29,8 +30,8 @@ export function CompanySettings() {
|
|||
setSelectedCompanyId
|
||||
} = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// General settings local state
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
|
@ -174,6 +175,7 @@ export function CompanySettings() {
|
|||
setSnippetCopied(false);
|
||||
setSnippetCopyDelightId(0);
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: ({
|
||||
companyId,
|
||||
|
|
@ -461,6 +463,33 @@ export function CompanySettings() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import / Export */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Company Packages
|
||||
</div>
|
||||
<div className="rounded-md border border-border px-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Import and export have moved to dedicated pages accessible from the{" "}
|
||||
<a href="/org" className="underline hover:text-foreground">Org Chart</a> header.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href="/company/export">
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export
|
||||
</a>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href="/company/import">
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Import
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
|
||||
|
|
|
|||
1170
ui/src/pages/CompanySkills.tsx
Normal file
1170
ui/src/pages/CompanySkills.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -24,11 +24,14 @@ export function InstanceExperimentalSettings() {
|
|||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) =>
|
||||
instanceSettingsApi.updateExperimental({ enableIsolatedWorkspaces: enabled }),
|
||||
mutationFn: async (patch: { enableIsolatedWorkspaces?: boolean; autoRestartDevServerWhenIdle?: boolean }) =>
|
||||
instanceSettingsApi.updateExperimental(patch),
|
||||
onSuccess: async () => {
|
||||
setActionError(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to update experimental settings.");
|
||||
|
|
@ -50,6 +53,7 @@ export function InstanceExperimentalSettings() {
|
|||
}
|
||||
|
||||
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
||||
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
|
|
@ -72,7 +76,7 @@ export function InstanceExperimentalSettings() {
|
|||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Enabled Isolated Workspaces</h2>
|
||||
<h2 className="text-sm font-semibold">Enable Isolated Workspaces</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Show execution workspace controls in project configuration and allow isolated workspace behavior for new
|
||||
and existing issue runs.
|
||||
|
|
@ -83,15 +87,46 @@ export function InstanceExperimentalSettings() {
|
|||
aria-label="Toggle isolated workspaces experimental setting"
|
||||
disabled={toggleMutation.isPending}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted",
|
||||
)}
|
||||
onClick={() => toggleMutation.mutate(!enableIsolatedWorkspaces)}
|
||||
onClick={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform",
|
||||
enableIsolatedWorkspaces ? "translate-x-6" : "translate-x-0.5",
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
enableIsolatedWorkspaces ? "translate-x-4.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Auto-Restart Dev Server When Idle</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
In `pnpm dev:once`, wait for all queued and running local agent runs to finish, then restart the server
|
||||
automatically when backend changes or migrations make the current boot stale.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle guarded dev-server auto-restart"
|
||||
disabled={toggleMutation.isPending}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
autoRestartDevServerWhenIdle ? "bg-green-600" : "bg-muted",
|
||||
)}
|
||||
onClick={() =>
|
||||
toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
autoRestartDevServerWhenIdle ? "translate-x-4.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
|
|
|||
103
ui/src/pages/InstanceGeneralSettings.tsx
Normal file
103
ui/src/pages/InstanceGeneralSettings.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export function InstanceGeneralSettings() {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Instance Settings" },
|
||||
{ label: "General" },
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const generalQuery = useQuery({
|
||||
queryKey: queryKeys.instance.generalSettings,
|
||||
queryFn: () => instanceSettingsApi.getGeneral(),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) =>
|
||||
instanceSettingsApi.updateGeneral({ censorUsernameInLogs: enabled }),
|
||||
onSuccess: async () => {
|
||||
setActionError(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
|
||||
},
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to update general settings.");
|
||||
},
|
||||
});
|
||||
|
||||
if (generalQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading general settings...</div>;
|
||||
}
|
||||
|
||||
if (generalQuery.error) {
|
||||
return (
|
||||
<div className="text-sm text-destructive">
|
||||
{generalQuery.error instanceof Error
|
||||
? generalQuery.error.message
|
||||
: "Failed to load general settings."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">General</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure instance-wide defaults that affect how operator-visible logs are displayed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Censor username in logs</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Hide the username segment in home-directory paths and similar operator-visible log output. Standalone
|
||||
username mentions outside of paths are not yet masked in the live transcript view. This is off by
|
||||
default.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle username log censoring"
|
||||
disabled={toggleMutation.isPending}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
censorUsernameInLogs ? "bg-green-600" : "bg-muted",
|
||||
)}
|
||||
onClick={() => toggleMutation.mutate(!censorUsernameInLogs)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
censorUsernameInLogs ? "translate-x-4.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,9 +4,11 @@ import { useNavigate, useSearchParams } from "@/lib/router";
|
|||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { companySkillsApi } from "../api/companySkills";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { AGENT_ROLES } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
|
@ -68,6 +70,7 @@ export function NewAgent() {
|
|||
const [role, setRole] = useState("general");
|
||||
const [reportsTo, setReportsTo] = useState<string | null>(null);
|
||||
const [configValues, setConfigValues] = useState<CreateConfigValues>(defaultCreateValues);
|
||||
const [selectedSkillKeys, setSelectedSkillKeys] = useState<string[]>([]);
|
||||
const [roleOpen, setRoleOpen] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -90,6 +93,12 @@ export function NewAgent() {
|
|||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
|
||||
const { data: companySkills } = useQuery({
|
||||
queryKey: queryKeys.companySkills.list(selectedCompanyId ?? ""),
|
||||
queryFn: () => companySkillsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
|
||||
const isFirstAgent = !agents || agents.length === 0;
|
||||
const effectiveRole = isFirstAgent ? "ceo" : role;
|
||||
|
||||
|
|
@ -172,7 +181,8 @@ export function NewAgent() {
|
|||
name: name.trim(),
|
||||
role: effectiveRole,
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
...(reportsTo != null ? { reportsTo } : {}),
|
||||
...(reportsTo ? { reportsTo } : {}),
|
||||
...(selectedSkillKeys.length > 0 ? { desiredSkills: selectedSkillKeys } : {}),
|
||||
adapterType: configValues.adapterType,
|
||||
adapterConfig: buildAdapterConfig(),
|
||||
runtimeConfig: {
|
||||
|
|
@ -188,6 +198,17 @@ export function NewAgent() {
|
|||
});
|
||||
}
|
||||
|
||||
const availableSkills = (companySkills ?? []).filter((skill) => !skill.key.startsWith("paperclipai/paperclip/"));
|
||||
|
||||
function toggleSkill(key: string, checked: boolean) {
|
||||
setSelectedSkillKeys((prev) => {
|
||||
if (checked) {
|
||||
return prev.includes(key) ? prev : [...prev, key];
|
||||
}
|
||||
return prev.filter((value) => value !== key);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
|
|
@ -266,6 +287,44 @@ export function NewAgent() {
|
|||
adapterModels={adapterModels}
|
||||
/>
|
||||
|
||||
<div className="border-t border-border px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">Company skills</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Optional skills from the company library. Built-in Paperclip runtime skills are added automatically.
|
||||
</p>
|
||||
</div>
|
||||
{availableSkills.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No optional company skills installed yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{availableSkills.map((skill) => {
|
||||
const inputId = `skill-${skill.id}`;
|
||||
const checked = selectedSkillKeys.includes(skill.key);
|
||||
return (
|
||||
<div key={skill.id} className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id={inputId}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => toggleSkill(skill.key, next === true)}
|
||||
/>
|
||||
<label htmlFor={inputId} className="grid gap-1 leading-none">
|
||||
<span className="text-sm font-medium">{skill.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{skill.description ?? skill.key}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
{isFirstAgent && (
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { Link, useNavigate } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentsApi, type OrgNode } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { agentUrl } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { Network } from "lucide-react";
|
||||
import { Download, Network, Upload } from "lucide-react";
|
||||
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
||||
|
||||
// Layout constants
|
||||
|
|
@ -267,9 +268,24 @@ export function OrgChart() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="mb-2 flex items-center justify-start gap-2 shrink-0">
|
||||
<Link to="/company/import">
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Import company
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/company/export">
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export company
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-[calc(100dvh-6rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
|
||||
className="w-full flex-1 min-h-0 overflow-hidden relative bg-muted/20 border border-border rounded-lg"
|
||||
style={{ cursor: dragging ? "grabbing" : "grab" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
|
|
@ -419,10 +435,11 @@ export function OrgChart() {
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
||||
const roleLabels: Record<string, string> = AGENT_ROLE_LABELS;
|
||||
|
||||
function roleLabel(role: string): string {
|
||||
return roleLabels[role] ?? role;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue