mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue