Merge branch 'master' into add-gpt-5-4-xhigh-effort

This commit is contained in:
Dotta 2026-03-31 06:19:26 -05:00 committed by GitHub
commit 19aaa54ae4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
882 changed files with 316479 additions and 9403 deletions

View file

@ -1,5 +1,4 @@
import { useEffect, useRef } from "react";
import { Navigate, Outlet, Route, Routes, useLocation } from "@/lib/router";
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Layout } from "./components/Layout";
@ -12,8 +11,12 @@ import { Agents } from "./pages/Agents";
import { AgentDetail } from "./pages/AgentDetail";
import { Projects } from "./pages/Projects";
import { ProjectDetail } from "./pages/ProjectDetail";
import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail";
import { Issues } from "./pages/Issues";
import { IssueDetail } from "./pages/IssueDetail";
import { Routines } from "./pages/Routines";
import { RoutineDetail } from "./pages/RoutineDetail";
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
import { Goals } from "./pages/Goals";
import { GoalDetail } from "./pages/GoalDetail";
import { Approvals } from "./pages/Approvals";
@ -22,24 +25,39 @@ import { Costs } from "./pages/Costs";
import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { CompanySkills } from "./pages/CompanySkills";
import { CompanyExport } from "./pages/CompanyExport";
import { CompanyImport } from "./pages/CompanyImport";
import { DesignGuide } from "./pages/DesignGuide";
import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings";
import { InstanceSettings } from "./pages/InstanceSettings";
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
import { PluginManager } from "./pages/PluginManager";
import { PluginSettings } from "./pages/PluginSettings";
import { PluginPage } from "./pages/PluginPage";
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
import { OrgChart } from "./pages/OrgChart";
import { NewAgent } from "./pages/NewAgent";
import { AuthPage } from "./pages/Auth";
import { BoardClaimPage } from "./pages/BoardClaim";
import { CliAuthPage } from "./pages/CliAuth";
import { InviteLandingPage } from "./pages/InviteLanding";
import { NotFoundPage } from "./pages/NotFound";
import { queryKeys } from "./lib/queryKeys";
import { useCompany } from "./context/CompanyContext";
import { useDialog } from "./context/DialogContext";
import { loadLastInboxTab } from "./lib/inbox";
import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route";
function BootstrapPendingPage() {
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">Instance setup required</h1>
<p className="mt-2 text-sm text-muted-foreground">
No instance admin exists yet. Run this command in your Paperclip environment to generate
the first admin invite URL:
{hasActiveInvite
? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:"
: "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"}
</p>
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
{`pnpm paperclipai auth bootstrap-ceo`}
@ -55,6 +73,15 @@ function CloudAccessGate() {
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
refetchInterval: (query) => {
const data = query.state.data as
| { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" }
| undefined;
return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending"
? 2000
: false;
},
refetchIntervalInBackground: true,
});
const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated";
@ -78,7 +105,7 @@ function CloudAccessGate() {
}
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
return <BootstrapPendingPage />;
return <BootstrapPendingPage hasActiveInvite={healthQuery.data.bootstrapInviteActive} />;
}
if (isAuthenticatedMode && !sessionQuery.data) {
@ -94,8 +121,15 @@ function boardRoutes() {
<>
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<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 />} />
<Route path="org" element={<OrgChart />} />
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
<Route path="agents/all" element={<Agents />} />
@ -111,6 +145,10 @@ function boardRoutes() {
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
<Route path="projects/:projectId/workspaces/:workspaceId" element={<ProjectWorkspaceDetail />} />
<Route path="projects/:projectId/workspaces" element={<ProjectDetail />} />
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
<Route path="issues" element={<Issues />} />
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
@ -118,6 +156,9 @@ function boardRoutes() {
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="routines" element={<Routines />} />
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
@ -126,29 +167,87 @@ function boardRoutes() {
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<Activity />} />
<Route path="inbox" element={<Navigate to="/inbox/new" replace />} />
<Route path="inbox/new" element={<Inbox />} />
<Route path="inbox" element={<InboxRootRedirect />} />
<Route path="inbox/mine" element={<Inbox />} />
<Route path="inbox/recent" element={<Inbox />} />
<Route path="inbox/unread" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} />
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
<Route path=":pluginRoutePath" element={<PluginPage />} />
<Route path="*" element={<NotFoundPage scope="board" />} />
</>
);
}
function InboxRootRedirect() {
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
}
function LegacySettingsRedirect() {
const location = useLocation();
return <Navigate to={`/instance/settings/general${location.search}${location.hash}`} replace />;
}
function OnboardingRoutePage() {
const { companies } = useCompany();
const { openOnboarding } = useDialog();
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const matchedCompany = companyPrefix
? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null
: null;
const title = matchedCompany
? `Add another agent to ${matchedCompany.name}`
: companies.length > 0
? "Create another company"
: "Create your first company";
const description = matchedCompany
? "Run onboarding again to add an agent and a starter task for this company."
: companies.length > 0
? "Run onboarding again to create another company and seed its first agent."
: "Get started by creating a company and your first agent.";
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">{title}</h1>
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
<div className="mt-4">
<Button
onClick={() =>
matchedCompany
? openOnboarding({ initialStep: 2, companyId: matchedCompany.id })
: openOnboarding()
}
>
{matchedCompany ? "Add Agent" : "Start Onboarding"}
</Button>
</div>
</div>
</div>
);
}
function CompanyRootRedirect() {
const { companies, selectedCompany, loading } = useCompany();
const { onboardingOpen } = useDialog();
const location = useLocation();
if (loading) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
}
// Keep the first-run onboarding mounted until it completes.
if (onboardingOpen) {
return <NoCompaniesStartPage autoOpen={false} />;
}
const targetCompany = selectedCompany ?? companies[0] ?? null;
if (!targetCompany) {
if (
shouldRedirectCompanylessRouteToOnboarding({
pathname: location.pathname,
hasCompanies: false,
})
) {
return <Navigate to="/onboarding" replace />;
}
return <NoCompaniesStartPage />;
}
@ -165,6 +264,14 @@ function UnprefixedBoardRedirect() {
const targetCompany = selectedCompany ?? companies[0] ?? null;
if (!targetCompany) {
if (
shouldRedirectCompanylessRouteToOnboarding({
pathname: location.pathname,
hasCompanies: false,
})
) {
return <Navigate to="/onboarding" replace />;
}
return <NoCompaniesStartPage />;
}
@ -176,16 +283,8 @@ function UnprefixedBoardRedirect() {
);
}
function NoCompaniesStartPage({ autoOpen = true }: { autoOpen?: boolean }) {
function NoCompaniesStartPage() {
const { openOnboarding } = useDialog();
const opened = useRef(false);
useEffect(() => {
if (!autoOpen) return;
if (opened.current) return;
opened.current = true;
openOnboarding();
}, [autoOpen, openOnboarding]);
return (
<div className="mx-auto max-w-xl py-10">
@ -208,13 +307,29 @@ export function App() {
<Routes>
<Route path="auth" element={<AuthPage />} />
<Route path="board-claim/:token" element={<BoardClaimPage />} />
<Route path="cli-auth/:id" element={<CliAuthPage />} />
<Route path="invite/:token" element={<InviteLandingPage />} />
<Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="instance" element={<Navigate to="/instance/settings/general" replace />} />
<Route path="instance/settings" element={<Layout />}>
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<InstanceGeneralSettings />} />
<Route path="heartbeats" element={<InstanceSettings />} />
<Route path="experimental" element={<InstanceExperimentalSettings />} />
<Route path="plugins" element={<PluginManager />} />
<Route path="plugins/:pluginId" element={<PluginSettings />} />
</Route>
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="routines" element={<UnprefixedBoardRedirect />} />
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="agents" element={<UnprefixedBoardRedirect />} />
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
@ -225,9 +340,15 @@ export function App() {
<Route path="projects/:projectId/overview" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/workspaces" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()}
</Route>
<Route path="*" element={<NotFoundPage scope="global" />} />
</Route>
</Routes>
<OnboardingWizard />

View file

@ -7,6 +7,7 @@ import {
help,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
@ -15,38 +16,57 @@ const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
export function ClaudeLocalConfigFields({
mode,
isCreate,
adapterType,
values,
set,
config,
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}
set={set}
config={config}
mark={mark}
eff={eff}
mode={mode}
adapterType={adapterType}
models={models}
/>
</>
);
}
@ -105,9 +125,9 @@ export function ClaudeLocalAdvancedFields({
value={eff(
"adapterConfig",
"maxTurnsPerRun",
Number(config.maxTurnsPerRun ?? 80),
Number(config.maxTurnsPerRun ?? 300),
)}
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 80)}
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 300)}
immediate
className={inputClass}
/>

View file

@ -6,49 +6,56 @@ import {
help,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime. Note: Codex may still auto-apply repo-scoped AGENTS.md files from the workspace.";
export function CodexLocalConfigFields({
mode,
isCreate,
adapterType,
values,
set,
config,
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}
@ -81,6 +88,17 @@ export function CodexLocalConfigFields({
: mark("adapterConfig", "search", v)
}
/>
<LocalWorkspaceRuntimeFields
isCreate={isCreate}
values={values}
set={set}
config={config}
mark={mark}
eff={eff}
mode={mode}
adapterType={adapterType}
models={models}
/>
</>
);
}

View file

@ -17,7 +17,9 @@ export function CursorLocalConfigFields({
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">

View file

@ -0,0 +1,51 @@
import type { AdapterConfigFieldsProps } from "../types";
import {
DraftInput,
Field,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Prepended to the Gemini prompt at runtime.";
export function GeminiLocalConfigFields({
isCreate,
values,
set,
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return (
<>
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
</>
);
}

View file

@ -0,0 +1,12 @@
import type { UIAdapterModule } from "../types";
import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui";
import { GeminiLocalConfigFields } from "./config-fields";
import { buildGeminiLocalConfig } from "@paperclipai/adapter-gemini-local/ui";
export const geminiLocalUIAdapter: UIAdapterModule = {
type: "gemini_local",
label: "Gemini CLI (local)",
parseStdoutLine: parseGeminiStdoutLine,
ConfigFields: GeminiLocalConfigFields,
buildAdapterConfig: buildGeminiLocalConfig,
};

View file

@ -0,0 +1,49 @@
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
DraftInput,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
export function HermesLocalConfigFields({
isCreate,
values,
set,
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
);
}

View file

@ -0,0 +1,12 @@
import type { UIAdapterModule } from "../types";
import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui";
import { HermesLocalConfigFields } from "./config-fields";
import { buildHermesConfig } from "hermes-paperclip-adapter/ui";
export const hermesLocalUIAdapter: UIAdapterModule = {
type: "hermes_local",
label: "Hermes Agent",
parseStdoutLine: parseHermesStdoutLine,
ConfigFields: HermesLocalConfigFields,
buildAdapterConfig: buildHermesConfig,
};

View file

@ -1,4 +1,4 @@
export { getUIAdapter } from "./registry";
export { getUIAdapter, listUIAdapters } from "./registry";
export { buildTranscript } from "./transcript";
export type {
TranscriptEntry,
@ -6,3 +6,4 @@ export type {
UIAdapterModule,
AdapterConfigFieldsProps,
} from "./types";
export type { RunLogChunk } from "./transcript";

View file

@ -0,0 +1,5 @@
import type { AdapterConfigFieldsProps } from "./types";
export function LocalWorkspaceRuntimeFields(_props: AdapterConfigFieldsProps) {
return null;
}

View file

@ -6,6 +6,10 @@ import {
DraftInput,
help,
} from "../../components/agent-config-primitives";
import {
PayloadTemplateJsonField,
RuntimeServicesJsonField,
} from "../runtime-json-fields";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
@ -112,6 +116,22 @@ export function OpenClawGatewayConfigFields({
/>
</Field>
<PayloadTemplateJsonField
isCreate={isCreate}
values={values}
set={set}
config={config}
mark={mark}
/>
<RuntimeServicesJsonField
isCreate={isCreate}
values={values}
set={set}
config={config}
mark={mark}
/>
{!isCreate && (
<>
<Field label="Paperclip API URL override">

View file

@ -1,7 +1,9 @@
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
ToggleField,
DraftInput,
help,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
@ -17,31 +19,54 @@ export function OpenCodeLocalConfigFields({
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
<>
{!hideInstructionsFile && (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
)}
<ToggleField
label="Skip permissions"
hint={help.dangerouslySkipPermissions}
checked={
isCreate
? values!.dangerouslySkipPermissions
: eff(
"adapterConfig",
"dangerouslySkipPermissions",
config.dangerouslySkipPermissions !== false,
)
}
onChange={(v) =>
isCreate
? set!({ dangerouslySkipPermissions: v })
: mark("adapterConfig", "dangerouslySkipPermissions", v)
}
/>
</>
);
}

View file

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

View file

@ -2,25 +2,35 @@ import type { UIAdapterModule } from "./types";
import { claudeLocalUIAdapter } from "./claude-local";
import { codexLocalUIAdapter } from "./codex-local";
import { cursorLocalUIAdapter } from "./cursor";
import { geminiLocalUIAdapter } from "./gemini-local";
import { hermesLocalUIAdapter } from "./hermes-local";
import { openCodeLocalUIAdapter } from "./opencode-local";
import { piLocalUIAdapter } from "./pi-local";
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
import { processUIAdapter } from "./process";
import { httpUIAdapter } from "./http";
const uiAdapters: UIAdapterModule[] = [
claudeLocalUIAdapter,
codexLocalUIAdapter,
geminiLocalUIAdapter,
hermesLocalUIAdapter,
openCodeLocalUIAdapter,
piLocalUIAdapter,
cursorLocalUIAdapter,
openClawGatewayUIAdapter,
processUIAdapter,
httpUIAdapter,
];
const adaptersByType = new Map<string, UIAdapterModule>(
[
claudeLocalUIAdapter,
codexLocalUIAdapter,
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];
}

View file

@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import type { AdapterConfigFieldsProps } from "./types";
import { Field, help } from "../components/agent-config-primitives";
// TODO(issue-worktree-support): re-enable this UI once the workflow is ready to ship.
const SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI = false;
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function formatJsonObject(value: unknown): string {
const record = asRecord(value);
return Object.keys(record).length > 0 ? JSON.stringify(record, null, 2) : "";
}
function updateJsonConfig(
isCreate: boolean,
key: "runtimeServicesJson" | "payloadTemplateJson",
next: string,
set: AdapterConfigFieldsProps["set"],
mark: AdapterConfigFieldsProps["mark"],
configKey: string,
) {
if (isCreate) {
set?.({ [key]: next });
return;
}
const trimmed = next.trim();
if (!trimmed) {
mark("adapterConfig", configKey, undefined);
return;
}
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
mark("adapterConfig", configKey, parsed);
}
} catch {
// Keep local draft until JSON is valid.
}
}
type JsonFieldProps = Pick<
AdapterConfigFieldsProps,
"isCreate" | "values" | "set" | "config" | "mark"
>;
export function RuntimeServicesJsonField({
isCreate,
values,
set,
config,
mark,
}: JsonFieldProps) {
if (!SHOW_EXPERIMENTAL_ISSUE_WORKTREE_UI) {
return null;
}
const existing = formatJsonObject(config.workspaceRuntime);
const [draft, setDraft] = useState(existing);
useEffect(() => {
if (!isCreate) setDraft(existing);
}, [existing, isCreate]);
const value = isCreate ? values?.runtimeServicesJson ?? "" : draft;
return (
<Field label="Runtime services JSON" hint={help.runtimeServicesJson}>
<textarea
className={`${inputClass} min-h-[148px]`}
value={value}
onChange={(e) => {
const next = e.target.value;
if (!isCreate) setDraft(next);
updateJsonConfig(isCreate, "runtimeServicesJson", next, set, mark, "workspaceRuntime");
}}
placeholder={`{\n "services": [\n {\n "name": "preview",\n "lifecycle": "ephemeral",\n "metadata": {\n "purpose": "remote preview"\n }\n }\n ]\n}`}
/>
</Field>
);
}
export function PayloadTemplateJsonField({
isCreate,
values,
set,
config,
mark,
}: JsonFieldProps) {
const existing = formatJsonObject(config.payloadTemplate);
const [draft, setDraft] = useState(existing);
useEffect(() => {
if (!isCreate) setDraft(existing);
}, [existing, isCreate]);
const value = isCreate ? values?.payloadTemplateJson ?? "" : draft;
return (
<Field label="Payload template JSON" hint={help.payloadTemplateJson}>
<textarea
className={`${inputClass} min-h-[132px]`}
value={value}
onChange={(e) => {
const next = e.target.value;
if (!isCreate) setDraft(next);
updateJsonConfig(isCreate, "payloadTemplateJson", next, set, mark, "payloadTemplate");
}}
placeholder={`{\n "agentId": "remote-agent-123",\n "metadata": {\n "team": "platform"\n }\n}`}
/>
</Field>
);
}

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { buildTranscript, type RunLogChunk } from "./transcript";
describe("buildTranscript", () => {
const ts = "2026-03-20T13:00:00.000Z";
const chunks: RunLogChunk[] = [
{ ts, stream: "stdout", chunk: "opened /Users/dotta/project\n" },
{ ts, stream: "stderr", chunk: "stderr /Users/dotta/project" },
];
it("defaults username censoring to off when options are omitted", () => {
const entries = buildTranscript(chunks, (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }]);
expect(entries).toEqual([
{ kind: "stdout", ts, text: "opened /Users/dotta/project" },
{ kind: "stderr", ts, text: "stderr /Users/dotta/project" },
]);
});
it("still redacts usernames when explicitly enabled", () => {
const entries = buildTranscript(chunks, (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }], {
censorUsernameInLogs: true,
});
expect(entries).toEqual([
{ kind: "stdout", ts, text: "opened /Users/d****/project" },
{ kind: "stderr", ts, text: "stderr /Users/d****/project" },
]);
});
});

View file

@ -1,8 +1,10 @@
import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils";
import type { TranscriptEntry, StdoutLineParser } from "./types";
type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
const last = entries[entries.length - 1];
if (last && last.kind === entry.kind && last.delta) {
@ -14,17 +16,28 @@ function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntr
entries.push(entry);
}
export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser): TranscriptEntry[] {
export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: TranscriptEntry[]) {
for (const entry of incoming) {
appendTranscriptEntry(entries, entry);
}
}
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: 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: chunk.chunk });
entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk, redactionOptions) });
continue;
}
@ -34,18 +47,14 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser)
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
for (const entry of parser(trimmed, chunk.ts)) {
appendTranscriptEntry(entries, entry);
}
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();
for (const entry of parser(trailing, ts)) {
appendTranscriptEntry(entries, entry);
}
appendTranscriptEntries(entries, parser(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
}
return entries;

View file

@ -20,6 +20,8 @@ export interface AdapterConfigFieldsProps {
mark: (group: "adapterConfig", field: string, value: unknown) => void;
/** Available models for dropdowns */
models: { id: string; label: string }[];
/** When true, hides the instructions file path field (e.g. during import where it's set automatically) */
hideInstructionsFile?: boolean;
}
export interface UIAdapterModule {

View file

@ -4,6 +4,7 @@ import { api } from "./client";
type InviteSummary = {
id: string;
companyId: string | null;
companyName?: string | null;
inviteType: "company_join" | "bootstrap_ceo";
allowedJoinTypes: "human" | "agent" | "both";
expiresAt: string;
@ -64,12 +65,30 @@ type BoardClaimStatus = {
claimedByUserId: string | null;
};
type CliAuthChallengeStatus = {
id: string;
status: "pending" | "approved" | "cancelled" | "expired";
command: string;
clientName: string | null;
requestedAccess: "board" | "instance_admin_required";
requestedCompanyId: string | null;
requestedCompanyName: string | null;
approvedAt: string | null;
cancelledAt: string | null;
expiresAt: string;
approvedByUser: { id: string; name: string; email: string } | null;
requiresSignIn: boolean;
canApprove: boolean;
currentUserId: string | null;
};
type CompanyInviteCreated = {
id: string;
token: string;
inviteUrl: string;
expiresAt: string;
allowedJoinTypes: "human" | "agent" | "both";
companyName?: string | null;
onboardingTextPath?: string;
onboardingTextUrl?: string;
inviteMessage?: string | null;
@ -127,4 +146,16 @@ export const accessApi = {
claimBoard: (token: string, code: string) =>
api.post<{ claimed: true; userId: string }>(`/board-claim/${token}/claim`, { code }),
getCliAuthChallenge: (id: string, token: string) =>
api.get<CliAuthChallengeStatus>(`/cli-auth/challenges/${id}?token=${encodeURIComponent(token)}`),
approveCliAuthChallenge: (id: string, token: string) =>
api.post<{ approved: boolean; status: string; userId: string; keyId: string | null; expiresAt: string }>(
`/cli-auth/challenges/${id}/approve`,
{ token },
),
cancelCliAuthChallenge: (id: string, token: string) =>
api.post<{ cancelled: boolean; status: string }>(`/cli-auth/challenges/${id}/cancel`, { token }),
};

View file

@ -22,7 +22,14 @@ export interface IssueForRun {
}
export const activityApi = {
list: (companyId: string) => api.get<ActivityEvent[]>(`/companies/${companyId}/activity`),
list: (companyId: string, filters?: { entityType?: string; entityId?: string; agentId?: string }) => {
const params = new URLSearchParams();
if (filters?.entityType) params.set("entityType", filters.entityType);
if (filters?.entityId) params.set("entityId", filters.entityId);
if (filters?.agentId) params.set("agentId", filters.agentId);
const qs = params.toString();
return api.get<ActivityEvent[]>(`/companies/${companyId}/activity${qs ? `?${qs}` : ""}`);
},
forIssue: (issueId: string) => api.get<ActivityEvent[]>(`/issues/${issueId}/activity`),
runsForIssue: (issueId: string) => api.get<RunForIssue[]>(`/issues/${issueId}/runs`),
issuesForRun: (runId: string) => api.get<IssueForRun[]>(`/heartbeat-runs/${runId}/issues`),

View file

@ -1,5 +1,9 @@
import type {
Agent,
AgentDetail,
AgentInstructionsBundle,
AgentInstructionsFileDetail,
AgentSkillSnapshot,
AdapterEnvironmentTestResult,
AgentKeyCreated,
AgentRuntimeState,
@ -23,6 +27,12 @@ export interface AdapterModel {
label: string;
}
export interface DetectedAdapterModel {
model: string;
provider: string;
source: string;
}
export interface ClaudeLoginResult {
exitCode: number | null;
signal: string | null;
@ -45,6 +55,11 @@ export interface AgentHireResponse {
approval: Approval | null;
}
export interface AgentPermissionUpdate {
canCreateAgents: boolean;
canAssignTasks: boolean;
}
function withCompanyScope(path: string, companyId?: string) {
if (!companyId) return path;
const separator = path.includes("?") ? "&" : "?";
@ -62,7 +77,7 @@ export const agentsApi = {
api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`),
get: async (id: string, companyId?: string) => {
try {
return await api.get<Agent>(agentPath(id, companyId));
return await api.get<AgentDetail>(agentPath(id, companyId));
} catch (error) {
// Backward-compat fallback: if backend shortname lookup reports ambiguity,
// resolve using company agent list while ignoring terminated agents.
@ -83,7 +98,7 @@ export const agentsApi = {
(agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey,
);
if (matches.length !== 1) throw error;
return api.get<Agent>(agentPath(matches[0]!.id, companyId));
return api.get<AgentDetail>(agentPath(matches[0]!.id, companyId));
}
},
getConfiguration: (id: string, companyId?: string) =>
@ -100,13 +115,42 @@ export const agentsApi = {
api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data),
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
api.patch<Agent>(agentPath(id, companyId), data),
updatePermissions: (id: string, data: { canCreateAgents: boolean }, companyId?: string) =>
api.patch<Agent>(agentPath(id, companyId, "/permissions"), data),
updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
api.patch<AgentDetail>(agentPath(id, companyId, "/permissions"), data),
instructionsBundle: (id: string, companyId?: string) =>
api.get<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle")),
updateInstructionsBundle: (
id: string,
data: {
mode?: "managed" | "external";
rootPath?: string | null;
entryFile?: string;
clearLegacyPromptTemplate?: boolean;
},
companyId?: string,
) => api.patch<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle"), data),
instructionsFile: (id: string, relativePath: string, companyId?: string) =>
api.get<AgentInstructionsFileDetail>(
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
),
saveInstructionsFile: (
id: string,
data: { path: string; content: string; clearLegacyPromptTemplate?: boolean },
companyId?: string,
) => api.put<AgentInstructionsFileDetail>(agentPath(id, companyId, "/instructions-bundle/file"), data),
deleteInstructionsFile: (id: string, relativePath: string, companyId?: string) =>
api.delete<AgentInstructionsBundle>(
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
),
pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)),
listKeys: (id: string, companyId?: string) => api.get<AgentKey[]>(agentPath(id, companyId, "/keys")),
skills: (id: string, companyId?: string) =>
api.get<AgentSkillSnapshot>(agentPath(id, companyId, "/skills")),
syncSkills: (id: string, desiredSkills: string[], companyId?: string) =>
api.post<AgentSkillSnapshot>(agentPath(id, companyId, "/skills/sync"), { desiredSkills }),
createKey: (id: string, name: string, companyId?: string) =>
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name }),
revokeKey: (agentId: string, keyId: string, companyId?: string) =>
@ -121,6 +165,10 @@ export const agentsApi = {
api.get<AdapterModel[]>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
),
detectModel: (companyId: string, type: string) =>
api.get<DetectedAdapterModel | null>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
),
testEnvironment: (
companyId: string,
type: string,
@ -144,4 +192,12 @@ export const agentsApi = {
) => api.post<HeartbeatRun | { status: "skipped" }>(agentPath(id, companyId, "/wakeup"), data),
loginWithClaude: (id: string, companyId?: string) =>
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
availableSkills: () =>
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
};
export interface AvailableSkill {
name: string;
description: string;
isPaperclipManaged: boolean;
}

View file

@ -11,11 +11,19 @@ export const assetsApi = {
const safeFile = new File([buffer], file.name, { type: file.type });
const form = new FormData();
form.append("file", safeFile);
if (namespace && namespace.trim().length > 0) {
form.append("namespace", namespace.trim());
}
form.append("file", safeFile);
return api.postForm<AssetImage>(`/companies/${companyId}/assets/images`, form);
},
};
uploadCompanyLogo: async (companyId: string, file: File) => {
const buffer = await file.arrayBuffer();
const safeFile = new File([buffer], file.name, { type: file.type });
const form = new FormData();
form.append("file", safeFile);
return api.postForm<AssetImage>(`/companies/${companyId}/logo`, form);
},
};

20
ui/src/api/budgets.ts Normal file
View file

@ -0,0 +1,20 @@
import type {
BudgetIncident,
BudgetIncidentResolutionInput,
BudgetOverview,
BudgetPolicySummary,
BudgetPolicyUpsertInput,
} from "@paperclipai/shared";
import { api } from "./client";
export const budgetsApi = {
overview: (companyId: string) =>
api.get<BudgetOverview>(`/companies/${companyId}/budgets/overview`),
upsertPolicy: (companyId: string, data: BudgetPolicyUpsertInput) =>
api.post<BudgetPolicySummary>(`/companies/${companyId}/budgets/policies`, data),
resolveIncident: (companyId: string, incidentId: string, data: BudgetIncidentResolutionInput) =>
api.post<BudgetIncident>(
`/companies/${companyId}/budget-incidents/${encodeURIComponent(incidentId)}/resolve`,
data,
),
};

View file

@ -32,6 +32,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
errorBody,
);
}
if (res.status === 204) return undefined as T;
return res.json();
}
@ -41,6 +42,8 @@ export const api = {
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
postForm: <T>(path: string, body: FormData) =>
request<T>(path, { method: "POST", body }),
put: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),

View file

@ -1,10 +1,13 @@
import type {
Company,
CompanyPortabilityExportRequest,
CompanyPortabilityExportPreviewResult,
CompanyPortabilityExportResult,
CompanyPortabilityImportRequest,
CompanyPortabilityImportResult,
CompanyPortabilityPreviewRequest,
CompanyPortabilityPreviewResult,
UpdateCompanyBranding,
} from "@paperclipai/shared";
import { api } from "./client";
@ -14,21 +17,40 @@ export const companiesApi = {
list: () => api.get<Company[]>("/companies"),
get: (companyId: string) => api.get<Company>(`/companies/${companyId}`),
stats: () => api.get<CompanyStats>("/companies/stats"),
create: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) =>
create: (data: {
name: string;
description?: string | null;
budgetMonthlyCents?: number;
}) =>
api.post<Company>("/companies", data),
update: (
companyId: string,
data: Partial<
Pick<
Company,
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor"
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" | "logoAssetId"
>
>,
) => api.patch<Company>(`/companies/${companyId}`, data),
updateBranding: (companyId: string, data: UpdateCompanyBranding) =>
api.patch<Company>(`/companies/${companyId}/branding`, data),
archive: (companyId: string) => api.post<Company>(`/companies/${companyId}/archive`, {}),
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
exportBundle: (companyId: string, data: { include?: { company?: boolean; agents?: boolean } }) =>
exportBundle: (
companyId: string,
data: CompanyPortabilityExportRequest,
) =>
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/export`, data),
exportPreview: (
companyId: string,
data: CompanyPortabilityExportRequest,
) =>
api.post<CompanyPortabilityExportPreviewResult>(`/companies/${companyId}/exports/preview`, data),
exportPackage: (
companyId: string,
data: CompanyPortabilityExportRequest,
) =>
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/exports`, data),
importPreview: (data: CompanyPortabilityPreviewRequest) =>
api.post<CompanyPortabilityPreviewResult>("/companies/import/preview", data),
importBundle: (data: CompanyPortabilityImportRequest) =>

View file

@ -0,0 +1,54 @@
import type {
CompanySkill,
CompanySkillCreateRequest,
CompanySkillDetail,
CompanySkillFileDetail,
CompanySkillImportResult,
CompanySkillListItem,
CompanySkillProjectScanRequest,
CompanySkillProjectScanResult,
CompanySkillUpdateStatus,
} from "@paperclipai/shared";
import { api } from "./client";
export const companySkillsApi = {
list: (companyId: string) =>
api.get<CompanySkillListItem[]>(`/companies/${encodeURIComponent(companyId)}/skills`),
detail: (companyId: string, skillId: string) =>
api.get<CompanySkillDetail>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
),
updateStatus: (companyId: string, skillId: string) =>
api.get<CompanySkillUpdateStatus>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/update-status`,
),
file: (companyId: string, skillId: string, relativePath: string) =>
api.get<CompanySkillFileDetail>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/files?path=${encodeURIComponent(relativePath)}`,
),
updateFile: (companyId: string, skillId: string, path: string, content: string) =>
api.patch<CompanySkillFileDetail>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/files`,
{ path, content },
),
create: (companyId: string, payload: CompanySkillCreateRequest) =>
api.post<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills`,
payload,
),
importFromSource: (companyId: string, source: string) =>
api.post<CompanySkillImportResult>(
`/companies/${encodeURIComponent(companyId)}/skills/import`,
{ source },
),
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
api.post<CompanySkillProjectScanResult>(
`/companies/${encodeURIComponent(companyId)}/skills/scan-projects`,
payload,
),
installUpdate: (companyId: string, skillId: string) =>
api.post<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`,
{},
),
};

View file

@ -1,14 +1,19 @@
import type { CostSummary, CostByAgent } from "@paperclipai/shared";
import type {
CostSummary,
CostByAgent,
CostByProviderModel,
CostByBiller,
CostByAgentModel,
CostByProject,
CostWindowSpendRow,
FinanceSummary,
FinanceByBiller,
FinanceByKind,
FinanceEvent,
ProviderQuotaResult,
} from "@paperclipai/shared";
import { api } from "./client";
export interface CostByProject {
projectId: string | null;
projectName: string | null;
costCents: number;
inputTokens: number;
outputTokens: number;
}
function dateParams(from?: string, to?: string): string {
const params = new URLSearchParams();
if (from) params.set("from", from);
@ -22,6 +27,33 @@ export const costsApi = {
api.get<CostSummary>(`/companies/${companyId}/costs/summary${dateParams(from, to)}`),
byAgent: (companyId: string, from?: string, to?: string) =>
api.get<CostByAgent[]>(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`),
byAgentModel: (companyId: string, from?: string, to?: string) =>
api.get<CostByAgentModel[]>(`/companies/${companyId}/costs/by-agent-model${dateParams(from, to)}`),
byProject: (companyId: string, from?: string, to?: string) =>
api.get<CostByProject[]>(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`),
byProvider: (companyId: string, from?: string, to?: string) =>
api.get<CostByProviderModel[]>(`/companies/${companyId}/costs/by-provider${dateParams(from, to)}`),
byBiller: (companyId: string, from?: string, to?: string) =>
api.get<CostByBiller[]>(`/companies/${companyId}/costs/by-biller${dateParams(from, to)}`),
financeSummary: (companyId: string, from?: string, to?: string) =>
api.get<FinanceSummary>(`/companies/${companyId}/costs/finance-summary${dateParams(from, to)}`),
financeByBiller: (companyId: string, from?: string, to?: string) =>
api.get<FinanceByBiller[]>(`/companies/${companyId}/costs/finance-by-biller${dateParams(from, to)}`),
financeByKind: (companyId: string, from?: string, to?: string) =>
api.get<FinanceByKind[]>(`/companies/${companyId}/costs/finance-by-kind${dateParams(from, to)}`),
financeEvents: (companyId: string, from?: string, to?: string, limit: number = 100) =>
api.get<FinanceEvent[]>(`/companies/${companyId}/costs/finance-events${dateParamsWithLimit(from, to, limit)}`),
windowSpend: (companyId: string) =>
api.get<CostWindowSpendRow[]>(`/companies/${companyId}/costs/window-spend`),
quotaWindows: (companyId: string) =>
api.get<ProviderQuotaResult[]>(`/companies/${companyId}/costs/quota-windows`),
};
function dateParamsWithLimit(from?: string, to?: string, limit?: number): string {
const params = new URLSearchParams();
if (from) params.set("from", from);
if (to) params.set("to", to);
if (limit) params.set("limit", String(limit));
const qs = params.toString();
return qs ? `?${qs}` : "";
}

View file

@ -0,0 +1,35 @@
import type { ExecutionWorkspace, ExecutionWorkspaceCloseReadiness, WorkspaceOperation } from "@paperclipai/shared";
import { api } from "./client";
export const executionWorkspacesApi = {
list: (
companyId: string,
filters?: {
projectId?: string;
projectWorkspaceId?: string;
issueId?: string;
status?: string;
reuseEligible?: boolean;
},
) => {
const params = new URLSearchParams();
if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId);
if (filters?.issueId) params.set("issueId", filters.issueId);
if (filters?.status) params.set("status", filters.status);
if (filters?.reuseEligible) params.set("reuseEligible", "true");
const qs = params.toString();
return api.get<ExecutionWorkspace[]>(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`);
},
get: (id: string) => api.get<ExecutionWorkspace>(`/execution-workspaces/${id}`),
getCloseReadiness: (id: string) =>
api.get<ExecutionWorkspaceCloseReadiness>(`/execution-workspaces/${id}/close-readiness`),
listWorkspaceOperations: (id: string) =>
api.get<WorkspaceOperation[]>(`/execution-workspaces/${id}/workspace-operations`),
controlRuntimeServices: (id: string, action: "start" | "stop" | "restart") =>
api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>(
`/execution-workspaces/${id}/runtime-services/${action}`,
{},
),
update: (id: string, data: Record<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
};

View file

@ -1,12 +1,29 @@
export type DevServerHealthStatus = {
enabled: true;
restartRequired: boolean;
reason: "backend_changes" | "pending_migrations" | "backend_changes_and_pending_migrations" | null;
lastChangedAt: string | null;
changedPathCount: number;
changedPathsSample: string[];
pendingMigrations: string[];
autoRestartEnabled: boolean;
activeRunCount: number;
waitingForIdle: boolean;
lastRestartAt: string | null;
};
export type HealthStatus = {
status: "ok";
version?: string;
deploymentMode?: "local_trusted" | "authenticated";
deploymentExposure?: "private" | "public";
authReady?: boolean;
bootstrapStatus?: "ready" | "bootstrap_pending";
bootstrapInviteActive?: boolean;
features?: {
companyDeletionEnabled?: boolean;
};
devServer?: DevServerHealthStatus;
};
export const healthApi = {

View file

@ -1,4 +1,9 @@
import type { HeartbeatRun, HeartbeatRunEvent } from "@paperclipai/shared";
import type {
HeartbeatRun,
HeartbeatRunEvent,
InstanceSchedulerHeartbeatAgent,
WorkspaceOperation,
} from "@paperclipai/shared";
import { api } from "./client";
export interface ActiveRunForIssue extends HeartbeatRun {
@ -29,6 +34,7 @@ export const heartbeatsApi = {
const qs = searchParams.toString();
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`);
},
get: (runId: string) => api.get<HeartbeatRun>(`/heartbeat-runs/${runId}`),
events: (runId: string, afterSeq = 0, limit = 200) =>
api.get<HeartbeatRunEvent[]>(
`/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
@ -37,6 +43,12 @@ export const heartbeatsApi = {
api.get<{ runId: string; store: string; logRef: string; content: string; nextOffset?: number }>(
`/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
),
workspaceOperations: (runId: string) =>
api.get<WorkspaceOperation[]>(`/heartbeat-runs/${runId}/workspace-operations`),
workspaceOperationLog: (operationId: string, offset = 0, limitBytes = 256000) =>
api.get<{ operationId: string; store: string; logRef: string; content: string; nextOffset?: number }>(
`/workspace-operations/${operationId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
),
cancel: (runId: string) => api.post<void>(`/heartbeat-runs/${runId}/cancel`, {}),
liveRunsForIssue: (issueId: string) =>
api.get<LiveRunForIssue[]>(`/issues/${issueId}/live-runs`),
@ -44,4 +56,6 @@ export const heartbeatsApi = {
api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`),
liveRunsForCompany: (companyId: string, minCount?: number) =>
api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs${minCount ? `?minCount=${minCount}` : ""}`),
listInstanceSchedulerAgents: () =>
api.get<InstanceSchedulerHeartbeatAgent[]>("/instance/scheduler-heartbeats"),
};

View file

@ -6,10 +6,13 @@ export { companiesApi } from "./companies";
export { agentsApi } from "./agents";
export { projectsApi } from "./projects";
export { issuesApi } from "./issues";
export { routinesApi } from "./routines";
export { goalsApi } from "./goals";
export { approvalsApi } from "./approvals";
export { costsApi } from "./costs";
export { activityApi } from "./activity";
export { dashboardApi } from "./dashboard";
export { heartbeatsApi } from "./heartbeats";
export { instanceSettingsApi } from "./instanceSettings";
export { sidebarBadgesApi } from "./sidebarBadges";
export { companySkillsApi } from "./companySkills";

View file

@ -0,0 +1,18 @@
import type {
InstanceExperimentalSettings,
InstanceGeneralSettings,
PatchInstanceGeneralSettings,
PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
import { api } from "./client";
export const instanceSettingsApi = {
getGeneral: () =>
api.get<InstanceGeneralSettings>("/instance/settings/general"),
updateGeneral: (patch: PatchInstanceGeneralSettings) =>
api.patch<InstanceGeneralSettings>("/instance/settings/general", patch),
getExperimental: () =>
api.get<InstanceExperimentalSettings>("/instance/settings/experimental"),
updateExperimental: (patch: PatchInstanceExperimentalSettings) =>
api.patch<InstanceExperimentalSettings>("/instance/settings/experimental", patch),
};

View file

@ -1,6 +1,20 @@
import type { Approval, Issue, IssueAttachment, IssueComment, IssueLabel } from "@paperclipai/shared";
import type {
Approval,
DocumentRevision,
Issue,
IssueAttachment,
IssueComment,
IssueDocument,
IssueLabel,
IssueWorkProduct,
UpsertIssueDocument,
} from "@paperclipai/shared";
import { api } from "./client";
export type IssueUpdateResponse = Issue & {
comment?: IssueComment | null;
};
export const issuesApi = {
list: (
companyId: string,
@ -8,10 +22,16 @@ export const issuesApi = {
status?: string;
projectId?: string;
assigneeAgentId?: string;
participantAgentId?: string;
assigneeUserId?: string;
touchedByUserId?: string;
inboxArchivedByUserId?: string;
unreadForUserId?: string;
labelId?: string;
executionWorkspaceId?: string;
originKind?: string;
originId?: string;
includeRoutineExecutions?: boolean;
q?: string;
},
) => {
@ -19,10 +39,16 @@ export const issuesApi = {
if (filters?.status) params.set("status", filters.status);
if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
if (filters?.inboxArchivedByUserId) params.set("inboxArchivedByUserId", filters.inboxArchivedByUserId);
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
if (filters?.labelId) params.set("labelId", filters.labelId);
if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId);
if (filters?.originKind) params.set("originKind", filters.originKind);
if (filters?.originId) params.set("originId", filters.originId);
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
if (filters?.q) params.set("q", filters.q);
const qs = params.toString();
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
@ -33,9 +59,15 @@ export const issuesApi = {
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
get: (id: string) => api.get<Issue>(`/issues/${id}`),
markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}),
markUnread: (id: string) => api.delete<{ id: string; removed: boolean }>(`/issues/${id}/read`),
archiveFromInbox: (id: string) =>
api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}),
unarchiveFromInbox: (id: string) =>
api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Issue>(`/companies/${companyId}/issues`, data),
update: (id: string, data: Record<string, unknown>) => api.patch<Issue>(`/issues/${id}`, data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<IssueUpdateResponse>(`/issues/${id}`, data),
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
checkout: (id: string, agentId: string) =>
api.post<Issue>(`/issues/${id}/checkout`, {
@ -53,6 +85,14 @@ export const issuesApi = {
...(interrupt === undefined ? {} : { interrupt }),
},
),
listDocuments: (id: string) => api.get<IssueDocument[]>(`/issues/${id}/documents`),
getDocument: (id: string, key: string) => api.get<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
upsertDocument: (id: string, key: string, data: UpsertIssueDocument) =>
api.put<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`, data),
listDocumentRevisions: (id: string, key: string) =>
api.get<DocumentRevision[]>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`),
deleteDocument: (id: string, key: string) =>
api.delete<{ ok: true }>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
listAttachments: (id: string) => api.get<IssueAttachment[]>(`/issues/${id}/attachments`),
uploadAttachment: (
companyId: string,
@ -73,4 +113,10 @@ export const issuesApi = {
api.post<Approval[]>(`/issues/${id}/approvals`, { approvalId }),
unlinkApproval: (id: string, approvalId: string) =>
api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`),
listWorkProducts: (id: string) => api.get<IssueWorkProduct[]>(`/issues/${id}/work-products`),
createWorkProduct: (id: string, data: Record<string, unknown>) =>
api.post<IssueWorkProduct>(`/issues/${id}/work-products`, data),
updateWorkProduct: (id: string, data: Record<string, unknown>) =>
api.patch<IssueWorkProduct>(`/work-products/${id}`, data),
deleteWorkProduct: (id: string) => api.delete<IssueWorkProduct>(`/work-products/${id}`),
};

423
ui/src/api/plugins.ts Normal file
View file

@ -0,0 +1,423 @@
/**
* @fileoverview Frontend API client for the Paperclip plugin system.
*
* All functions in `pluginsApi` map 1:1 to REST endpoints on
* `server/src/routes/plugins.ts`. Call sites should consume these functions
* through React Query hooks (`useQuery` / `useMutation`) and reference cache
* keys from `queryKeys.plugins.*`.
*
* @see ui/src/lib/queryKeys.ts for cache key definitions.
* @see server/src/routes/plugins.ts for endpoint implementation details.
*/
import type {
PluginLauncherDeclaration,
PluginLauncherRenderContextSnapshot,
PluginUiSlotDeclaration,
PluginRecord,
PluginConfig,
PluginStatus,
} from "@paperclipai/shared";
import { api } from "./client";
/**
* Normalized UI contribution record returned by `GET /api/plugins/ui-contributions`.
*
* Only populated for plugins in `ready` state that declare at least one UI slot
* or launcher. The `slots` array is sourced from `manifest.ui.slots`. The
* `launchers` array aggregates both legacy `manifest.launchers` and
* `manifest.ui.launchers`.
*/
export type PluginUiContribution = {
pluginId: string;
pluginKey: string;
displayName: string;
version: string;
updatedAt?: string;
/**
* Relative filename of the UI entry module within the plugin's UI directory.
* The host constructs the full import URL as
* `/_plugins/${pluginId}/ui/${uiEntryFile}`.
*/
uiEntryFile: string;
slots: PluginUiSlotDeclaration[];
launchers: PluginLauncherDeclaration[];
};
/**
* Health check result returned by `GET /api/plugins/:pluginId/health`.
*
* The `healthy` flag summarises whether all checks passed. Individual check
* results are available in `checks` for detailed diagnostics display.
*/
export interface PluginHealthCheckResult {
pluginId: string;
/** The plugin's current lifecycle status at time of check. */
status: string;
/** True if all health checks passed. */
healthy: boolean;
/** Individual diagnostic check results. */
checks: Array<{
name: string;
passed: boolean;
/** Human-readable description of a failure, if any. */
message?: string;
}>;
/** The most recent error message if the plugin is in `error` state. */
lastError?: string;
}
/**
* Worker diagnostics returned as part of the dashboard response.
*/
export interface PluginWorkerDiagnostics {
status: string;
pid: number | null;
uptime: number | null;
consecutiveCrashes: number;
totalCrashes: number;
pendingRequests: number;
lastCrashAt: number | null;
nextRestartAt: number | null;
}
/**
* A recent job run entry returned in the dashboard response.
*/
export interface PluginDashboardJobRun {
id: string;
jobId: string;
jobKey?: string;
trigger: string;
status: string;
durationMs: number | null;
error: string | null;
startedAt: string | null;
finishedAt: string | null;
createdAt: string;
}
/**
* A recent webhook delivery entry returned in the dashboard response.
*/
export interface PluginDashboardWebhookDelivery {
id: string;
webhookKey: string;
status: string;
durationMs: number | null;
error: string | null;
startedAt: string | null;
finishedAt: string | null;
createdAt: string;
}
/**
* Aggregated health dashboard data returned by `GET /api/plugins/:pluginId/dashboard`.
*
* Contains worker diagnostics, recent job runs, recent webhook deliveries,
* and the current health check result all in a single response.
*/
export interface PluginDashboardData {
pluginId: string;
/** Worker process diagnostics, or null if no worker is registered. */
worker: PluginWorkerDiagnostics | null;
/** Recent job execution history (newest first, max 10). */
recentJobRuns: PluginDashboardJobRun[];
/** Recent inbound webhook deliveries (newest first, max 10). */
recentWebhookDeliveries: PluginDashboardWebhookDelivery[];
/** Current health check results. */
health: PluginHealthCheckResult;
/** ISO 8601 timestamp when the dashboard data was generated. */
checkedAt: string;
}
export interface AvailablePluginExample {
packageName: string;
pluginKey: string;
displayName: string;
description: string;
localPath: string;
tag: "example";
}
/**
* Plugin management API client.
*
* All methods are thin wrappers around the `api` base client. They return
* promises that resolve to typed JSON responses or throw on HTTP errors.
*
* @example
* ```tsx
* // In a component:
* const { data: plugins } = useQuery({
* queryKey: queryKeys.plugins.all,
* queryFn: () => pluginsApi.list(),
* });
* ```
*/
export const pluginsApi = {
/**
* List all installed plugins, optionally filtered by lifecycle status.
*
* @param status - Optional filter; must be a valid `PluginStatus` value.
* Invalid values are rejected by the server with HTTP 400.
*/
list: (status?: PluginStatus) =>
api.get<PluginRecord[]>(`/plugins${status ? `?status=${status}` : ""}`),
/**
* List bundled example plugins available from the current repo checkout.
*/
listExamples: () =>
api.get<AvailablePluginExample[]>("/plugins/examples"),
/**
* Fetch a single plugin record by its UUID or plugin key.
*
* @param pluginId - The plugin's UUID (from `PluginRecord.id`) or plugin key.
*/
get: (pluginId: string) =>
api.get<PluginRecord>(`/plugins/${pluginId}`),
/**
* Install a plugin from npm or a local path.
*
* On success, the plugin is registered in the database and transitioned to
* `ready` state. The response is the newly created `PluginRecord`.
*
* @param params.packageName - npm package name (e.g. `@paperclip/plugin-linear`)
* or a filesystem path when `isLocalPath` is `true`.
* @param params.version - Target npm version tag/range (optional; defaults to latest).
* @param params.isLocalPath - Set to `true` when `packageName` is a local path.
*/
install: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
api.post<PluginRecord>("/plugins/install", params),
/**
* Uninstall a plugin.
*
* @param pluginId - UUID of the plugin to remove.
* @param purge - If `true`, permanently delete all plugin data (hard delete).
* Otherwise the plugin is soft-deleted with a 30-day data retention window.
*/
uninstall: (pluginId: string, purge?: boolean) =>
api.delete<{ ok: boolean }>(`/plugins/${pluginId}${purge ? "?purge=true" : ""}`),
/**
* Transition a plugin from `error` state back to `ready`.
* No-ops if the plugin is already enabled.
*
* @param pluginId - UUID of the plugin to enable.
*/
enable: (pluginId: string) =>
api.post<{ ok: boolean }>(`/plugins/${pluginId}/enable`, {}),
/**
* Disable a plugin (transition to `error` state with an operator sentinel).
* The plugin's worker is stopped; it will not process events until re-enabled.
*
* @param pluginId - UUID of the plugin to disable.
* @param reason - Optional human-readable reason stored in `lastError`.
*/
disable: (pluginId: string, reason?: string) =>
api.post<{ ok: boolean }>(`/plugins/${pluginId}/disable`, reason ? { reason } : {}),
/**
* Run health diagnostics for a plugin.
*
* Only meaningful for plugins in `ready` state. Returns the result of all
* registered health checks. Called on a 30-second polling interval by
* {@link PluginSettings}.
*
* @param pluginId - UUID of the plugin to health-check.
*/
health: (pluginId: string) =>
api.get<PluginHealthCheckResult>(`/plugins/${pluginId}/health`),
/**
* Fetch aggregated health dashboard data for a plugin.
*
* Returns worker diagnostics, recent job runs, recent webhook deliveries,
* and the current health check result in a single request. Used by the
* {@link PluginSettings} page to render the runtime dashboard section.
*
* @param pluginId - UUID of the plugin.
*/
dashboard: (pluginId: string) =>
api.get<PluginDashboardData>(`/plugins/${pluginId}/dashboard`),
/**
* Fetch recent log entries for a plugin.
*
* @param pluginId - UUID of the plugin.
* @param options - Optional filters: limit, level, since.
*/
logs: (pluginId: string, options?: { limit?: number; level?: string; since?: string }) => {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", String(options.limit));
if (options?.level) params.set("level", options.level);
if (options?.since) params.set("since", options.since);
const qs = params.toString();
return api.get<Array<{ id: string; pluginId: string; level: string; message: string; meta: Record<string, unknown> | null; createdAt: string }>>(
`/plugins/${pluginId}/logs${qs ? `?${qs}` : ""}`,
);
},
/**
* Upgrade a plugin to a newer version.
*
* If the new version declares additional capabilities, the plugin is
* transitioned to `upgrade_pending` state awaiting operator approval.
*
* @param pluginId - UUID of the plugin to upgrade.
* @param version - Target version (optional; defaults to latest published).
*/
upgrade: (pluginId: string, version?: string) =>
api.post<{ ok: boolean }>(`/plugins/${pluginId}/upgrade`, version ? { version } : {}),
/**
* Returns normalized UI contribution declarations for ready plugins.
* Used by the slot host runtime and launcher discovery surfaces.
*
* Response shape:
* - `slots`: concrete React mount declarations from `manifest.ui.slots`
* - `launchers`: host-owned entry points from `manifest.ui.launchers` plus
* the legacy top-level `manifest.launchers`
*
* @example
* ```ts
* const rows = await pluginsApi.listUiContributions();
* const toolbarLaunchers = rows.flatMap((row) =>
* row.launchers.filter((launcher) => launcher.placementZone === "toolbarButton"),
* );
* ```
*/
listUiContributions: () =>
api.get<PluginUiContribution[]>("/plugins/ui-contributions"),
// ===========================================================================
// Plugin configuration endpoints
// ===========================================================================
/**
* Fetch the current configuration for a plugin.
*
* Returns the `PluginConfig` record if one exists, or `null` if the plugin
* has not yet been configured.
*
* @param pluginId - UUID of the plugin.
*/
getConfig: (pluginId: string) =>
api.get<PluginConfig | null>(`/plugins/${pluginId}/config`),
/**
* Save (create or update) the configuration for a plugin.
*
* The server validates `configJson` against the plugin's `instanceConfigSchema`
* and returns the persisted `PluginConfig` record on success.
*
* @param pluginId - UUID of the plugin.
* @param configJson - Configuration values matching the plugin's `instanceConfigSchema`.
*/
saveConfig: (pluginId: string, configJson: Record<string, unknown>) =>
api.post<PluginConfig>(`/plugins/${pluginId}/config`, { configJson }),
/**
* Call the plugin's `validateConfig` RPC method to test the configuration
* without persisting it.
*
* Returns `{ valid: true }` on success, or `{ valid: false, message: string }`
* when the plugin reports a validation failure.
*
* Only available when the plugin declares a `validateConfig` RPC handler.
*
* @param pluginId - UUID of the plugin.
* @param configJson - Configuration values to validate.
*/
testConfig: (pluginId: string, configJson: Record<string, unknown>) =>
api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { configJson }),
// ===========================================================================
// Bridge proxy endpoints — used by the plugin UI bridge runtime
// ===========================================================================
/**
* Proxy a `getData` call from a plugin UI component to its worker backend.
*
* This is the HTTP transport for `usePluginData(key, params)`. The bridge
* runtime calls this method and maps the response into `PluginDataResult<T>`.
*
* On success, the response is `{ data: T }`.
* On failure, the response body is a `PluginBridgeError`-shaped object
* with `code`, `message`, and optional `details`.
*
* @param pluginId - UUID of the plugin whose worker should handle the request
* @param key - Plugin-defined data key (e.g. `"sync-health"`)
* @param params - Optional query parameters forwarded to the worker handler
* @param companyId - Optional company scope used for board/company access checks.
* @param renderEnvironment - Optional launcher/page snapshot forwarded for
* launcher-backed UI so workers can distinguish modal, drawer, popover, and
* page execution.
*
* Error responses:
* - `401`/`403` when auth or company access checks fail
* - `404` when the plugin or handler key does not exist
* - `409` when the plugin is not in a callable runtime state
* - `5xx` with a `PluginBridgeError`-shaped body when the worker throws
*
* @see PLUGIN_SPEC.md §13.8 `getData`
* @see PLUGIN_SPEC.md §19.7 Error Propagation Through The Bridge
*/
bridgeGetData: (
pluginId: string,
key: string,
params?: Record<string, unknown>,
companyId?: string | null,
renderEnvironment?: PluginLauncherRenderContextSnapshot | null,
) =>
api.post<{ data: unknown }>(`/plugins/${pluginId}/data/${encodeURIComponent(key)}`, {
companyId: companyId ?? undefined,
params,
renderEnvironment: renderEnvironment ?? undefined,
}),
/**
* Proxy a `performAction` call from a plugin UI component to its worker backend.
*
* This is the HTTP transport for `usePluginAction(key)`. The bridge runtime
* calls this method when the action function is invoked.
*
* On success, the response is `{ data: T }`.
* On failure, the response body is a `PluginBridgeError`-shaped object
* with `code`, `message`, and optional `details`.
*
* @param pluginId - UUID of the plugin whose worker should handle the request
* @param key - Plugin-defined action key (e.g. `"resync"`)
* @param params - Optional parameters forwarded to the worker handler
* @param companyId - Optional company scope used for board/company access checks.
* @param renderEnvironment - Optional launcher/page snapshot forwarded for
* launcher-backed UI so workers can distinguish modal, drawer, popover, and
* page execution.
*
* Error responses:
* - `401`/`403` when auth or company access checks fail
* - `404` when the plugin or handler key does not exist
* - `409` when the plugin is not in a callable runtime state
* - `5xx` with a `PluginBridgeError`-shaped body when the worker throws
*
* @see PLUGIN_SPEC.md §13.9 `performAction`
* @see PLUGIN_SPEC.md §19.7 Error Propagation Through The Bridge
*/
bridgePerformAction: (
pluginId: string,
key: string,
params?: Record<string, unknown>,
companyId?: string | null,
renderEnvironment?: PluginLauncherRenderContextSnapshot | null,
) =>
api.post<{ data: unknown }>(`/plugins/${pluginId}/actions/${encodeURIComponent(key)}`, {
companyId: companyId ?? undefined,
params,
renderEnvironment: renderEnvironment ?? undefined,
}),
};

View file

@ -1,4 +1,4 @@
import type { Project, ProjectWorkspace } from "@paperclipai/shared";
import type { Project, ProjectWorkspace, WorkspaceOperation } from "@paperclipai/shared";
import { api } from "./client";
function withCompanyScope(path: string, companyId?: string) {
@ -27,6 +27,16 @@ export const projectsApi = {
projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`),
data,
),
controlWorkspaceRuntimeServices: (
projectId: string,
workspaceId: string,
action: "start" | "stop" | "restart",
companyId?: string,
) =>
api.post<{ workspace: ProjectWorkspace; operation: WorkspaceOperation }>(
projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`),
{},
),
removeWorkspace: (projectId: string, workspaceId: string, companyId?: string) =>
api.delete<ProjectWorkspace>(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)),
remove: (id: string, companyId?: string) => api.delete<Project>(projectPath(id, companyId)),

58
ui/src/api/routines.ts Normal file
View file

@ -0,0 +1,58 @@
import type {
ActivityEvent,
Routine,
RoutineDetail,
RoutineListItem,
RoutineRun,
RoutineRunSummary,
RoutineTrigger,
RoutineTriggerSecretMaterial,
} from "@paperclipai/shared";
import { activityApi } from "./activity";
import { api } from "./client";
export interface RoutineTriggerResponse {
trigger: RoutineTrigger;
secretMaterial: RoutineTriggerSecretMaterial | null;
}
export interface RotateRoutineTriggerResponse {
trigger: RoutineTrigger;
secretMaterial: RoutineTriggerSecretMaterial;
}
export const routinesApi = {
list: (companyId: string) => api.get<RoutineListItem[]>(`/companies/${companyId}/routines`),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Routine>(`/companies/${companyId}/routines`, data),
get: (id: string) => api.get<RoutineDetail>(`/routines/${id}`),
update: (id: string, data: Record<string, unknown>) => api.patch<Routine>(`/routines/${id}`, data),
listRuns: (id: string, limit: number = 50) => api.get<RoutineRunSummary[]>(`/routines/${id}/runs?limit=${limit}`),
createTrigger: (id: string, data: Record<string, unknown>) =>
api.post<RoutineTriggerResponse>(`/routines/${id}/triggers`, data),
updateTrigger: (id: string, data: Record<string, unknown>) =>
api.patch<RoutineTrigger>(`/routine-triggers/${id}`, data),
deleteTrigger: (id: string) => api.delete<void>(`/routine-triggers/${id}`),
rotateTriggerSecret: (id: string) =>
api.post<RotateRoutineTriggerResponse>(`/routine-triggers/${id}/rotate-secret`, {}),
run: (id: string, data?: Record<string, unknown>) =>
api.post<RoutineRun>(`/routines/${id}/run`, data ?? {}),
activity: async (
companyId: string,
routineId: string,
related?: { triggerIds?: string[]; runIds?: string[] },
) => {
const requests = [
activityApi.list(companyId, { entityType: "routine", entityId: routineId }),
...(related?.triggerIds ?? []).map((triggerId) =>
activityApi.list(companyId, { entityType: "routine_trigger", entityId: triggerId })),
...(related?.runIds ?? []).map((runId) =>
activityApi.list(companyId, { entityType: "routine_run", entityId: runId })),
];
const events = (await Promise.all(requests)).flat();
const deduped = new Map(events.map((event) => [event.id, event]));
return [...deduped.values()].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
},
};

View file

@ -0,0 +1,69 @@
import { Database, Gauge, ReceiptText } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
const SURFACES = [
{
title: "Inference ledger",
description: "Request-scoped usage and billed runs from cost_events.",
icon: Database,
points: ["tokens + billed dollars", "provider, biller, model", "subscription and overage aware"],
tone: "from-sky-500/12 via-sky-500/6 to-transparent",
},
{
title: "Finance ledger",
description: "Account-level charges that are not one prompt-response pair.",
icon: ReceiptText,
points: ["top-ups, refunds, fees", "Bedrock provisioned or training charges", "credit expiries and adjustments"],
tone: "from-amber-500/14 via-amber-500/6 to-transparent",
},
{
title: "Live quotas",
description: "Provider or biller windows that can stop traffic in real time.",
icon: Gauge,
points: ["provider quota windows", "biller credit systems", "errors surfaced directly"],
tone: "from-emerald-500/14 via-emerald-500/6 to-transparent",
},
] as const;
export function AccountingModelCard() {
return (
<Card className="relative overflow-hidden border-border/70">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(244,114,182,0.08),transparent_35%),radial-gradient(circle_at_bottom_right,rgba(56,189,248,0.1),transparent_32%)]" />
<CardHeader className="relative px-5 pt-5 pb-2">
<CardTitle className="text-sm font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Accounting model
</CardTitle>
<CardDescription className="max-w-2xl text-sm leading-6">
Paperclip now separates request-level inference usage from account-level finance events.
That keeps provider reporting honest when the biller is OpenRouter, Cloudflare, Bedrock, or another intermediary.
</CardDescription>
</CardHeader>
<CardContent className="relative grid gap-3 px-5 pb-5 md:grid-cols-3">
{SURFACES.map((surface) => {
const Icon = surface.icon;
return (
<div
key={surface.title}
className={`rounded-2xl border border-border/70 bg-gradient-to-br ${surface.tone} p-4 shadow-sm`}
>
<div className="mb-3 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-border/70 bg-background/80">
<Icon className="h-4 w-4 text-foreground" />
</div>
<div>
<div className="text-sm font-semibold">{surface.title}</div>
<div className="text-xs text-muted-foreground">{surface.description}</div>
</div>
</div>
<div className="space-y-1.5 text-xs text-muted-foreground">
{surface.points.map((point) => (
<div key={point}>{point}</div>
))}
</div>
</div>
);
})}
</CardContent>
</Card>
);
}

View file

@ -1,191 +1,19 @@
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { useMemo } from "react";
import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import type { Issue, LiveEvent } from "@paperclipai/shared";
import type { Issue } from "@paperclipai/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import { getUIAdapter } from "../adapters";
import type { TranscriptEntry } from "../adapters";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { ExternalLink } from "lucide-react";
import { Identity } from "./Identity";
import { RunTranscriptView } from "./transcript/RunTranscriptView";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
type FeedTone = "info" | "warn" | "error" | "assistant" | "tool";
interface FeedItem {
id: string;
ts: string;
runId: string;
agentId: string;
agentName: string;
text: string;
tone: FeedTone;
dedupeKey: string;
streamingKind?: "assistant" | "thinking";
}
const MAX_FEED_ITEMS = 40;
const MAX_FEED_TEXT_LENGTH = 220;
const MAX_STREAMING_TEXT_LENGTH = 4000;
const MIN_DASHBOARD_RUNS = 4;
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function summarizeEntry(entry: TranscriptEntry): { text: string; tone: FeedTone } | null {
if (entry.kind === "assistant") {
const text = entry.text.trim();
return text ? { text, tone: "assistant" } : null;
}
if (entry.kind === "thinking") {
const text = entry.text.trim();
return text ? { text: `[thinking] ${text}`, tone: "info" } : null;
}
if (entry.kind === "tool_call") {
return { text: `tool ${entry.name}`, tone: "tool" };
}
if (entry.kind === "tool_result") {
const base = entry.content.trim();
return {
text: entry.isError ? `tool error: ${base}` : `tool result: ${base}`,
tone: entry.isError ? "error" : "tool",
};
}
if (entry.kind === "stderr") {
const text = entry.text.trim();
return text ? { text, tone: "error" } : null;
}
if (entry.kind === "system") {
const text = entry.text.trim();
return text ? { text, tone: "warn" } : null;
}
if (entry.kind === "stdout") {
const text = entry.text.trim();
return text ? { text, tone: "info" } : null;
}
return null;
}
function createFeedItem(
run: LiveRunForIssue,
ts: string,
text: string,
tone: FeedTone,
nextId: number,
options?: {
streamingKind?: "assistant" | "thinking";
preserveWhitespace?: boolean;
},
): FeedItem | null {
if (!text.trim()) return null;
const base = options?.preserveWhitespace ? text : text.trim();
const maxLength = options?.streamingKind ? MAX_STREAMING_TEXT_LENGTH : MAX_FEED_TEXT_LENGTH;
const normalized = base.length > maxLength ? base.slice(-maxLength) : base;
return {
id: `${run.id}:${nextId}`,
ts,
runId: run.id,
agentId: run.agentId,
agentName: run.agentName,
text: normalized,
tone,
dedupeKey: `feed:${run.id}:${ts}:${tone}:${normalized}`,
streamingKind: options?.streamingKind,
};
}
function parseStdoutChunk(
run: LiveRunForIssue,
chunk: string,
ts: string,
pendingByRun: Map<string, string>,
nextIdRef: MutableRefObject<number>,
): FeedItem[] {
const pendingKey = `${run.id}:stdout`;
const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`;
const split = combined.split(/\r?\n/);
pendingByRun.set(pendingKey, split.pop() ?? "");
const adapter = getUIAdapter(run.adapterType);
const summarized: Array<{ text: string; tone: FeedTone; streamingKind?: "assistant" | "thinking" }> = [];
const appendSummary = (entry: TranscriptEntry) => {
if (entry.kind === "assistant" && entry.delta) {
const text = entry.text;
if (!text.trim()) return;
const last = summarized[summarized.length - 1];
if (last && last.streamingKind === "assistant") {
last.text += text;
} else {
summarized.push({ text, tone: "assistant", streamingKind: "assistant" });
}
return;
}
if (entry.kind === "thinking" && entry.delta) {
const text = entry.text;
if (!text.trim()) return;
const last = summarized[summarized.length - 1];
if (last && last.streamingKind === "thinking") {
last.text += text;
} else {
summarized.push({ text: `[thinking] ${text}`, tone: "info", streamingKind: "thinking" });
}
return;
}
const summary = summarizeEntry(entry);
if (!summary) return;
summarized.push({ text: summary.text, tone: summary.tone });
};
const items: FeedItem[] = [];
for (const line of split.slice(-8)) {
const trimmed = line.trim();
if (!trimmed) continue;
const parsed = adapter.parseStdoutLine(trimmed, ts);
if (parsed.length === 0) {
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
if (fallback) items.push(fallback);
continue;
}
for (const entry of parsed) {
appendSummary(entry);
}
}
for (const summary of summarized) {
const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++, {
streamingKind: summary.streamingKind,
preserveWhitespace: !!summary.streamingKind,
});
if (item) items.push(item);
}
return items;
}
function parseStderrChunk(
run: LiveRunForIssue,
chunk: string,
ts: string,
pendingByRun: Map<string, string>,
nextIdRef: MutableRefObject<number>,
): FeedItem[] {
const pendingKey = `${run.id}:stderr`;
const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`;
const split = combined.split(/\r?\n/);
pendingByRun.set(pendingKey, split.pop() ?? "");
const items: FeedItem[] = [];
for (const line of split.slice(-8)) {
const item = createFeedItem(run, ts, line, "error", nextIdRef.current++);
if (item) items.push(item);
}
return items;
}
function isRunActive(run: LiveRunForIssue): boolean {
return run.status === "queued" || run.status === "running";
}
@ -195,11 +23,6 @@ interface ActiveAgentsPanelProps {
}
export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
const [feedByRun, setFeedByRun] = useState<Map<string, FeedItem[]>>(new Map());
const seenKeysRef = useRef(new Set<string>());
const pendingByRunRef = useRef(new Map<string, string>());
const nextIdRef = useRef(1);
const { data: liveRuns } = useQuery({
queryKey: [...queryKeys.liveRuns(companyId), "dashboard"],
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, MIN_DASHBOARD_RUNS),
@ -220,179 +43,30 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
return map;
}, [issues]);
const runById = useMemo(() => new Map(runs.map((r) => [r.id, r])), [runs]);
const activeRunIds = useMemo(() => new Set(runs.filter(isRunActive).map((r) => r.id)), [runs]);
// Clean up pending buffers for runs that ended
useEffect(() => {
const stillActive = new Set<string>();
for (const runId of activeRunIds) {
stillActive.add(`${runId}:stdout`);
stillActive.add(`${runId}:stderr`);
}
for (const key of pendingByRunRef.current.keys()) {
if (!stillActive.has(key)) {
pendingByRunRef.current.delete(key);
}
}
}, [activeRunIds]);
// WebSocket connection for streaming
useEffect(() => {
if (activeRunIds.size === 0) return;
let closed = false;
let reconnectTimer: number | null = null;
let socket: WebSocket | null = null;
const appendItems = (runId: string, items: FeedItem[]) => {
if (items.length === 0) return;
setFeedByRun((prev) => {
const next = new Map(prev);
const existing = [...(next.get(runId) ?? [])];
for (const item of items) {
if (seenKeysRef.current.has(item.dedupeKey)) continue;
seenKeysRef.current.add(item.dedupeKey);
const last = existing[existing.length - 1];
if (
item.streamingKind &&
last &&
last.runId === item.runId &&
last.streamingKind === item.streamingKind
) {
const mergedText = `${last.text}${item.text}`;
const nextText =
mergedText.length > MAX_STREAMING_TEXT_LENGTH
? mergedText.slice(-MAX_STREAMING_TEXT_LENGTH)
: mergedText;
existing[existing.length - 1] = {
...last,
ts: item.ts,
text: nextText,
dedupeKey: last.dedupeKey,
};
continue;
}
existing.push(item);
}
if (seenKeysRef.current.size > 6000) {
seenKeysRef.current.clear();
}
next.set(runId, existing.slice(-MAX_FEED_ITEMS));
return next;
});
};
const scheduleReconnect = () => {
if (closed) return;
reconnectTimer = window.setTimeout(connect, 1500);
};
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`;
socket = new WebSocket(url);
socket.onmessage = (message) => {
const raw = typeof message.data === "string" ? message.data : "";
if (!raw) return;
let event: LiveEvent;
try {
event = JSON.parse(raw) as LiveEvent;
} catch {
return;
}
if (event.companyId !== companyId) return;
const payload = event.payload ?? {};
const runId = readString(payload["runId"]);
if (!runId || !activeRunIds.has(runId)) return;
const run = runById.get(runId);
if (!run) return;
if (event.type === "heartbeat.run.event") {
const seq = typeof payload["seq"] === "number" ? payload["seq"] : null;
const eventType = readString(payload["eventType"]) ?? "event";
const messageText = readString(payload["message"]) ?? eventType;
const dedupeKey = `${runId}:event:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`;
if (seenKeysRef.current.has(dedupeKey)) return;
seenKeysRef.current.add(dedupeKey);
if (seenKeysRef.current.size > 6000) seenKeysRef.current.clear();
const tone = eventType === "error" ? "error" : eventType === "lifecycle" ? "warn" : "info";
const item = createFeedItem(run, event.createdAt, messageText, tone, nextIdRef.current++);
if (item) appendItems(run.id, [item]);
return;
}
if (event.type === "heartbeat.run.status") {
const status = readString(payload["status"]) ?? "updated";
const dedupeKey = `${runId}:status:${status}:${readString(payload["finishedAt"]) ?? ""}`;
if (seenKeysRef.current.has(dedupeKey)) return;
seenKeysRef.current.add(dedupeKey);
if (seenKeysRef.current.size > 6000) seenKeysRef.current.clear();
const tone = status === "failed" || status === "timed_out" ? "error" : "warn";
const item = createFeedItem(run, event.createdAt, `run ${status}`, tone, nextIdRef.current++);
if (item) appendItems(run.id, [item]);
return;
}
if (event.type === "heartbeat.run.log") {
const chunk = readString(payload["chunk"]);
if (!chunk) return;
const stream = readString(payload["stream"]) === "stderr" ? "stderr" : "stdout";
if (stream === "stderr") {
appendItems(run.id, parseStderrChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef));
return;
}
appendItems(run.id, parseStdoutChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef));
}
};
socket.onerror = () => {
socket?.close();
};
socket.onclose = () => {
scheduleReconnect();
};
};
connect();
return () => {
closed = true;
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
if (socket) {
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
socket.close(1000, "active_agents_panel_unmount");
}
};
}, [activeRunIds, companyId, runById]);
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
runs,
companyId,
maxChunksPerRun: 120,
});
return (
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Agents
</h3>
{runs.length === 0 ? (
<div className="border border-border rounded-lg p-4">
<div className="rounded-xl border border-border p-4">
<p className="text-sm text-muted-foreground">No recent agent runs.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-2 sm:gap-4">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4">
{runs.map((run) => (
<AgentRunCard
key={run.id}
run={run}
issue={run.issueId ? issueById.get(run.issueId) : undefined}
feed={feedByRun.get(run.id) ?? []}
transcript={transcriptByRun.get(run.id) ?? []}
hasOutput={hasOutputForRun(run.id)}
isActive={isRunActive(run)}
/>
))}
@ -405,104 +79,77 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
function AgentRunCard({
run,
issue,
feed,
transcript,
hasOutput,
isActive,
}: {
run: LiveRunForIssue;
issue?: Issue;
feed: FeedItem[];
transcript: TranscriptEntry[];
hasOutput: boolean;
isActive: boolean;
}) {
const bodyRef = useRef<HTMLDivElement>(null);
const recent = feed.slice(-20);
useEffect(() => {
const body = bodyRef.current;
if (!body) return;
body.scrollTo({ top: body.scrollHeight, behavior: "smooth" });
}, [feed.length]);
return (
<div className={cn(
"flex flex-col rounded-lg border overflow-hidden min-h-[200px]",
"flex h-[320px] flex-col overflow-hidden rounded-xl border shadow-sm",
isActive
? "border-blue-500/30 bg-background/80 shadow-[0_0_12px_rgba(59,130,246,0.08)]"
: "border-border bg-background/50",
? "border-cyan-500/25 bg-cyan-500/[0.04] shadow-[0_16px_40px_rgba(6,182,212,0.08)]"
: "border-border bg-background/70",
)}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2 min-w-0">
{isActive ? (
<span className="relative flex h-2 w-2 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
) : (
<span className="flex h-2 w-2 shrink-0">
<span className="inline-flex rounded-full h-2 w-2 bg-muted-foreground/40" />
</span>
)}
<Identity name={run.agentName} size="sm" />
{isActive && (
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
)}
</div>
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground shrink-0"
>
<ExternalLink className="h-2.5 w-2.5" />
</Link>
</div>
<div className="border-b border-border/60 px-3 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
{isActive ? (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-cyan-400 opacity-70" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-cyan-500" />
</span>
) : (
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-muted-foreground/35" />
)}
<Identity name={run.agentName} size="sm" className="[&>span:last-child]:!text-[11px]" />
</div>
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}</span>
</div>
</div>
{/* Issue context */}
{run.issueId && (
<div className="px-3 py-1.5 border-b border-border/40 text-xs flex items-center gap-1 min-w-0">
<Link
to={`/issues/${issue?.identifier ?? run.issueId}`}
className={cn(
"hover:underline min-w-0 line-clamp-2 min-h-[2rem]",
isActive ? "text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300" : "text-muted-foreground hover:text-foreground",
)}
title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)}
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2 py-1 text-[10px] text-muted-foreground transition-colors hover:text-foreground"
>
{issue?.identifier ?? run.issueId.slice(0, 8)}
{issue?.title ? ` - ${issue.title}` : ""}
<ExternalLink className="h-2.5 w-2.5" />
</Link>
</div>
)}
{/* Feed body */}
<div ref={bodyRef} className="flex-1 max-h-[140px] overflow-y-auto p-2 font-mono text-[11px] space-y-1">
{isActive && recent.length === 0 && (
<div className="text-xs text-muted-foreground">Waiting for output...</div>
)}
{!isActive && recent.length === 0 && (
<div className="text-xs text-muted-foreground">
{run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}
{run.issueId && (
<div className="mt-3 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-xs">
<Link
to={`/issues/${issue?.identifier ?? run.issueId}`}
className={cn(
"line-clamp-2 hover:underline",
isActive ? "text-cyan-700 dark:text-cyan-300" : "text-muted-foreground hover:text-foreground",
)}
title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)}
>
{issue?.identifier ?? run.issueId.slice(0, 8)}
{issue?.title ? ` - ${issue.title}` : ""}
</Link>
</div>
)}
{recent.map((item, index) => (
<div
key={item.id}
className={cn(
"flex gap-2 items-start",
index === recent.length - 1 && isActive && "animate-in fade-in slide-in-from-bottom-1 duration-300",
)}
>
<span className="text-[10px] text-muted-foreground shrink-0">{relativeTime(item.ts)}</span>
<span className={cn(
"min-w-0 break-words",
item.tone === "error" && "text-red-600 dark:text-red-300",
item.tone === "warn" && "text-amber-600 dark:text-amber-300",
item.tone === "assistant" && "text-emerald-700 dark:text-emerald-200",
item.tone === "tool" && "text-cyan-600 dark:text-cyan-300",
item.tone === "info" && "text-foreground/80",
)}>
{item.text}
</span>
</div>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<RunTranscriptView
entries={transcript}
density="compact"
limit={5}
streaming={isActive}
collapseStdout
thinkingClassName="!text-[10px] !leading-4"
emptyMessage={hasOutput ? "Waiting for transcript parsing..." : isActive ? "Waiting for output..." : "No transcript captured."}
/>
</div>
</div>
);

View file

@ -12,6 +12,9 @@ const ACTION_VERBS: Record<string, string> = {
"issue.comment_added": "commented on",
"issue.attachment_added": "attached file to",
"issue.attachment_removed": "removed attachment from",
"issue.document_created": "created document for",
"issue.document_updated": "updated document on",
"issue.document_deleted": "deleted document from",
"issue.commented": "commented on",
"issue.deleted": "deleted",
"agent.created": "created",

View file

@ -0,0 +1,51 @@
import { Pause, Play } from "lucide-react";
import { Button } from "@/components/ui/button";
export function RunButton({
onClick,
disabled,
label = "Run now",
size = "sm",
}: {
onClick: () => void;
disabled?: boolean;
label?: string;
size?: "sm" | "default";
}) {
return (
<Button variant="outline" size={size} onClick={onClick} disabled={disabled}>
<Play className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{label}</span>
</Button>
);
}
export function PauseResumeButton({
isPaused,
onPause,
onResume,
disabled,
size = "sm",
}: {
isPaused: boolean;
onPause: () => void;
onResume: () => void;
disabled?: boolean;
size?: "sm" | "default";
}) {
if (isPaused) {
return (
<Button variant="outline" size={size} onClick={onResume} disabled={disabled}>
<Play className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Resume</span>
</Button>
);
}
return (
<Button variant="outline" size={size} onClick={onPause} disabled={disabled}>
<Pause className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Pause</span>
</Button>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import type {
@ -16,6 +16,7 @@ import {
DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import {
Popover,
PopoverContent,
@ -43,6 +44,8 @@ import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-field
import { MarkdownEditor } from "./MarkdownEditor";
import { ChoosePathButton } from "./PathInstructionsModal";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { ReportsToPicker } from "./ReportsToPicker";
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
/* ---- Create mode values ---- */
@ -59,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 +173,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();
@ -223,7 +236,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
/** Build accumulated patch and send to parent */
function handleSave() {
const handleCancel = useCallback(() => {
setOverlay({ ...emptyOverlay });
}, []);
const handleSave = useCallback(() => {
if (isCreate || !isDirty) return;
const agent = props.agent;
const patch: Record<string, unknown> = {};
@ -233,9 +250,26 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
if (overlay.adapterType !== undefined) {
patch.adapterType = overlay.adapterType;
// When adapter type changes, send only the new config — don't merge
// with old config since old adapter fields are meaningless for the new type
patch.adapterConfig = overlay.adapterConfig;
// When adapter type changes, replace adapter-specific fields but preserve
// adapter-agnostic fields (env, promptTemplate, etc.) that are shared
// across all adapter types.
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
const adapterAgnosticKeys = [
"env",
"promptTemplate",
"instructionsFilePath",
"cwd",
"timeoutSec",
"graceSec",
"bootstrapPromptTemplate",
];
const preserved: Record<string, unknown> = {};
for (const key of adapterAgnosticKeys) {
if (key in existing) {
preserved[key] = existing[key];
}
}
patch.adapterConfig = { ...preserved, ...overlay.adapterConfig };
} else if (Object.keys(overlay.adapterConfig).length > 0) {
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
@ -250,21 +284,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
props.onSave(patch);
}
}, [isCreate, isDirty, overlay, props]);
useEffect(() => {
if (!isCreate) {
props.onDirtyChange?.(isDirty);
props.onSaveActionChange?.(() => handleSave());
props.onCancelActionChange?.(() => setOverlay({ ...emptyOverlay }));
return () => {
props.onSaveActionChange?.(null);
props.onCancelActionChange?.(null);
props.onDirtyChange?.(false);
};
props.onSaveActionChange?.(handleSave);
props.onCancelActionChange?.(handleCancel);
}
return;
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, overlay]); // eslint-disable-line react-hooks/exhaustive-deps
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]);
useEffect(() => {
if (isCreate) return;
return () => {
props.onSaveActionChange?.(null);
props.onCancelActionChange?.(null);
props.onDirtyChange?.(false);
};
}, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]);
// ---- Resolve values ----
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
@ -277,8 +314,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const isLocal =
adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor";
const isHermesLocal = adapterType === "hermes_local";
const showLegacyWorkingDirectoryField =
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
// Fetch adapter models for the effective adapter type
@ -293,6 +336,28 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
enabled: Boolean(selectedCompanyId),
});
const models = fetchedModels ?? externalModels ?? [];
const {
data: detectedModelData,
refetch: refetchDetectedModel,
} = useQuery({
queryKey: selectedCompanyId
? queryKeys.agents.detectModel(selectedCompanyId, adapterType)
: ["agents", "none", "detect-model", adapterType],
queryFn: () => {
if (!selectedCompanyId) {
throw new Error("Select a company to detect the Hermes model");
}
return agentsApi.detectModel(selectedCompanyId, adapterType);
},
enabled: Boolean(selectedCompanyId && isHermesLocal),
});
const detectedModel = detectedModelData?.model ?? null;
const { data: companyAgents = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: Boolean(!isCreate && selectedCompanyId),
});
/** Props passed to adapter-specific config field components */
const adapterFieldProps = {
@ -305,6 +370,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
@ -369,13 +435,33 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)
: adapterType === "cursor"
? eff("adapterConfig", "mode", String(config.mode ?? ""))
: adapterType === "opencode_local"
? eff("adapterConfig", "variant", String(config.variant ?? ""))
: adapterType === "opencode_local"
? eff("adapterConfig", "variant", String(config.variant ?? ""))
: eff("adapterConfig", "effort", String(config.effort ?? ""));
const showThinkingEffort = adapterType !== "gemini_local";
const codexSearchEnabled = adapterType === "codex_local"
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
: false;
const effectiveRuntimeConfig = useMemo(() => {
if (isCreate) {
return {
heartbeat: {
enabled: val!.heartbeatEnabled,
intervalSec: val!.intervalSec,
},
};
}
const mergedHeartbeat = {
...(runtimeConfig.heartbeat && typeof runtimeConfig.heartbeat === "object"
? runtimeConfig.heartbeat as Record<string, unknown>
: {}),
...overlay.heartbeat,
};
return {
...runtimeConfig,
heartbeat: mergedHeartbeat,
};
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
return (
<div className={cn("relative", cards && "space-y-6")}>
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
@ -420,6 +506,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
placeholder="e.g. VP of Engineering"
/>
</Field>
<Field label="Reports to" hint={help.reportsTo}>
<ReportsToPicker
agents={companyAgents}
value={eff("identity", "reportsTo", props.agent.reportsTo ?? null)}
onChange={(id) => mark("identity", "reportsTo", id)}
excludeAgentIds={[props.agent.id]}
chooseLabel="Choose manager…"
/>
</Field>
<Field label="Capabilities" hint={help.capabilities}>
<MarkdownEditor
value={eff("identity", "capabilities", props.agent.capabilities ?? "")}
@ -435,24 +530,29 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}}
/>
</Field>
{isLocal && (
<Field label="Prompt Template" hint={help.promptTemplate}>
<MarkdownEditor
value={eff(
"adapterConfig",
"promptTemplate",
String(config.promptTemplate ?? ""),
)}
onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")}
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
contentClassName="min-h-[88px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = `agents/${props.agent.id}/prompt-template`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
{isLocal && !props.hidePromptTemplate && (
<>
<Field label="Prompt Template" hint={help.promptTemplate}>
<MarkdownEditor
value={eff(
"adapterConfig",
"promptTemplate",
String(config.promptTemplate ?? ""),
)}
onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")}
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
contentClassName="min-h-[88px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = `agents/${props.agent.id}/prompt-template`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
</div>
</>
)}
</div>
</div>
@ -465,65 +565,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 === "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 === "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">
@ -538,8 +646,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
@ -564,19 +672,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{/* Prompt template (create mode only — edit mode shows this in Identity) */}
{isLocal && isCreate && (
<Field label="Prompt Template" hint={help.promptTemplate}>
<MarkdownEditor
value={val!.promptTemplate}
onChange={(v) => set!({ promptTemplate: v })}
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
contentClassName="min-h-[88px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = "agents/drafts/prompt-template";
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<>
<Field label="Prompt Template" hint={help.promptTemplate}>
<MarkdownEditor
value={val!.promptTemplate}
onChange={(v) => set!({ promptTemplate: v })}
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
contentClassName="min-h-[88px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = "agents/drafts/prompt-template";
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Prompt template is replayed on every heartbeat. Prefer small task framing and variables like <code>{"{{ context.* }}"}</code> or <code>{"{{ run.* }}"}</code>; avoid repeating stable instructions here.
</div>
</>
)}
{/* Adapter-specific fields */}
@ -610,8 +723,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
placeholder={
adapterType === "codex_local"
? "codex"
: adapterType === "cursor"
? "agent"
: adapterType === "gemini_local"
? "gemini"
: adapterType === "hermes_local"
? "hermes"
: adapterType === "pi_local"
? "pi"
: adapterType === "cursor"
? "agent"
: adapterType === "opencode_local"
? "opencode"
: "claude"
@ -629,9 +748,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={adapterType !== "opencode_local"}
required={adapterType === "opencode_local"}
allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"}
required={adapterType === "opencode_local" || adapterType === "hermes_local"}
groupByProvider={adapterType === "opencode_local"}
creatable={adapterType === "hermes_local"}
detectedModel={adapterType === "hermes_local" ? detectedModel : null}
onDetectModel={adapterType === "hermes_local"
? async () => {
const result = await refetchDetectedModel();
return result.data?.model ?? null;
}
: undefined}
detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined}
/>
{fetchedModelsError && (
<p className="text-xs text-destructive">
@ -641,51 +769,54 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</p>
)}
<ThinkingEffortDropdown
value={currentThinkingEffort}
options={thinkingEffortOptions}
onChange={(v) =>
isCreate
? set!({ thinkingEffort: v })
: mark("adapterConfig", thinkingEffortKey, v || undefined)
}
open={thinkingEffortOpen}
onOpenChange={setThinkingEffortOpen}
/>
{adapterType === "codex_local" &&
codexSearchEnabled &&
currentThinkingEffort === "minimal" && (
<p className="text-xs text-amber-400">
Codex may reject `minimal` thinking when search is enabled.
</p>
)}
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
<MarkdownEditor
value={
isCreate
? val!.bootstrapPrompt
: eff(
"adapterConfig",
"bootstrapPromptTemplate",
String(config.bootstrapPromptTemplate ?? ""),
)
}
onChange={(v) =>
isCreate
? set!({ bootstrapPrompt: v })
: mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
}
placeholder="Optional initial setup prompt for the first run"
contentClassName="min-h-[44px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = isCreate
? "agents/drafts/bootstrap-prompt"
: `agents/${props.agent.id}/bootstrap-prompt`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
{showThinkingEffort && (
<>
<ThinkingEffortDropdown
value={currentThinkingEffort}
options={thinkingEffortOptions}
onChange={(v) =>
isCreate
? set!({ thinkingEffort: v })
: mark("adapterConfig", thinkingEffortKey, v || undefined)
}
open={thinkingEffortOpen}
onOpenChange={setThinkingEffortOpen}
/>
{adapterType === "codex_local" &&
codexSearchEnabled &&
currentThinkingEffort === "minimal" && (
<p className="text-xs text-amber-400">
Codex may reject `minimal` thinking when search is enabled.
</p>
)}
</>
)}
{!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && (
<>
<Field label="Bootstrap prompt (legacy)" hint={help.bootstrapPrompt}>
<MarkdownEditor
value={eff(
"adapterConfig",
"bootstrapPromptTemplate",
String(config.bootstrapPromptTemplate ?? ""),
)}
onChange={(v) =>
mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
}
placeholder="Optional initial setup prompt for the first run"
contentClassName="min-h-[44px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = `agents/${props.agent.id}/bootstrap-prompt`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
Bootstrap prompt is legacy and will be removed in a future release. Consider moving this content into the agent&apos;s prompt template or instructions file instead.
</div>
</>
)}
{adapterType === "claude_local" && (
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
)}
@ -763,7 +894,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>
@ -784,7 +915,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>
@ -850,7 +981,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</CollapsibleSection>
</div>
</div>
)}
) : null}
</div>
);
@ -893,7 +1024,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
/* ---- Internal sub-components ---- */
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local", "cursor"]);
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
@ -1210,6 +1341,10 @@ function ModelDropdown({
allowDefault,
required,
groupByProvider,
creatable,
detectedModel,
onDetectModel,
detectModelLabel,
}: {
models: AdapterModel[];
value: string;
@ -1219,9 +1354,20 @@ function ModelDropdown({
allowDefault: boolean;
required: boolean;
groupByProvider: boolean;
creatable?: boolean;
detectedModel?: string | null;
onDetectModel?: () => Promise<string | null>;
detectModelLabel?: string;
}) {
const [modelSearch, setModelSearch] = useState("");
const [detectingModel, setDetectingModel] = useState(false);
const selected = models.find((m) => m.id === value);
const manualModel = modelSearch.trim();
const canCreateManualModel = Boolean(
creatable &&
manualModel &&
!models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()),
);
const filteredModels = useMemo(() => {
return models.filter((m) => {
if (!modelSearch.trim()) return true;
@ -1258,6 +1404,21 @@ function ModelDropdown({
}));
}, [filteredModels, groupByProvider]);
async function handleDetectModel() {
if (!onDetectModel) return;
setDetectingModel(true);
try {
const nextModel = await onDetectModel();
if (nextModel) {
onChange(nextModel);
onOpenChange(false);
setModelSearch("");
}
} finally {
setDetectingModel(false);
}
}
return (
<Field label="Model" hint={help.model}>
<Popover
@ -1268,7 +1429,7 @@ function ModelDropdown({
}}
>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>
{selected
? selected.label
@ -1278,16 +1439,84 @@ function ModelDropdown({
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search models..."
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
<div className="relative mb-1">
<input
className="w-full px-2 py-1.5 pr-6 text-xs bg-transparent outline-none border-b border-border placeholder:text-muted-foreground/50"
placeholder={creatable ? "Search models... (type to create)" : "Search models..."}
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
{modelSearch && (
<button
type="button"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setModelSearch("")}
>
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
{onDetectModel && !detectedModel && !modelSearch.trim() && (
<button
type="button"
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
onClick={() => {
void handleDetectModel();
}}
disabled={detectingModel}
>
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
{detectingModel ? "Detecting..." : (detectModelLabel ?? "Detect from config")}
</button>
)}
{value && !models.some((m) => m.id === value) && (
<button
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded bg-accent/50",
)}
onClick={() => {
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
{value}
</span>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 border border-green-500/20">
current
</span>
</button>
)}
{detectedModel && detectedModel !== value && (
<button
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
)}
onClick={() => {
onChange(detectedModel);
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
{detectedModel}
</span>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/20">
detected
</span>
</button>
)}
<div className="max-h-[240px] overflow-y-auto">
{allowDefault && (
<button
type="button"
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!value && "bg-accent",
@ -1300,6 +1529,20 @@ function ModelDropdown({
Default
</button>
)}
{canCreateManualModel && (
<button
type="button"
className="flex items-center justify-between gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50"
onClick={() => {
onChange(manualModel);
onOpenChange(false);
setModelSearch("");
}}
>
<span>Use manual model</span>
<span className="text-xs font-mono text-muted-foreground">{manualModel}</span>
</button>
)}
{groupedModels.map((group) => (
<div key={group.provider} className="mb-1 last:mb-0">
{groupByProvider && (
@ -1309,6 +1552,7 @@ function ModelDropdown({
)}
{group.entries.map((m) => (
<button
type="button"
key={m.id}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
@ -1326,8 +1570,14 @@ function ModelDropdown({
))}
</div>
))}
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
{filteredModels.length === 0 && !canCreateManualModel && (
<div className="px-2 py-2 space-y-2">
<p className="text-xs text-muted-foreground">
{onDetectModel
? "No Hermes model detected yet. Configure Hermes or enter a provider/model manually."
: "No models found."}
</p>
</div>
)}
</div>
</PopoverContent>

View file

@ -1,46 +1,5 @@
import { useState, useMemo } from "react";
import {
Bot,
Cpu,
Brain,
Zap,
Rocket,
Code,
Terminal,
Shield,
Eye,
Search,
Wrench,
Hammer,
Lightbulb,
Sparkles,
Star,
Heart,
Flame,
Bug,
Cog,
Database,
Globe,
Lock,
Mail,
MessageSquare,
FileCode,
GitBranch,
Package,
Puzzle,
Target,
Wand2,
Atom,
CircuitBoard,
Radar,
Swords,
Telescope,
Microscope,
Crown,
Gem,
Hexagon,
Pentagon,
Fingerprint,
type LucideIcon,
} from "lucide-react";
import { AGENT_ICON_NAMES, type AgentIconName } from "@paperclipai/shared";
@ -51,60 +10,10 @@ import {
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
export const AGENT_ICONS: Record<AgentIconName, LucideIcon> = {
bot: Bot,
cpu: Cpu,
brain: Brain,
zap: Zap,
rocket: Rocket,
code: Code,
terminal: Terminal,
shield: Shield,
eye: Eye,
search: Search,
wrench: Wrench,
hammer: Hammer,
lightbulb: Lightbulb,
sparkles: Sparkles,
star: Star,
heart: Heart,
flame: Flame,
bug: Bug,
cog: Cog,
database: Database,
globe: Globe,
lock: Lock,
mail: Mail,
"message-square": MessageSquare,
"file-code": FileCode,
"git-branch": GitBranch,
package: Package,
puzzle: Puzzle,
target: Target,
wand: Wand2,
atom: Atom,
"circuit-board": CircuitBoard,
radar: Radar,
swords: Swords,
telescope: Telescope,
microscope: Microscope,
crown: Crown,
gem: Gem,
hexagon: Hexagon,
pentagon: Pentagon,
fingerprint: Fingerprint,
};
import { AGENT_ICONS, getAgentIcon } from "../lib/agent-icons";
const DEFAULT_ICON: AgentIconName = "bot";
export function getAgentIcon(iconName: string | null | undefined): LucideIcon {
if (iconName && AGENT_ICON_NAMES.includes(iconName as AgentIconName)) {
return AGENT_ICONS[iconName as AgentIconName];
}
return AGENT_ICONS[DEFAULT_ICON];
}
interface AgentIconProps {
icon: string | null | undefined;
className?: string;

View file

@ -17,6 +17,7 @@ interface AgentPropertiesProps {
const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
gemini_local: "Gemini CLI (local)",
opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",

View file

@ -2,7 +2,7 @@ import { CheckCircle2, XCircle, Clock } from "lucide-react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { Identity } from "./Identity";
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
import { timeAgo } from "../lib/timeAgo";
import type { Approval, Agent } from "@paperclipai/shared";
@ -32,7 +32,10 @@ export function ApprovalCard({
isPending: boolean;
}) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
const label = typeLabel[approval.type] ?? approval.type;
const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
const showResolutionButtons =
approval.type !== "budget_override_required" &&
(approval.status === "pending" || approval.status === "revision_requested");
return (
<div className="border border-border rounded-lg p-4 space-y-0">
@ -67,7 +70,7 @@ export function ApprovalCard({
)}
{/* Actions */}
{(approval.status === "pending" || approval.status === "revision_requested") && (
{showResolutionButtons && (
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
<Button
size="sm"

View file

@ -1,13 +1,25 @@
import { UserPlus, Lightbulb, ShieldCheck } from "lucide-react";
import { UserPlus, Lightbulb, ShieldAlert, ShieldCheck } from "lucide-react";
import { formatCents } from "../lib/utils";
export const typeLabel: Record<string, string> = {
hire_agent: "Hire Agent",
approve_ceo_strategy: "CEO Strategy",
budget_override_required: "Budget Override",
};
/** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */
export function approvalLabel(type: string, payload?: Record<string, unknown> | null): string {
const base = typeLabel[type] ?? type;
if (type === "hire_agent" && payload?.name) {
return `${base}: ${String(payload.name)}`;
}
return base;
}
export const typeIcon: Record<string, typeof UserPlus> = {
hire_agent: UserPlus,
approve_ceo_strategy: Lightbulb,
budget_override_required: ShieldAlert,
};
export const defaultTypeIcon = ShieldCheck;
@ -22,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">
@ -46,6 +83,7 @@ export function HireAgentPayload({ payload }: { payload: Record<string, unknown>
</span>
</div>
)}
<SkillList values={payload.desiredSkills} />
</div>
);
}
@ -69,7 +107,28 @@ export function CeoStrategyPayload({ payload }: { payload: Record<string, unknow
);
}
export function BudgetOverridePayload({ payload }: { payload: Record<string, unknown> }) {
const budgetAmount = typeof payload.budgetAmount === "number" ? payload.budgetAmount : null;
const observedAmount = typeof payload.observedAmount === "number" ? payload.observedAmount : null;
return (
<div className="mt-3 space-y-1.5 text-sm">
<PayloadField label="Scope" value={payload.scopeName ?? payload.scopeType} />
<PayloadField label="Window" value={payload.windowKind} />
<PayloadField label="Metric" value={payload.metric} />
{(budgetAmount !== null || observedAmount !== null) ? (
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
Limit {budgetAmount !== null ? formatCents(budgetAmount) : "—"} · Observed {observedAmount !== null ? formatCents(observedAmount) : "—"}
</div>
) : null}
{!!payload.guidance && (
<p className="text-muted-foreground">{String(payload.guidance)}</p>
)}
</div>
);
}
export function ApprovalPayloadRenderer({ type, payload }: { type: string; payload: Record<string, unknown> }) {
if (type === "hire_agent") return <HireAgentPayload payload={payload} />;
if (type === "budget_override_required") return <BudgetOverridePayload payload={payload} />;
return <CeoStrategyPayload payload={payload} />;
}

View file

@ -0,0 +1,145 @@
import { useMemo } from "react";
import type { CostByBiller, CostByProviderModel } from "@paperclipai/shared";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { QuotaBar } from "./QuotaBar";
import { billingTypeDisplayName, formatCents, formatTokens, providerDisplayName } from "@/lib/utils";
interface BillerSpendCardProps {
row: CostByBiller;
weekSpendCents: number;
budgetMonthlyCents: number;
totalCompanySpendCents: number;
providerRows: CostByProviderModel[];
}
export function BillerSpendCard({
row,
weekSpendCents,
budgetMonthlyCents,
totalCompanySpendCents,
providerRows,
}: BillerSpendCardProps) {
const providerBreakdown = useMemo(() => {
const map = new Map<string, { provider: string; costCents: number; inputTokens: number; outputTokens: number }>();
for (const entry of providerRows) {
const current = map.get(entry.provider) ?? {
provider: entry.provider,
costCents: 0,
inputTokens: 0,
outputTokens: 0,
};
current.costCents += entry.costCents;
current.inputTokens += entry.inputTokens + entry.cachedInputTokens;
current.outputTokens += entry.outputTokens;
map.set(entry.provider, current);
}
return Array.from(map.values()).sort((a, b) => b.costCents - a.costCents);
}, [providerRows]);
const billingTypeBreakdown = useMemo(() => {
const map = new Map<string, number>();
for (const entry of providerRows) {
map.set(entry.billingType, (map.get(entry.billingType) ?? 0) + entry.costCents);
}
return Array.from(map.entries()).sort((a, b) => b[1] - a[1]);
}, [providerRows]);
const providerBudgetShare =
budgetMonthlyCents > 0 && totalCompanySpendCents > 0
? (row.costCents / totalCompanySpendCents) * budgetMonthlyCents
: budgetMonthlyCents;
const budgetPct =
providerBudgetShare > 0
? Math.min(100, (row.costCents / providerBudgetShare) * 100)
: 0;
return (
<Card>
<CardHeader className="px-4 pt-4 pb-0 gap-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-sm font-semibold">
{providerDisplayName(row.biller)}
</CardTitle>
<CardDescription className="text-xs mt-0.5">
<span className="font-mono">{formatTokens(row.inputTokens + row.cachedInputTokens)}</span> in
{" · "}
<span className="font-mono">{formatTokens(row.outputTokens)}</span> out
{" · "}
{row.providerCount} provider{row.providerCount === 1 ? "" : "s"}
{" · "}
{row.modelCount} model{row.modelCount === 1 ? "" : "s"}
</CardDescription>
</div>
<span className="text-xl font-bold tabular-nums shrink-0">
{formatCents(row.costCents)}
</span>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 pt-3 space-y-4">
{budgetMonthlyCents > 0 && (
<QuotaBar
label="Period spend"
percentUsed={budgetPct}
leftLabel={formatCents(row.costCents)}
rightLabel={`${Math.round(budgetPct)}% of allocation`}
/>
)}
<div className="text-xs text-muted-foreground">
{row.apiRunCount > 0 ? `${row.apiRunCount} metered run${row.apiRunCount === 1 ? "" : "s"}` : "0 metered runs"}
{" · "}
{row.subscriptionRunCount > 0
? `${row.subscriptionRunCount} subscription run${row.subscriptionRunCount === 1 ? "" : "s"}`
: "0 subscription runs"}
{" · "}
{formatCents(weekSpendCents)} this week
</div>
{billingTypeBreakdown.length > 0 && (
<>
<div className="border-t border-border" />
<div className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Billing types
</p>
<div className="space-y-1.5">
{billingTypeBreakdown.map(([billingType, costCents]) => (
<div key={billingType} className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted-foreground">{billingTypeDisplayName(billingType as any)}</span>
<span className="font-medium tabular-nums">{formatCents(costCents)}</span>
</div>
))}
</div>
</div>
</>
)}
{providerBreakdown.length > 0 && (
<>
<div className="border-t border-border" />
<div className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Upstream providers
</p>
<div className="space-y-1.5">
{providerBreakdown.map((entry) => (
<div key={entry.provider} className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted-foreground">{providerDisplayName(entry.provider)}</span>
<div className="text-right tabular-nums">
<div className="font-medium">{formatCents(entry.costCents)}</div>
<div className="text-muted-foreground">
{formatTokens(entry.inputTokens + entry.outputTokens)} tok
</div>
</div>
</div>
))}
</div>
</div>
</>
)}
</CardContent>
</Card>
);
}

View file

@ -2,6 +2,7 @@ import { Link } from "@/lib/router";
import { Menu } from "lucide-react";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { useCompany } from "../context/CompanyContext";
import { Button } from "@/components/ui/button";
import {
Breadcrumb,
@ -11,13 +12,46 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Fragment } from "react";
import { Fragment, useMemo } from "react";
import { PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { PluginLauncherOutlet, usePluginLaunchers } from "@/plugins/launchers";
type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null };
function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) {
const { slots } = usePluginSlots({ slotTypes: ["globalToolbarButton"], companyId: context.companyId });
const { launchers } = usePluginLaunchers({ placementZones: ["globalToolbarButton"], companyId: context.companyId, enabled: !!context.companyId });
if (slots.length === 0 && launchers.length === 0) return null;
return (
<div className="flex items-center gap-1 ml-auto shrink-0 pl-2">
<PluginSlotOutlet slotTypes={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
<PluginLauncherOutlet placementZones={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
</div>
);
}
export function BreadcrumbBar() {
const { breadcrumbs } = useBreadcrumbs();
const { toggleSidebar, isMobile } = useSidebar();
const { selectedCompanyId, selectedCompany } = useCompany();
if (breadcrumbs.length === 0) return null;
const globalToolbarSlotContext = useMemo(
() => ({
companyId: selectedCompanyId ?? null,
companyPrefix: selectedCompany?.issuePrefix ?? null,
}),
[selectedCompanyId, selectedCompany?.issuePrefix],
);
const globalToolbarSlots = <GlobalToolbarPlugins context={globalToolbarSlotContext} />;
if (breadcrumbs.length === 0) {
return (
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
{globalToolbarSlots}
</div>
);
}
const menuButton = isMobile && (
<Button
@ -34,40 +68,46 @@ export function BreadcrumbBar() {
// Single breadcrumb = page title (uppercase)
if (breadcrumbs.length === 1) {
return (
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center min-w-0 overflow-hidden">
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
{menuButton}
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
{breadcrumbs[0].label}
</h1>
<div className="min-w-0 overflow-hidden flex-1">
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
{breadcrumbs[0].label}
</h1>
</div>
{globalToolbarSlots}
</div>
);
}
// Multiple breadcrumbs = breadcrumb trail
return (
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center min-w-0 overflow-hidden">
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
{menuButton}
<Breadcrumb className="min-w-0 overflow-hidden">
<BreadcrumbList className="flex-nowrap">
{breadcrumbs.map((crumb, i) => {
const isLast = i === breadcrumbs.length - 1;
return (
<Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
{isLast || !crumb.href ? (
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link to={crumb.href}>{crumb.label}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
</Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
<div className="min-w-0 overflow-hidden flex-1">
<Breadcrumb className="min-w-0 overflow-hidden">
<BreadcrumbList className="flex-nowrap">
{breadcrumbs.map((crumb, i) => {
const isLast = i === breadcrumbs.length - 1;
return (
<Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
{isLast || !crumb.href ? (
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link to={crumb.href}>{crumb.label}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
</Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
</div>
{globalToolbarSlots}
</div>
);
}

View file

@ -0,0 +1,100 @@
import { useState } from "react";
import type { BudgetIncident } from "@paperclipai/shared";
import { AlertOctagon, ArrowUpRight, PauseCircle } from "lucide-react";
import { formatCents } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function centsInputValue(value: number) {
return (value / 100).toFixed(2);
}
function parseDollarInput(value: string) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return Math.round(parsed * 100);
}
export function BudgetIncidentCard({
incident,
onRaiseAndResume,
onKeepPaused,
isMutating,
}: {
incident: BudgetIncident;
onRaiseAndResume: (amountCents: number) => void;
onKeepPaused: () => void;
isMutating?: boolean;
}) {
const [draftAmount, setDraftAmount] = useState(
centsInputValue(Math.max(incident.amountObserved + 1000, incident.amountLimit)),
);
const parsed = parseDollarInput(draftAmount);
return (
<Card className="overflow-hidden border-red-500/20 bg-[linear-gradient(180deg,rgba(255,70,70,0.10),rgba(255,255,255,0.02))]">
<CardHeader className="px-5 pt-5 pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-[0.22em] text-red-200/80">
{incident.scopeType} hard stop
</div>
<CardTitle className="mt-1 text-base text-red-50">{incident.scopeName}</CardTitle>
<CardDescription className="mt-1 text-red-100/70">
Spending reached {formatCents(incident.amountObserved)} against a limit of {formatCents(incident.amountLimit)}.
</CardDescription>
</div>
<div className="rounded-full border border-red-400/30 bg-red-500/10 p-2 text-red-200">
<AlertOctagon className="h-4 w-4" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 px-5 pb-5 pt-0">
<div className="flex items-start gap-2 rounded-xl border border-red-400/20 bg-red-500/10 px-3 py-2 text-sm text-red-50/90">
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
{incident.scopeType === "project"
? "Project execution is paused. New work in this project will not start until you resolve the budget incident."
: "This scope is paused. New heartbeats will not start until you resolve the budget incident."}
</div>
</div>
<div className="rounded-xl border border-border/60 bg-background/60 p-3">
<label className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
New budget (USD)
</label>
<div className="mt-2 flex flex-col gap-3 sm:flex-row">
<Input
value={draftAmount}
onChange={(event) => setDraftAmount(event.target.value)}
inputMode="decimal"
placeholder="0.00"
/>
<Button
className="gap-2"
disabled={isMutating || parsed === null || parsed <= incident.amountObserved}
onClick={() => {
if (typeof parsed === "number") onRaiseAndResume(parsed);
}}
>
<ArrowUpRight className="h-4 w-4" />
{isMutating ? "Applying..." : "Raise budget & resume"}
</Button>
</div>
{parsed !== null && parsed <= incident.amountObserved ? (
<p className="mt-2 text-xs text-red-200/80">
The new budget must exceed current observed spend.
</p>
) : null}
</div>
<div className="flex justify-end">
<Button variant="ghost" className="text-muted-foreground" disabled={isMutating} onClick={onKeepPaused}>
Keep paused
</Button>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,219 @@
import { useEffect, useState } from "react";
import type { BudgetPolicySummary } from "@paperclipai/shared";
import { AlertTriangle, PauseCircle, ShieldAlert, Wallet } from "lucide-react";
import { cn, formatCents } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function centsInputValue(value: number) {
return (value / 100).toFixed(2);
}
function parseDollarInput(value: string) {
const normalized = value.trim();
if (normalized.length === 0) return 0;
const parsed = Number(normalized);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return Math.round(parsed * 100);
}
function windowLabel(windowKind: BudgetPolicySummary["windowKind"]) {
return windowKind === "lifetime" ? "Lifetime budget" : "Monthly UTC budget";
}
function statusTone(status: BudgetPolicySummary["status"]) {
if (status === "hard_stop") return "text-red-300 border-red-500/30 bg-red-500/10";
if (status === "warning") return "text-amber-200 border-amber-500/30 bg-amber-500/10";
return "text-emerald-200 border-emerald-500/30 bg-emerald-500/10";
}
export function BudgetPolicyCard({
summary,
onSave,
isSaving,
compact = false,
variant = "card",
}: {
summary: BudgetPolicySummary;
onSave?: (amountCents: number) => void;
isSaving?: boolean;
compact?: boolean;
variant?: "card" | "plain";
}) {
const [draftBudget, setDraftBudget] = useState(centsInputValue(summary.amount));
useEffect(() => {
setDraftBudget(centsInputValue(summary.amount));
}, [summary.amount]);
const parsedDraft = parseDollarInput(draftBudget);
const canSave = typeof parsedDraft === "number" && parsedDraft !== summary.amount && Boolean(onSave);
const progress = summary.amount > 0 ? Math.min(100, summary.utilizationPercent) : 0;
const StatusIcon = summary.status === "hard_stop" ? ShieldAlert : summary.status === "warning" ? AlertTriangle : Wallet;
const isPlain = variant === "plain";
const observedBudgetGrid = isPlain ? (
<div className="grid gap-6 sm:grid-cols-2">
<div>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Observed</div>
<div className="mt-2 text-xl font-semibold tabular-nums">{formatCents(summary.observedAmount)}</div>
<div className="mt-1 text-xs text-muted-foreground">
{summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"}
</div>
</div>
<div>
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Budget</div>
<div className="mt-2 text-xl font-semibold tabular-nums">
{summary.amount > 0 ? formatCents(summary.amount) : "Disabled"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
Soft alert at {summary.warnPercent}%{summary.paused && summary.pauseReason ? ` · ${summary.pauseReason} pause` : ""}
</div>
</div>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border/70 bg-black/[0.18] px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Observed</div>
<div className="mt-2 text-xl font-semibold tabular-nums">{formatCents(summary.observedAmount)}</div>
<div className="mt-1 text-xs text-muted-foreground">
{summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"}
</div>
</div>
<div className="rounded-xl border border-border/70 bg-black/[0.18] px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Budget</div>
<div className="mt-2 text-xl font-semibold tabular-nums">
{summary.amount > 0 ? formatCents(summary.amount) : "Disabled"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
Soft alert at {summary.warnPercent}%{summary.paused && summary.pauseReason ? ` · ${summary.pauseReason} pause` : ""}
</div>
</div>
</div>
);
const progressSection = (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Remaining</span>
<span>{summary.amount > 0 ? formatCents(summary.remainingAmount) : "Unlimited"}</span>
</div>
<div className={cn("h-2 overflow-hidden rounded-full", isPlain ? "bg-border/70" : "bg-muted/70")}>
<div
className={cn(
"h-full rounded-full transition-[width,background-color] duration-200",
summary.status === "hard_stop"
? "bg-red-400"
: summary.status === "warning"
? "bg-amber-300"
: "bg-emerald-300",
)}
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
const pausedPane = summary.paused ? (
<div className="flex items-start gap-2 rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-100">
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
{summary.scopeType === "project"
? "Execution is paused for this project until the budget is raised or the incident is dismissed."
: "Heartbeats are paused for this scope until the budget is raised or the incident is dismissed."}
</div>
</div>
) : null;
const saveSection = onSave ? (
<div className={cn("flex flex-col gap-3 sm:flex-row sm:items-end", isPlain ? "" : "rounded-xl border border-border/70 bg-background/50 p-3")}>
<div className="min-w-0 flex-1">
<label className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Budget (USD)
</label>
<Input
value={draftBudget}
onChange={(event) => setDraftBudget(event.target.value)}
className="mt-2"
inputMode="decimal"
placeholder="0.00"
/>
</div>
<Button
onClick={() => {
if (typeof parsedDraft === "number" && onSave) onSave(parsedDraft);
}}
disabled={!canSave || isSaving || parsedDraft === null}
>
{isSaving ? "Saving..." : summary.amount > 0 ? "Update budget" : "Set budget"}
</Button>
</div>
) : null;
if (isPlain) {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-6">
<div>
<div className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
{summary.scopeType}
</div>
<div className="mt-2 text-xl font-semibold">{summary.scopeName}</div>
<div className="mt-2 text-sm text-muted-foreground">{windowLabel(summary.windowKind)}</div>
</div>
<div
className={cn(
"inline-flex items-center gap-2 text-[11px] uppercase tracking-[0.18em]",
summary.status === "hard_stop"
? "text-red-300"
: summary.status === "warning"
? "text-amber-200"
: "text-muted-foreground",
)}
>
<StatusIcon className="h-3.5 w-3.5" />
{summary.paused ? "Paused" : summary.status === "warning" ? "Warning" : summary.status === "hard_stop" ? "Hard stop" : "Healthy"}
</div>
</div>
{observedBudgetGrid}
{progressSection}
{pausedPane}
{saveSection}
{parsedDraft === null ? (
<p className="text-xs text-destructive">Enter a valid non-negative dollar amount.</p>
) : null}
</div>
);
}
return (
<Card className={cn("overflow-hidden border-border/70 bg-card/80", compact ? "" : "shadow-[0_20px_80px_-40px_rgba(0,0,0,0.55)]")}>
<CardHeader className={cn("gap-3", compact ? "px-4 pt-4 pb-2" : "px-5 pt-5 pb-3")}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
{summary.scopeType}
</div>
<CardTitle className="mt-1 text-base">{summary.scopeName}</CardTitle>
<CardDescription className="mt-1">{windowLabel(summary.windowKind)}</CardDescription>
</div>
<div className={cn("inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] uppercase tracking-[0.18em]", statusTone(summary.status))}>
<StatusIcon className="h-3.5 w-3.5" />
{summary.paused ? "Paused" : summary.status === "warning" ? "Warning" : summary.status === "hard_stop" ? "Hard stop" : "Healthy"}
</div>
</div>
</CardHeader>
<CardContent className={cn("space-y-4", compact ? "px-4 pb-4 pt-0" : "px-5 pb-5 pt-0")}>
{observedBudgetGrid}
{progressSection}
{pausedPane}
{saveSection}
{parsedDraft === null ? (
<p className="text-xs text-destructive">Enter a valid non-negative dollar amount.</p>
) : null}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,13 @@
import { DollarSign } from "lucide-react";
export function BudgetSidebarMarker({ title = "Paused by budget" }: { title?: string }) {
return (
<span
title={title}
aria-label={title}
className="ml-auto inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-red-500/90 text-white shadow-[0_0_0_1px_rgba(255,255,255,0.08)]"
>
<DollarSign className="h-3 w-3" />
</span>
);
}

View file

@ -0,0 +1,140 @@
import type { QuotaWindow } from "@paperclipai/shared";
import { cn, quotaSourceDisplayName } from "@/lib/utils";
interface ClaudeSubscriptionPanelProps {
windows: QuotaWindow[];
source?: string | null;
error?: string | null;
}
const WINDOW_ORDER = [
"currentsession",
"currentweekallmodels",
"currentweeksonnetonly",
"currentweeksonnet",
"currentweekopusonly",
"currentweekopus",
"extrausage",
] as const;
function normalizeLabel(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function detailText(window: QuotaWindow): string | null {
if (typeof window.detail === "string" && window.detail.trim().length > 0) return window.detail.trim();
if (window.resetsAt) {
const formatted = new Date(window.resetsAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `Resets ${formatted}`;
}
return null;
}
function orderedWindows(windows: QuotaWindow[]): QuotaWindow[] {
return [...windows].sort((a, b) => {
const aIndex = WINDOW_ORDER.indexOf(normalizeLabel(a.label) as (typeof WINDOW_ORDER)[number]);
const bIndex = WINDOW_ORDER.indexOf(normalizeLabel(b.label) as (typeof WINDOW_ORDER)[number]);
return (aIndex === -1 ? WINDOW_ORDER.length : aIndex) - (bIndex === -1 ? WINDOW_ORDER.length : bIndex);
});
}
function fillClass(usedPercent: number | null): string {
if (usedPercent == null) return "bg-zinc-700";
if (usedPercent >= 90) return "bg-red-400";
if (usedPercent >= 70) return "bg-amber-400";
return "bg-primary/70";
}
export function ClaudeSubscriptionPanel({
windows,
source = null,
error = null,
}: ClaudeSubscriptionPanelProps) {
const ordered = orderedWindows(windows);
return (
<div className="border border-border px-4 py-4">
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
Anthropic subscription
</div>
<div className="mt-1 text-sm text-muted-foreground">
Live Claude quota windows.
</div>
</div>
{source ? (
<span className="shrink-0 border border-border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{quotaSourceDisplayName(source)}
</span>
) : null}
</div>
{error ? (
<div className="mt-4 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<div className="mt-4 space-y-4">
{ordered.map((window) => {
const normalized = normalizeLabel(window.label);
const detail = detailText(window);
if (normalized === "extrausage") {
return (
<div
key={window.label}
className="border border-border px-3.5 py-3"
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-foreground">{window.label}</div>
{window.valueLabel ? (
<div className="text-sm font-medium text-foreground">{window.valueLabel}</div>
) : null}
</div>
{detail ? (
<div className="mt-2 text-sm text-muted-foreground">{detail}</div>
) : null}
</div>
);
}
const width = Math.min(100, Math.max(0, window.usedPercent ?? 0));
return (
<div
key={window.label}
className="border border-border px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{window.label}</div>
{detail ? (
<div className="mt-1 text-xs text-muted-foreground">{detail}</div>
) : null}
</div>
{window.usedPercent != null ? (
<div className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
{window.usedPercent}% used
</div>
) : null}
</div>
<div className="mt-3 h-2 overflow-hidden bg-muted">
<div
className={cn("h-full transition-[width] duration-200", fillClass(window.usedPercent))}
style={{ width: `${width}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,157 @@
import type { QuotaWindow } from "@paperclipai/shared";
import { cn, quotaSourceDisplayName } from "@/lib/utils";
interface CodexSubscriptionPanelProps {
windows: QuotaWindow[];
source?: string | null;
error?: string | null;
}
const WINDOW_PRIORITY = [
"5hlimit",
"weeklylimit",
"credits",
] as const;
function normalizeLabel(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function orderedWindows(windows: QuotaWindow[]): QuotaWindow[] {
return [...windows].sort((a, b) => {
const aBase = normalizeLabel(a.label).replace(/^gpt53codexspark/, "");
const bBase = normalizeLabel(b.label).replace(/^gpt53codexspark/, "");
const aIndex = WINDOW_PRIORITY.indexOf(aBase as (typeof WINDOW_PRIORITY)[number]);
const bIndex = WINDOW_PRIORITY.indexOf(bBase as (typeof WINDOW_PRIORITY)[number]);
return (aIndex === -1 ? WINDOW_PRIORITY.length : aIndex) - (bIndex === -1 ? WINDOW_PRIORITY.length : bIndex);
});
}
function detailText(window: QuotaWindow): string | null {
if (typeof window.detail === "string" && window.detail.trim().length > 0) return window.detail.trim();
if (!window.resetsAt) return null;
const formatted = new Date(window.resetsAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `Resets ${formatted}`;
}
function fillClass(usedPercent: number | null): string {
if (usedPercent == null) return "bg-zinc-700";
if (usedPercent >= 90) return "bg-red-400";
if (usedPercent >= 70) return "bg-amber-400";
return "bg-primary/70";
}
function isModelSpecific(label: string): boolean {
const normalized = normalizeLabel(label);
return normalized.includes("gpt53codexspark") || normalized.includes("gpt5");
}
export function CodexSubscriptionPanel({
windows,
source = null,
error = null,
}: CodexSubscriptionPanelProps) {
const ordered = orderedWindows(windows);
const accountWindows = ordered.filter((window) => !isModelSpecific(window.label));
const modelWindows = ordered.filter((window) => isModelSpecific(window.label));
return (
<div className="border border-border px-4 py-4">
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
Codex subscription
</div>
<div className="mt-1 text-sm text-muted-foreground">
Live Codex quota windows.
</div>
</div>
{source ? (
<span className="shrink-0 border border-border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{quotaSourceDisplayName(source)}
</span>
) : null}
</div>
{error ? (
<div className="mt-4 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<div className="mt-4 space-y-5">
<div className="space-y-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Account windows
</div>
<div className="space-y-3">
{accountWindows.map((window) => (
<QuotaWindowRow key={window.label} window={window} />
))}
</div>
</div>
{modelWindows.length > 0 ? (
<div className="space-y-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Model windows
</div>
<div className="space-y-3">
{modelWindows.map((window) => (
<QuotaWindowRow key={window.label} window={window} />
))}
</div>
</div>
) : null}
</div>
</div>
);
}
function QuotaWindowRow({ window }: { window: QuotaWindow }) {
const detail = detailText(window);
if (window.usedPercent == null) {
return (
<div className="border border-border px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-foreground">{window.label}</div>
{window.valueLabel ? (
<div className="text-sm font-semibold tabular-nums text-foreground">{window.valueLabel}</div>
) : null}
</div>
{detail ? (
<div className="mt-2 text-xs text-muted-foreground">{detail}</div>
) : null}
</div>
);
}
return (
<div className="border border-border px-3.5 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{window.label}</div>
{detail ? (
<div className="mt-1 text-xs text-muted-foreground">{detail}</div>
) : null}
</div>
<div className="shrink-0 text-sm font-semibold tabular-nums text-foreground">
{window.usedPercent}% used
</div>
</div>
<div className="mt-3 h-2 overflow-hidden bg-muted">
<div
className={cn("h-full transition-[width] duration-200", fillClass(window.usedPercent))}
style={{ width: `${Math.max(0, Math.min(100, window.usedPercent))}%` }}
/>
</div>
</div>
);
}

View file

@ -75,11 +75,15 @@ export function CommandPalette() {
enabled: !!selectedCompanyId && open,
});
const { data: projects = [] } = useQuery({
const { data: allProjects = [] } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && open,
});
const projects = useMemo(
() => allProjects.filter((p) => !p.archivedAt),
[allProjects],
);
function go(path: string) {
setOpen(false);

View file

@ -10,10 +10,16 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma
import { StatusBadge } from "./StatusBadge";
import { AgentIcon } from "./AgentIconPicker";
import { formatDateTime } from "../lib/utils";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import { PluginSlotOutlet } from "@/plugins/slots";
interface CommentWithRunMeta extends IssueComment {
runId?: string | null;
runAgentId?: string | null;
clientId?: string;
clientStatus?: "pending" | "queued";
queueState?: "queued";
queueTargetRunId?: string | null;
}
interface LinkedRunItem {
@ -31,7 +37,10 @@ interface CommentReassignment {
interface CommentThreadProps {
comments: CommentWithRunMeta[];
queuedComments?: CommentWithRunMeta[];
linkedRuns?: LinkedRunItem[];
companyId?: string | null;
projectId?: string | null;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
issueStatus?: string;
agentMap?: Map<string, Agent>;
@ -43,10 +52,12 @@ interface CommentThreadProps {
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[];
onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null;
}
const CLOSED_STATUSES = new Set(["done", "cancelled"]);
const DRAFT_DEBOUNCE_MS = 800;
function loadDraft(draftKey: string): string {
@ -111,6 +122,122 @@ function CopyMarkdownButton({ text }: { text: string }) {
);
}
function CommentCard({
comment,
agentMap,
companyId,
projectId,
highlightCommentId,
queued = false,
}: {
comment: CommentWithRunMeta;
agentMap?: Map<string, Agent>;
companyId?: string | null;
projectId?: string | null;
highlightCommentId?: string | null;
queued?: boolean;
}) {
const isHighlighted = highlightCommentId === comment.id;
const isPending = comment.clientStatus === "pending";
const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued";
return (
<div
key={comment.id}
id={`comment-${comment.id}`}
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${
isQueued
? "border-amber-300/70 bg-amber-50/70 dark:border-amber-500/40 dark:bg-amber-500/10"
: isHighlighted
? "border-primary/50 bg-primary/5"
: "border-border"
} ${isPending ? "opacity-80" : ""}`}
>
<div className="flex items-center justify-between mb-1">
{comment.authorAgentId ? (
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
<Identity
name={agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8)}
size="sm"
/>
</Link>
) : (
<Identity name="You" size="sm" />
)}
<span className="flex items-center gap-1.5">
{isQueued ? (
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
Queued
</span>
) : null}
{companyId && !isPending ? (
<PluginSlotOutlet
slotTypes={["commentContextMenuItem"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="flex flex-wrap items-center gap-1.5"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
) : null}
{isPending ? (
<span className="text-xs text-muted-foreground">{isQueued ? "Queueing..." : "Sending..."}</span>
) : (
<a
href={`#comment-${comment.id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
{formatDateTime(comment.createdAt)}
</a>
)}
<CopyMarkdownButton text={comment.body} />
</span>
</div>
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
{companyId && !isPending ? (
<div className="mt-2 space-y-2">
<PluginSlotOutlet
slotTypes={["commentAnnotation"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="space-y-2"
itemClassName="rounded-md"
missingBehavior="placeholder"
/>
</div>
) : null}
{comment.runId && !isPending ? (
<div className="mt-2 pt-2 border-t border-border/60">
{comment.runAgentId ? (
<Link
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
run {comment.runId.slice(0, 8)}
</Link>
) : (
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
run {comment.runId.slice(0, 8)}
</span>
)}
</div>
) : null}
</div>
);
}
type TimelineItem =
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
@ -118,10 +245,14 @@ type TimelineItem =
const TimelineList = memo(function TimelineList({
timeline,
agentMap,
companyId,
projectId,
highlightCommentId,
}: {
timeline: TimelineItem[];
agentMap?: Map<string, Agent>;
companyId?: string | null;
projectId?: string | null;
highlightCommentId?: string | null;
}) {
if (timeline.length === 0) {
@ -161,52 +292,15 @@ const TimelineList = memo(function TimelineList({
}
const comment = item.comment;
const isHighlighted = highlightCommentId === comment.id;
return (
<div
<CommentCard
key={comment.id}
id={`comment-${comment.id}`}
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${isHighlighted ? "border-primary/50 bg-primary/5" : "border-border"}`}
>
<div className="flex items-center justify-between mb-1">
{comment.authorAgentId ? (
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
<Identity
name={agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8)}
size="sm"
/>
</Link>
) : (
<Identity name="You" size="sm" />
)}
<span className="flex items-center gap-1.5">
<a
href={`#comment-${comment.id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
{formatDateTime(comment.createdAt)}
</a>
<CopyMarkdownButton text={comment.body} />
</span>
</div>
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
{comment.runId && (
<div className="mt-2 pt-2 border-t border-border/60">
{comment.runAgentId ? (
<Link
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
run {comment.runId.slice(0, 8)}
</Link>
) : (
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
run {comment.runId.slice(0, 8)}
</span>
)}
</div>
)}
</div>
comment={comment}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
/>
);
})}
</div>
@ -215,9 +309,11 @@ const TimelineList = memo(function TimelineList({
export function CommentThread({
comments,
queuedComments = [],
linkedRuns = [],
companyId,
projectId,
onAdd,
issueStatus,
agentMap,
imageUploadHandler,
onAttachImage,
@ -226,13 +322,17 @@ export function CommentThread({
enableReassign = false,
reassignOptions = [],
currentAssigneeValue = "",
suggestedAssigneeValue,
mentions: providedMentions,
onInterruptQueued,
interruptingQueuedRunId = null,
}: CommentThreadProps) {
const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
const [reassignTarget, setReassignTarget] = useState(currentAssigneeValue);
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null);
@ -240,8 +340,6 @@ export function CommentThread({
const location = useLocation();
const hasScrolledRef = useRef(false);
const isClosed = issueStatus ? CLOSED_STATUSES.has(issueStatus) : false;
const timeline = useMemo<TimelineItem[]>(() => {
const commentItems: TimelineItem[] = comments.map((comment) => ({
kind: "comment",
@ -269,8 +367,11 @@ export function CommentThread({
return Array.from(agentMap.values())
.filter((a) => a.status !== "terminated")
.map((a) => ({
id: a.id,
id: `agent:${a.id}`,
name: a.name,
kind: "agent",
agentId: a.id,
agentIcon: a.icon,
}));
}, [agentMap, providedMentions]);
@ -294,13 +395,13 @@ export function CommentThread({
}, []);
useEffect(() => {
setReassignTarget(currentAssigneeValue);
}, [currentAssigneeValue]);
setReassignTarget(effectiveSuggestedAssigneeValue);
}, [effectiveSuggestedAssigneeValue]);
// Scroll to comment when URL hash matches #comment-{id}
useEffect(() => {
const hash = location.hash;
if (!hash.startsWith("#comment-") || comments.length === 0) return;
if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return;
const commentId = hash.slice("#comment-".length);
// Only scroll once per hash
if (hasScrolledRef.current) return;
@ -313,21 +414,31 @@ export function CommentThread({
const timer = setTimeout(() => setHighlightCommentId(null), 3000);
return () => clearTimeout(timer);
}
}, [location.hash, comments]);
}, [location.hash, comments, queuedComments]);
async function handleSubmit() {
const trimmed = body.trim();
if (!trimmed) return;
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
const submittedBody = trimmed;
setSubmitting(true);
setBody("");
try {
await onAdd(trimmed, isClosed && reopen ? true : undefined, reassignment ?? undefined);
setBody("");
// TODO: wire an explicit "send + interrupt" action through the composer if we expose it in the UI.
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
if (draftKey) clearDraft(draftKey);
setReopen(false);
setReassignTarget(currentAssigneeValue);
setReopen(true);
setReassignTarget(effectiveSuggestedAssigneeValue);
} catch {
setBody((current) =>
restoreSubmittedCommentDraft({
currentBody: current,
submittedBody,
}),
);
// Parent mutation handlers surface the failure and the draft is restored for retry.
} finally {
setSubmitting(false);
}
@ -335,10 +446,17 @@ export function CommentThread({
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
const file = evt.target.files?.[0];
if (!file || !onAttachImage) return;
if (!file) return;
setAttaching(true);
try {
await onAttachImage(file);
if (imageUploadHandler) {
const url = await imageUploadHandler(file);
const safeName = file.name.replace(/[[\]]/g, "\\$&");
const markdown = `![${safeName}](${url})`;
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
} else if (onAttachImage) {
await onAttachImage(file);
}
} finally {
setAttaching(false);
if (attachInputRef.current) attachInputRef.current.value = "";
@ -349,12 +467,54 @@ export function CommentThread({
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length})</h3>
<h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length + queuedComments.length})</h3>
<TimelineList timeline={timeline} agentMap={agentMap} highlightCommentId={highlightCommentId} />
{timeline.length > 0 ? (
<TimelineList
timeline={timeline}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
/>
) : null}
{liveRunSlot}
{queuedComments.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.14em] text-amber-700 dark:text-amber-300">
Queued Comments ({queuedComments.length})
</h4>
{onInterruptQueued && queuedComments[0]?.queueTargetRunId ? (
<Button
size="sm"
variant="outline"
className="border-red-300 text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
disabled={interruptingQueuedRunId === queuedComments[0].queueTargetRunId}
onClick={() => void onInterruptQueued(queuedComments[0]!.queueTargetRunId!)}
>
{interruptingQueuedRunId === queuedComments[0].queueTargetRunId ? "Interrupting..." : "Interrupt"}
</Button>
) : null}
</div>
<div className="space-y-3">
{queuedComments.map((comment) => (
<CommentCard
key={comment.id}
comment={comment}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
queued
/>
))}
</div>
</div>
)}
<div className="space-y-2">
<MarkdownEditor
ref={editorRef}
@ -367,7 +527,7 @@ export function CommentThread({
contentClassName="min-h-[60px] text-sm"
/>
<div className="flex items-center justify-end gap-3">
{onAttachImage && (
{(imageUploadHandler || onAttachImage) && (
<div className="mr-auto flex items-center gap-3">
<input
ref={attachInputRef}
@ -387,17 +547,15 @@ export function CommentThread({
</Button>
</div>
)}
{isClosed && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={reopen}
onChange={(e) => setReopen(e.target.checked)}
className="rounded border-border"
/>
Re-open
</label>
)}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={reopen}
onChange={(e) => setReopen(e.target.checked)}
className="rounded border-border"
/>
Re-open
</label>
{enableReassign && reassignOptions.length > 0 && (
<InlineEntitySelector
value={reassignTarget}

View file

@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { cn } from "../lib/utils";
const BAYER_4X4 = [
@ -10,6 +10,7 @@ const BAYER_4X4 = [
interface CompanyPatternIconProps {
companyName: string;
logoUrl?: string | null;
brandColor?: string | null;
className?: string;
}
@ -159,8 +160,18 @@ function makeCompanyPatternDataUrl(seed: string, brandColor?: string | null, log
return canvas.toDataURL("image/png");
}
export function CompanyPatternIcon({ companyName, brandColor, className }: CompanyPatternIconProps) {
export function CompanyPatternIcon({
companyName,
logoUrl,
brandColor,
className,
}: CompanyPatternIconProps) {
const initial = companyName.trim().charAt(0).toUpperCase() || "?";
const [imageError, setImageError] = useState(false);
const logo = !imageError && typeof logoUrl === "string" && logoUrl.trim().length > 0 ? logoUrl : null;
useEffect(() => {
setImageError(false);
}, [logoUrl]);
const patternDataUrl = useMemo(
() => makeCompanyPatternDataUrl(companyName.trim().toLowerCase(), brandColor),
[companyName, brandColor],
@ -173,7 +184,14 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa
className,
)}
>
{patternDataUrl ? (
{logo ? (
<img
src={logo}
alt={`${companyName} logo`}
onError={() => setImageError(true)}
className="absolute inset-0 h-full w-full object-cover"
/>
) : patternDataUrl ? (
<img
src={patternDataUrl}
alt=""
@ -184,9 +202,11 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa
) : (
<div className="absolute inset-0 bg-muted" />
)}
<span className="relative z-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]">
{initial}
</span>
{!logo && (
<span className="relative z-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]">
{initial}
</span>
)}
</div>
);
}

View file

@ -4,7 +4,7 @@ import { useQueries } from "@tanstack/react-query";
import {
DndContext,
closestCenter,
PointerSensor,
MouseSensor,
useSensor,
useSensors,
type DragEndEvent,
@ -22,6 +22,7 @@ import { cn } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { sidebarBadgesApi } from "../api/sidebarBadges";
import { heartbeatsApi } from "../api/heartbeats";
import { useLocation, useNavigate } from "@/lib/router";
import {
Tooltip,
TooltipContent,
@ -121,6 +122,7 @@ function SortableCompanyItem({
>
<CompanyPatternIcon
companyName={company.name}
logoUrl={company.logoUrl}
brandColor={company.brandColor}
className={cn(
isSelected
@ -132,7 +134,7 @@ function SortableCompanyItem({
{hasLiveAgents && (
<span className="pointer-events-none absolute -right-0.5 -top-0.5 z-10">
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-80" />
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-80" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500 ring-2 ring-background" />
</span>
</span>
@ -154,6 +156,10 @@ function SortableCompanyItem({
export function CompanyRail() {
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openOnboarding } = useDialog();
const navigate = useNavigate();
const location = useLocation();
const isInstanceRoute = location.pathname.startsWith("/instance/");
const highlightedCompanyId = isInstanceRoute ? null : selectedCompanyId;
const sidebarCompanies = useMemo(
() => companies.filter((company) => company.status !== "archived"),
[companies],
@ -238,7 +244,8 @@ export function CompanyRail() {
// Require 8px of movement before starting a drag to avoid interfering with clicks
const sensors = useSensors(
useSensor(PointerSensor, {
// Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
})
);
@ -282,10 +289,15 @@ export function CompanyRail() {
<SortableCompanyItem
key={company.id}
company={company}
isSelected={company.id === selectedCompanyId}
isSelected={company.id === highlightedCompanyId}
hasLiveAgents={hasLiveAgentsByCompanyId.get(company.id) ?? false}
hasUnreadInbox={hasUnreadInboxByCompanyId.get(company.id) ?? false}
onSelect={() => setSelectedCompanyId(company.id)}
onSelect={() => {
setSelectedCompanyId(company.id);
if (isInstanceRoute) {
navigate(`/${company.issuePrefix}/dashboard`);
}
}}
/>
))}
</SortableContext>

View file

@ -0,0 +1,89 @@
import { AlertTriangle, RotateCcw, TimerReset } from "lucide-react";
import type { DevServerHealthStatus } from "../api/health";
function formatRelativeTimestamp(value: string | null): string | null {
if (!value) return null;
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) return null;
const deltaMs = Date.now() - timestamp;
if (deltaMs < 60_000) return "just now";
const deltaMinutes = Math.round(deltaMs / 60_000);
if (deltaMinutes < 60) return `${deltaMinutes}m ago`;
const deltaHours = Math.round(deltaMinutes / 60);
if (deltaHours < 24) return `${deltaHours}h ago`;
const deltaDays = Math.round(deltaHours / 24);
return `${deltaDays}d ago`;
}
function describeReason(devServer: DevServerHealthStatus): string {
if (devServer.reason === "backend_changes_and_pending_migrations") {
return "backend files changed and migrations are pending";
}
if (devServer.reason === "pending_migrations") {
return "pending migrations need a fresh boot";
}
return "backend files changed since this server booted";
}
export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthStatus }) {
if (!devServer?.enabled || !devServer.restartRequired) return null;
const changedAt = formatRelativeTimestamp(devServer.lastChangedAt);
const sample = devServer.changedPathsSample.slice(0, 3);
return (
<div className="border-b border-amber-300/60 bg-amber-50 text-amber-950 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-100">
<div className="flex flex-col gap-3 px-3 py-2.5 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[12px] font-semibold uppercase tracking-[0.18em]">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
<span>Restart Required</span>
{devServer.autoRestartEnabled ? (
<span className="rounded-full bg-amber-900/10 px-2 py-0.5 text-[10px] tracking-[0.14em] dark:bg-amber-100/10">
Auto-Restart On
</span>
) : null}
</div>
<p className="mt-1 text-sm">
{describeReason(devServer)}
{changedAt ? ` · updated ${changedAt}` : ""}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-amber-900/80 dark:text-amber-100/75">
{sample.length > 0 ? (
<span>
Changed: {sample.join(", ")}
{devServer.changedPathCount > sample.length ? ` +${devServer.changedPathCount - sample.length} more` : ""}
</span>
) : null}
{devServer.pendingMigrations.length > 0 ? (
<span>
Pending migrations: {devServer.pendingMigrations.slice(0, 2).join(", ")}
{devServer.pendingMigrations.length > 2 ? ` +${devServer.pendingMigrations.length - 2} more` : ""}
</span>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs font-medium">
{devServer.waitingForIdle ? (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<TimerReset className="h-3.5 w-3.5" />
<span>Waiting for {devServer.activeRunCount} live run{devServer.activeRunCount === 1 ? "" : "s"} to finish</span>
</div>
) : devServer.autoRestartEnabled ? (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<RotateCcw className="h-3.5 w-3.5" />
<span>Auto-restart will trigger when the instance is idle</span>
</div>
) : (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<RotateCcw className="h-3.5 w-3.5" />
<span>Restart <code>pnpm dev:once</code> after the active work is safe to interrupt</span>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,314 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ExecutionWorkspace } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Loader2 } from "lucide-react";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { formatDateTime, issueUrl } from "../lib/utils";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
type ExecutionWorkspaceCloseDialogProps = {
workspaceId: string;
workspaceName: string;
currentStatus: ExecutionWorkspace["status"];
open: boolean;
onOpenChange: (open: boolean) => void;
onClosed?: (workspace: ExecutionWorkspace) => void;
};
function readinessTone(state: "ready" | "ready_with_warnings" | "blocked") {
if (state === "blocked") {
return "border-destructive/30 bg-destructive/5 text-destructive";
}
if (state === "ready_with_warnings") {
return "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-300";
}
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
}
export function ExecutionWorkspaceCloseDialog({
workspaceId,
workspaceName,
currentStatus,
open,
onOpenChange,
onClosed,
}: ExecutionWorkspaceCloseDialogProps) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const actionLabel = currentStatus === "cleanup_failed" ? "Retry close" : "Close workspace";
const readinessQuery = useQuery({
queryKey: queryKeys.executionWorkspaces.closeReadiness(workspaceId),
queryFn: () => executionWorkspacesApi.getCloseReadiness(workspaceId),
enabled: open,
});
const closeWorkspace = useMutation({
mutationFn: () => executionWorkspacesApi.update(workspaceId, { status: "archived" }),
onSuccess: (workspace) => {
queryClient.setQueryData(queryKeys.executionWorkspaces.detail(workspace.id), workspace);
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(workspace.id) });
pushToast({
title: currentStatus === "cleanup_failed" ? "Workspace close retried" : "Workspace closed",
tone: "success",
});
onOpenChange(false);
onClosed?.(workspace);
},
onError: (error) => {
pushToast({
title: "Failed to close workspace",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const readiness = readinessQuery.data ?? null;
const blockingIssues = readiness?.linkedIssues.filter((issue) => !issue.isTerminal) ?? [];
const otherLinkedIssues = readiness?.linkedIssues.filter((issue) => issue.isTerminal) ?? [];
const confirmDisabled =
currentStatus === "archived" ||
closeWorkspace.isPending ||
readinessQuery.isLoading ||
readiness == null ||
readiness.state === "blocked";
return (
<Dialog open={open} onOpenChange={(nextOpen) => {
if (!closeWorkspace.isPending) onOpenChange(nextOpen);
}}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{actionLabel}</DialogTitle>
<DialogDescription className="break-words">
Archive <span className="font-medium text-foreground">{workspaceName}</span> and clean up any owned workspace
artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views.
</DialogDescription>
</DialogHeader>
{readinessQuery.isLoading ? (
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Checking whether this workspace is safe to close...
</div>
) : readinessQuery.error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
</div>
) : readiness ? (
<div className="space-y-4">
<div className={`rounded-xl border px-4 py-3 text-sm ${readinessTone(readiness.state)}`}>
<div className="font-medium">
{readiness.state === "blocked"
? "Close is blocked"
: readiness.state === "ready_with_warnings"
? "Close is allowed with warnings"
: "Close is ready"}
</div>
<div className="mt-1 text-xs opacity-80">
{readiness.isSharedWorkspace
? "This is a shared workspace session. Archiving it removes this session record but keeps the underlying project workspace."
: readiness.git?.workspacePath && readiness.git.repoRoot && readiness.git.workspacePath !== readiness.git.repoRoot
? "This execution workspace has its own checkout path and can be archived independently."
: readiness.isProjectPrimaryWorkspace
? "This execution workspace currently points at the project's primary workspace path."
: "This workspace is disposable and can be archived."}
</div>
</div>
{blockingIssues.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Blocking issues</h3>
<div className="space-y-2">
{blockingIssues.map((issue) => (
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
{issue.identifier ?? issue.id} · {issue.title}
</Link>
<span className="text-xs text-muted-foreground">{issue.status}</span>
</div>
</div>
))}
</div>
</section>
) : null}
{readiness.blockingReasons.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Blocking reasons</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
{readiness.blockingReasons.map((reason, idx) => (
<li key={`blocking-${idx}`} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive">
{reason}
</li>
))}
</ul>
</section>
) : null}
{readiness.warnings.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Warnings</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
{readiness.warnings.map((warning, idx) => (
<li key={`warning-${idx}`} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
{warning}
</li>
))}
</ul>
</section>
) : null}
{readiness.git ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Git status</h3>
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="grid gap-2 sm:grid-cols-2">
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
<div className="font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Base ref</div>
<div className="font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div>
<div>{readiness.git.isMergedIntoBase == null ? "Unknown" : readiness.git.isMergedIntoBase ? "Yes" : "No"}</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Ahead / behind</div>
<div>
{(readiness.git.aheadCount ?? 0).toString()} / {(readiness.git.behindCount ?? 0).toString()}
</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Dirty tracked files</div>
<div>{readiness.git.dirtyEntryCount}</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Untracked files</div>
<div>{readiness.git.untrackedEntryCount}</div>
</div>
</div>
</div>
</section>
) : null}
{otherLinkedIssues.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Other linked issues</h3>
<div className="space-y-2">
{otherLinkedIssues.map((issue) => (
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
{issue.identifier ?? issue.id} · {issue.title}
</Link>
<span className="text-xs text-muted-foreground">{issue.status}</span>
</div>
</div>
))}
</div>
</section>
) : null}
{readiness.runtimeServices.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Attached runtime services</h3>
<div className="space-y-2">
{readiness.runtimeServices.map((service) => (
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span className="font-medium">{service.serviceName}</span>
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
</div>
<div className="mt-1 break-words text-xs text-muted-foreground">
{service.url ?? service.command ?? service.cwd ?? "No additional details"}
</div>
</div>
))}
</div>
</section>
) : null}
<section className="space-y-2">
<h3 className="text-sm font-medium">Cleanup actions</h3>
<div className="space-y-2">
{readiness.plannedActions.map((action, index) => (
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="font-medium">{action.label}</div>
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
{action.command ? (
<pre className="mt-2 whitespace-pre-wrap break-all rounded-lg bg-background px-3 py-2 font-mono text-xs text-foreground">
{action.command}
</pre>
) : null}
</div>
))}
</div>
</section>
{currentStatus === "cleanup_failed" ? (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 text-sm text-muted-foreground">
Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the
workspace status if it succeeds.
</div>
) : null}
{currentStatus === "archived" ? (
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
This workspace is already archived.
</div>
) : null}
{readiness.git?.repoRoot ? (
<div className="break-words text-xs text-muted-foreground">
Repo root: <span className="font-mono break-all">{readiness.git.repoRoot}</span>
{readiness.git.workspacePath ? (
<>
{" · "}Workspace path: <span className="font-mono break-all">{readiness.git.workspacePath}</span>
</>
) : null}
</div>
) : null}
<div className="text-xs text-muted-foreground">
Last checked {formatDateTime(new Date())}
</div>
</div>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={closeWorkspace.isPending}
>
Cancel
</Button>
<Button
variant={currentStatus === "cleanup_failed" ? "default" : "destructive"}
onClick={() => closeWorkspace.mutate()}
disabled={confirmDisabled}
>
{closeWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{actionLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,44 @@
import type { FinanceByBiller } from "@paperclipai/shared";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCents, providerDisplayName } from "@/lib/utils";
interface FinanceBillerCardProps {
row: FinanceByBiller;
}
export function FinanceBillerCard({ row }: FinanceBillerCardProps) {
return (
<Card>
<CardHeader className="px-4 pt-4 pb-1">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{providerDisplayName(row.biller)}</CardTitle>
<CardDescription className="mt-1 text-xs">
{row.eventCount} event{row.eventCount === 1 ? "" : "s"} across {row.kindCount} kind{row.kindCount === 1 ? "" : "s"}
</CardDescription>
</div>
<div className="text-right">
<div className="text-lg font-semibold tabular-nums">{formatCents(row.netCents)}</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">net</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4 pt-3">
<div className="grid gap-2 text-sm sm:grid-cols-3">
<div className="border border-border p-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">debits</div>
<div className="mt-1 font-medium tabular-nums">{formatCents(row.debitCents)}</div>
</div>
<div className="border border-border p-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">credits</div>
<div className="mt-1 font-medium tabular-nums">{formatCents(row.creditCents)}</div>
</div>
<div className="border border-border p-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">estimated</div>
<div className="mt-1 font-medium tabular-nums">{formatCents(row.estimatedDebitCents)}</div>
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,43 @@
import type { FinanceByKind } from "@paperclipai/shared";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { financeEventKindDisplayName, formatCents } from "@/lib/utils";
interface FinanceKindCardProps {
rows: FinanceByKind[];
}
export function FinanceKindCard({ rows }: FinanceKindCardProps) {
return (
<Card>
<CardHeader className="px-4 pt-4 pb-1">
<CardTitle className="text-base">Financial event mix</CardTitle>
<CardDescription>Account-level charges grouped by event kind.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 px-4 pb-4 pt-3">
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">No finance events in this period.</p>
) : (
rows.map((row) => (
<div
key={row.eventKind}
className="flex items-center justify-between gap-3 border border-border px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{financeEventKindDisplayName(row.eventKind)}</div>
<div className="text-xs text-muted-foreground">
{row.eventCount} event{row.eventCount === 1 ? "" : "s"} · {row.billerCount} biller{row.billerCount === 1 ? "" : "s"}
</div>
</div>
<div className="text-right tabular-nums">
<div className="text-sm font-medium">{formatCents(row.netCents)}</div>
<div className="text-xs text-muted-foreground">
{formatCents(row.debitCents)} debits
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,71 @@
import type { FinanceEvent } from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
financeDirectionDisplayName,
financeEventKindDisplayName,
formatCents,
formatDateTime,
providerDisplayName,
} from "@/lib/utils";
interface FinanceTimelineCardProps {
rows: FinanceEvent[];
emptyMessage?: string;
}
export function FinanceTimelineCard({
rows,
emptyMessage = "No financial events in this period.",
}: FinanceTimelineCardProps) {
return (
<Card>
<CardHeader className="px-4 pt-4 pb-1">
<CardTitle className="text-base">Recent financial events</CardTitle>
<CardDescription>Top-ups, fees, credits, commitments, and other non-request charges.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4 pt-3">
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
) : (
rows.map((row) => (
<div
key={row.id}
className="border border-border p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{financeEventKindDisplayName(row.eventKind)}</Badge>
<Badge variant={row.direction === "credit" ? "outline" : "secondary"}>
{financeDirectionDisplayName(row.direction)}
</Badge>
<span className="text-xs text-muted-foreground">{formatDateTime(row.occurredAt)}</span>
</div>
<div className="text-sm font-medium">
{providerDisplayName(row.biller)}
{row.provider ? ` -> ${providerDisplayName(row.provider)}` : ""}
{row.model ? <span className="ml-1 font-mono text-xs text-muted-foreground">{row.model}</span> : null}
</div>
{(row.description || row.externalInvoiceId || row.region || row.pricingTier) && (
<div className="space-y-1 text-xs text-muted-foreground">
{row.description ? <div>{row.description}</div> : null}
{row.externalInvoiceId ? <div>invoice {row.externalInvoiceId}</div> : null}
{row.region ? <div>region {row.region}</div> : null}
{row.pricingTier ? <div>tier {row.pricingTier}</div> : null}
</div>
)}
</div>
<div className="text-right tabular-nums">
<div className="text-sm font-semibold">{formatCents(row.amountCents)}</div>
<div className="text-xs text-muted-foreground">{row.currency}</div>
{row.estimated ? <div className="text-[11px] uppercase tracking-[0.12em] text-amber-600">estimated</div> : null}
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,43 @@
import { cn } from "../lib/utils";
interface HermesIconProps {
className?: string;
}
/**
* Hermes caduceus icon winged staff with two intertwined serpents.
* Replaces the generic Zap icon for the hermes_local adapter type.
*
* inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings.
*/
export function HermesIcon({ className }: HermesIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(className)}
>
{/* Central staff */}
<line x1="12" y1="6" x2="12" y2="23" />
{/* Left serpent curves */}
<path d="M12 8 C10 9 9.5 11 10.5 13 C11.5 15 10 17 12 18" />
{/* Right serpent curves */}
<path d="M12 8 C14 9 14.5 11 13.5 13 C12.5 15 14 17 12 18" />
{/* Snake heads facing outward */}
<circle cx="10" cy="8" r="0.8" fill="currentColor" stroke="none" />
<circle cx="14" cy="8" r="0.8" fill="currentColor" stroke="none" />
{/* Wings at top of staff */}
<path d="M12 6 L8 3 L6 5 L9 6" strokeWidth="1.2" />
<path d="M12 6 L16 3 L18 5 L15 6" strokeWidth="1.2" />
{/* Wing feather details */}
<line x1="7.5" y1="4" x2="7" y2="5.2" strokeWidth="1" />
<line x1="16.5" y1="4" x2="17" y2="5.2" strokeWidth="1" />
{/* Staff sphere at top */}
<circle cx="12" cy="6.5" r="1.2" />
</svg>
);
}

View file

@ -1,12 +1,11 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
interface InlineEditorProps {
value: string;
onSave: (value: string) => void;
onSave: (value: string) => void | Promise<unknown>;
as?: "h1" | "h2" | "p" | "span";
className?: string;
placeholder?: string;
@ -17,6 +16,8 @@ interface InlineEditorProps {
/** Shared padding so display and edit modes occupy the exact same box. */
const pad = "px-1 -mx-1";
const markdownPad = "px-1";
const AUTOSAVE_DEBOUNCE_MS = 900;
export function InlineEditor({
value,
@ -29,12 +30,30 @@ export function InlineEditor({
mentions,
}: InlineEditorProps) {
const [editing, setEditing] = useState(false);
const [multilineFocused, setMultilineFocused] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLTextAreaElement>(null);
const markdownRef = useRef<MarkdownEditorRef>(null);
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const {
state: autosaveState,
markDirty,
reset,
runSave,
} = useAutosaveIndicator();
useEffect(() => {
if (multiline && multilineFocused) return;
setDraft(value);
}, [value]);
}, [value, multiline, multilineFocused]);
useEffect(() => {
return () => {
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
};
}, []);
const autoSize = useCallback((el: HTMLTextAreaElement | null) => {
if (!el) return;
@ -52,58 +71,140 @@ export function InlineEditor({
}
}, [editing, autoSize]);
function commit() {
const trimmed = draft.trim();
useEffect(() => {
if (!editing || !multiline) return;
const frame = requestAnimationFrame(() => {
markdownRef.current?.focus();
});
return () => cancelAnimationFrame(frame);
}, [editing, multiline]);
const commit = useCallback(async (nextValue = draft) => {
const trimmed = nextValue.trim();
if (trimmed && trimmed !== value) {
onSave(trimmed);
await Promise.resolve(onSave(trimmed));
} else {
setDraft(value);
}
setEditing(false);
}
if (!multiline) {
setEditing(false);
}
}, [draft, multiline, onSave, value]);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && !multiline) {
e.preventDefault();
commit();
void commit();
}
if (e.key === "Escape") {
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
reset();
setDraft(value);
setEditing(false);
if (multiline) {
setMultilineFocused(false);
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
} else {
setEditing(false);
}
}
}
if (editing) {
if (multiline) {
return (
<div className={cn("space-y-2", pad)}>
<MarkdownEditor
value={draft}
onChange={setDraft}
placeholder={placeholder}
contentClassName={className}
imageUploadHandler={imageUploadHandler}
mentions={mentions}
onSubmit={commit}
/>
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setDraft(value);
setEditing(false);
}}
>
Cancel
</Button>
<Button size="sm" onClick={commit}>
Save
</Button>
</div>
</div>
);
useEffect(() => {
if (!multiline) return;
if (!multilineFocused) return;
const trimmed = draft.trim();
if (!trimmed || trimmed === value) {
if (autosaveState !== "saved") {
reset();
}
return;
}
markDirty();
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
autosaveDebounceRef.current = setTimeout(() => {
void runSave(() => commit(trimmed));
}, AUTOSAVE_DEBOUNCE_MS);
return () => {
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
};
}, [autosaveState, commit, draft, markDirty, multiline, multilineFocused, reset, runSave, value]);
if (multiline) {
return (
<div
className={cn(
markdownPad,
"rounded transition-colors",
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
)}
onFocusCapture={() => setMultilineFocused(true)}
onBlurCapture={(event) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
setMultilineFocused(false);
const trimmed = draft.trim();
if (!trimmed || trimmed === value) {
reset();
void commit();
return;
}
void runSave(() => commit());
}}
onKeyDown={handleKeyDown}
>
<MarkdownEditor
ref={markdownRef}
value={draft}
onChange={setDraft}
placeholder={placeholder}
bordered={false}
className="bg-transparent"
contentClassName={cn("paperclip-edit-in-place-content", className)}
imageUploadHandler={imageUploadHandler}
mentions={mentions}
onSubmit={() => {
const trimmed = draft.trim();
if (!trimmed || trimmed === value) {
reset();
void commit();
return;
}
void runSave(() => commit());
}}
/>
<div className="flex min-h-4 items-center justify-end pr-1">
<span
className={cn(
"text-[11px] transition-opacity duration-150",
autosaveState === "error" ? "text-destructive" : "text-muted-foreground",
autosaveState === "idle" ? "opacity-0" : "opacity-100",
)}
>
{autosaveState === "saving"
? "Autosaving..."
: autosaveState === "saved"
? "Saved"
: autosaveState === "error"
? "Could not save"
: "Idle"}
</span>
</div>
</div>
);
}
if (editing) {
return (
<textarea
@ -114,7 +215,9 @@ export function InlineEditor({
setDraft(e.target.value);
autoSize(e.target);
}}
onBlur={commit}
onBlur={() => {
void commit();
}}
onKeyDown={handleKeyDown}
className={cn(
"w-full bg-transparent rounded outline-none resize-none overflow-hidden",
@ -132,18 +235,14 @@ export function InlineEditor({
return (
<DisplayTag
className={cn(
"cursor-pointer rounded hover:bg-accent/50 transition-colors",
"cursor-pointer rounded hover:bg-accent/50 transition-colors overflow-hidden",
pad,
!value && "text-muted-foreground italic",
className
className,
)}
onClick={() => setEditing(true)}
>
{value && multiline ? (
<MarkdownBody>{value}</MarkdownBody>
) : (
value || placeholder
)}
{value || placeholder}
</DisplayTag>
);
}

View file

@ -0,0 +1,53 @@
import { useQuery } from "@tanstack/react-query";
import { Clock3, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { SidebarNavItem } from "./SidebarNavItem";
export function InstanceSidebar() {
const { data: plugins } = useQuery({
queryKey: queryKeys.plugins.all,
queryFn: () => pluginsApi.list(),
});
return (
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex items-center gap-2 px-3 h-12 shrink-0">
<Settings className="h-4 w-4 text-muted-foreground shrink-0 ml-1" />
<span className="flex-1 text-sm font-bold text-foreground truncate">
Instance Settings
</span>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/instance/settings/general" label="General" icon={SlidersHorizontal} end />
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
{(plugins ?? []).length > 0 ? (
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
{(plugins ?? []).map((plugin) => (
<NavLink
key={plugin.id}
to={`/instance/settings/plugins/${plugin.id}`}
className={({ isActive }) =>
[
"rounded-md px-2 py-1.5 text-xs transition-colors",
isActive
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
].join(" ")
}
>
{plugin.manifestJson.displayName ?? plugin.packageName}
</NavLink>
))}
</div>
) : null}
</div>
</nav>
</aside>
);
}

View file

@ -0,0 +1,891 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Issue, IssueDocument } from "@paperclipai/shared";
import { useLocation } from "@/lib/router";
import { ApiError } from "../api/client";
import { issuesApi } from "../api/issues";
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Check, ChevronDown, ChevronRight, Copy, Download, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
type DraftState = {
key: string;
title: string;
body: string;
baseRevisionId: string | null;
isNew: boolean;
};
type DocumentConflictState = {
key: string;
serverDocument: IssueDocument;
localDraft: DraftState;
showRemote: boolean;
};
const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900;
const DOCUMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
const getFoldedDocumentsStorageKey = (issueId: string) => `paperclip:issue-document-folds:${issueId}`;
function loadFoldedDocumentKeys(issueId: string) {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(getFoldedDocumentsStorageKey(issueId));
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : [];
} catch {
return [];
}
}
function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys));
}
function renderBody(body: string, className?: string) {
return <MarkdownBody className={className}>{body}</MarkdownBody>;
}
function isPlanKey(key: string) {
return key.trim().toLowerCase() === "plan";
}
function titlesMatchKey(title: string | null | undefined, key: string) {
return (title ?? "").trim().toLowerCase() === key.trim().toLowerCase();
}
function isDocumentConflictError(error: unknown) {
return error instanceof ApiError && error.status === 409;
}
function downloadDocumentFile(key: string, body: string) {
const blob = new Blob([body], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${key}.md`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
export function IssueDocumentsSection({
issue,
canDeleteDocuments,
mentions,
imageUploadHandler,
extraActions,
}: {
issue: Issue;
canDeleteDocuments: boolean;
mentions?: MentionOption[];
imageUploadHandler?: (file: File) => Promise<string>;
extraActions?: ReactNode;
}) {
const queryClient = useQueryClient();
const location = useLocation();
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<DraftState | null>(null);
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id));
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasScrolledToHashRef = useRef(false);
const {
state: autosaveState,
markDirty,
reset,
runSave,
} = useAutosaveIndicator();
const { data: documents } = useQuery({
queryKey: queryKeys.issues.documents(issue.id),
queryFn: () => issuesApi.listDocuments(issue.id),
});
const invalidateIssueDocuments = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
};
const upsertDocument = useMutation({
mutationFn: async (nextDraft: DraftState) =>
issuesApi.upsertDocument(issue.id, nextDraft.key, {
title: isPlanKey(nextDraft.key) ? null : nextDraft.title.trim() || null,
format: "markdown",
body: nextDraft.body,
baseRevisionId: nextDraft.baseRevisionId,
}),
});
const deleteDocument = useMutation({
mutationFn: (key: string) => issuesApi.deleteDocument(issue.id, key),
onSuccess: () => {
setError(null);
setConfirmDeleteKey(null);
invalidateIssueDocuments();
},
onError: (err) => {
setError(err instanceof Error ? err.message : "Failed to delete document");
},
});
const sortedDocuments = useMemo(() => {
return [...(documents ?? [])].sort((a, b) => {
if (a.key === "plan" && b.key !== "plan") return -1;
if (a.key !== "plan" && b.key === "plan") return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
}, [documents]);
const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan");
const isEmpty = sortedDocuments.length === 0 && !issue.legacyPlanDocument;
const newDocumentKeyError =
draft?.isNew && draft.key.trim().length > 0 && !DOCUMENT_KEY_PATTERN.test(draft.key.trim())
? "Use lowercase letters, numbers, -, or _, and start with a letter or number."
: null;
const resetAutosaveState = useCallback(() => {
setAutosaveDocumentKey(null);
reset();
}, [reset]);
const markDocumentDirty = useCallback((key: string) => {
setAutosaveDocumentKey(key);
markDirty();
}, [markDirty]);
const beginNewDocument = () => {
resetAutosaveState();
setDocumentConflict(null);
setDraft({
key: "",
title: "",
body: "",
baseRevisionId: null,
isNew: true,
});
setError(null);
};
const beginEdit = (key: string) => {
const doc = sortedDocuments.find((entry) => entry.key === key);
if (!doc) return;
const conflictedDraft = documentConflict?.key === key ? documentConflict.localDraft : null;
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key));
resetAutosaveState();
setDocumentConflict((current) => current?.key === key ? current : null);
setDraft({
key: conflictedDraft?.key ?? doc.key,
title: conflictedDraft?.title ?? doc.title ?? "",
body: conflictedDraft?.body ?? doc.body,
baseRevisionId: conflictedDraft?.baseRevisionId ?? doc.latestRevisionId,
isNew: false,
});
setError(null);
};
const cancelDraft = () => {
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
resetAutosaveState();
setDocumentConflict(null);
setDraft(null);
setError(null);
};
const commitDraft = useCallback(async (
currentDraft: DraftState | null,
options?: { clearAfterSave?: boolean; trackAutosave?: boolean; overrideConflict?: boolean },
) => {
if (!currentDraft || upsertDocument.isPending) return false;
const normalizedKey = currentDraft.key.trim().toLowerCase();
const normalizedBody = currentDraft.body.trim();
const normalizedTitle = currentDraft.title.trim();
const activeConflict = documentConflict?.key === normalizedKey ? documentConflict : null;
if (activeConflict && !options?.overrideConflict) {
if (options?.trackAutosave) {
resetAutosaveState();
}
return false;
}
if (!normalizedKey || !normalizedBody) {
if (currentDraft.isNew) {
setError("Document key and body are required");
} else if (!normalizedBody) {
setError("Document body cannot be empty");
}
if (options?.trackAutosave) {
resetAutosaveState();
}
return false;
}
if (!DOCUMENT_KEY_PATTERN.test(normalizedKey)) {
setError("Document key must start with a letter or number and use only lowercase letters, numbers, -, or _.");
if (options?.trackAutosave) {
resetAutosaveState();
}
return false;
}
const existing = sortedDocuments.find((doc) => doc.key === normalizedKey);
if (
!currentDraft.isNew &&
existing &&
existing.body === currentDraft.body &&
(existing.title ?? "") === currentDraft.title
) {
if (options?.clearAfterSave) {
setDraft((value) => (value?.key === normalizedKey ? null : value));
}
if (options?.trackAutosave) {
resetAutosaveState();
}
return true;
}
const save = async () => {
const saved = await upsertDocument.mutateAsync({
...currentDraft,
key: normalizedKey,
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
body: currentDraft.body,
baseRevisionId: options?.overrideConflict
? activeConflict?.serverDocument.latestRevisionId ?? currentDraft.baseRevisionId
: currentDraft.baseRevisionId,
});
setError(null);
setDocumentConflict((current) => current?.key === normalizedKey ? null : current);
setDraft((value) => {
if (!value || value.key !== normalizedKey) return value;
if (options?.clearAfterSave) return null;
return {
key: saved.key,
title: saved.title ?? "",
body: saved.body,
baseRevisionId: saved.latestRevisionId,
isNew: false,
};
});
invalidateIssueDocuments();
};
try {
if (options?.trackAutosave) {
setAutosaveDocumentKey(normalizedKey);
await runSave(save);
} else {
await save();
}
return true;
} catch (err) {
if (isDocumentConflictError(err)) {
try {
const latestDocument = await issuesApi.getDocument(issue.id, normalizedKey);
setDocumentConflict({
key: normalizedKey,
serverDocument: latestDocument,
localDraft: {
key: normalizedKey,
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
body: currentDraft.body,
baseRevisionId: currentDraft.baseRevisionId,
isNew: false,
},
showRemote: true,
});
setFoldedDocumentKeys((current) => current.filter((key) => key !== normalizedKey));
setError(null);
resetAutosaveState();
return false;
} catch {
setError("Document changed remotely and the latest version could not be loaded");
return false;
}
}
setError(err instanceof Error ? err.message : "Failed to save document");
return false;
}
}, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, upsertDocument]);
const reloadDocumentFromServer = useCallback((key: string) => {
if (documentConflict?.key !== key) return;
const serverDocument = documentConflict.serverDocument;
setDraft({
key: serverDocument.key,
title: serverDocument.title ?? "",
body: serverDocument.body,
baseRevisionId: serverDocument.latestRevisionId,
isNew: false,
});
setDocumentConflict(null);
resetAutosaveState();
setError(null);
}, [documentConflict, resetAutosaveState]);
const overwriteDocumentFromDraft = useCallback(async (key: string) => {
if (documentConflict?.key !== key) return;
const sourceDraft =
draft && draft.key === key && !draft.isNew
? draft
: documentConflict.localDraft;
await commitDraft(
{
...sourceDraft,
baseRevisionId: documentConflict.serverDocument.latestRevisionId,
},
{
clearAfterSave: false,
trackAutosave: true,
overrideConflict: true,
},
);
}, [commitDraft, documentConflict, draft]);
const keepConflictedDraft = useCallback((key: string) => {
if (documentConflict?.key !== key) return;
setDraft(documentConflict.localDraft);
setDocumentConflict((current) =>
current?.key === key
? { ...current, showRemote: false }
: current,
);
setError(null);
}, [documentConflict]);
const copyDocumentBody = useCallback(async (key: string, body: string) => {
try {
await navigator.clipboard.writeText(body);
setCopiedDocumentKey(key);
if (copiedDocumentTimerRef.current) {
clearTimeout(copiedDocumentTimerRef.current);
}
copiedDocumentTimerRef.current = setTimeout(() => {
setCopiedDocumentKey((current) => current === key ? null : current);
}, 1400);
} catch {
setError("Could not copy document");
}
}, []);
const handleDraftBlur = async (event: React.FocusEvent<HTMLDivElement>) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
await commitDraft(draft, { clearAfterSave: true, trackAutosave: true });
};
const handleDraftKeyDown = async (event: React.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
cancelDraft();
return;
}
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
await commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
}
};
useEffect(() => {
setFoldedDocumentKeys(loadFoldedDocumentKeys(issue.id));
}, [issue.id]);
useEffect(() => {
hasScrolledToHashRef.current = false;
}, [issue.id, location.hash]);
useEffect(() => {
const validKeys = new Set(sortedDocuments.map((doc) => doc.key));
setFoldedDocumentKeys((current) => {
const next = current.filter((key) => validKeys.has(key));
if (next.length !== current.length) {
saveFoldedDocumentKeys(issue.id, next);
}
return next;
});
}, [issue.id, sortedDocuments]);
useEffect(() => {
saveFoldedDocumentKeys(issue.id, foldedDocumentKeys);
}, [foldedDocumentKeys, issue.id]);
useEffect(() => {
if (!documentConflict) return;
const latest = sortedDocuments.find((doc) => doc.key === documentConflict.key);
if (!latest || latest.latestRevisionId === documentConflict.serverDocument.latestRevisionId) return;
setDocumentConflict((current) =>
current?.key === latest.key
? { ...current, serverDocument: latest }
: current,
);
}, [documentConflict, sortedDocuments]);
useEffect(() => {
const hash = location.hash;
if (!hash.startsWith("#document-")) return;
const documentKey = decodeURIComponent(hash.slice("#document-".length));
const targetExists = sortedDocuments.some((doc) => doc.key === documentKey)
|| (documentKey === "plan" && Boolean(issue.legacyPlanDocument));
if (!targetExists || hasScrolledToHashRef.current) return;
setFoldedDocumentKeys((current) => current.filter((key) => key !== documentKey));
const element = document.getElementById(`document-${documentKey}`);
if (!element) return;
hasScrolledToHashRef.current = true;
setHighlightDocumentKey(documentKey);
element.scrollIntoView({ behavior: "smooth", block: "center" });
const timer = setTimeout(() => setHighlightDocumentKey((current) => current === documentKey ? null : current), 3000);
return () => clearTimeout(timer);
}, [issue.legacyPlanDocument, location.hash, sortedDocuments]);
useEffect(() => {
return () => {
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
if (copiedDocumentTimerRef.current) {
clearTimeout(copiedDocumentTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!draft || draft.isNew) return;
if (documentConflict?.key === draft.key) return;
const existing = sortedDocuments.find((doc) => doc.key === draft.key);
if (!existing) return;
const hasChanges =
existing.body !== draft.body ||
(existing.title ?? "") !== draft.title;
if (!hasChanges) {
if (autosaveState !== "saved") {
resetAutosaveState();
}
return;
}
markDocumentDirty(draft.key);
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
autosaveDebounceRef.current = setTimeout(() => {
void commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
}, DOCUMENT_AUTOSAVE_DEBOUNCE_MS);
return () => {
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
};
}, [autosaveState, commitDraft, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]);
const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md";
const documentBodyPaddingClassName = "";
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7";
const toggleFoldedDocument = (key: string) => {
setFoldedDocumentKeys((current) =>
current.includes(key)
? current.filter((entry) => entry !== key)
: [...current, key],
);
};
return (
<div className="space-y-3">
{isEmpty && !draft?.isNew ? (
<div className="flex items-center justify-end gap-2 min-w-0">
{extraActions}
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
<Plus className="mr-1.5 h-3.5 w-3.5" />
<span className="hidden sm:inline">New document</span>
<span className="sm:hidden">New</span>
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-2 min-w-0">
<h3 className="text-sm font-medium text-muted-foreground shrink-0">Documents</h3>
<div className="flex items-center gap-2 min-w-0">
{extraActions}
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
<Plus className="mr-1.5 h-3.5 w-3.5" />
<span className="hidden sm:inline">New document</span>
<span className="sm:hidden">New</span>
</Button>
</div>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{draft?.isNew && (
<div
className="space-y-3 rounded-lg border border-border bg-accent/10 p-3"
onBlurCapture={handleDraftBlur}
onKeyDown={handleDraftKeyDown}
>
<Input
autoFocus
value={draft.key}
onChange={(event) =>
setDraft((current) => current ? { ...current, key: event.target.value.toLowerCase() } : current)
}
placeholder="Document key"
/>
{newDocumentKeyError && (
<p className="text-xs text-destructive">{newDocumentKeyError}</p>
)}
{!isPlanKey(draft.key) && (
<Input
value={draft.title}
onChange={(event) =>
setDraft((current) => current ? { ...current, title: event.target.value } : current)
}
placeholder="Optional title"
/>
)}
<MarkdownEditor
value={draft.body}
onChange={(body) =>
setDraft((current) => current ? { ...current, body } : current)
}
placeholder="Markdown body"
bordered={false}
className="bg-transparent"
contentClassName="min-h-[220px] text-[15px] leading-7"
mentions={mentions}
imageUploadHandler={imageUploadHandler}
onSubmit={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
/>
<div className="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" onClick={cancelDraft}>
<X className="mr-1.5 h-3.5 w-3.5" />
Cancel
</Button>
<Button
size="sm"
onClick={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
disabled={upsertDocument.isPending}
>
{upsertDocument.isPending ? "Saving..." : "Create document"}
</Button>
</div>
</div>
)}
{!hasRealPlan && issue.legacyPlanDocument ? (
<div
id="document-plan"
className={cn(
"rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 transition-colors duration-1000",
highlightDocumentKey === "plan" && "border-primary/50 bg-primary/5",
)}
>
<div className="mb-2 flex items-center gap-2">
<FileText className="h-4 w-4 text-amber-600" />
<span className="rounded-full border border-amber-500/30 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-700 dark:text-amber-300">
PLAN
</span>
</div>
<div className={documentBodyPaddingClassName}>
{renderBody(issue.legacyPlanDocument.body, documentBodyContentClassName)}
</div>
</div>
) : null}
<div className="space-y-3">
{sortedDocuments.map((doc) => {
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
const activeConflict = documentConflict?.key === doc.key ? documentConflict : null;
const isFolded = foldedDocumentKeys.includes(doc.key);
const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim() && !titlesMatchKey(doc.title, doc.key);
return (
<div
key={doc.id}
id={`document-${doc.key}`}
className={cn(
"rounded-lg border border-border p-3 transition-colors duration-1000",
highlightDocumentKey === doc.key && "border-primary/50 bg-primary/5",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<button
type="button"
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
onClick={() => toggleFoldedDocument(doc.key)}
aria-label={isFolded ? `Expand ${doc.key} document` : `Collapse ${doc.key} document`}
aria-expanded={!isFolded}
>
{isFolded ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
{doc.key}
</span>
<a
href={`#document-${encodeURIComponent(doc.key)}`}
className="truncate text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline"
>
rev {doc.latestRevisionNumber} updated {relativeTime(doc.updatedAt)}
</a>
</div>
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon-xs"
className={cn(
"text-muted-foreground transition-colors",
copiedDocumentKey === doc.key && "text-foreground",
)}
title={copiedDocumentKey === doc.key ? "Copied" : "Copy document"}
onClick={() => void copyDocumentBody(doc.key, activeDraft?.body ?? doc.body)}
>
{copiedDocumentKey === doc.key ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
title="Document actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => downloadDocumentFile(doc.key, activeDraft?.body ?? doc.body)}
>
<Download className="h-3.5 w-3.5" />
Download document
</DropdownMenuItem>
{canDeleteDocuments ? <DropdownMenuSeparator /> : null}
{canDeleteDocuments ? (
<DropdownMenuItem
variant="destructive"
onClick={() => setConfirmDeleteKey(doc.key)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete document
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{!isFolded ? (
<div
className="mt-3 space-y-3"
onFocusCapture={() => {
if (!activeDraft) {
beginEdit(doc.key);
}
}}
onBlurCapture={async (event) => {
if (activeDraft) {
await handleDraftBlur(event);
}
}}
onKeyDown={async (event) => {
if (activeDraft) {
await handleDraftKeyDown(event);
}
}}
>
{activeConflict && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-amber-200">Out of date</p>
<p className="text-xs text-muted-foreground">
This document changed while you were editing. Your local draft is preserved and autosave is paused.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
setDocumentConflict((current) =>
current?.key === doc.key
? { ...current, showRemote: !current.showRemote }
: current,
)
}
>
{activeConflict.showRemote ? "Hide remote" : "Review remote"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => keepConflictedDraft(doc.key)}
>
Keep my draft
</Button>
<Button
variant="outline"
size="sm"
onClick={() => reloadDocumentFromServer(doc.key)}
>
Reload remote
</Button>
<Button
size="sm"
onClick={() => void overwriteDocumentFromDraft(doc.key)}
disabled={upsertDocument.isPending}
>
{upsertDocument.isPending ? "Saving..." : "Overwrite remote"}
</Button>
</div>
</div>
{activeConflict.showRemote && (
<div className="mt-3 rounded-md border border-border/70 bg-background/60 p-3">
<div className="mb-2 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>Remote revision {activeConflict.serverDocument.latestRevisionNumber}</span>
<span></span>
<span>updated {relativeTime(activeConflict.serverDocument.updatedAt)}</span>
</div>
{!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (
<p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p>
) : null}
{renderBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
</div>
)}
</div>
)}
{activeDraft && !isPlanKey(doc.key) && (
<Input
value={activeDraft.title}
onChange={(event) => {
markDocumentDirty(doc.key);
setDraft((current) => current ? { ...current, title: event.target.value } : current);
}}
placeholder="Optional title"
/>
)}
<div
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
activeDraft ? "" : "hover:bg-accent/10"
}`}
>
<MarkdownEditor
value={activeDraft?.body ?? doc.body}
onChange={(body) => {
markDocumentDirty(doc.key);
setDraft((current) => {
if (current && current.key === doc.key && !current.isNew) {
return { ...current, body };
}
return {
key: doc.key,
title: doc.title ?? "",
body,
baseRevisionId: doc.latestRevisionId,
isNew: false,
};
});
}}
placeholder="Markdown body"
bordered={false}
className="bg-transparent"
contentClassName={documentBodyContentClassName}
mentions={mentions}
imageUploadHandler={imageUploadHandler}
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
/>
</div>
<div className="flex min-h-4 items-center justify-end px-1">
<span
className={`text-[11px] transition-opacity duration-150 ${
activeConflict
? "text-amber-300"
: autosaveState === "error"
? "text-destructive"
: "text-muted-foreground"
} ${activeDraft ? "opacity-100" : "opacity-0"}`}
>
{activeDraft
? activeConflict
? "Out of date"
: autosaveDocumentKey === doc.key
? autosaveState === "saving"
? "Autosaving..."
: autosaveState === "saved"
? "Saved"
: autosaveState === "error"
? "Could not save"
: ""
: ""
: ""}
</span>
</div>
</div>
) : null}
{confirmDeleteKey === doc.key && (
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/5 px-4 py-3">
<p className="text-sm text-destructive font-medium">
Delete this document? This cannot be undone.
</p>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmDeleteKey(null)}
disabled={deleteDocument.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteDocument.mutate(doc.key)}
disabled={deleteDocument.isPending}
>
{deleteDocument.isPending ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View file

@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link } from "@/lib/router";
import type { Issue } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@ -10,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
@ -20,6 +22,24 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
function defaultProjectWorkspaceIdForProject(project: {
workspaces?: Array<{ id: string; isPrimary: boolean }>;
executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null;
} | null | undefined) {
if (!project) return null;
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
?? project.workspaces?.find((workspace) => workspace.isPrimary)?.id
?? project.workspaces?.[0]?.id
?? null;
}
function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) {
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
if (defaultMode === "isolated_workspace" || defaultMode === "operator_branch") return defaultMode;
if (defaultMode === "adapter_default") return "agent_default";
return "shared_workspace";
}
interface IssuePropertiesProps {
issue: Issue;
onUpdate: (data: Record<string, unknown>) => void;
@ -127,8 +147,12 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
queryFn: () => projectsApi.list(companyId!),
enabled: !!companyId,
});
const activeProjects = useMemo(
() => (projects ?? []).filter((p) => !p.archivedAt || p.id === issue.projectId),
[projects, issue.projectId],
);
const { orderedProjects } = useProjectOrder({
projects: projects ?? [],
projects: activeProjects,
companyId,
userId: currentUserId,
});
@ -176,6 +200,9 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
const project = orderedProjects.find((p) => p.id === id);
return project?.name ?? id.slice(0, 8);
};
const currentProject = issue.projectId
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
: null;
const projectLink = (id: string | null) => {
if (!id) return null;
const project = projects?.find((p) => p.id === id) ?? null;
@ -191,14 +218,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
const assignee = issue.assigneeAgentId
? agents?.find((a) => a.id === issue.assigneeAgentId)
: null;
const userLabel = (userId: string | null | undefined) =>
userId
? userId === "local-board"
? "Board"
: currentUserId && userId === currentUserId
? "Me"
: userId.slice(0, 5)
: null;
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
const assigneeUserLabel = userLabel(issue.assigneeUserId);
const creatorUserLabel = userLabel(issue.createdByUserId);
@ -211,7 +231,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
style={{
borderColor: label.color,
backgroundColor: `${label.color}22`,
color: label.color,
color: pickTextColorForPillBg(label.color, 0.13),
}}
>
{label.name}
@ -334,7 +354,22 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
>
No assignee
</button>
{issue.createdByUserId && (
{currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: currentUserId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
Assign to me
</button>
)}
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
@ -346,7 +381,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{creatorUserLabel ? `Assign to ${creatorUserLabel === "Me" ? "me" : creatorUserLabel}` : "Assign to requester"}
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
</button>
)}
{sortedAgents
@ -402,7 +437,16 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
!issue.projectId && "bg-accent"
)}
onClick={() => { onUpdate({ projectId: null }); setProjectOpen(false); }}
onClick={() => {
onUpdate({
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
});
setProjectOpen(false);
}}
>
No project
</button>
@ -419,7 +463,19 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
p.id === issue.projectId && "bg-accent"
)}
onClick={() => { onUpdate({ projectId: p.id }); setProjectOpen(false); }}
onClick={() => {
const defaultMode = defaultExecutionWorkspaceModeForProject(p);
onUpdate({
projectId: p.id,
projectWorkspaceId: defaultProjectWorkspaceIdForProject(p),
executionWorkspaceId: null,
executionWorkspacePreference: defaultMode,
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
? { mode: defaultMode }
: null,
});
setProjectOpen(false);
}}
>
<span
className="shrink-0 h-3 w-3 rounded-sm"
@ -525,6 +581,23 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
<Separator />
<div className="space-y-1">
{(issue.createdByAgentId || issue.createdByUserId) && (
<PropertyRow label="Created by">
{issue.createdByAgentId ? (
<Link
to={`/agents/${issue.createdByAgentId}`}
className="hover:underline"
>
<Identity name={agentName(issue.createdByAgentId) ?? issue.createdByAgentId.slice(0, 8)} size="sm" />
</Link>
) : (
<>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm">{creatorUserLabel ?? "User"}</span>
</>
)}
</PropertyRow>
)}
{issue.startedAt && (
<PropertyRow label="Started">
<span className="text-sm">{formatDate(issue.startedAt)}</span>

View file

@ -0,0 +1,116 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueRow } from "./IssueRow";
vi.mock("@/lib/router", () => ({
Link: ({ children, className, ...props }: React.ComponentProps<"a">) => (
<a className={className} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
identifier: "PAP-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Inbox item",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
isUnreadForMe: false,
...overrides,
};
}
describe("IssueRow", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("suppresses accent hover styling when the row is selected", () => {
const root = createRoot(container);
const issue = createIssue();
act(() => {
root.render(<IssueRow issue={issue} selected />);
});
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.className).toContain("hover:bg-transparent");
expect(link?.className).not.toContain("hover:bg-accent/50");
act(() => {
root.unmount();
});
});
it("neutralizes selected status and unread dot accents", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} selected unreadState="visible" />);
});
const markReadButton = container.querySelector('button[aria-label="Mark as read"]');
const unreadDot = markReadButton?.querySelector("span");
const statusIcon = container.querySelector('span[class*="border-muted-foreground"]');
expect(markReadButton).not.toBeNull();
expect(markReadButton?.className).toContain("hover:bg-muted/80");
expect(markReadButton?.className).not.toContain("hover:bg-blue-500/20");
expect(unreadDot).not.toBeNull();
expect(unreadDot?.className).toContain("bg-muted-foreground/70");
expect(unreadDot?.className).not.toContain("bg-blue-600");
expect(statusIcon).not.toBeNull();
expect(statusIcon?.className).toContain("!border-muted-foreground");
expect(statusIcon?.className).toContain("!text-muted-foreground");
act(() => {
root.unmount();
});
});
});

View file

@ -0,0 +1,158 @@
import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { X } from "lucide-react";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
type UnreadState = "hidden" | "visible" | "fading";
interface IssueRowProps {
issue: Issue;
issueLinkState?: unknown;
selected?: boolean;
mobileLeading?: ReactNode;
desktopMetaLeading?: ReactNode;
desktopLeadingSpacer?: boolean;
mobileMeta?: ReactNode;
desktopTrailing?: ReactNode;
trailingMeta?: ReactNode;
unreadState?: UnreadState | null;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
}
export function IssueRow({
issue,
issueLinkState,
selected = false,
mobileLeading,
desktopMetaLeading,
desktopLeadingSpacer = false,
mobileMeta,
desktopTrailing,
trailingMeta,
unreadState = null,
onMarkRead,
onArchive,
archiveDisabled,
className,
}: IssueRowProps) {
const issuePathId = issue.identifier ?? issue.id;
const identifier = issue.identifier ?? issue.id.slice(0, 8);
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
return (
<Link
to={createIssueDetailPath(issuePathId, issueLinkState)}
state={issueLinkState}
data-inbox-issue-link
className={cn(
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
className,
)}
>
<span className="shrink-0 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} className={selectedStatusClass} />}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
{desktopLeadingSpacer ? (
<span className="hidden w-3.5 shrink-0 sm:block" />
) : null}
{desktopMetaLeading ?? (
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} className={selectedStatusClass} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier}
</span>
</>
)}
{mobileMeta ? (
<>
<span className="text-xs text-muted-foreground sm:hidden" aria-hidden="true">
&middot;
</span>
<span className="text-xs text-muted-foreground sm:hidden">{mobileMeta}</span>
</>
) : null}
</span>
</span>
{(desktopTrailing || trailingMeta) ? (
<span className="ml-auto hidden shrink-0 items-center gap-2 sm:order-3 sm:flex sm:gap-3">
{desktopTrailing}
{trailingMeta ? (
<span className="text-xs text-muted-foreground">{trailingMeta}</span>
) : null}
</span>
) : null}
{showUnreadSlot ? (
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onMarkRead?.();
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onMarkRead?.();
}
}}
className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20",
)}
aria-label="Mark as read"
>
<span
className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300",
selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)}
/>
</button>
) : onArchive ? (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onArchive();
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
onArchive();
}}
disabled={archiveDisabled}
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
aria-label="Dismiss from inbox"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
</span>
) : null}
</Link>
);
}

View file

@ -0,0 +1,445 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "@/lib/router";
import type { Issue, ExecutionWorkspace } from "@paperclipai/shared";
import { useQuery } from "@tanstack/react-query";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { instanceSettingsApi } from "../api/instanceSettings";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { cn, projectWorkspaceUrl } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
/* -------------------------------------------------------------------------- */
/* Utility helpers (mirrored from IssueProperties for self-containment) */
/* -------------------------------------------------------------------------- */
const EXECUTION_WORKSPACE_OPTIONS = [
{ value: "shared_workspace", label: "Project default" },
{ value: "isolated_workspace", label: "New isolated workspace" },
{ value: "reuse_existing", label: "Reuse existing workspace" },
] as const;
function issueModeForExistingWorkspace(mode: string | null | undefined) {
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") return mode;
if (mode === "adapter_managed" || mode === "cloud_sandbox") return "agent_default";
return "shared_workspace";
}
function shouldPresentExistingWorkspaceSelection(issue: Issue) {
const persistedMode =
issue.currentExecutionWorkspace?.mode
?? issue.executionWorkspaceSettings?.mode
?? issue.executionWorkspacePreference;
return Boolean(
issue.executionWorkspaceId &&
(persistedMode === "isolated_workspace" || persistedMode === "operator_branch"),
);
}
function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined) {
const defaultMode = project?.executionWorkspacePolicy?.enabled ? project.executionWorkspacePolicy.defaultMode : null;
if (defaultMode === "isolated_workspace" || defaultMode === "operator_branch") return defaultMode;
if (defaultMode === "adapter_default") return "agent_default";
return "shared_workspace";
}
/* -------------------------------------------------------------------------- */
/* Sub-components */
/* -------------------------------------------------------------------------- */
function BreakablePath({ text }: { text: string }) {
const parts: React.ReactNode[] = [];
const segments = text.split(/(?<=[\/-])/);
for (let i = 0; i < segments.length; i++) {
if (i > 0) parts.push(<wbr key={i} />);
parts.push(segments[i]);
}
return <>{parts}</>;
}
function CopyableInline({ value, label, mono }: { value: string; label?: string; mono?: boolean }) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
} catch { /* noop */ }
}, [value]);
return (
<span className="inline-flex items-center gap-1 group/copy">
{label && <span className="text-muted-foreground">{label}</span>}
<span className={cn("min-w-0", mono && "font-mono")} style={{ overflowWrap: "anywhere" }}>
<BreakablePath text={value} />
</span>
<button
type="button"
className="shrink-0 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground opacity-0 group-hover/copy:opacity-100 focus:opacity-100"
onClick={handleCopy}
title={copied ? "Copied!" : "Copy"}
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
</button>
</span>
);
}
function workspaceModeLabel(mode: string | null | undefined) {
switch (mode) {
case "isolated_workspace": return "Isolated workspace";
case "operator_branch": return "Operator branch";
case "cloud_sandbox": return "Cloud sandbox";
case "adapter_managed": return "Adapter managed";
default: return "Workspace";
}
}
function configuredWorkspaceLabel(
selection: string | null | undefined,
reusableWorkspace: ExecutionWorkspace | null,
) {
switch (selection) {
case "isolated_workspace":
return "New isolated workspace";
case "reuse_existing":
return reusableWorkspace?.mode === "isolated_workspace"
? "Existing isolated workspace"
: "Reuse existing workspace";
default:
return "Project default";
}
}
function projectWorkspaceDetailLink(input: {
projectId: string | null | undefined;
projectWorkspaceId: string | null | undefined;
}) {
if (!input.projectId || !input.projectWorkspaceId) return null;
return projectWorkspaceUrl({ id: input.projectId, urlKey: input.projectId }, input.projectWorkspaceId);
}
function workspaceDetailLink(input: {
projectId: string | null | undefined;
issueProjectWorkspaceId: string | null | undefined;
workspace: ExecutionWorkspace | null | undefined;
}) {
const linkedProjectWorkspaceId = input.workspace?.projectWorkspaceId ?? input.issueProjectWorkspaceId ?? null;
if (input.workspace?.mode === "shared_workspace") {
return projectWorkspaceDetailLink({
projectId: input.projectId,
projectWorkspaceId: linkedProjectWorkspaceId,
});
}
return input.workspace ? `/execution-workspaces/${input.workspace.id}` : null;
}
function statusBadge(status: string) {
const colors: Record<string, string> = {
active: "bg-green-500/15 text-green-700 dark:text-green-400",
idle: "bg-muted text-muted-foreground",
in_review: "bg-blue-500/15 text-blue-700 dark:text-blue-400",
archived: "bg-muted text-muted-foreground",
};
return (
<span className={cn("text-[10px] px-1.5 py-0.5 rounded-full font-medium", colors[status] ?? colors.idle)}>
{status.replace(/_/g, " ")}
</span>
);
}
/* -------------------------------------------------------------------------- */
/* Main component */
/* -------------------------------------------------------------------------- */
interface IssueWorkspaceCardProps {
issue: Issue;
project: { id: string; executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null; defaultProjectWorkspaceId?: string | null } | null; workspaces?: Array<{ id: string; isPrimary: boolean }> } | null;
onUpdate: (data: Record<string, unknown>) => void;
}
export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceCardProps) {
const { selectedCompanyId } = useCompany();
const companyId = issue.companyId ?? selectedCompanyId;
const [editing, setEditing] = useState(false);
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
&& Boolean(project?.executionWorkspacePolicy?.enabled);
const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined;
const { data: reusableExecutionWorkspaces } = useQuery({
queryKey: queryKeys.executionWorkspaces.list(companyId!, {
projectId: issue.projectId ?? undefined,
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
reuseEligible: true,
}),
queryFn: () =>
executionWorkspacesApi.list(companyId!, {
projectId: issue.projectId ?? undefined,
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
reuseEligible: true,
}),
enabled: Boolean(companyId) && Boolean(issue.projectId) && editing,
});
const deduplicatedReusableWorkspaces = useMemo(() => {
const workspaces = reusableExecutionWorkspaces ?? [];
const seen = new Map<string, typeof workspaces[number]>();
for (const ws of workspaces) {
const key = ws.cwd ?? ws.id;
const existing = seen.get(key);
if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) {
seen.set(key, ws);
}
}
return Array.from(seen.values());
}, [reusableExecutionWorkspaces]);
const selectedReusableExecutionWorkspace =
deduplicatedReusableWorkspaces.find((w) => w.id === issue.executionWorkspaceId)
?? workspace
?? null;
const currentSelection = shouldPresentExistingWorkspaceSelection(issue)
? "reuse_existing"
: (
issue.executionWorkspacePreference
?? issue.executionWorkspaceSettings?.mode
?? defaultExecutionWorkspaceModeForProject(project)
);
const [draftSelection, setDraftSelection] = useState(currentSelection);
const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? "");
useEffect(() => {
if (editing) return;
setDraftSelection(currentSelection);
setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? "");
}, [currentSelection, editing, issue.executionWorkspaceId]);
const activeNonDefaultWorkspace = Boolean(workspace && workspace.mode !== "shared_workspace");
const configuredReusableWorkspace =
deduplicatedReusableWorkspaces.find((w) => w.id === draftExecutionWorkspaceId)
?? (draftExecutionWorkspaceId === issue.executionWorkspaceId ? selectedReusableExecutionWorkspace : null);
const selectedReusableWorkspaceLink = workspaceDetailLink({
projectId: project?.id,
issueProjectWorkspaceId: issue.projectWorkspaceId,
workspace: selectedReusableExecutionWorkspace,
});
const currentWorkspaceLink = workspaceDetailLink({
projectId: project?.id,
issueProjectWorkspaceId: issue.projectWorkspaceId,
workspace,
});
const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0;
const handleSave = useCallback(() => {
if (!canSaveWorkspaceConfig) return;
onUpdate({
executionWorkspacePreference: draftSelection,
executionWorkspaceId: draftSelection === "reuse_existing" ? draftExecutionWorkspaceId || null : null,
executionWorkspaceSettings: {
mode:
draftSelection === "reuse_existing"
? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode)
: draftSelection,
},
});
setEditing(false);
}, [
canSaveWorkspaceConfig,
configuredReusableWorkspace?.mode,
draftExecutionWorkspaceId,
draftSelection,
onUpdate,
]);
const handleCancel = useCallback(() => {
setDraftSelection(currentSelection);
setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? "");
setEditing(false);
}, [currentSelection, issue.executionWorkspaceId]);
if (!policyEnabled || !project) return null;
return (
<div className="rounded-lg border border-border p-3 space-y-2">
{/* Header row */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<GitBranch className="h-3.5 w-3.5 text-muted-foreground" />
{activeNonDefaultWorkspace && workspace
? workspaceModeLabel(workspace.mode)
: configuredWorkspaceLabel(currentSelection, selectedReusableExecutionWorkspace)}
{workspace ? statusBadge(workspace.status) : statusBadge("idle")}
</div>
<div className="flex items-center gap-1">
{editing ? (
<>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={handleCancel}
>
<X className="h-3 w-3 mr-1" />Cancel
</Button>
<Button
size="sm"
className="h-6 px-2 text-xs"
onClick={handleSave}
disabled={!canSaveWorkspaceConfig}
>
Save
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={() => setEditing(true)}
>
<Pencil className="h-3 w-3 mr-1" />Edit
</Button>
)}
</div>
</div>
{/* Read-only info */}
{!editing && (
<div className="space-y-1.5 text-xs">
{workspace?.branchName && (
<div className="flex items-center gap-1.5">
<GitBranch className="h-3 w-3 text-muted-foreground shrink-0" />
<CopyableInline value={workspace.branchName} mono />
</div>
)}
{workspace?.cwd && (
<div className="flex items-center gap-1.5">
<FolderOpen className="h-3 w-3 text-muted-foreground shrink-0" />
<CopyableInline value={workspace.cwd} mono />
</div>
)}
{workspace?.repoUrl && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<span className="text-[11px]">Repo:</span>
<CopyableInline value={workspace.repoUrl} mono />
</div>
)}
{!workspace && (
<div className="text-muted-foreground">
{currentSelection === "isolated_workspace"
? "A fresh isolated workspace will be created when this issue runs."
: currentSelection === "reuse_existing"
? "This issue will reuse an existing workspace when it runs."
: "This issue will use the project default workspace configuration when it runs."}
</div>
)}
{currentSelection === "reuse_existing" && selectedReusableExecutionWorkspace && (
<div className="text-muted-foreground" style={{ overflowWrap: "anywhere" }}>
Reusing:{" "}
{selectedReusableWorkspaceLink ? (
<Link
to={selectedReusableWorkspaceLink}
className="hover:text-foreground hover:underline"
>
<BreakablePath text={selectedReusableExecutionWorkspace.name} />
</Link>
) : (
<BreakablePath text={selectedReusableExecutionWorkspace.name} />
)}
</div>
)}
{workspace && currentWorkspaceLink && (
<div className="pt-0.5">
<Link
to={currentWorkspaceLink}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
>
View workspace details
</Link>
</div>
)}
</div>
)}
{/* Editing controls */}
{editing && (
<div className="space-y-2 pt-1">
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={draftSelection}
onChange={(e) => {
const nextMode = e.target.value;
setDraftSelection(nextMode);
if (nextMode !== "reuse_existing") {
setDraftExecutionWorkspaceId("");
} else if (!draftExecutionWorkspaceId && issue.executionWorkspaceId) {
setDraftExecutionWorkspaceId(issue.executionWorkspaceId);
}
}}
>
{EXECUTION_WORKSPACE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.value === "reuse_existing" && configuredReusableWorkspace?.mode === "isolated_workspace"
? "Existing isolated workspace"
: option.label}
</option>
))}
</select>
{draftSelection === "reuse_existing" && (
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={draftExecutionWorkspaceId}
onChange={(e) => {
setDraftExecutionWorkspaceId(e.target.value);
}}
>
<option value="">Choose an existing workspace</option>
{deduplicatedReusableWorkspaces.map((w) => (
<option key={w.id} value={w.id}>
{w.name} · {w.status} · {w.branchName ?? w.cwd ?? w.id.slice(0, 8)}
</option>
))}
</select>
)}
{/* Current workspace summary when editing */}
{workspace && (
<div className="text-[11px] text-muted-foreground space-y-0.5 pt-1 border-t border-border/50">
<div style={{ overflowWrap: "anywhere" }}>
Current:{" "}
{currentWorkspaceLink ? (
<Link
to={currentWorkspaceLink}
className="hover:text-foreground hover:underline"
>
<BreakablePath text={workspace.name} />
</Link>
) : (
<BreakablePath text={workspace.name} />
)}
{" · "}
{workspace.status}
</div>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -1,23 +1,27 @@
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
import { Link } from "@/lib/router";
import { startTransition, useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { issuesApi } from "../api/issues";
import { authApi } from "../api/auth";
import { queryKeys } from "../lib/queryKeys";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { groupBy } from "../lib/groupBy";
import { formatDate, cn } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { EmptyState } from "./EmptyState";
import { Identity } from "./Identity";
import { IssueRow } from "./IssueRow";
import { PageSkeleton } from "./PageSkeleton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search, ArrowDown } from "lucide-react";
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
import { KanbanBoard } from "./KanbanBoard";
import type { Issue } from "@paperclipai/shared";
@ -37,6 +41,7 @@ export type IssueViewState = {
priorities: string[];
assignees: string[];
labels: string[];
projects: string[];
sortField: "status" | "priority" | "title" | "created" | "updated";
sortDir: "asc" | "desc";
groupBy: "status" | "priority" | "assignee" | "none";
@ -49,6 +54,7 @@ const defaultViewState: IssueViewState = {
priorities: [],
assignees: [],
labels: [],
projects: [],
sortField: "updated",
sortDir: "desc",
groupBy: "none",
@ -62,6 +68,7 @@ const quickFilterPresets = [
{ label: "Backlog", statuses: ["backlog"] },
{ label: "Done", statuses: ["done", "cancelled"] },
];
const ISSUE_SEARCH_COMMIT_DELAY_MS = 150;
function getViewState(key: string): IssueViewState {
try {
@ -86,12 +93,22 @@ function toggleInArray(arr: string[], value: string): string[] {
return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value];
}
function applyFilters(issues: Issue[], state: IssueViewState): Issue[] {
function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] {
let result = issues;
if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status));
if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority));
if (state.assignees.length > 0) result = result.filter((i) => i.assigneeAgentId != null && state.assignees.includes(i.assigneeAgentId));
if (state.assignees.length > 0) {
result = result.filter((issue) => {
for (const assignee of state.assignees) {
if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true;
if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true;
if (issue.assigneeAgentId === assignee) return true;
}
return false;
});
}
if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id)));
if (state.projects.length > 0) result = result.filter((i) => i.projectId != null && state.projects.includes(i.projectId));
return result;
}
@ -123,6 +140,7 @@ function countActiveFilters(state: IssueViewState): number {
if (state.priorities.length > 0) count++;
if (state.assignees.length > 0) count++;
if (state.labels.length > 0) count++;
if (state.projects.length > 0) count++;
return count;
}
@ -133,35 +151,91 @@ interface Agent {
name: string;
}
interface ProjectOption {
id: string;
name: string;
}
interface IssuesListProps {
issues: Issue[];
isLoading?: boolean;
error?: Error | null;
agents?: Agent[];
projects?: ProjectOption[];
liveIssueIds?: Set<string>;
projectId?: string;
viewStateKey: string;
issueLinkState?: unknown;
initialAssignees?: string[];
initialSearch?: string;
searchFilters?: {
participantAgentId?: string;
};
onSearchChange?: (search: string) => void;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
}
interface IssuesSearchInputProps {
initialValue: string;
onValueCommitted: (value: string) => void;
}
function IssuesSearchInput({ initialValue, onValueCommitted }: IssuesSearchInputProps) {
const [value, setValue] = useState(initialValue);
const onValueCommittedRef = useRef(onValueCommitted);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
useEffect(() => {
onValueCommittedRef.current = onValueCommitted;
}, [onValueCommitted]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
onValueCommittedRef.current(value);
}, ISSUE_SEARCH_COMMIT_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [value]);
return (
<div className="relative w-48 sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Search issues..."
className="pl-7 text-xs sm:text-sm"
aria-label="Search issues"
/>
</div>
);
}
export function IssuesList({
issues,
isLoading,
error,
agents,
projects,
liveIssueIds,
projectId,
viewStateKey,
issueLinkState,
initialAssignees,
initialSearch,
searchFilters,
onSearchChange,
onUpdateIssue,
}: IssuesListProps) {
const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialog();
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
// Scope the storage key per company so folding/view state is independent across companies.
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
@ -175,20 +249,12 @@ export function IssuesList({
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
const [debouncedIssueSearch, setDebouncedIssueSearch] = useState(issueSearch);
const normalizedIssueSearch = debouncedIssueSearch.trim();
const normalizedIssueSearch = issueSearch.trim();
useEffect(() => {
setIssueSearch(initialSearch ?? "");
}, [initialSearch]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedIssueSearch(issueSearch);
}, 300);
return () => window.clearTimeout(timeoutId);
}, [issueSearch]);
// Reload view state from localStorage when company changes (scopedKey changes).
const prevScopedKey = useRef(scopedKey);
useEffect(() => {
@ -200,6 +266,13 @@ export function IssuesList({
}
}, [scopedKey, initialAssignees]);
const handleIssueSearchCommit = useCallback((nextSearch: string) => {
startTransition(() => {
setIssueSearch(nextSearch);
});
onSearchChange?.(nextSearch);
}, [onSearchChange]);
const updateView = useCallback((patch: Partial<IssueViewState>) => {
setViewState((prev) => {
const next = { ...prev, ...patch };
@ -209,9 +282,13 @@ export function IssuesList({
}, [scopedKey]);
const { data: searchedIssues = [] } = useQuery({
queryKey: queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId }),
queryKey: [
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
searchFilters ?? {},
],
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
placeholderData: (previousData) => previousData,
});
const agentName = useCallback((id: string | null) => {
@ -221,9 +298,9 @@ export function IssuesList({
const filtered = useMemo(() => {
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
const filteredByControls = applyFilters(sourceIssues, viewState);
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
return sortIssues(filteredByControls, viewState);
}, [issues, searchedIssues, viewState, normalizedIssueSearch]);
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!),
@ -233,24 +310,6 @@ export function IssuesList({
const activeFilterCount = countActiveFilters(viewState);
const [showScrollBottom, setShowScrollBottom] = useState(false);
useEffect(() => {
const el = document.getElementById("main-content");
if (!el) return;
const check = () => {
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setShowScrollBottom(distanceFromBottom > 300);
};
check();
el.addEventListener("scroll", check, { passive: true });
return () => el.removeEventListener("scroll", check);
}, [filtered.length]);
const scrollToBottom = useCallback(() => {
const el = document.getElementById("main-content");
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}, []);
const groupedContent = useMemo(() => {
if (viewState.groupBy === "none") {
return [{ key: "__all", label: null as string | null, items: filtered }];
@ -268,13 +327,21 @@ export function IssuesList({
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
}
// assignee
const groups = groupBy(filtered, (i) => i.assigneeAgentId ?? "__unassigned");
const groups = groupBy(
filtered,
(issue) => issue.assigneeAgentId ?? (issue.assigneeUserId ? `__user:${issue.assigneeUserId}` : "__unassigned"),
);
return Object.keys(groups).map((key) => ({
key,
label: key === "__unassigned" ? "Unassigned" : (agentName(key) ?? key.slice(0, 8)),
label:
key === "__unassigned"
? "Unassigned"
: key.startsWith("__user:")
? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User")
: (agentName(key) ?? key.slice(0, 8)),
items: groups[key]!,
}));
}, [filtered, viewState.groupBy, agents]); // eslint-disable-line react-hooks/exhaustive-deps
}, [filtered, viewState.groupBy, agents, agentName, currentUserId]);
const newIssueDefaults = (groupKey?: string) => {
const defaults: Record<string, string> = {};
@ -282,13 +349,16 @@ export function IssuesList({
if (groupKey) {
if (viewState.groupBy === "status") defaults.status = groupKey;
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") defaults.assigneeAgentId = groupKey;
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") {
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
else defaults.assigneeAgentId = groupKey;
}
}
return defaults;
};
const assignIssue = (issueId: string, assigneeAgentId: string | null) => {
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null });
const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
setAssigneePickerIssueId(null);
setAssigneeSearch("");
};
@ -302,19 +372,10 @@ export function IssuesList({
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">New Issue</span>
</Button>
<div className="relative w-48 sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={issueSearch}
onChange={(e) => {
setIssueSearch(e.target.value);
onSearchChange?.(e.target.value);
}}
placeholder="Search issues..."
className="pl-7 text-xs sm:text-sm"
aria-label="Search issues"
/>
</div>
<IssuesSearchInput
initialValue={initialSearch ?? ""}
onValueCommitted={handleIssueSearchCommit}
/>
</div>
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
@ -350,7 +411,7 @@ export function IssuesList({
className="h-3 w-3 ml-1 hidden sm:block"
onClick={(e) => {
e.stopPropagation();
updateView({ statuses: [], priorities: [], assignees: [], labels: [] });
updateView({ statuses: [], priorities: [], assignees: [], labels: [], projects: [] });
}}
/>
)}
@ -434,22 +495,37 @@ export function IssuesList({
</div>
{/* Assignee */}
{agents && agents.length > 0 && (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Assignee</span>
<div className="space-y-0.5 max-h-32 overflow-y-auto">
{agents.map((agent) => (
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes(agent.id)}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
/>
<span className="text-sm">{agent.name}</span>
</label>
))}
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Assignee</span>
<div className="space-y-0.5 max-h-32 overflow-y-auto">
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes("__unassigned")}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__unassigned") })}
/>
<span className="text-sm">No assignee</span>
</label>
{currentUserId && (
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes("__me")}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__me") })}
/>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm">Me</span>
</label>
)}
{(agents ?? []).map((agent) => (
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes(agent.id)}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
/>
<span className="text-sm">{agent.name}</span>
</label>
))}
</div>
)}
</div>
{labels && labels.length > 0 && (
<div className="space-y-1">
@ -468,6 +544,23 @@ export function IssuesList({
</div>
</div>
)}
{projects && projects.length > 0 && (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Project</span>
<div className="space-y-0.5 max-h-32 overflow-y-auto">
{projects.map((project) => (
<label key={project.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.projects.includes(project.id)}
onCheckedChange={() => updateView({ projects: toggleInArray(viewState.projects, project.id) })}
/>
<span className="text-sm">{project.name}</span>
</label>
))}
</div>
</div>
)}
</div>
</div>
</div>
@ -605,54 +698,79 @@ export function IssuesList({
)}
<CollapsibleContent>
{group.items.map((issue) => (
<Link
<IssueRow
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex items-center gap-2 py-2 pl-1 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit"
>
{/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */}
<div className="w-3.5 shrink-0 hidden sm:block" />
<div className="shrink-0" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</div>
<span className="text-sm text-muted-foreground font-mono shrink-0">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="truncate flex-1 min-w-0">{issue.title}</span>
{(issue.labels ?? []).length > 0 && (
<div className="hidden md:flex items-center gap-1 max-w-[240px] overflow-hidden">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: label.color,
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
)}
</div>
issue={issue}
issueLinkState={issueLinkState}
desktopLeadingSpacer
mobileLeading={(
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
)}
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-auto">
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 rounded-full bg-blue-500/10">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>
desktopMetaLeading={(
<>
<span
className="hidden shrink-0 sm:inline-flex"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
)}
<div className="hidden sm:block">
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
mobileMeta={timeAgo(issue.updatedAt)}
desktopTrailing={(
<>
{(issue.labels ?? []).length > 0 && (
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: pickTextColorForPillBg(label.color, 0.12),
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{(issue.labels ?? []).length - 3}
</span>
)}
</span>
)}
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
@ -662,7 +780,7 @@ export function IssuesList({
>
<PopoverTrigger asChild>
<button
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 hover:bg-accent/50 transition-colors"
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -670,6 +788,13 @@ export function IssuesList({
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
@ -687,8 +812,8 @@ export function IssuesList({
onPointerDownOutside={() => setAssigneeSearch("")}
>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search agents..."
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search assignees..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
@ -696,33 +821,51 @@ export function IssuesList({
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.assigneeAgentId && "bg-accent"
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null);
assignIssue(issue.id, null, null);
}}
>
No assignee
</button>
{currentUserId && (
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, currentUserId);
}}
>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Me</span>
</button>
)}
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
return agent.name
.toLowerCase()
.includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
issue.assigneeAgentId === agent.id && "bg-accent"
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeAgentId === agent.id && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id);
assignIssue(issue.id, agent.id, null);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
@ -731,26 +874,15 @@ export function IssuesList({
</div>
</PopoverContent>
</Popover>
</div>
<span className="text-xs text-muted-foreground hidden sm:inline">
{formatDate(issue.createdAt)}
</span>
</div>
</Link>
</>
)}
trailingMeta={formatDate(issue.createdAt)}
/>
))}
</CollapsibleContent>
</Collapsible>
))
)}
{showScrollBottom && (
<button
onClick={scrollToBottom}
className="fixed bottom-6 right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-colors"
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</button>
)}
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -154,7 +154,7 @@ function KanbanCard({
</span>
{isLive && (
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
)}

View file

@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useState, type UIEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, Moon, Sun } from "lucide-react";
import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
import { BookOpen, Moon, Settings, Sun } from "lucide-react";
import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
import { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar";
import { SidebarNavItem } from "./SidebarNavItem";
import { InstanceSidebar } from "./InstanceSidebar";
import { BreadcrumbBar } from "./BreadcrumbBar";
import { PropertiesPanel } from "./PropertiesPanel";
import { CommandPalette } from "./CommandPalette";
@ -14,6 +14,8 @@ import { NewGoalDialog } from "./NewGoalDialog";
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";
@ -22,27 +24,66 @@ import { useTheme } from "../context/ThemeContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import {
DEFAULT_INSTANCE_SETTINGS_PATH,
normalizeRememberedInstanceSettingsPath,
} from "../lib/instance-settings";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
function readRememberedInstanceSettingsPath(): string {
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
try {
return normalizeRememberedInstanceSettingsPath(window.localStorage.getItem(INSTANCE_SETTINGS_MEMORY_KEY));
} catch {
return DEFAULT_INSTANCE_SETTINGS_PATH;
}
}
export function Layout() {
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
const { openNewIssue, openOnboarding } = useDialog();
const { togglePanelVisible } = usePanel();
const { companies, loading: companiesLoading, selectedCompanyId, setSelectedCompanyId } = useCompany();
const {
companies,
loading: companiesLoading,
selectedCompany,
selectedCompanyId,
selectionSource,
setSelectedCompanyId,
} = useCompany();
const { theme, toggleTheme } = useTheme();
const { companyPrefix } = useParams<{ companyPrefix: string }>();
const navigate = useNavigate();
const location = useLocation();
const isInstanceSettingsRoute = location.pathname.startsWith("/instance/");
const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
const nextTheme = theme === "dark" ? "light" : "dark";
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
const requestedPrefix = companyPrefix.toUpperCase();
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix) ?? null;
}, [companies, companyPrefix]);
const hasUnknownCompanyPrefix =
Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;
const { data: health } = useQuery({
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(() => {
@ -57,56 +98,52 @@ export function Layout() {
useEffect(() => {
if (!companyPrefix || companiesLoading || companies.length === 0) return;
const requestedPrefix = companyPrefix.toUpperCase();
const matched = companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix);
if (!matched) {
const fallback =
(selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null)
?? companies[0]!;
navigate(`/${fallback.issuePrefix}/dashboard`, { replace: true });
if (!matchedCompany) {
const fallback = (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null)
?? companies[0]
?? null;
if (fallback && selectedCompanyId !== fallback.id) {
setSelectedCompanyId(fallback.id, { source: "route_sync" });
}
return;
}
if (companyPrefix !== matched.issuePrefix) {
if (companyPrefix !== matchedCompany.issuePrefix) {
const suffix = location.pathname.replace(/^\/[^/]+/, "");
navigate(`/${matched.issuePrefix}${suffix}${location.search}`, { replace: true });
navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, { replace: true });
return;
}
if (selectedCompanyId !== matched.id) {
setSelectedCompanyId(matched.id, { source: "route_sync" });
if (
shouldSyncCompanySelectionFromRoute({
selectionSource,
selectedCompanyId,
routeCompanyId: matchedCompany.id,
})
) {
setSelectedCompanyId(matchedCompany.id, { source: "route_sync" });
}
}, [
companyPrefix,
companies,
companiesLoading,
matchedCompany,
location.pathname,
location.search,
navigate,
selectionSource,
selectedCompanyId,
setSelectedCompanyId,
]);
const togglePanel = togglePanelVisible;
// Cmd+1..9 to switch companies
const switchCompany = useCallback(
(index: number) => {
if (index < companies.length) {
setSelectedCompanyId(companies[index]!.id);
}
},
[companies, setSelectedCompanyId],
);
useCompanyPageMemory();
useKeyboardShortcuts({
onNewIssue: () => openNewIssue(),
onToggleSidebar: toggleSidebar,
onTogglePanel: togglePanel,
onSwitchCompany: switchCompany,
});
useEffect(() => {
@ -163,128 +200,233 @@ export function Layout() {
};
}, [isMobile, sidebarOpen, setSidebarOpen]);
const handleMainScroll = useCallback(
(event: UIEvent<HTMLElement>) => {
if (!isMobile) return;
const updateMobileNavVisibility = useCallback((currentTop: number) => {
const delta = currentTop - lastMainScrollTop.current;
const currentTop = event.currentTarget.scrollTop;
const delta = currentTop - lastMainScrollTop.current;
if (currentTop <= 24) {
setMobileNavVisible(true);
} else if (delta > 8) {
setMobileNavVisible(false);
} else if (delta < -8) {
setMobileNavVisible(true);
}
if (currentTop <= 24) {
setMobileNavVisible(true);
} else if (delta > 8) {
setMobileNavVisible(false);
} else if (delta < -8) {
setMobileNavVisible(true);
}
lastMainScrollTop.current = currentTop;
}, []);
lastMainScrollTop.current = currentTop;
},
[isMobile],
);
useEffect(() => {
if (!isMobile) {
setMobileNavVisible(true);
lastMainScrollTop.current = 0;
return;
}
const onScroll = () => {
updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
};
}, [isMobile, updateMobileNavVisibility]);
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = isMobile ? "visible" : "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isMobile]);
useEffect(() => {
if (!location.pathname.startsWith("/instance/settings/")) return;
const nextPath = normalizeRememberedInstanceSettingsPath(
`${location.pathname}${location.search}${location.hash}`,
);
setInstanceSettingsTarget(nextPath);
try {
window.localStorage.setItem(INSTANCE_SETTINGS_MEMORY_KEY, nextPath);
} catch {
// Ignore storage failures in restricted environments.
}
}, [location.hash, location.pathname, location.search]);
return (
<div className="flex h-dvh bg-background text-foreground overflow-hidden pt-[env(safe-area-inset-top)]">
<div
className={cn(
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
)}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Skip to Main Content
</a>
{/* Mobile backdrop */}
{isMobile && sidebarOpen && (
<button
type="button"
className="fixed inset-0 z-40 bg-black/50"
onClick={() => setSidebarOpen(false)}
aria-label="Close sidebar"
/>
)}
<WorktreeBanner />
<DevRestartBanner devServer={health?.devServer} />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
{isMobile && sidebarOpen && (
<button
type="button"
className="fixed inset-0 z-40 bg-black/50"
onClick={() => setSidebarOpen(false)}
aria-label="Close sidebar"
/>
)}
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
{isMobile ? (
<div
className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-100 ease-out",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex flex-1 min-h-0 overflow-hidden">
<CompanyRail />
<Sidebar />
</div>
<div className="border-t border-r border-border px-3 py-2 bg-background">
<div className="flex items-center gap-1">
<SidebarNavItem
to="/docs"
label="Documentation"
icon={BookOpen}
className="flex-1 min-w-0"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
{isMobile ? (
<div
className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-100 ease-out",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex flex-1 min-h-0 overflow-hidden">
<CompanyRail />
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
</div>
<div className="border-t border-r border-border px-3 py-2 bg-background">
<div className="flex items-center gap-1">
<a
href="https://docs.paperclip.ing/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
{health?.version && (
<Tooltip>
<TooltipTrigger asChild>
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
</TooltipTrigger>
<TooltipContent>v{health.version}</TooltipContent>
</Tooltip>
)}
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to={instanceSettingsTarget}
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
>
<Settings className="h-4 w-4" />
</Link>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
</div>
) : (
<div className="flex flex-col shrink-0 h-full">
<div className="flex flex-1 min-h-0">
<CompanyRail />
<div
) : (
<div className="flex h-full flex-col shrink-0">
<div className="flex flex-1 min-h-0">
<CompanyRail />
<div
className={cn(
"overflow-hidden transition-[width] duration-100 ease-out",
sidebarOpen ? "w-60" : "w-0"
)}
>
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
</div>
</div>
<div className="border-t border-r border-border px-3 py-2">
<div className="flex items-center gap-1">
<a
href="https://docs.paperclip.ing/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
>
<BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span>
</a>
{health?.version && (
<Tooltip>
<TooltipTrigger asChild>
<span className="px-2 text-xs text-muted-foreground shrink-0 cursor-default">v</span>
</TooltipTrigger>
<TooltipContent>v{health.version}</TooltipContent>
</Tooltip>
)}
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to={instanceSettingsTarget}
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
>
<Settings className="h-4 w-4" />
</Link>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
)}
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div
className={cn(
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
)}
>
<BreadcrumbBar />
</div>
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main
id="main-content"
tabIndex={-1}
className={cn(
"overflow-hidden transition-[width] duration-100 ease-out",
sidebarOpen ? "w-60" : "w-0"
"flex-1 p-4 md:p-6",
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
)}
>
<Sidebar />
</div>
{hasUnknownCompanyPrefix ? (
<NotFoundPage
scope="invalid_company_prefix"
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
/>
) : (
<Outlet />
)}
</main>
<PropertiesPanel />
</div>
<div className="border-t border-r border-border px-3 py-2">
<div className="flex items-center gap-1">
<SidebarNavItem
to="/docs"
label="Documentation"
icon={BookOpen}
className="flex-1 min-w-0"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
)}
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 h-full">
<BreadcrumbBar />
<div className="flex flex-1 min-h-0">
<main
id="main-content"
tabIndex={-1}
className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-[calc(5rem+env(safe-area-inset-bottom))]")}
onScroll={handleMainScroll}
>
<Outlet />
</main>
<PropertiesPanel />
</div>
</div>
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}

View file

@ -1,262 +1,32 @@
import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { useMemo, useState } from "react";
import { Link } from "@/lib/router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclipai/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import { getUIAdapter } from "../adapters";
import type { TranscriptEntry } from "../adapters";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime, formatDateTime } from "../lib/utils";
import { formatDateTime } from "../lib/utils";
import { ExternalLink, Square } from "lucide-react";
import { Identity } from "./Identity";
import { StatusBadge } from "./StatusBadge";
import { RunTranscriptView } from "./transcript/RunTranscriptView";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
interface LiveRunWidgetProps {
issueId: string;
companyId?: string | null;
}
type FeedTone = "info" | "warn" | "error" | "assistant" | "tool";
interface FeedItem {
id: string;
ts: string;
runId: string;
agentId: string;
agentName: string;
text: string;
tone: FeedTone;
dedupeKey: string;
streamingKind?: "assistant" | "thinking";
}
const MAX_FEED_ITEMS = 80;
const MAX_FEED_TEXT_LENGTH = 220;
const MAX_STREAMING_TEXT_LENGTH = 4000;
const LOG_POLL_INTERVAL_MS = 2000;
const LOG_READ_LIMIT_BYTES = 256_000;
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function toIsoString(value: string | Date | null | undefined): string | null {
if (!value) return null;
return typeof value === "string" ? value : value.toISOString();
}
function summarizeEntry(entry: TranscriptEntry): { text: string; tone: FeedTone } | null {
if (entry.kind === "assistant") {
const text = entry.text.trim();
return text ? { text, tone: "assistant" } : null;
}
if (entry.kind === "thinking") {
const text = entry.text.trim();
return text ? { text: `[thinking] ${text}`, tone: "info" } : null;
}
if (entry.kind === "tool_call") {
return { text: `tool ${entry.name}`, tone: "tool" };
}
if (entry.kind === "tool_result") {
const base = entry.content.trim();
return {
text: entry.isError ? `tool error: ${base}` : `tool result: ${base}`,
tone: entry.isError ? "error" : "tool",
};
}
if (entry.kind === "stderr") {
const text = entry.text.trim();
return text ? { text, tone: "error" } : null;
}
if (entry.kind === "system") {
const text = entry.text.trim();
return text ? { text, tone: "warn" } : null;
}
if (entry.kind === "stdout") {
const text = entry.text.trim();
return text ? { text, tone: "info" } : null;
}
return null;
}
function createFeedItem(
run: LiveRunForIssue,
ts: string,
text: string,
tone: FeedTone,
nextId: number,
options?: {
streamingKind?: "assistant" | "thinking";
preserveWhitespace?: boolean;
},
): FeedItem | null {
if (!text.trim()) return null;
const base = options?.preserveWhitespace ? text : text.trim();
const maxLength = options?.streamingKind ? MAX_STREAMING_TEXT_LENGTH : MAX_FEED_TEXT_LENGTH;
const normalized = base.length > maxLength ? base.slice(-maxLength) : base;
return {
id: `${run.id}:${nextId}`,
ts,
runId: run.id,
agentId: run.agentId,
agentName: run.agentName,
text: normalized,
tone,
dedupeKey: `feed:${run.id}:${ts}:${tone}:${normalized}`,
streamingKind: options?.streamingKind,
};
}
function parseStdoutChunk(
run: LiveRunForIssue,
chunk: string,
ts: string,
pendingByRun: Map<string, string>,
nextIdRef: MutableRefObject<number>,
): FeedItem[] {
const pendingKey = `${run.id}:stdout`;
const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`;
const split = combined.split(/\r?\n/);
pendingByRun.set(pendingKey, split.pop() ?? "");
const adapter = getUIAdapter(run.adapterType);
const summarized: Array<{ text: string; tone: FeedTone; streamingKind?: "assistant" | "thinking" }> = [];
const appendSummary = (entry: TranscriptEntry) => {
if (entry.kind === "assistant" && entry.delta) {
const text = entry.text;
if (!text.trim()) return;
const last = summarized[summarized.length - 1];
if (last && last.streamingKind === "assistant") {
last.text += text;
} else {
summarized.push({ text, tone: "assistant", streamingKind: "assistant" });
}
return;
}
if (entry.kind === "thinking" && entry.delta) {
const text = entry.text;
if (!text.trim()) return;
const last = summarized[summarized.length - 1];
if (last && last.streamingKind === "thinking") {
last.text += text;
} else {
summarized.push({ text: `[thinking] ${text}`, tone: "info", streamingKind: "thinking" });
}
return;
}
const summary = summarizeEntry(entry);
if (!summary) return;
summarized.push({ text: summary.text, tone: summary.tone });
};
const items: FeedItem[] = [];
for (const line of split.slice(-8)) {
const trimmed = line.trim();
if (!trimmed) continue;
const parsed = adapter.parseStdoutLine(trimmed, ts);
if (parsed.length === 0) {
if (run.adapterType === "openclaw_gateway") {
continue;
}
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
if (fallback) items.push(fallback);
continue;
}
for (const entry of parsed) {
appendSummary(entry);
}
}
for (const summary of summarized) {
const item = createFeedItem(run, ts, summary.text, summary.tone, nextIdRef.current++, {
streamingKind: summary.streamingKind,
preserveWhitespace: !!summary.streamingKind,
});
if (item) items.push(item);
}
return items;
}
function parseStderrChunk(
run: LiveRunForIssue,
chunk: string,
ts: string,
pendingByRun: Map<string, string>,
nextIdRef: MutableRefObject<number>,
): FeedItem[] {
const pendingKey = `${run.id}:stderr`;
const combined = `${pendingByRun.get(pendingKey) ?? ""}${chunk}`;
const split = combined.split(/\r?\n/);
pendingByRun.set(pendingKey, split.pop() ?? "");
const items: FeedItem[] = [];
for (const line of split.slice(-8)) {
const item = createFeedItem(run, ts, line, "error", nextIdRef.current++);
if (item) items.push(item);
}
return items;
}
function parsePersistedLogContent(
runId: string,
content: string,
pendingByRun: Map<string, string>,
): Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> {
if (!content) return [];
const pendingKey = `${runId}:records`;
const combined = `${pendingByRun.get(pendingKey) ?? ""}${content}`;
const split = combined.split("\n");
pendingByRun.set(pendingKey, split.pop() ?? "");
const parsed: Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }> = [];
for (const line of split) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown };
const stream = raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout";
const chunk = typeof raw.chunk === "string" ? raw.chunk : "";
const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString();
if (!chunk) continue;
parsed.push({ ts, stream, chunk });
} catch {
// Ignore malformed log rows.
}
}
return parsed;
function isRunActive(status: string): boolean {
return status === "queued" || status === "running";
}
export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
const queryClient = useQueryClient();
const [feed, setFeed] = useState<FeedItem[]>([]);
const [cancellingRunIds, setCancellingRunIds] = useState(new Set<string>());
const seenKeysRef = useRef(new Set<string>());
const pendingByRunRef = useRef(new Map<string, string>());
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>());
const runMetaByIdRef = useRef(new Map<string, { agentId: string; agentName: string }>());
const nextIdRef = useRef(1);
const bodyRef = useRef<HTMLDivElement>(null);
const handleCancelRun = async (runId: string) => {
setCancellingRunIds((prev) => new Set(prev).add(runId));
try {
await heartbeatsApi.cancel(runId);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) });
} finally {
setCancellingRunIds((prev) => {
const next = new Set(prev);
next.delete(runId);
return next;
});
}
};
const { data: liveRuns } = useQuery({
queryKey: queryKeys.issues.liveRuns(issueId),
@ -297,329 +67,94 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
);
}, [activeRun, issueId, liveRuns]);
const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]);
const activeRunIds = useMemo(() => new Set(runs.map((run) => run.id)), [runs]);
const runIdsKey = useMemo(
() => runs.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","),
[runs],
);
const appendItems = (items: FeedItem[]) => {
if (items.length === 0) return;
setFeed((prev) => {
const next = [...prev];
for (const item of items) {
if (seenKeysRef.current.has(item.dedupeKey)) continue;
seenKeysRef.current.add(item.dedupeKey);
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs, companyId });
const last = next[next.length - 1];
if (
item.streamingKind &&
last &&
last.runId === item.runId &&
last.streamingKind === item.streamingKind
) {
const mergedText = `${last.text}${item.text}`;
const nextText =
mergedText.length > MAX_STREAMING_TEXT_LENGTH
? mergedText.slice(-MAX_STREAMING_TEXT_LENGTH)
: mergedText;
next[next.length - 1] = {
...last,
ts: item.ts,
text: nextText,
dedupeKey: last.dedupeKey,
};
continue;
}
next.push(item);
}
if (seenKeysRef.current.size > 6000) {
seenKeysRef.current.clear();
}
if (next.length === prev.length) return prev;
return next.slice(-MAX_FEED_ITEMS);
});
const handleCancelRun = async (runId: string) => {
setCancellingRunIds((prev) => new Set(prev).add(runId));
try {
await heartbeatsApi.cancel(runId);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) });
} finally {
setCancellingRunIds((prev) => {
const next = new Set(prev);
next.delete(runId);
return next;
});
}
};
useEffect(() => {
const body = bodyRef.current;
if (!body) return;
body.scrollTo({ top: body.scrollHeight, behavior: "smooth" });
}, [feed.length]);
useEffect(() => {
for (const run of runs) {
runMetaByIdRef.current.set(run.id, { agentId: run.agentId, agentName: run.agentName });
}
}, [runs]);
useEffect(() => {
const stillActive = new Set<string>();
for (const runId of activeRunIds) {
stillActive.add(`${runId}:stdout`);
stillActive.add(`${runId}:stderr`);
}
for (const key of pendingByRunRef.current.keys()) {
if (!stillActive.has(key)) {
pendingByRunRef.current.delete(key);
}
}
const liveRunIds = new Set(activeRunIds);
for (const key of pendingLogRowsByRunRef.current.keys()) {
const runId = key.replace(/:records$/, "");
if (!liveRunIds.has(runId)) {
pendingLogRowsByRunRef.current.delete(key);
}
}
for (const runId of logOffsetByRunRef.current.keys()) {
if (!liveRunIds.has(runId)) {
logOffsetByRunRef.current.delete(runId);
}
}
}, [activeRunIds]);
useEffect(() => {
if (runs.length === 0) return;
let cancelled = false;
const readRunLog = async (run: LiveRunForIssue) => {
const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
try {
const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES);
if (cancelled) return;
const rows = parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current);
const items: FeedItem[] = [];
for (const row of rows) {
if (row.stream === "stderr") {
items.push(
...parseStderrChunk(run, row.chunk, row.ts, pendingByRunRef.current, nextIdRef),
);
continue;
}
if (row.stream === "system") {
const item = createFeedItem(run, row.ts, row.chunk, "warn", nextIdRef.current++);
if (item) items.push(item);
continue;
}
items.push(
...parseStdoutChunk(run, row.chunk, row.ts, pendingByRunRef.current, nextIdRef),
);
}
appendItems(items);
if (result.nextOffset !== undefined) {
logOffsetByRunRef.current.set(run.id, result.nextOffset);
return;
}
if (result.content.length > 0) {
logOffsetByRunRef.current.set(run.id, offset + result.content.length);
}
} catch {
// Ignore log read errors while run output is initializing.
}
};
const readAll = async () => {
await Promise.all(runs.map((run) => readRunLog(run)));
};
void readAll();
const interval = window.setInterval(() => {
void readAll();
}, LOG_POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [runIdsKey, runs]);
useEffect(() => {
if (!companyId || activeRunIds.size === 0) return;
let closed = false;
let reconnectTimer: number | null = null;
let socket: WebSocket | null = null;
const scheduleReconnect = () => {
if (closed) return;
reconnectTimer = window.setTimeout(connect, 1500);
};
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`;
socket = new WebSocket(url);
socket.onmessage = (message) => {
const raw = typeof message.data === "string" ? message.data : "";
if (!raw) return;
let event: LiveEvent;
try {
event = JSON.parse(raw) as LiveEvent;
} catch {
return;
}
if (event.companyId !== companyId) return;
const payload = event.payload ?? {};
const runId = readString(payload["runId"]);
if (!runId || !activeRunIds.has(runId)) return;
const run = runById.get(runId);
if (!run) return;
if (event.type === "heartbeat.run.event") {
const seq = typeof payload["seq"] === "number" ? payload["seq"] : null;
const eventType = readString(payload["eventType"]) ?? "event";
const messageText = readString(payload["message"]) ?? eventType;
const dedupeKey = `${runId}:event:${seq ?? `${eventType}:${messageText}:${event.createdAt}`}`;
if (seenKeysRef.current.has(dedupeKey)) return;
seenKeysRef.current.add(dedupeKey);
if (seenKeysRef.current.size > 2000) {
seenKeysRef.current.clear();
}
const tone = eventType === "error" ? "error" : eventType === "lifecycle" ? "warn" : "info";
const item = createFeedItem(run, event.createdAt, messageText, tone, nextIdRef.current++);
if (item) appendItems([item]);
return;
}
if (event.type === "heartbeat.run.status") {
const status = readString(payload["status"]) ?? "updated";
const dedupeKey = `${runId}:status:${status}:${readString(payload["finishedAt"]) ?? ""}`;
if (seenKeysRef.current.has(dedupeKey)) return;
seenKeysRef.current.add(dedupeKey);
if (seenKeysRef.current.size > 2000) {
seenKeysRef.current.clear();
}
const tone = status === "failed" || status === "timed_out" ? "error" : "warn";
const item = createFeedItem(run, event.createdAt, `run ${status}`, tone, nextIdRef.current++);
if (item) appendItems([item]);
return;
}
if (event.type === "heartbeat.run.log") {
const chunk = readString(payload["chunk"]);
if (!chunk) return;
const stream = readString(payload["stream"]) === "stderr" ? "stderr" : "stdout";
if (stream === "stderr") {
appendItems(parseStderrChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef));
return;
}
appendItems(parseStdoutChunk(run, chunk, event.createdAt, pendingByRunRef.current, nextIdRef));
}
};
socket.onerror = () => {
socket?.close();
};
socket.onclose = () => {
scheduleReconnect();
};
};
connect();
return () => {
closed = true;
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
if (socket) {
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
socket.close(1000, "issue_live_widget_unmount");
}
};
}, [activeRunIds, companyId, runById]);
if (runs.length === 0 && feed.length === 0) return null;
const recent = feed.slice(-25);
if (runs.length === 0) return null;
return (
<div className="rounded-lg border border-cyan-500/30 bg-background/80 overflow-hidden shadow-[0_0_12px_rgba(6,182,212,0.08)]">
{runs.length > 0 ? (
runs.map((run) => (
<div key={run.id} className="px-3 py-2 border-b border-border/50">
<div className="flex items-center justify-between mb-2">
<Link to={`/agents/${run.agentId}`} className="hover:underline">
<Identity name={run.agentName} size="sm" />
</Link>
<span className="text-xs text-muted-foreground">
{formatDateTime(run.startedAt ?? run.createdAt)}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Run</span>
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center rounded-md border border-border bg-accent/40 px-2 py-1 font-mono text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors"
>
{run.id.slice(0, 8)}
</Link>
<StatusBadge status={run.status} />
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => handleCancelRun(run.id)}
disabled={cancellingRunIds.has(run.id)}
className="inline-flex items-center gap-1 text-[10px] text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"
>
<Square className="h-2 w-2" fill="currentColor" />
{cancellingRunIds.has(run.id) ? "Stopping…" : "Stop"}
</button>
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 text-[10px] text-cyan-600 hover:text-cyan-500 dark:text-cyan-300 dark:hover:text-cyan-200"
>
Open run
<ExternalLink className="h-2.5 w-2.5" />
</Link>
</div>
</div>
</div>
))
) : (
<div className="flex items-center px-3 py-2 border-b border-border/50">
<span className="text-xs font-medium text-muted-foreground">Recent run updates</span>
<div className="overflow-hidden rounded-xl border border-cyan-500/25 bg-background/80 shadow-[0_18px_50px_rgba(6,182,212,0.08)]">
<div className="border-b border-border/60 bg-cyan-500/[0.04] px-4 py-3">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-700 dark:text-cyan-300">
Live Runs
</div>
<div className="mt-1 text-xs text-muted-foreground">
Streamed with the same transcript UI used on the full run detail page.
</div>
)}
<div ref={bodyRef} className="max-h-[220px] overflow-y-auto p-2 font-mono text-[11px] space-y-1">
{recent.length === 0 && (
<div className="text-xs text-muted-foreground">Waiting for run output...</div>
)}
{recent.map((item, index) => (
<div
key={item.id}
className={cn(
"grid grid-cols-[auto_1fr] gap-2 items-start",
index === recent.length - 1 && "animate-in fade-in slide-in-from-bottom-1 duration-300",
)}
>
<span className="text-[10px] text-muted-foreground">{relativeTime(item.ts)}</span>
<div className={cn(
"min-w-0",
item.tone === "error" && "text-red-600 dark:text-red-300",
item.tone === "warn" && "text-amber-600 dark:text-amber-300",
item.tone === "assistant" && "text-emerald-700 dark:text-emerald-200",
item.tone === "tool" && "text-cyan-600 dark:text-cyan-300",
item.tone === "info" && "text-foreground/80",
)}>
<Identity name={item.agentName} size="sm" className="text-cyan-600 dark:text-cyan-400" />
<span className="text-muted-foreground"> [{item.runId.slice(0, 8)}] </span>
<span className="break-words">{item.text}</span>
</div>
</div>
))}
</div>
<div className="divide-y divide-border/60">
{runs.map((run) => {
const isActive = isRunActive(run.status);
const transcript = transcriptByRun.get(run.id) ?? [];
return (
<section key={run.id} className="px-4 py-4">
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<Link to={`/agents/${run.agentId}`} className="inline-flex hover:underline">
<Identity name={run.agentName} size="sm" />
</Link>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center rounded-full border border-border/70 bg-background/70 px-2 py-1 font-mono hover:border-cyan-500/30 hover:text-foreground"
>
{run.id.slice(0, 8)}
</Link>
<StatusBadge status={run.status} />
<span>{formatDateTime(run.startedAt ?? run.createdAt)}</span>
</div>
</div>
<div className="flex items-center gap-2">
{isActive && (
<button
onClick={() => handleCancelRun(run.id)}
disabled={cancellingRunIds.has(run.id)}
className="inline-flex items-center gap-1 rounded-full border border-red-500/20 bg-red-500/[0.06] px-2.5 py-1 text-[11px] font-medium text-red-700 transition-colors hover:bg-red-500/[0.12] dark:text-red-300 disabled:opacity-50"
>
<Square className="h-2.5 w-2.5" fill="currentColor" />
{cancellingRunIds.has(run.id) ? "Stopping…" : "Stop"}
</button>
)}
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2.5 py-1 text-[11px] font-medium text-cyan-700 transition-colors hover:border-cyan-500/30 hover:text-cyan-600 dark:text-cyan-300"
>
Open run
<ExternalLink className="h-3 w-3" />
</Link>
</div>
</div>
<div className="max-h-[320px] overflow-y-auto pr-1">
<RunTranscriptView
entries={transcript}
density="compact"
limit={8}
streaming={isActive}
collapseStdout
emptyMessage={hasOutputForRun(run.id) ? "Waiting for transcript parsing..." : "Waiting for run output..."}
/>
</div>
</section>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,49 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody";
describe("MarkdownBody", () => {
it("renders markdown images without a resolver", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<MarkdownBody>{"![](/api/attachments/test/content)"}</MarkdownBody>
</ThemeProvider>,
);
expect(html).toContain('<img src="/api/attachments/test/content" alt=""/>');
});
it("resolves relative image paths when a resolver is provided", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<MarkdownBody resolveImageSrc={(src) => `/resolved/${src}`}>
{"![Org chart](images/org-chart.png)"}
</MarkdownBody>
</ThemeProvider>,
);
expect(html).toContain('src="/resolved/images/org-chart.png"');
expect(html).toContain('alt="Org chart"');
});
it("renders agent and project mentions as chips", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<MarkdownBody>
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")})`}
</MarkdownBody>
</ThemeProvider>,
);
expect(html).toContain('href="/agents/agent-123"');
expect(html).toContain('data-mention-kind="agent"');
expect(html).toContain("--paperclip-mention-icon-mask");
expect(html).toContain('href="/projects/project-456"');
expect(html).toContain('data-mention-kind="project"');
expect(html).toContain("--paperclip-mention-project-color:#336699");
});
});

View file

@ -1,13 +1,15 @@
import { isValidElement, useEffect, useId, useState, type CSSProperties, type ReactNode } from "react";
import Markdown from "react-markdown";
import { isValidElement, useEffect, useId, useState, type ReactNode } from "react";
import Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { parseProjectMentionHref } from "@paperclipai/shared";
import { cn } from "../lib/utils";
import { useTheme } from "../context/ThemeContext";
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
interface MarkdownBodyProps {
children: string;
className?: string;
/** Optional resolver for relative image paths (e.g. within export packages) */
resolveImageSrc?: (src: string) => string | null;
}
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
@ -34,29 +36,6 @@ function extractMermaidSource(children: ReactNode): string | null {
return flattenText(childProps.children).replace(/\n$/, "");
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!match) return null;
const value = match[1];
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
};
}
function mentionChipStyle(color: string | null): CSSProperties | undefined {
if (!color) return undefined;
const rgb = hexToRgb(color);
if (!rgb) return undefined;
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
return {
borderColor: color,
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`,
color: luminance > 0.55 ? "#111827" : "#f8fafc",
};
}
function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) {
const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
const [svg, setSvg] = useState<string | null>(null);
@ -112,48 +91,60 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
);
}
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownBodyProps) {
const { theme } = useTheme();
const components: Components = {
pre: ({ node: _node, children: preChildren, ...preProps }) => {
const mermaidSource = extractMermaidSource(preChildren);
if (mermaidSource) {
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
}
return <pre {...preProps}>{preChildren}</pre>;
},
a: ({ href, children: linkChildren }) => {
const parsed = href ? parseMentionChipHref(href) : null;
if (parsed) {
const targetHref = parsed.kind === "project"
? `/projects/${parsed.projectId}`
: `/agents/${parsed.agentId}`;
return (
<a
href={targetHref}
className={cn(
"paperclip-mention-chip",
`paperclip-mention-chip--${parsed.kind}`,
parsed.kind === "project" && "paperclip-project-mention-chip",
)}
data-mention-kind={parsed.kind}
style={mentionChipInlineStyle(parsed)}
>
{linkChildren}
</a>
);
}
return (
<a href={href} rel="noreferrer">
{linkChildren}
</a>
);
},
};
if (resolveImageSrc) {
components.img = ({ node: _node, src, alt, ...imgProps }) => {
const resolved = src ? resolveImageSrc(src) : null;
return <img {...imgProps} src={resolved ?? src} alt={alt ?? ""} />;
};
}
return (
<div
className={cn(
"prose prose-sm max-w-none prose-p:my-2 prose-p:leading-[1.4] prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5 prose-li:leading-[1.4] prose-pre:my-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-headings:my-2 prose-headings:text-sm prose-blockquote:leading-[1.4] prose-table:my-2 prose-th:px-3 prose-th:py-1.5 prose-td:px-3 prose-td:py-1.5 prose-code:break-all [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden",
theme === "dark" && "prose-invert",
className,
)}
>
<Markdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ node: _node, children: preChildren, ...preProps }) => {
const mermaidSource = extractMermaidSource(preChildren);
if (mermaidSource) {
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
}
return <pre {...preProps}>{preChildren}</pre>;
},
a: ({ href, children: linkChildren }) => {
const parsed = href ? parseProjectMentionHref(href) : null;
if (parsed) {
const label = linkChildren;
return (
<a
href={`/projects/${parsed.projectId}`}
className="paperclip-project-mention-chip"
style={mentionChipStyle(parsed.color)}
>
{label}
</a>
);
}
return (
<a href={href} rel="noreferrer">
{linkChildren}
</a>
);
},
}}
>
<Markdown remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) => url}>
{children}
</Markdown>
</div>

View file

@ -6,9 +6,9 @@ import {
useMemo,
useRef,
useState,
type CSSProperties,
type DragEvent,
} from "react";
import { createPortal } from "react-dom";
import {
CodeMirrorEditor,
MDXEditor,
@ -27,7 +27,11 @@ import {
thematicBreakPlugin,
type RealmPlugin,
} from "@mdxeditor/editor";
import { buildProjectMentionHref, parseProjectMentionHref } from "@paperclipai/shared";
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
import { AgentIcon } from "./AgentIconPicker";
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
import { mentionDeletionPlugin } from "../lib/mention-deletion";
import { cn } from "../lib/utils";
/* ---- Mention types ---- */
@ -36,6 +40,8 @@ export interface MentionOption {
id: string;
name: string;
kind?: "agent" | "project";
agentId?: string;
agentIcon?: string | null;
projectId?: string;
projectColor?: string | null;
}
@ -61,12 +67,25 @@ export interface MarkdownEditorRef {
focus: () => void;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function isSafeMarkdownLinkUrl(url: string): boolean {
const trimmed = url.trim();
if (!trimmed) return true;
return !/^(javascript|data|vbscript):/i.test(trimmed);
}
/* ---- Mention detection helpers ---- */
interface MentionState {
query: string;
top: number;
left: number;
/** Viewport-relative coords for portal positioning */
viewportTop: number;
viewportLeft: number;
textNode: Text;
atPos: number;
endPos: number;
@ -140,6 +159,8 @@ function detectMention(container: HTMLElement): MentionState | null {
query,
top: rect.bottom - containerRect.top,
left: rect.left - containerRect.left,
viewportTop: rect.bottom,
viewportLeft: rect.left,
textNode: textNode as Text,
atPos,
endPos: offset,
@ -150,7 +171,8 @@ function mentionMarkdown(option: MentionOption): string {
if (option.kind === "project" && option.projectId) {
return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `;
}
return `@${option.name} `;
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
}
/** Replace `@<query>` in the markdown string with the selected mention token. */
@ -162,31 +184,6 @@ function applyMention(markdown: string, query: string, option: MentionOption): s
return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length);
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const trimmed = hex.trim();
const match = /^#([0-9a-f]{6})$/i.exec(trimmed);
if (!match) return null;
const value = match[1];
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
};
}
function mentionChipStyle(color: string | null): CSSProperties | undefined {
if (!color) return undefined;
const rgb = hexToRgb(color);
if (!rgb) return undefined;
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
const textColor = luminance > 0.55 ? "#111827" : "#f8fafc";
return {
borderColor: color,
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`,
color: textColor,
};
}
/* ---- Component ---- */
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(function MarkdownEditor({
@ -217,11 +214,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const mentionStateRef = useRef<MentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const mentionActive = mentionState !== null && mentions && mentions.length > 0;
const projectColorById = useMemo(() => {
const map = new Map<string, string | null>();
const mentionOptionByKey = useMemo(() => {
const map = new Map<string, MentionOption>();
for (const mention of mentions ?? []) {
if (mention.kind === "agent") {
const agentId = mention.agentId ?? mention.id.replace(/^agent:/, "");
map.set(`agent:${agentId}`, mention);
}
if (mention.kind === "project" && mention.projectId) {
map.set(mention.projectId, mention.projectColor ?? null);
map.set(`project:${mention.projectId}`, mention);
}
}
return map;
@ -251,6 +252,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
try {
const src = await handler(file);
setUploadError(null);
// After MDXEditor inserts the image, ensure two newlines follow it
// so the cursor isn't stuck right next to the image.
setTimeout(() => {
const current = latestValueRef.current;
const escapedSrc = escapeRegExp(src);
const updated = current.replace(
new RegExp(`(!\\[[^\\]]*\\]\\(${escapedSrc}\\))(?!\\n\\n)`, "g"),
"$1\n\n",
);
if (updated !== current) {
latestValueRef.current = updated;
ref.current?.setMarkdown(updated);
onChange(updated);
requestAnimationFrame(() => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
});
}
}, 100);
return src;
} catch (err) {
const message = err instanceof Error ? err.message : "Image upload failed";
@ -264,8 +283,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
listsPlugin(),
quotePlugin(),
tablePlugin(),
linkPlugin(),
linkPlugin({ validateUrl: isSafeMarkdownLinkUrl }),
linkDialogPlugin(),
mentionDeletionPlugin(),
thematicBreakPlugin(),
codeBlockPlugin({
defaultCodeBlockLanguage: "txt",
@ -293,31 +313,28 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const links = editable.querySelectorAll("a");
for (const node of links) {
const link = node as HTMLAnchorElement;
const parsed = parseProjectMentionHref(link.getAttribute("href") ?? "");
const parsed = parseMentionChipHref(link.getAttribute("href") ?? "");
if (!parsed) {
if (link.dataset.projectMention === "true") {
link.dataset.projectMention = "false";
link.classList.remove("paperclip-project-mention-chip");
link.removeAttribute("contenteditable");
link.style.removeProperty("border-color");
link.style.removeProperty("background-color");
link.style.removeProperty("color");
}
clearMentionChipDecoration(link);
continue;
}
const color = parsed.color ?? projectColorById.get(parsed.projectId) ?? null;
link.dataset.projectMention = "true";
link.classList.add("paperclip-project-mention-chip");
link.setAttribute("contenteditable", "false");
const style = mentionChipStyle(color);
if (style) {
link.style.borderColor = style.borderColor ?? "";
link.style.backgroundColor = style.backgroundColor ?? "";
link.style.color = style.color ?? "";
if (parsed.kind === "project") {
const option = mentionOptionByKey.get(`project:${parsed.projectId}`);
applyMentionChipDecoration(link, {
...parsed,
color: parsed.color ?? option?.projectColor ?? null,
});
continue;
}
const option = mentionOptionByKey.get(`agent:${parsed.agentId}`);
applyMentionChipDecoration(link, {
...parsed,
icon: parsed.icon ?? option?.agentIcon ?? null,
});
}
}, [projectColorById]);
}, [mentionOptionByKey]);
// Mention detection: listen for selection changes and input events
const checkMention = useCallback(() => {
@ -373,94 +390,67 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// update state between the last render and this callback firing).
const state = mentionStateRef.current;
if (!state) return;
if (option.kind === "project" && option.projectId) {
const current = latestValueRef.current;
const next = applyMention(current, state.query, option);
if (next !== current) {
latestValueRef.current = next;
ref.current?.setMarkdown(next);
onChange(next);
}
requestAnimationFrame(() => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
decorateProjectMentions();
});
mentionStateRef.current = null;
setMentionState(null);
return;
}
const replacement = mentionMarkdown(option);
// Replace @query directly via DOM selection so the cursor naturally
// lands after the inserted text. Lexical picks up the change through
// its normal input-event handling.
const sel = window.getSelection();
if (sel && state.textNode.isConnected) {
const range = document.createRange();
range.setStart(state.textNode, state.atPos);
range.setEnd(state.textNode, state.endPos);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertText", false, replacement);
// After Lexical reconciles the DOM, the cursor position set by
// execCommand may be lost. Explicitly reposition it after the
// inserted mention text.
const cursorTarget = state.atPos + replacement.length;
requestAnimationFrame(() => {
const newSel = window.getSelection();
if (!newSel) return;
// Try the original text node first (it may still be valid)
if (state.textNode.isConnected) {
const len = state.textNode.textContent?.length ?? 0;
if (cursorTarget <= len) {
const r = document.createRange();
r.setStart(state.textNode, cursorTarget);
r.collapse(true);
newSel.removeAllRanges();
newSel.addRange(r);
return;
}
}
// Fallback: search for the replacement in text nodes
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
if (!editable) return;
const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const text = node.textContent ?? "";
const idx = text.indexOf(replacement);
if (idx !== -1) {
const pos = idx + replacement.length;
if (pos <= text.length) {
const r = document.createRange();
r.setStart(node, pos);
r.collapse(true);
newSel.removeAllRanges();
newSel.addRange(r);
return;
}
}
}
});
} else {
// Fallback: full markdown replacement when DOM node is stale
const current = latestValueRef.current;
const next = applyMention(current, state.query, option);
if (next !== current) {
latestValueRef.current = next;
ref.current?.setMarkdown(next);
onChange(next);
}
requestAnimationFrame(() => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
});
const current = latestValueRef.current;
const next = applyMention(current, state.query, option);
if (next !== current) {
latestValueRef.current = next;
ref.current?.setMarkdown(next);
onChange(next);
}
requestAnimationFrame(() => {
decorateProjectMentions();
requestAnimationFrame(() => {
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
if (!(editable instanceof HTMLElement)) return;
decorateProjectMentions();
editable.focus();
const mentionHref = option.kind === "project" && option.projectId
? buildProjectMentionHref(option.projectId, option.projectColor ?? null)
: buildAgentMentionHref(
option.agentId ?? option.id.replace(/^agent:/, ""),
option.agentIcon ?? null,
);
const matchingMentions = Array.from(editable.querySelectorAll("a"))
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
.filter((link) => {
const href = link.getAttribute("href") ?? "";
return href === mentionHref && link.textContent === `@${option.name}`;
});
const containerRect = containerRef.current?.getBoundingClientRect();
const target = matchingMentions.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
const leftA = containerRect ? rectA.left - containerRect.left : rectA.left;
const topA = containerRect ? rectA.top - containerRect.top : rectA.top;
const leftB = containerRect ? rectB.left - containerRect.left : rectB.left;
const topB = containerRect ? rectB.top - containerRect.top : rectB.top;
const distA = Math.hypot(leftA - state.left, topA - state.top);
const distB = Math.hypot(leftB - state.left, topB - state.top);
return distA - distB;
})[0] ?? null;
if (!target) return;
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
const nextSibling = target.nextSibling;
if (nextSibling?.nodeType === Node.TEXT_NODE) {
const text = nextSibling.textContent ?? "";
if (text.startsWith(" ")) {
range.setStart(nextSibling, 1);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return;
}
}
range.setStartAfter(target);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
});
});
mentionStateRef.current = null;
@ -566,47 +556,55 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
"paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item",
contentClassName,
)}
overlayContainer={containerRef.current}
additionalLexicalNodes={[MentionAwareLinkNode, mentionAwareLinkNodeReplacement]}
plugins={plugins}
/>
{/* Mention dropdown */}
{mentionActive && filteredMentions.length > 0 && (
<div
className="absolute z-50 min-w-[180px] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
style={{ top: mentionState.top + 4, left: mentionState.left }}
>
{filteredMentions.map((option, i) => (
<button
key={option.id}
className={cn(
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
i === mentionIndex && "bg-accent",
)}
onMouseDown={(e) => {
e.preventDefault(); // prevent blur
selectMention(option);
}}
onMouseEnter={() => setMentionIndex(i)}
>
{option.kind === "project" && option.projectId ? (
<span
className="inline-flex h-2 w-2 rounded-full border border-border/50"
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
/>
) : (
<span className="text-muted-foreground">@</span>
)}
<span>{option.name}</span>
{option.kind === "project" && option.projectId && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Project
</span>
)}
</button>
))}
</div>
)}
{/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */}
{mentionActive && filteredMentions.length > 0 &&
createPortal(
<div
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
style={{
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
}}
>
{filteredMentions.map((option, i) => (
<button
key={option.id}
className={cn(
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
i === mentionIndex && "bg-accent",
)}
onMouseDown={(e) => {
e.preventDefault(); // prevent blur
selectMention(option);
}}
onMouseEnter={() => setMentionIndex(i)}
>
{option.kind === "project" && option.projectId ? (
<span
className="inline-flex h-2 w-2 rounded-full border border-border/50"
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
/>
) : (
<AgentIcon
icon={option.agentIcon}
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
)}
<span>{option.name}</span>
{option.kind === "project" && option.projectId && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Project
</span>
)}
</button>
))}
</div>,
document.body,
)}
{isDragOver && canDropImage && (
<div

View file

@ -18,7 +18,7 @@ export function MetricCard({ icon: Icon, value, label, description, to, onClick
<div className={`h-full px-4 py-4 sm:px-5 sm:py-5 rounded-lg transition-colors${isClickable ? " hover:bg-accent/50 cursor-pointer" : ""}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-2xl sm:text-3xl font-semibold tracking-tight">
<p className="text-2xl sm:text-3xl font-semibold tracking-tight tabular-nums">
{value}
</p>
<p className="text-xs sm:text-sm font-medium text-muted-foreground mt-1">

View file

@ -1,6 +1,5 @@
import { useMemo } from "react";
import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import {
House,
CircleDot,
@ -8,11 +7,10 @@ import {
Users,
Inbox,
} from "lucide-react";
import { sidebarBadgesApi } from "../api/sidebarBadges";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { useInboxBadge } from "../hooks/useInboxBadge";
interface MobileBottomNavProps {
visible: boolean;
@ -39,12 +37,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
const location = useLocation();
const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialog();
const { data: sidebarBadges } = useQuery({
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const inboxBadge = useInboxBadge(selectedCompanyId);
const items = useMemo<MobileNavItem[]>(
() => [
@ -57,10 +50,10 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
to: "/inbox",
label: "Inbox",
icon: Inbox,
badge: sidebarBadges?.inbox,
badge: inboxBadge.inbox,
},
],
[openNewIssue, sidebarBadges?.inbox],
[openNewIssue, inboxBadge.inbox],
);
return (

View file

@ -14,20 +14,24 @@ import {
ArrowLeft,
Bot,
Code,
Gem,
MousePointer2,
Sparkles,
Terminal,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { HermesIcon } from "./HermesIcon";
type AdvancedAdapterType =
| "claude_local"
| "codex_local"
| "gemini_local"
| "opencode_local"
| "pi_local"
| "cursor"
| "openclaw_gateway";
| "openclaw_gateway"
| "hermes_local";
const ADVANCED_ADAPTER_OPTIONS: Array<{
value: AdvancedAdapterType;
@ -50,12 +54,24 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
desc: "Local Codex agent",
recommended: true,
},
{
value: "gemini_local",
label: "Gemini CLI",
icon: Gem,
desc: "Local Gemini agent",
},
{
value: "opencode_local",
label: "OpenCode",
icon: OpenCodeLogoIcon,
desc: "Local multi-provider agent",
},
{
value: "hermes_local",
label: "Hermes Agent",
icon: HermesIcon,
desc: "Local multi-provider agent",
},
{
value: "pi_local",
label: "Pi",

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,9 @@
import { useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
import { goalsApi } from "../api/goals";
import { assetsApi } from "../api/assets";
import { queryKeys } from "../lib/queryKeys";
@ -23,13 +24,16 @@ import {
Calendar,
Plus,
X,
FolderOpen,
Github,
GitBranch,
HelpCircle,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PROJECT_COLORS } from "@paperclipai/shared";
import { cn } from "../lib/utils";
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { StatusBadge } from "./StatusBadge";
import { ChoosePathButton } from "./PathInstructionsModal";
@ -41,9 +45,6 @@ const projectStatuses = [
{ value: "cancelled", label: "Cancelled" },
];
type WorkspaceSetup = "none" | "local" | "repo" | "both";
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
export function NewProjectDialog() {
const { newProjectOpen, closeNewProject } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
@ -54,7 +55,6 @@ export function NewProjectDialog() {
const [goalIds, setGoalIds] = useState<string[]>([]);
const [targetDate, setTargetDate] = useState("");
const [expanded, setExpanded] = useState(false);
const [workspaceSetup, setWorkspaceSetup] = useState<WorkspaceSetup>("none");
const [workspaceLocalPath, setWorkspaceLocalPath] = useState("");
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
@ -69,6 +69,29 @@ export function NewProjectDialog() {
enabled: !!selectedCompanyId && newProjectOpen,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && newProjectOpen,
});
const mentionOptions = useMemo<MentionOption[]>(() => {
const options: MentionOption[] = [];
const activeAgents = [...(agents ?? [])]
.filter((agent) => agent.status !== "terminated")
.sort((a, b) => a.name.localeCompare(b.name));
for (const agent of activeAgents) {
options.push({
id: `agent:${agent.id}`,
name: agent.name,
kind: "agent",
agentId: agent.id,
agentIcon: agent.icon,
});
}
return options;
}, [agents]);
const createProject = useMutation({
mutationFn: (data: Record<string, unknown>) =>
projectsApi.create(selectedCompanyId!, data),
@ -88,7 +111,6 @@ export function NewProjectDialog() {
setGoalIds([]);
setTargetDate("");
setExpanded(false);
setWorkspaceSetup("none");
setWorkspaceLocalPath("");
setWorkspaceRepoUrl("");
setWorkspaceError(null);
@ -125,24 +147,17 @@ export function NewProjectDialog() {
}
};
const toggleWorkspaceSetup = (next: WorkspaceSetup) => {
setWorkspaceSetup((prev) => (prev === next ? "none" : next));
setWorkspaceError(null);
};
async function handleSubmit() {
if (!selectedCompanyId || !name.trim()) return;
const localRequired = workspaceSetup === "local" || workspaceSetup === "both";
const repoRequired = workspaceSetup === "repo" || workspaceSetup === "both";
const localPath = workspaceLocalPath.trim();
const repoUrl = workspaceRepoUrl.trim();
if (localRequired && !isAbsolutePath(localPath)) {
if (localPath && !isAbsolutePath(localPath)) {
setWorkspaceError("Local folder must be a full absolute path.");
return;
}
if (repoRequired && !isGitHubRepoUrl(repoUrl)) {
setWorkspaceError("Repo workspace must use a valid GitHub repo URL.");
if (repoUrl && !isGitHubRepoUrl(repoUrl)) {
setWorkspaceError("Repo must use a valid GitHub repo URL.");
return;
}
@ -158,29 +173,15 @@ export function NewProjectDialog() {
...(targetDate ? { targetDate } : {}),
});
const workspacePayloads: Array<Record<string, unknown>> = [];
if (localRequired && repoRequired) {
workspacePayloads.push({
name: deriveWorkspaceNameFromPath(localPath),
cwd: localPath,
repoUrl,
});
} else if (localRequired) {
workspacePayloads.push({
name: deriveWorkspaceNameFromPath(localPath),
cwd: localPath,
});
} else if (repoRequired) {
workspacePayloads.push({
name: deriveWorkspaceNameFromRepo(repoUrl),
cwd: REPO_ONLY_CWD_SENTINEL,
repoUrl,
});
}
for (const workspacePayload of workspacePayloads) {
await projectsApi.createWorkspace(created.id, {
...workspacePayload,
});
if (localPath || repoUrl) {
const workspacePayload: Record<string, unknown> = {
name: localPath
? deriveWorkspaceNameFromPath(localPath)
: deriveWorkspaceNameFromRepo(repoUrl),
...(localPath ? { cwd: localPath } : {}),
...(repoUrl ? { repoUrl } : {}),
};
await projectsApi.createWorkspace(created.id, workspacePayload);
}
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) });
@ -273,6 +274,7 @@ export function NewProjectDialog() {
onChange={setDescription}
placeholder="Add description..."
bordered={false}
mentions={mentionOptions}
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")}
imageUploadHandler={async (file) => {
const asset = await uploadDescriptionImage.mutateAsync(file);
@ -281,81 +283,52 @@ export function NewProjectDialog() {
/>
</div>
<div className="px-4 pb-3 space-y-3 border-t border-border">
<div className="pt-3">
<p className="text-sm font-medium">Where will work be done on this project?</p>
<p className="text-xs text-muted-foreground">Add local folder and/or GitHub repo workspace hints.</p>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<button
type="button"
className={cn(
"rounded-lg border px-3 py-3 text-left transition-colors",
workspaceSetup === "local" ? "border-foreground bg-accent/40" : "border-border hover:bg-accent/30",
)}
onClick={() => toggleWorkspaceSetup("local")}
>
<div className="flex items-center gap-2 text-sm font-medium">
<FolderOpen className="h-4 w-4" />
A local folder
</div>
<p className="mt-1 text-xs text-muted-foreground">Use a full path on this machine.</p>
</button>
<button
type="button"
className={cn(
"rounded-lg border px-3 py-3 text-left transition-colors",
workspaceSetup === "repo" ? "border-foreground bg-accent/40" : "border-border hover:bg-accent/30",
)}
onClick={() => toggleWorkspaceSetup("repo")}
>
<div className="flex items-center gap-2 text-sm font-medium">
<Github className="h-4 w-4" />
A github repo
</div>
<p className="mt-1 text-xs text-muted-foreground">Paste a GitHub URL.</p>
</button>
<button
type="button"
className={cn(
"rounded-lg border px-3 py-3 text-left transition-colors",
workspaceSetup === "both" ? "border-foreground bg-accent/40" : "border-border hover:bg-accent/30",
)}
onClick={() => toggleWorkspaceSetup("both")}
>
<div className="flex items-center gap-2 text-sm font-medium">
<GitBranch className="h-4 w-4" />
Both
</div>
<p className="mt-1 text-xs text-muted-foreground">Configure local + repo hints.</p>
</button>
<div className="px-4 pt-3 pb-3 space-y-3 border-t border-border">
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="block text-xs text-muted-foreground">Repo URL</label>
<span className="text-xs text-muted-foreground/50">optional</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
Link a GitHub repository so agents can clone, read, and push code for this project.
</TooltipContent>
</Tooltip>
</div>
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceRepoUrl}
onChange={(e) => { setWorkspaceRepoUrl(e.target.value); setWorkspaceError(null); }}
placeholder="https://github.com/org/repo"
/>
</div>
{(workspaceSetup === "local" || workspaceSetup === "both") && (
<div className="rounded-md border border-border p-2">
<label className="mb-1 block text-xs text-muted-foreground">Local folder (full path)</label>
<div className="flex items-center gap-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceLocalPath}
onChange={(e) => setWorkspaceLocalPath(e.target.value)}
placeholder="/absolute/path/to/workspace"
/>
<ChoosePathButton />
</div>
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="block text-xs text-muted-foreground">Local folder</label>
<span className="text-xs text-muted-foreground/50">optional</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
Set an absolute path on this machine where local agents will read and write files for this project.
</TooltipContent>
</Tooltip>
</div>
)}
{(workspaceSetup === "repo" || workspaceSetup === "both") && (
<div className="rounded-md border border-border p-2">
<label className="mb-1 block text-xs text-muted-foreground">GitHub repo URL</label>
<div className="flex items-center gap-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceRepoUrl}
onChange={(e) => setWorkspaceRepoUrl(e.target.value)}
placeholder="https://github.com/org/repo"
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceLocalPath}
onChange={(e) => { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }}
placeholder="/absolute/path/to/workspace"
/>
<ChoosePathButton />
</div>
)}
</div>
{workspaceError && (
<p className="text-xs text-destructive">{workspaceError}</p>
)}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,318 @@
import type { ReactNode } from "react";
import { cn } from "../lib/utils";
import {
ChevronDown,
ChevronRight,
FileCode2,
FileText,
Folder,
FolderOpen,
} from "lucide-react";
// ── Tree types ────────────────────────────────────────────────────────
export type FileTreeNode = {
name: string;
path: string;
kind: "dir" | "file";
children: FileTreeNode[];
/** Optional per-node metadata (e.g. import action) */
action?: string | null;
};
const TREE_BASE_INDENT = 16;
const TREE_STEP_INDENT = 24;
const TREE_ROW_HEIGHT_CLASS = "min-h-9";
// ── Helpers ───────────────────────────────────────────────────────────
export function buildFileTree(
files: Record<string, unknown>,
actionMap?: Map<string, string>,
): FileTreeNode[] {
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
for (const filePath of Object.keys(files)) {
const segments = filePath.split("/").filter(Boolean);
let current = root;
let currentPath = "";
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
const isLeaf = i === segments.length - 1;
let next = current.children.find((c) => c.name === segment);
if (!next) {
next = {
name: segment,
path: currentPath,
kind: isLeaf ? "file" : "dir",
children: [],
action: isLeaf ? (actionMap?.get(filePath) ?? null) : null,
};
current.children.push(next);
}
current = next;
}
}
function sortNode(node: FileTreeNode) {
node.children.sort((a, b) => {
// Files before directories so PROJECT.md appears above tasks/
if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
return a.name.localeCompare(b.name);
});
node.children.forEach(sortNode);
}
sortNode(root);
return root.children;
}
export function countFiles(nodes: FileTreeNode[]): number {
let count = 0;
for (const node of nodes) {
if (node.kind === "file") count++;
else count += countFiles(node.children);
}
return count;
}
export function collectAllPaths(
nodes: FileTreeNode[],
type: "file" | "dir" | "all" = "all",
): Set<string> {
const paths = new Set<string>();
for (const node of nodes) {
if (type === "all" || node.kind === type) paths.add(node.path);
for (const p of collectAllPaths(node.children, type)) paths.add(p);
}
return paths;
}
function fileIcon(name: string) {
if (name.endsWith(".yaml") || name.endsWith(".yml")) return FileCode2;
return FileText;
}
// ── Frontmatter helpers ───────────────────────────────────────────────
export type FrontmatterData = Record<string, string | string[]>;
export function parseFrontmatter(content: string): { data: FrontmatterData; body: string } | null {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return null;
const data: FrontmatterData = {};
const rawYaml = match[1];
const body = match[2];
let currentKey: string | null = null;
let currentList: string[] | null = null;
for (const line of rawYaml.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
if (trimmed.startsWith("- ") && currentKey) {
if (!currentList) currentList = [];
currentList.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ""));
continue;
}
if (currentKey && currentList) {
data[currentKey] = currentList;
currentList = null;
currentKey = null;
}
const kvMatch = trimmed.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
if (kvMatch) {
const key = kvMatch[1];
const val = kvMatch[2].trim().replace(/^["']|["']$/g, "");
if (val === "null") {
currentKey = null;
continue;
}
if (val) {
data[key] = val;
currentKey = null;
} else {
currentKey = key;
}
}
}
if (currentKey && currentList) {
data[currentKey] = currentList;
}
return Object.keys(data).length > 0 ? { data, body } : null;
}
export const FRONTMATTER_FIELD_LABELS: Record<string, string> = {
name: "Name",
title: "Title",
kind: "Kind",
reportsTo: "Reports to",
skills: "Skills",
status: "Status",
description: "Description",
priority: "Priority",
assignee: "Assignee",
project: "Project",
recurring: "Recurring",
targetDate: "Target date",
};
// ── File tree component ───────────────────────────────────────────────
export function PackageFileTree({
nodes,
selectedFile,
expandedDirs,
checkedFiles,
onToggleDir,
onSelectFile,
onToggleCheck,
renderFileExtra,
fileRowClassName,
showCheckboxes = true,
depth = 0,
}: {
nodes: FileTreeNode[];
selectedFile: string | null;
expandedDirs: Set<string>;
checkedFiles?: Set<string>;
onToggleDir: (path: string) => void;
onSelectFile: (path: string) => void;
onToggleCheck?: (path: string, kind: "file" | "dir") => void;
/** Optional extra content rendered at the end of each file row (e.g. action badge) */
renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode;
/** Optional additional className for file rows */
fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined;
showCheckboxes?: boolean;
depth?: number;
}) {
const effectiveCheckedFiles = checkedFiles ?? new Set<string>();
return (
<div>
{nodes.map((node) => {
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
if (node.kind === "dir") {
const childFiles = collectAllPaths(node.children, "file");
const allChecked = [...childFiles].every((p) => effectiveCheckedFiles.has(p));
const someChecked = [...childFiles].some((p) => effectiveCheckedFiles.has(p));
return (
<div key={node.path}>
<div
className={cn(
showCheckboxes
? "group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground"
: "group grid w-full grid-cols-[minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground",
TREE_ROW_HEIGHT_CLASS,
)}
style={{
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
}}
>
{showCheckboxes && (
<label className="flex items-center pl-2">
<input
type="checkbox"
checked={allChecked}
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked; }}
onChange={() => onToggleCheck?.(node.path, "dir")}
className="mr-2 accent-foreground"
/>
</label>
)}
<button
type="button"
className="flex min-w-0 items-center gap-2 py-1 text-left"
onClick={() => onToggleDir(node.path)}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{expanded ? (
<FolderOpen className="h-3.5 w-3.5" />
) : (
<Folder className="h-3.5 w-3.5" />
)}
</span>
<span className="truncate">{node.name}</span>
</button>
<button
type="button"
className="flex h-9 w-9 items-center justify-center self-center rounded-sm text-muted-foreground opacity-70 transition-[background-color,color,opacity] hover:bg-accent hover:text-foreground group-hover:opacity-100"
onClick={() => onToggleDir(node.path)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
</div>
{expanded && (
<PackageFileTree
nodes={node.children}
selectedFile={selectedFile}
expandedDirs={expandedDirs}
checkedFiles={effectiveCheckedFiles}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
onToggleCheck={onToggleCheck}
renderFileExtra={renderFileExtra}
fileRowClassName={fileRowClassName}
showCheckboxes={showCheckboxes}
depth={depth + 1}
/>
)}
</div>
);
}
const FileIcon = fileIcon(node.name);
const checked = effectiveCheckedFiles.has(node.path);
const extraClassName = fileRowClassName?.(node, checked);
return (
<div
key={node.path}
className={cn(
"flex w-full items-center gap-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer",
TREE_ROW_HEIGHT_CLASS,
node.path === selectedFile && "text-foreground bg-accent/20",
extraClassName,
)}
style={{
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
}}
onClick={() => onSelectFile(node.path)}
>
{showCheckboxes && (
<label className="flex items-center pl-2">
<input
type="checkbox"
checked={checked}
onChange={() => onToggleCheck?.(node.path, "file")}
className="mr-2 accent-foreground"
/>
</label>
)}
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left"
onClick={() => onSelectFile(node.path)}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<FileIcon className="h-3.5 w-3.5" />
</span>
<span className="truncate">{node.name}</span>
</button>
{renderFileExtra?.(node, checked)}
</div>
);
})}
</div>
);
}

View file

@ -11,9 +11,10 @@ interface PageTabBarProps {
items: PageTabItem[];
value?: string;
onValueChange?: (value: string) => void;
align?: "center" | "start";
}
export function PageTabBar({ items, value, onValueChange }: PageTabBarProps) {
export function PageTabBar({ items, value, onValueChange, align = "center" }: PageTabBarProps) {
const { isMobile } = useSidebar();
if (isMobile && value !== undefined && onValueChange) {
@ -33,7 +34,7 @@ export function PageTabBar({ items, value, onValueChange }: PageTabBarProps) {
}
return (
<TabsList variant="line">
<TabsList variant="line" className={align === "start" ? "justify-start" : undefined}>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value}>
{item.label}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,416 @@
import { useMemo } from "react";
import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { QuotaBar } from "./QuotaBar";
import { ClaudeSubscriptionPanel } from "./ClaudeSubscriptionPanel";
import { CodexSubscriptionPanel } from "./CodexSubscriptionPanel";
import {
billingTypeDisplayName,
formatCents,
formatTokens,
providerDisplayName,
quotaSourceDisplayName,
} from "@/lib/utils";
// ordered display labels for rolling-window rows
const ROLLING_WINDOWS = ["5h", "24h", "7d"] as const;
interface ProviderQuotaCardProps {
provider: string;
rows: CostByProviderModel[];
/** company monthly budget in cents (0 means unlimited) */
budgetMonthlyCents: number;
/** total company spend in this period in cents, all providers */
totalCompanySpendCents: number;
/** spend in the current calendar week in cents, this provider only */
weekSpendCents: number;
/** rolling window rows for this provider: 5h, 24h, 7d */
windowRows: CostWindowSpendRow[];
showDeficitNotch: boolean;
/** live subscription quota windows from the provider's own api */
quotaWindows?: QuotaWindow[];
quotaError?: string | null;
quotaSource?: string | null;
quotaLoading?: boolean;
}
export function ProviderQuotaCard({
provider,
rows,
budgetMonthlyCents,
totalCompanySpendCents,
weekSpendCents,
windowRows,
showDeficitNotch,
quotaWindows = [],
quotaError = null,
quotaSource = null,
quotaLoading = false,
}: ProviderQuotaCardProps) {
// single-pass aggregation over rows — memoized so the 8 derived values are not
// recomputed on every parent render tick (providers tab polls every 30s, and each
// card is mounted twice: once in the "all" tab grid and once in its per-provider tab).
const totals = useMemo(() => {
let inputTokens = 0, outputTokens = 0, costCents = 0;
let apiRunCount = 0, subRunCount = 0, subInputTokens = 0, subOutputTokens = 0;
for (const r of rows) {
inputTokens += r.inputTokens;
outputTokens += r.outputTokens;
costCents += r.costCents;
apiRunCount += r.apiRunCount;
subRunCount += r.subscriptionRunCount;
subInputTokens += r.subscriptionInputTokens;
subOutputTokens += r.subscriptionOutputTokens;
}
const totalTokens = inputTokens + outputTokens;
const subTokens = subInputTokens + subOutputTokens;
// denominator: api-billed tokens (from cost_events) + subscription tokens (from heartbeat_runs)
const allTokens = totalTokens + subTokens;
return {
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
totalTokens,
totalCostCents: costCents,
totalApiRuns: apiRunCount,
totalSubRuns: subRunCount,
totalSubInputTokens: subInputTokens,
totalSubOutputTokens: subOutputTokens,
totalSubTokens: subTokens,
subSharePct: allTokens > 0 ? (subTokens / allTokens) * 100 : 0,
};
}, [rows]);
const {
totalInputTokens,
totalOutputTokens,
totalTokens,
totalCostCents,
totalApiRuns,
totalSubRuns,
totalSubInputTokens,
totalSubOutputTokens,
totalSubTokens,
subSharePct,
} = totals;
// budget bars: use this provider's own spend vs its pro-rata share of budget
// pro-rata: if a provider is 40% of total spend, it gets 40% of the budget allocated.
// falls back to raw provider spend vs total budget when totalCompanySpend is 0.
const providerBudgetShare =
budgetMonthlyCents > 0 && totalCompanySpendCents > 0
? (totalCostCents / totalCompanySpendCents) * budgetMonthlyCents
: budgetMonthlyCents;
const budgetPct =
providerBudgetShare > 0
? Math.min(100, (totalCostCents / providerBudgetShare) * 100)
: 0;
// 4.33 = average weeks per calendar month (52 / 12)
const weeklyBudgetShare = providerBudgetShare > 0 ? providerBudgetShare / 4.33 : 0;
const weekPct =
weeklyBudgetShare > 0 ? Math.min(100, (weekSpendCents / weeklyBudgetShare) * 100) : 0;
const hasBudget = budgetMonthlyCents > 0;
// memoized so the Map and max are not reconstructed on every parent render tick
const windowMap = useMemo(
() => new Map(windowRows.map((r) => [r.window, r])),
[windowRows],
);
const maxWindowCents = useMemo(
() => Math.max(...windowRows.map((r) => r.costCents), 0),
[windowRows],
);
const isClaudeQuotaPanel = provider === "anthropic";
const isCodexQuotaPanel = provider === "openai" && quotaSource?.startsWith("codex-");
const supportsSubscriptionQuota = provider === "anthropic" || provider === "openai";
const showSubscriptionQuotaSection =
supportsSubscriptionQuota && (quotaLoading || quotaWindows.length > 0 || quotaError != null);
return (
<Card>
<CardHeader className="px-4 pt-4 pb-0 gap-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-sm font-semibold">
{providerDisplayName(provider)}
</CardTitle>
<CardDescription className="text-xs mt-0.5">
<span className="font-mono">{formatTokens(totalInputTokens)}</span> in
{" · "}
<span className="font-mono">{formatTokens(totalOutputTokens)}</span> out
{(totalApiRuns > 0 || totalSubRuns > 0) && (
<span className="ml-1.5">
·{" "}
{totalApiRuns > 0 && `~${totalApiRuns} api`}
{totalApiRuns > 0 && totalSubRuns > 0 && " / "}
{totalSubRuns > 0 && `~${totalSubRuns} sub`}
{" runs"}
</span>
)}
</CardDescription>
</div>
<span className="text-xl font-bold tabular-nums shrink-0">
{formatCents(totalCostCents)}
</span>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 pt-3 space-y-4">
{hasBudget && (
<div className="space-y-3">
<QuotaBar
label="Period spend"
percentUsed={budgetPct}
leftLabel={formatCents(totalCostCents)}
rightLabel={`${Math.round(budgetPct)}% of allocation`}
showDeficitNotch={showDeficitNotch}
/>
<QuotaBar
label="This week"
percentUsed={weekPct}
leftLabel={formatCents(weekSpendCents)}
rightLabel={`~${formatCents(Math.round(weeklyBudgetShare))} / wk`}
showDeficitNotch={weekPct >= 100}
/>
</div>
)}
{/* rolling window consumption — always shown when data is available */}
{windowRows.length > 0 && (
<>
<div className="border-t border-border" />
<div className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Rolling windows
</p>
<div className="space-y-2.5">
{ROLLING_WINDOWS.map((w) => {
const row = windowMap.get(w);
// omit windows with no data rather than showing false $0.00 zeros
if (!row) return null;
const cents = row.costCents;
const tokens = row.inputTokens + row.outputTokens;
const barPct = maxWindowCents > 0 ? (cents / maxWindowCents) * 100 : 0;
return (
<div key={w} className="space-y-1">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-mono text-muted-foreground w-6 shrink-0">{w}</span>
<span className="text-muted-foreground font-mono flex-1">
{formatTokens(tokens)} tok
</span>
<span className="font-medium tabular-nums">{formatCents(cents)}</span>
</div>
<div className="h-2 w-full border border-border overflow-hidden">
<div
className="h-full bg-primary/60 transition-[width] duration-150"
style={{ width: `${barPct}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
</>
)}
{/* subscription usage — shown when any subscription-billed runs exist */}
{totalSubRuns > 0 && (
<>
<div className="border-t border-border" />
<div className="space-y-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Subscription
</p>
<p className="text-xs text-muted-foreground">
<span className="font-mono text-foreground">{totalSubRuns}</span> runs
{" · "}
{totalSubTokens > 0 && (
<>
<span className="font-mono text-foreground">{formatTokens(totalSubTokens)}</span> total
{" · "}
</>
)}
<span className="font-mono text-foreground">{formatTokens(totalSubInputTokens)}</span> in
{" · "}
<span className="font-mono text-foreground">{formatTokens(totalSubOutputTokens)}</span> out
</p>
{subSharePct > 0 && (
<>
<div className="h-1.5 w-full border border-border overflow-hidden">
<div
className="h-full bg-primary/60 transition-[width] duration-150"
style={{ width: `${subSharePct}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{Math.round(subSharePct)}% of token usage via subscription
</p>
</>
)}
</div>
</>
)}
{/* model breakdown — always shown, with token-share bars */}
{rows.length > 0 && (
<>
<div className="border-t border-border" />
<div className="space-y-3">
{rows.map((row) => {
const rowTokens = row.inputTokens + row.outputTokens;
const tokenPct = totalTokens > 0 ? (rowTokens / totalTokens) * 100 : 0;
const costPct = totalCostCents > 0 ? (row.costCents / totalCostCents) * 100 : 0;
return (
<div key={`${row.provider}:${row.model}`} className="space-y-1.5">
{/* model name and cost */}
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<span className="text-xs text-muted-foreground truncate font-mono block">
{row.model}
</span>
<span className="text-[11px] text-muted-foreground truncate block">
{providerDisplayName(row.biller)} · {billingTypeDisplayName(row.billingType)}
</span>
</div>
<div className="flex items-center gap-3 shrink-0 tabular-nums text-xs">
<span className="text-muted-foreground">
{formatTokens(rowTokens)} tok
</span>
<span className="font-medium">{formatCents(row.costCents)}</span>
</div>
</div>
{/* token share bar */}
<div className="relative h-2 w-full border border-border overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-primary/60 transition-[width] duration-150"
style={{ width: `${tokenPct}%` }}
title={`${Math.round(tokenPct)}% of provider tokens`}
/>
{/* cost share overlay — narrower, opaque, shows relative cost weight */}
<div
className="absolute inset-y-0 left-0 bg-primary/85 transition-[width] duration-150"
style={{ width: `${costPct}%` }}
title={`${Math.round(costPct)}% of provider cost`}
/>
</div>
</div>
);
})}
</div>
</>
)}
{/* subscription quota windows from provider api — shown when data is available */}
{showSubscriptionQuotaSection && (
<>
<div className="border-t border-border" />
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Subscription quota
</p>
{quotaSource && !isClaudeQuotaPanel && !isCodexQuotaPanel ? (
<span className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
{quotaSourceDisplayName(quotaSource)}
</span>
) : null}
</div>
{quotaLoading ? (
<QuotaPanelSkeleton />
) : isClaudeQuotaPanel ? (
<ClaudeSubscriptionPanel windows={quotaWindows} source={quotaSource} error={quotaError} />
) : isCodexQuotaPanel ? (
<CodexSubscriptionPanel windows={quotaWindows} source={quotaSource} error={quotaError} />
) : (
<>
{quotaError ? (
<p className="text-xs text-destructive">
{quotaError}
</p>
) : null}
<div className="space-y-2.5">
{quotaWindows.map((qw) => {
const fillColor =
qw.usedPercent == null
? null
: qw.usedPercent >= 90
? "bg-red-400"
: qw.usedPercent >= 70
? "bg-yellow-400"
: "bg-green-400";
return (
<div key={qw.label} className="space-y-1">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-mono text-muted-foreground shrink-0">{qw.label}</span>
<span className="flex-1" />
{qw.valueLabel != null ? (
<span className="font-medium tabular-nums">{qw.valueLabel}</span>
) : qw.usedPercent != null ? (
<span className="font-medium tabular-nums">{qw.usedPercent}% used</span>
) : null}
</div>
{qw.usedPercent != null && fillColor != null && (
<div className="h-2 w-full border border-border overflow-hidden">
<div
className={`h-full transition-[width] duration-150 ${fillColor}`}
style={{ width: `${qw.usedPercent}%` }}
/>
</div>
)}
{qw.detail ? (
<p className="text-xs text-muted-foreground">
{qw.detail}
</p>
) : qw.resetsAt ? (
<p className="text-xs text-muted-foreground">
resets {new Date(qw.resetsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
</p>
) : null}
</div>
);
})}
</div>
</>
)}
</div>
</>
)}
</CardContent>
</Card>
);
}
function QuotaPanelSkeleton() {
return (
<div className="border border-border px-4 py-4">
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
<div className="min-w-0 space-y-2">
<Skeleton className="h-3 w-36" />
<Skeleton className="h-4 w-64 max-w-full" />
</div>
<Skeleton className="h-7 w-28" />
</div>
<div className="mt-4 space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="border border-border px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-44 max-w-full" />
</div>
<Skeleton className="h-4 w-20" />
</div>
<Skeleton className="mt-3 h-2 w-full" />
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
import { cn } from "@/lib/utils";
interface QuotaBarProps {
label: string;
// value between 0 and 100
percentUsed: number;
leftLabel: string;
rightLabel?: string;
// shows a 2px destructive notch at the fill tip when true
showDeficitNotch?: boolean;
className?: string;
}
function fillColor(pct: number): string {
if (pct > 90) return "bg-red-400";
if (pct > 70) return "bg-yellow-400";
return "bg-green-400";
}
export function QuotaBar({
label,
percentUsed,
leftLabel,
rightLabel,
showDeficitNotch = false,
className,
}: QuotaBarProps) {
const clampedPct = Math.min(100, Math.max(0, percentUsed));
// keep the notch visible even near the edges
const notchLeft = Math.min(clampedPct, 97);
return (
<div className={cn("space-y-1.5", className)}>
{/* row header */}
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs font-medium tabular-nums">{leftLabel}</span>
{rightLabel && (
<span className="text-xs text-muted-foreground tabular-nums">{rightLabel}</span>
)}
</div>
</div>
{/* track — boxed border, square corners to match the theme */}
<div className="relative h-2 w-full border border-border overflow-hidden">
{/* fill */}
<div
className={cn(
"absolute inset-y-0 left-0 transition-[width,background-color] duration-150",
fillColor(clampedPct),
)}
style={{ width: `${clampedPct}%` }}
/>
{/* deficit notch — 2px wide, sits at the fill tip */}
{showDeficitNotch && clampedPct > 0 && (
<div
className="absolute inset-y-0 w-[2px] bg-destructive z-10"
style={{ left: `${notchLeft}%` }}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,126 @@
import { useState } from "react";
import type { Agent } from "@paperclipai/shared";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { User } from "lucide-react";
import { cn } from "../lib/utils";
import { roleLabels } from "./agent-config-primitives";
import { AgentIcon } from "./AgentIconPicker";
export function ReportsToPicker({
agents,
value,
onChange,
disabled = false,
excludeAgentIds = [],
disabledEmptyLabel = "Reports to: N/A (CEO)",
chooseLabel = "Reports to...",
}: {
agents: Agent[];
value: string | null;
onChange: (id: string | null) => void;
disabled?: boolean;
excludeAgentIds?: string[];
disabledEmptyLabel?: string;
chooseLabel?: string;
}) {
const [open, setOpen] = useState(false);
const exclude = new Set(excludeAgentIds);
const rows = agents.filter(
(a) => a.status !== "terminated" && !exclude.has(a.id),
);
const current = value ? agents.find((a) => a.id === value) : null;
const terminatedManager = current?.status === "terminated";
const unknownManager = Boolean(value && !current);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
terminatedManager && "border-amber-600/45 bg-amber-500/5",
disabled && "opacity-60 cursor-not-allowed",
)}
disabled={disabled}
>
{unknownManager ? (
<>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-muted-foreground">Unknown manager (stale ID)</span>
</>
) : current ? (
<>
<AgentIcon icon={current.icon} className="h-3 w-3 shrink-0 text-muted-foreground" />
<span
className={cn(
"min-w-0 truncate",
terminatedManager && "text-amber-900 dark:text-amber-200",
)}
>
{`Reports to ${current.name}${terminatedManager ? " (terminated)" : ""}`}
</span>
</>
) : (
<>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">
{disabled ? disabledEmptyLabel : chooseLabel}
</span>
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="start">
<button
type="button"
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
value === null && "bg-accent",
)}
onClick={() => {
onChange(null);
setOpen(false);
}}
>
No manager
</button>
{terminatedManager && (
<div className="flex min-w-0 items-center gap-2 overflow-hidden px-2 py-1.5 text-xs text-muted-foreground border-b border-border mb-0.5">
<AgentIcon icon={current.icon} className="shrink-0 h-3 w-3" />
<span className="min-w-0 truncate">
Current: {current.name} (terminated)
</span>
</div>
)}
{unknownManager && (
<div className="px-2 py-1.5 text-xs text-muted-foreground border-b border-border mb-0.5">
Saved manager is missing from this company. Choose a new manager or clear.
</div>
)}
{rows.map((a) => (
<button
type="button"
key={a.id}
className={cn(
"flex items-center gap-2 w-full min-w-0 px-2 py-1.5 text-xs rounded hover:bg-accent/50 overflow-hidden",
a.id === value && "bg-accent",
)}
onClick={() => {
onChange(a.id);
setOpen(false);
}}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
<span className="min-w-0 truncate">{a.name}</span>
<span className="text-muted-foreground ml-auto shrink-0">{roleLabels[a.role] ?? a.role}</span>
</button>
))}
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,344 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { ChevronDown, ChevronRight } from "lucide-react";
type SchedulePreset = "every_minute" | "every_hour" | "every_day" | "weekdays" | "weekly" | "monthly" | "custom";
const PRESETS: { value: SchedulePreset; label: string }[] = [
{ value: "every_minute", label: "Every minute" },
{ value: "every_hour", label: "Every hour" },
{ value: "every_day", label: "Every day" },
{ value: "weekdays", label: "Weekdays" },
{ value: "weekly", label: "Weekly" },
{ value: "monthly", label: "Monthly" },
{ value: "custom", label: "Custom (cron)" },
];
const HOURS = Array.from({ length: 24 }, (_, i) => ({
value: String(i),
label: i === 0 ? "12 AM" : i < 12 ? `${i} AM` : i === 12 ? "12 PM" : `${i - 12} PM`,
}));
const MINUTES = Array.from({ length: 12 }, (_, i) => ({
value: String(i * 5),
label: String(i * 5).padStart(2, "0"),
}));
const DAYS_OF_WEEK = [
{ value: "1", label: "Mon" },
{ value: "2", label: "Tue" },
{ value: "3", label: "Wed" },
{ value: "4", label: "Thu" },
{ value: "5", label: "Fri" },
{ value: "6", label: "Sat" },
{ value: "0", label: "Sun" },
];
const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => ({
value: String(i + 1),
label: String(i + 1),
}));
function parseCronToPreset(cron: string): {
preset: SchedulePreset;
hour: string;
minute: string;
dayOfWeek: string;
dayOfMonth: string;
} {
const defaults = { hour: "10", minute: "0", dayOfWeek: "1", dayOfMonth: "1" };
if (!cron || !cron.trim()) {
return { preset: "every_day", ...defaults };
}
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
return { preset: "custom", ...defaults };
}
const [min, hr, dom, , dow] = parts;
// Every minute: "* * * * *"
if (min === "*" && hr === "*" && dom === "*" && dow === "*") {
return { preset: "every_minute", ...defaults };
}
// Every hour: "0 * * * *"
if (hr === "*" && dom === "*" && dow === "*") {
return { preset: "every_hour", ...defaults, minute: min === "*" ? "0" : min };
}
// Every day: "M H * * *"
if (dom === "*" && dow === "*" && hr !== "*") {
return { preset: "every_day", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
}
// Weekdays: "M H * * 1-5"
if (dom === "*" && dow === "1-5" && hr !== "*") {
return { preset: "weekdays", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
}
// Weekly: "M H * * D" (single day)
if (dom === "*" && /^\d$/.test(dow) && hr !== "*") {
return { preset: "weekly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfWeek: dow };
}
// Monthly: "M H D * *"
if (/^\d{1,2}$/.test(dom) && dow === "*" && hr !== "*") {
return { preset: "monthly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfMonth: dom };
}
return { preset: "custom", ...defaults };
}
function buildCron(preset: SchedulePreset, hour: string, minute: string, dayOfWeek: string, dayOfMonth: string): string {
switch (preset) {
case "every_minute":
return "* * * * *";
case "every_hour":
return `${minute} * * * *`;
case "every_day":
return `${minute} ${hour} * * *`;
case "weekdays":
return `${minute} ${hour} * * 1-5`;
case "weekly":
return `${minute} ${hour} * * ${dayOfWeek}`;
case "monthly":
return `${minute} ${hour} ${dayOfMonth} * *`;
case "custom":
return "";
}
}
function describeSchedule(cron: string): string {
const { preset, hour, minute, dayOfWeek, dayOfMonth } = parseCronToPreset(cron);
const hourLabel = HOURS.find((h) => h.value === hour)?.label ?? `${hour}`;
const timeStr = `${hourLabel.replace(/ (AM|PM)$/, "")}:${minute.padStart(2, "0")} ${hourLabel.match(/(AM|PM)$/)?.[0] ?? ""}`;
switch (preset) {
case "every_minute":
return "Every minute";
case "every_hour":
return `Every hour at :${minute.padStart(2, "0")}`;
case "every_day":
return `Every day at ${timeStr}`;
case "weekdays":
return `Weekdays at ${timeStr}`;
case "weekly": {
const day = DAYS_OF_WEEK.find((d) => d.value === dayOfWeek)?.label ?? dayOfWeek;
return `Every ${day} at ${timeStr}`;
}
case "monthly":
return `Monthly on the ${dayOfMonth}${ordinalSuffix(Number(dayOfMonth))} at ${timeStr}`;
case "custom":
return cron || "No schedule set";
}
}
function ordinalSuffix(n: number): string {
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return s[(v - 20) % 10] || s[v] || s[0];
}
export { describeSchedule };
export function ScheduleEditor({
value,
onChange,
}: {
value: string;
onChange: (cron: string) => void;
}) {
const parsed = useMemo(() => parseCronToPreset(value), [value]);
const [preset, setPreset] = useState<SchedulePreset>(parsed.preset);
const [hour, setHour] = useState(parsed.hour);
const [minute, setMinute] = useState(parsed.minute);
const [dayOfWeek, setDayOfWeek] = useState(parsed.dayOfWeek);
const [dayOfMonth, setDayOfMonth] = useState(parsed.dayOfMonth);
const [customCron, setCustomCron] = useState(preset === "custom" ? value : "");
// Sync from external value changes
useEffect(() => {
const p = parseCronToPreset(value);
setPreset(p.preset);
setHour(p.hour);
setMinute(p.minute);
setDayOfWeek(p.dayOfWeek);
setDayOfMonth(p.dayOfMonth);
if (p.preset === "custom") setCustomCron(value);
}, [value]);
const emitChange = useCallback(
(p: SchedulePreset, h: string, m: string, dow: string, dom: string, custom: string) => {
if (p === "custom") {
onChange(custom);
} else {
onChange(buildCron(p, h, m, dow, dom));
}
},
[onChange],
);
const handlePresetChange = (newPreset: SchedulePreset) => {
setPreset(newPreset);
if (newPreset === "custom") {
setCustomCron(value);
} else {
emitChange(newPreset, hour, minute, dayOfWeek, dayOfMonth, customCron);
}
};
return (
<div className="space-y-3">
<Select value={preset} onValueChange={(v) => handlePresetChange(v as SchedulePreset)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose frequency..." />
</SelectTrigger>
<SelectContent>
{PRESETS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
{preset === "custom" ? (
<div className="space-y-1.5">
<Input
value={customCron}
onChange={(e) => {
setCustomCron(e.target.value);
emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, e.target.value);
}}
placeholder="0 10 * * *"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Five fields: minute hour day-of-month month day-of-week
</p>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{preset !== "every_minute" && preset !== "every_hour" && (
<>
<span className="text-sm text-muted-foreground">at</span>
<Select
value={hour}
onValueChange={(h) => {
setHour(h);
emitChange(preset, h, minute, dayOfWeek, dayOfMonth, customCron);
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOURS.map((h) => (
<SelectItem key={h.value} value={h.value}>
{h.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">:</span>
<Select
value={minute}
onValueChange={(m) => {
setMinute(m);
emitChange(preset, hour, m, dayOfWeek, dayOfMonth, customCron);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
{preset === "every_hour" && (
<>
<span className="text-sm text-muted-foreground">at minute</span>
<Select
value={minute}
onValueChange={(m) => {
setMinute(m);
emitChange(preset, hour, m, dayOfWeek, dayOfMonth, customCron);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m.value} value={m.value}>
:{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
{preset === "weekly" && (
<>
<span className="text-sm text-muted-foreground">on</span>
<div className="flex gap-1">
{DAYS_OF_WEEK.map((d) => (
<Button
key={d.value}
type="button"
variant={dayOfWeek === d.value ? "default" : "outline"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
setDayOfWeek(d.value);
emitChange(preset, hour, minute, d.value, dayOfMonth, customCron);
}}
>
{d.label}
</Button>
))}
</div>
</>
)}
{preset === "monthly" && (
<>
<span className="text-sm text-muted-foreground">on day</span>
<Select
value={dayOfMonth}
onValueChange={(dom) => {
setDayOfMonth(dom);
emitChange(preset, hour, minute, dayOfWeek, dom, customCron);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DAYS_OF_MONTH.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,79 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDown } from "lucide-react";
function resolveScrollTarget() {
const mainContent = document.getElementById("main-content");
if (mainContent instanceof HTMLElement) {
const overflowY = window.getComputedStyle(mainContent).overflowY;
const usesOwnScroll =
(overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay")
&& mainContent.scrollHeight > mainContent.clientHeight + 1;
if (usesOwnScroll) {
return { type: "element" as const, element: mainContent };
}
}
return { type: "window" as const };
}
function distanceFromBottom(target: ReturnType<typeof resolveScrollTarget>) {
if (target.type === "element") {
return target.element.scrollHeight - target.element.scrollTop - target.element.clientHeight;
}
const scroller = document.scrollingElement ?? document.documentElement;
return scroller.scrollHeight - window.scrollY - window.innerHeight;
}
/**
* Floating scroll-to-bottom button that follows the active page scroller.
* On desktop that is `#main-content`; on mobile it falls back to window/page scroll.
*/
export function ScrollToBottom() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const check = () => {
setVisible(distanceFromBottom(resolveScrollTarget()) > 300);
};
const mainContent = document.getElementById("main-content");
check();
mainContent?.addEventListener("scroll", check, { passive: true });
window.addEventListener("scroll", check, { passive: true });
window.addEventListener("resize", check);
return () => {
mainContent?.removeEventListener("scroll", check);
window.removeEventListener("scroll", check);
window.removeEventListener("resize", check);
};
}, []);
const scroll = useCallback(() => {
const target = resolveScrollTarget();
if (target.type === "element") {
target.element.scrollTo({ top: target.element.scrollHeight, behavior: "smooth" });
return;
}
const scroller = document.scrollingElement ?? document.documentElement;
window.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
}, []);
if (!visible) return null;
return (
<button
onClick={scroll}
className="fixed bottom-[calc(1.5rem+5rem+env(safe-area-inset-bottom))] right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-colors md:bottom-6"
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</button>
);
}

View file

@ -8,6 +8,8 @@ import {
Search,
SquarePen,
Network,
Boxes,
Repeat,
Settings,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
@ -17,19 +19,16 @@ import { SidebarProjects } from "./SidebarProjects";
import { SidebarAgents } from "./SidebarAgents";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { sidebarBadgesApi } from "../api/sidebarBadges";
import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { Button } from "@/components/ui/button";
import { PluginSlotOutlet } from "@/plugins/slots";
export function Sidebar() {
const { openNewIssue } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
const { data: sidebarBadges } = useQuery({
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const inboxBadge = useInboxBadge(selectedCompanyId);
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
@ -42,6 +41,11 @@ export function Sidebar() {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
}
const pluginContext = {
companyId: selectedCompanyId,
companyPrefix: selectedCompany?.issuePrefix ?? null,
};
return (
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
@ -80,14 +84,22 @@ export function Sidebar() {
to="/inbox"
label="Inbox"
icon={Inbox}
badge={sidebarBadges?.inbox}
badgeTone={sidebarBadges?.failedRuns ? "danger" : "default"}
alert={(sidebarBadges?.failedRuns ?? 0) > 0}
badge={inboxBadge.inbox}
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0}
/>
<PluginSlotOutlet
slotTypes={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
itemClassName="text-[13px] font-medium"
missingBehavior="placeholder"
/>
</div>
<SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} textBadge="Beta" textBadgeTone="amber" />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
</SidebarSection>
@ -97,10 +109,19 @@ 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} />
</SidebarSection>
<PluginSlotOutlet
slotTypes={["sidebarPanel"]}
context={pluginContext}
className="flex flex-col gap-3"
itemClassName="rounded-lg border border-border p-3"
missingBehavior="placeholder"
/>
</nav>
</aside>
);

View file

@ -6,38 +6,19 @@ import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { heartbeatsApi } from "../api/heartbeats";
import { queryKeys } from "../lib/queryKeys";
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
import { useAgentOrder } from "../hooks/useAgentOrder";
import { AgentIcon } from "./AgentIconPicker";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { Agent } from "@paperclipai/shared";
/** BFS sort: roots first (no reportsTo), then their direct reports, etc. */
function sortByHierarchy(agents: Agent[]): Agent[] {
const byId = new Map(agents.map((a) => [a.id, a]));
const childrenOf = new Map<string | null, Agent[]>();
for (const a of agents) {
const parent = a.reportsTo && byId.has(a.reportsTo) ? a.reportsTo : null;
const list = childrenOf.get(parent) ?? [];
list.push(a);
childrenOf.set(parent, list);
}
const sorted: Agent[] = [];
const queue = childrenOf.get(null) ?? [];
while (queue.length > 0) {
const agent = queue.shift()!;
sorted.push(agent);
const children = childrenOf.get(agent.id);
if (children) queue.push(...children);
}
return sorted;
}
export function SidebarAgents() {
const [open, setOpen] = useState(true);
const { selectedCompanyId } = useCompany();
@ -50,6 +31,10 @@ export function SidebarAgents() {
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
@ -70,11 +55,19 @@ export function SidebarAgents() {
const filtered = (agents ?? []).filter(
(a: Agent) => a.status !== "terminated"
);
return sortByHierarchy(filtered);
return filtered;
}, [agents]);
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const { orderedAgents } = useAgentOrder({
agents: visibleAgents,
companyId: selectedCompanyId,
userId: currentUserId,
});
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)/);
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
const activeAgentId = agentMatch?.[1] ?? null;
const activeTab = agentMatch?.[2] ?? null;
return (
<Collapsible open={open} onOpenChange={setOpen}>
@ -106,12 +99,12 @@ export function SidebarAgents() {
<CollapsibleContent>
<div className="flex flex-col gap-0.5 mt-0.5">
{visibleAgents.map((agent: Agent) => {
{orderedAgents.map((agent: Agent) => {
const runCount = liveCountByAgent.get(agent.id) ?? 0;
return (
<NavLink
key={agent.id}
to={agentUrl(agent)}
to={activeTab ? `${agentUrl(agent)}/${activeTab}` : agentUrl(agent)}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
@ -124,15 +117,22 @@ export function SidebarAgents() {
>
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
<span className="flex-1 truncate">{agent.name}</span>
{runCount > 0 && (
{(agent.pauseReason === "budget" || runCount > 0) && (
<span className="ml-auto flex items-center gap-1.5 shrink-0">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
{runCount} live
</span>
{agent.pauseReason === "budget" ? (
<BudgetSidebarMarker title="Agent paused by budget" />
) : null}
{runCount > 0 ? (
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
) : null}
{runCount > 0 ? (
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
{runCount} live
</span>
) : null}
</span>
)}
</NavLink>

View file

@ -11,6 +11,8 @@ interface SidebarNavItemProps {
className?: string;
badge?: number;
badgeTone?: "default" | "danger";
textBadge?: string;
textBadgeTone?: "default" | "amber";
alert?: boolean;
liveCount?: number;
}
@ -23,6 +25,8 @@ export function SidebarNavItem({
className,
badge,
badgeTone = "default",
textBadge,
textBadgeTone = "default",
alert = false,
liveCount,
}: SidebarNavItemProps) {
@ -50,10 +54,22 @@ export function SidebarNavItem({
)}
</span>
<span className="flex-1 truncate">{label}</span>
{textBadge && (
<span
className={cn(
"ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none",
textBadgeTone === "amber"
? "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
: "bg-muted text-muted-foreground",
)}
>
{textBadge}
</span>
)}
{liveCount != null && liveCount > 0 && (
<span className="ml-auto flex items-center gap-1.5">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>

View file

@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Plus } from "lucide-react";
import {
DndContext,
PointerSensor,
MouseSensor,
closestCenter,
type DragEndEvent,
useSensor,
@ -20,22 +20,32 @@ import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { cn, projectRouteRef } from "../lib/utils";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import type { Project } from "@paperclipai/shared";
type ProjectSidebarSlot = ReturnType<typeof usePluginSlots>["slots"][number];
function SortableProjectItem({
activeProjectRef,
companyId,
companyPrefix,
isMobile,
project,
projectSidebarSlots,
setSidebarOpen,
}: {
activeProjectRef: string | null;
companyId: string | null;
companyPrefix: string | null;
isMobile: boolean;
project: Project;
projectSidebarSlots: ProjectSidebarSlot[];
setSidebarOpen: (open: boolean) => void;
}) {
const {
@ -61,31 +71,53 @@ function SortableProjectItem({
{...attributes}
{...listeners}
>
<NavLink
to={`/projects/${routeRef}/issues`}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
activeProjectRef === routeRef || activeProjectRef === project.id
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
<div className="flex flex-col gap-0.5">
<NavLink
to={`/projects/${routeRef}/issues`}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
activeProjectRef === routeRef || activeProjectRef === project.id
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)}
>
<span
className="shrink-0 h-3.5 w-3.5 rounded-sm"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
<span className="flex-1 truncate">{project.name}</span>
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
</NavLink>
{projectSidebarSlots.length > 0 && (
<div className="ml-5 flex flex-col gap-0.5">
{projectSidebarSlots.map((slot) => (
<PluginSlotMount
key={`${project.id}:${slot.pluginKey}:${slot.id}`}
slot={slot}
context={{
companyId,
companyPrefix,
projectId: project.id,
projectRef: routeRef,
entityId: project.id,
entityType: "project",
}}
missingBehavior="placeholder"
/>
))}
</div>
)}
>
<span
className="shrink-0 h-3.5 w-3.5 rounded-sm"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
<span className="flex-1 truncate">{project.name}</span>
</NavLink>
</div>
</div>
);
}
export function SidebarProjects() {
const [open, setOpen] = useState(true);
const { selectedCompanyId } = useCompany();
const { selectedCompany, selectedCompanyId } = useCompany();
const { openNewProject } = useDialog();
const { isMobile, setSidebarOpen } = useSidebar();
const location = useLocation();
@ -99,6 +131,12 @@ export function SidebarProjects() {
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { slots: projectSidebarSlots } = usePluginSlots({
slotTypes: ["projectSidebarItem"],
entityType: "project",
companyId: selectedCompanyId,
enabled: !!selectedCompanyId,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
@ -115,7 +153,8 @@ export function SidebarProjects() {
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
const activeProjectRef = projectMatch?.[1] ?? null;
const sensors = useSensors(
useSensor(PointerSensor, {
// Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior.
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
}),
);
@ -178,8 +217,11 @@ export function SidebarProjects() {
<SortableProjectItem
key={project.id}
activeProjectRef={activeProjectRef}
companyId={selectedCompanyId}
companyPrefix={selectedCompany?.issuePrefix ?? null}
isMobile={isMobile}
project={project}
projectSidebarSlots={projectSidebarSlots}
setSidebarOpen={setSidebarOpen}
/>
))}

View file

@ -0,0 +1,149 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SwipeToArchive } from "./SwipeToArchive";
// Tell React this environment uses act() for event flushing.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function dispatchTouchEvent(
node: Element,
type: "touchstart" | "touchmove" | "touchend",
coords: { x: number; y: number },
) {
const event = new Event(type, { bubbles: true, cancelable: true });
const touchPoint = { clientX: coords.x, clientY: coords.y };
Object.defineProperty(event, "touches", {
configurable: true,
value: type === "touchend" ? [] : [touchPoint],
});
Object.defineProperty(event, "changedTouches", {
configurable: true,
value: [touchPoint],
});
node.dispatchEvent(event);
}
describe("SwipeToArchive", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
container.remove();
});
it("suppresses descendant clicks after a horizontal swipe and archives the row", () => {
const onArchive = vi.fn();
const onClick = vi.fn();
const root = createRoot(container);
act(() => {
root.render(
<SwipeToArchive onArchive={onArchive}>
<button type="button" onClick={onClick}>
Open issue
</button>
</SwipeToArchive>,
);
});
const wrapper = container.firstElementChild as HTMLDivElement;
const button = container.querySelector("button");
expect(button).not.toBeNull();
Object.defineProperty(wrapper, "offsetWidth", { configurable: true, value: 200 });
Object.defineProperty(wrapper, "offsetHeight", { configurable: true, value: 48 });
act(() => {
dispatchTouchEvent(wrapper, "touchstart", { x: 180, y: 20 });
});
act(() => {
dispatchTouchEvent(wrapper, "touchmove", { x: 80, y: 22 });
});
act(() => {
dispatchTouchEvent(wrapper, "touchend", { x: 80, y: 22 });
});
act(() => {
button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onClick).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(140);
});
expect(onArchive).toHaveBeenCalledTimes(1);
act(() => {
root.unmount();
});
});
it("does not suppress a normal tap click", () => {
const onArchive = vi.fn();
const onClick = vi.fn();
const root = createRoot(container);
act(() => {
root.render(
<SwipeToArchive onArchive={onArchive}>
<button type="button" onClick={onClick}>
Open issue
</button>
</SwipeToArchive>,
);
});
const button = container.querySelector("button");
expect(button).not.toBeNull();
act(() => {
button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onClick).toHaveBeenCalledTimes(1);
expect(onArchive).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it("renders the selected inbox treatment on the swipe surface", () => {
const root = createRoot(container);
act(() => {
root.render(
<SwipeToArchive onArchive={() => {}} selected>
<button type="button">Open issue</button>
</SwipeToArchive>,
);
});
const surface = container.querySelector("[data-inbox-row-surface]") as HTMLDivElement | null;
expect(surface).not.toBeNull();
expect(surface?.className).toContain("bg-zinc-100");
expect(surface?.className).toContain("dark:bg-zinc-800");
expect(surface?.className).not.toContain("bg-card");
expect(surface?.style.backgroundColor).toBe("");
expect(surface?.style.boxShadow).toBe("");
act(() => {
root.unmount();
});
});
});

View file

@ -0,0 +1,167 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Archive } from "lucide-react";
import { cn } from "../lib/utils";
interface SwipeToArchiveProps {
children: ReactNode;
onArchive: () => void;
disabled?: boolean;
selected?: boolean;
className?: string;
}
const COMMIT_THRESHOLD = 0.32;
const MAX_SWIPE = 0.88;
const COMMIT_DELAY_MS = 140;
export function SwipeToArchive({
children,
onArchive,
disabled = false,
selected = false,
className,
}: SwipeToArchiveProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const startPointRef = useRef<{ x: number; y: number } | null>(null);
const widthRef = useRef(0);
const timeoutRef = useRef<number | null>(null);
const suppressClickRef = useRef(false);
const [offsetX, setOffsetX] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [isCollapsing, setIsCollapsing] = useState(false);
const [lockedHeight, setLockedHeight] = useState<number | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);
const reset = () => {
startPointRef.current = null;
setIsDragging(false);
setOffsetX(0);
};
const commitArchive = () => {
const node = containerRef.current;
if (!node) {
onArchive();
return;
}
setIsDragging(false);
setLockedHeight(node.offsetHeight);
setOffsetX(-Math.max(widthRef.current, node.offsetWidth));
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
setIsCollapsing(true);
});
});
timeoutRef.current = window.setTimeout(() => {
onArchive();
}, COMMIT_DELAY_MS);
};
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
if (disabled || event.touches.length !== 1) return;
const touch = event.touches[0];
const node = containerRef.current;
widthRef.current = node?.offsetWidth ?? 0;
setLockedHeight(node?.offsetHeight ?? null);
setIsCollapsing(false);
suppressClickRef.current = false;
startPointRef.current = { x: touch.clientX, y: touch.clientY };
};
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
if (disabled || isCollapsing) return;
const startPoint = startPointRef.current;
if (!startPoint || event.touches.length !== 1) return;
const touch = event.touches[0];
const deltaX = touch.clientX - startPoint.x;
const deltaY = touch.clientY - startPoint.y;
if (!isDragging) {
if (Math.abs(deltaX) < 6) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) {
startPointRef.current = null;
return;
}
suppressClickRef.current = true;
}
if (deltaX >= 0) {
event.preventDefault();
setIsDragging(true);
setOffsetX(0);
return;
}
const maxSwipe = widthRef.current > 0 ? widthRef.current * MAX_SWIPE : Number.POSITIVE_INFINITY;
event.preventDefault();
setIsDragging(true);
setOffsetX(Math.max(deltaX, -maxSwipe));
};
const handleTouchEnd = () => {
if (disabled || isCollapsing) return;
const shouldCommit =
widthRef.current > 0 && Math.abs(offsetX) >= widthRef.current * COMMIT_THRESHOLD;
if (shouldCommit) {
commitArchive();
return;
}
reset();
};
const archiveReveal = widthRef.current > 0 ? Math.min(Math.abs(offsetX) / widthRef.current, 1) : 0;
return (
<div
ref={containerRef}
className={cn("relative overflow-hidden touch-pan-y", className)}
style={{
height: lockedHeight === null ? undefined : isCollapsing ? 0 : lockedHeight,
opacity: isCollapsing ? 0 : 1,
transition: isCollapsing ? "height 200ms ease, opacity 200ms ease" : undefined,
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
onClickCapture={(event) => {
if (!suppressClickRef.current) return;
event.preventDefault();
event.stopPropagation();
suppressClickRef.current = false;
}}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-end bg-emerald-600 px-4 text-white"
style={{ opacity: Math.max(archiveReveal, 0.2) }}
>
<span className="inline-flex items-center gap-2 text-sm font-medium">
<Archive className="h-4 w-4" />
Archive
</span>
</div>
<div
data-inbox-row-surface
className={cn(
"relative will-change-transform",
selected ? "bg-zinc-100 dark:bg-zinc-800" : "bg-card",
)}
style={{
transform: `translate3d(${offsetX}px, 0, 0)`,
transition: isDragging ? "none" : "transform 180ms ease-out",
}}
>
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,25 @@
import { getWorktreeUiBranding } from "../lib/worktree-branding";
export function WorktreeBanner() {
const branding = getWorktreeUiBranding();
if (!branding) return null;
return (
<div
className="relative overflow-hidden border-b px-3 py-1.5 text-[11px] font-medium tracking-[0.2em] uppercase"
style={{
backgroundColor: branding.color,
color: branding.textColor,
borderColor: `${branding.textColor}22`,
boxShadow: `inset 0 -1px 0 ${branding.textColor}18`,
backgroundImage: `linear-gradient(90deg, ${branding.textColor}14, transparent 28%, transparent 72%, ${branding.textColor}12), repeating-linear-gradient(135deg, transparent 0 10px, ${branding.textColor}08 10px 20px)`,
}}
>
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
<span className="shrink-0 opacity-70">Worktree</span>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-70" aria-hidden="true" />
<span className="truncate font-semibold tracking-[0.12em]">{branding.name}</span>
</div>
</div>
);
}

View file

@ -8,7 +8,7 @@ export const defaultCreateValues: CreateConfigValues = {
model: "",
thinkingEffort: "",
chrome: false,
dangerouslySkipPermissions: false,
dangerouslySkipPermissions: true,
search: false,
dangerouslyBypassSandbox: false,
command: "",
@ -18,7 +18,13 @@ export const defaultCreateValues: CreateConfigValues = {
envBindings: {},
url: "",
bootstrapPrompt: "",
maxTurnsPerRun: 80,
payloadTemplateJson: "",
workspaceStrategyType: "project_primary",
workspaceBaseRef: "",
workspaceBranchTemplate: "",
worktreeParentDir: "",
runtimeServicesJson: "",
maxTurnsPerRun: 300,
heartbeatEnabled: false,
intervalSec: 300,
};

View file

@ -25,20 +25,27 @@ 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.",
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
cwd: "Deprecated legacy working directory fallback for local adapters. Existing agents may still carry this value, but new configurations should use project workspaces instead.",
promptTemplate: "Sent on every heartbeat. Keep this small and dynamic. Use it for current-task framing, not large static instructions. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} and other template variables.",
model: "Override the default model used by the adapter.",
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
chrome: "Enable Claude's Chrome integration by passing --chrome.",
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.",
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
search: "Enable Codex web search capability during runs.",
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",
workspaceBaseRef: "Base git ref used when creating a worktree branch. Leave blank to use the resolved workspace ref or HEAD.",
workspaceBranchTemplate: "Template for naming derived branches. Supports {{issue.identifier}}, {{issue.title}}, {{agent.name}}, {{project.id}}, {{workspace.repoRef}}, and {{slug}}.",
worktreeParentDir: "Directory where derived worktrees should be created. Absolute, ~-prefixed, and repo-relative paths are supported.",
runtimeServicesJson: "Optional workspace runtime service definitions. Use this for shared app servers, workers, or other long-lived companion processes attached to the workspace.",
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
command: "The command to execute (e.g. node, python).",
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).",
args: "Command-line arguments, comma-separated.",
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
bootstrapPrompt: "Only sent when Paperclip starts a fresh session. Use this for stable setup guidance that should not be repeated on every heartbeat.",
payloadTemplateJson: "Optional JSON merged into remote adapter request payloads before Paperclip adds its standard wake and workspace fields.",
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
intervalSec: "Seconds between automatic heartbeat invocations.",
@ -53,9 +60,11 @@ export const help: Record<string, string> = {
export const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
gemini_local: "Gemini CLI (local)",
opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
hermes_local: "Hermes Agent",
process: "Process",
http: "HTTP",
};
@ -96,11 +105,13 @@ export function ToggleField({
hint,
checked,
onChange,
toggleTestId,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (v: boolean) => void;
toggleTestId?: string;
}) {
return (
<div className="flex items-center justify-between">
@ -109,6 +120,9 @@ export function ToggleField({
{hint && <HintIcon text={hint} />}
</div>
<button
data-slot="toggle"
data-testid={toggleTestId}
type="button"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
checked ? "bg-green-600" : "bg-muted"
@ -157,6 +171,7 @@ export function ToggleWithNumber({
{hint && <HintIcon text={hint} />}
</div>
<button
data-slot="toggle"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0",
checked ? "bg-green-600" : "bg-muted"

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